Skip to main content

internal/notify/sender.go

internal/notify · 284 lines · 14 declarations · source

Declarations

type Payload

type Payload struct {
Version string `json:"version"`
Scope string `json:"scope"`
FormedThrough int64 `json:"formed_through"`
StoredThrough int64 `json:"stored_through"`
ParkedTurns int `json:"parked_turns"`
OccurredAt time.Time `json:"occurred_at"`
}

Payload is the whole of what a notification says.

Why it carries no memory

The destination is chosen per project by whoever holds a credential, which makes this the widest egress in the product — wider than the model endpoint, which an operator names once. So a notification carries identifiers and counts, and the content is read back through the authenticated API. That keeps every path that moves memory on the one road that already has a guard on it, and it means a misdirected notification discloses that a project formed, not what.

Why the offset is the whole protocol

Delivery is at-least-once and nothing here pretends otherwise. A log offset is contiguous within a scope, so a receiver that remembers the highest one it has handled discards a redelivery without coordinating with anybody. Exactly-once would be a promise kept by whoever believed it.

source

const PayloadVersion

const PayloadVersion = "taisce-notification/v1"

PayloadVersion is what a receiver branches on when this shape changes.

source

type Delivery

type Delivery struct {
ID string
Scope string
URL string
Secret []byte
FormedThrough int64
StoredThrough int64
ParkedTurns int
}

Delivery is one thing to send.

source

type Result

type Result struct {
Status int
Err error
// Retryable is false for a refusal that will not become acceptance: a destination the operator
// no longer permits, a body the receiver called malformed. Retrying those burns the attempt
// budget on an answer that is already final.
Retryable bool
}

Result is what happened, in the terms the store records.

source

type Sender

type Sender struct {
destinations Destinations
http *http.Client
log *slog.Logger
resolve func(string) ([]net.IP, error)
now func() time.Time
}

Sender posts notifications to permitted destinations.

source

func NewSender

func NewSender(destinations Destinations, log *slog.Logger) *Sender

NewSender builds one. The timeout is short on purpose: a notification that takes ten seconds to deliver is a worker not forming memory for ten seconds, and the receiver has the offset to catch up with anyway.

source

method Sender.WithHTTPClient

func (s *Sender) WithHTTPClient(client *http.Client) *Sender

WithHTTPClient replaces the client used to deliver.

The reader is an operator whose receiver presents a certificate from their own authority, which the default client has no reason to trust and every reason not to trust silently. It is also what lets the suite deliver to a server it stood up itself, which is the only way to hold the delivery path to anything: a mock of the receiver would prove the mock matched the assertion.

Whatever client is supplied is guarded exactly as the default one is. Replacing the client is how an operator with a private certificate authority, and the suite, deliver at all — so a protection built only into the default would vanish for precisely those callers.

source

method Sender.WithResolver

func (s *Sender) WithResolver(resolve func(string) ([]net.IP, error)) *Sender

WithResolver replaces the name lookup the destination check performs before sending.

It exists so a test can make the answer at check time differ from the answer at connect time, which is the whole of a rebinding attack and cannot otherwise be staged on one machine. The connect-time check does not use it: that one reads the address actually dialled.

source

method Sender.Enabled

func (s *Sender) Enabled() bool

Enabled reports whether any destination could be sent to.

source

method Sender.Allow

func (s *Sender) Allow(url string) error

Allow is the same check the sender makes, exposed so a registration can refuse a destination when somebody types it rather than hours later in a delivery nobody is watching.

source

method Sender.Send

func (s *Sender) Send(ctx context.Context, d Delivery) Result

Send delivers one notification, or says why it did not.

source

var errRedirectRefused, errNotRoutable

var (
// errRedirectRefused is a destination that answered with a redirect.
errRedirectRefused = errors.New("the destination answered with a redirect, which is not followed")
// errNotRoutable is a connection refused on the address actually dialled.
errNotRoutable = errors.New("the destination's address is inside this deployment's own network")
)

source

func guard

func guard(client *http.Client, d Destinations, log *slog.Logger) *http.Client

guard returns a client that will not be steered anywhere the operator did not permit.

Redirects are refused, not re-checked per hop

Re-running the allowlist on every hop is the other design, and it is worse: it keeps a way for a permitted receiver to choose a second destination, and makes the rule depend on getting a check right at every hop instead of once. A webhook has no reason to redirect.

The address is checked where it is dialled

The destination check before sending resolves the name, and the client resolves it again to connect. A name whose answer changes between the two — a rebinding answer — passes the first and connects to the second. So the same `routable` rule runs again inside the dialer, on the address the connection is actually being made to, under the same permission the operator gave for private addresses. There is nothing between that check and the socket for a DNS answer to change.

No proxy

Through a proxy, the address dialled is the proxy's and the destination is resolved by somebody else, so the check above would protect nothing. Notifications therefore ignore any proxy the environment names. A deployment that must send through one needs this decided again, deliberately.

A transport this cannot guard is replaced, not trusted

Only an *http.Transport exposes a dialer to put the check in. A client carrying some other transport has its transport replaced by a guarded default, and the replacement is logged: keeping an unguarded transport would be the protection failing open without anybody being told.

source

method Result.Reason

func (r Result) Reason() string

Reason is a failure as a category: the form the store may keep and a read-only caller may read.

Never the raw error. A Go network error carries the address and port it failed to reach, and the delivery list is readable by read-only credentials — so the raw string would tell anybody with a key which internal addresses a destination led to. The category says what kind of thing went wrong, which is all a receiver's owner can act on.

source