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.
const PayloadVersion
const PayloadVersion = "taisce-notification/v1"
PayloadVersion is what a receiver branches on when this shape changes.
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.
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.
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.
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.
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.
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.
method Sender.Enabled
func (s *Sender) Enabled() bool
Enabled reports whether any destination could be sent to.
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.
method Sender.Send
func (s *Sender) Send(ctx context.Context, d Delivery) Result
Send delivers one notification, or says why it did not.
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")
)
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.
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.