v0.22.0 several enhancements and fixes for security, network, doc, test, packaging
This commit is contained in:
1 parent
9ab8c356d2
commit
71d1b82f83
102 files changed
+7102
-2178
No files matched your search
+22
-5
@@ -414,13 +414,26 @@ func (p *Policy) Prepare(ctx context.Context, client *http.Client, baseURL strin
|
||||
if p == nil || p.dialer == nil {
|
||||
return nil
|
||||
}
|
||||
token := p.dialer.token.Load()
|
||||
if token == nil || time.Now().UnixNano() >= p.dialer.renewAt.Load() {
|
||||
d := p.dialer
|
||||
token := d.token.Load()
|
||||
if token == nil || time.Now().UnixNano() >= d.renewAt.Load() {
|
||||
pin, expires := d.pin.Load(), d.expires.Load()
|
||||
t, err := p.Token(ctx, client, baseURL)
|
||||
if err != nil {
|
||||
// A renewal put off while the old token holds (a throttled login behind
|
||||
// a shared address) keeps sending on it and retries in a few seconds.
|
||||
// Idle connections close first: Token dialed them with no pin, and the
|
||||
// token may ride only connections the old pin verifies
|
||||
var later putOff
|
||||
if left := time.Duration(expires - time.Now().UnixNano()); errors.As(err, &later) && token != nil && left > 0 {
|
||||
client.CloseIdleConnections()
|
||||
d.pin.Store(pin)
|
||||
d.token.Store(token)
|
||||
d.renewAt.Store(time.Now().Add(min(5*time.Second, left/2)).UnixNano())
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else {
|
||||
token = &t
|
||||
}
|
||||
token = &t
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+*token)
|
||||
return nil
|
||||
@@ -561,7 +574,11 @@ func (p *Policy) LogStartup(l *log.Logger, component, id string, trustNode bool)
|
||||
"hint", "set auth.allow or auth.allow_patterns to authorize named peers")
|
||||
}
|
||||
if p.BindsNode() {
|
||||
l.Info("msg", "Node labels bound to peer identity; trust_node is ignored",
|
||||
msg := "Connection node label bound to peer identity; trust_node still governs per-entry labels"
|
||||
if p.binding == BindingForce {
|
||||
msg = "Node labels bound to peer identity; trust_node is ignored"
|
||||
}
|
||||
l.Info("msg", msg,
|
||||
"component", component,
|
||||
"instance_id", id,
|
||||
"node_binding", p.binding,
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestBrowserSessionBehindProxy(t *testing.T) {
|
||||
}
|
||||
|
||||
// Behind a proxy logwisp never sees the certificate a client binds to, so
|
||||
// only unbound proofs (logwisp auth token -unbound) log in.
|
||||
// only unbound proofs (lw auth token -unbound) log in.
|
||||
func TestOnlyUnboundLoginsBehindProxy(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, f.serverTLS, RoleListener, HTTP)
|
||||
|
||||
+28
-7
@@ -167,12 +167,12 @@ func (c *Credentials) Marshal() ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header := "# logwisp SCRAM verifiers, written by `logwisp auth add-user`. Keep it private.\n"
|
||||
header := "# logwisp SCRAM verifiers, written by `lw auth add-user`. Keep it private.\n"
|
||||
return append([]byte(header), body...), nil
|
||||
}
|
||||
|
||||
// ReadPassword reads a password file, trimming one trailing line break so a
|
||||
// file from an editor and one from `logwisp auth add-user` agree.
|
||||
// file from an editor and one from `lw auth add-user` agree.
|
||||
func ReadPassword(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -355,7 +355,7 @@ func (p *Policy) finish(ip, nonce string, step authStep, cs *tls.ConnectionState
|
||||
p.rejected.Add(1)
|
||||
switch {
|
||||
case step.Binding != "" && l.proxy != nil:
|
||||
err = fmt.Errorf("%w; the client bound its proof to the proxy's certificate: behind trusted_proxies, log in unbound (logwisp auth token -unbound)", err)
|
||||
err = fmt.Errorf("%w; the client bound its proof to the proxy's certificate: behind trusted_proxies, log in unbound (lw auth token -unbound)", err)
|
||||
case step.Binding == "" && l.cb != nil:
|
||||
err = fmt.Errorf("%w; the client sent an unbound proof (-unbound), but this listener binds logins to its certificate", err)
|
||||
case step.Binding != "" && step.Binding != base64.StdEncoding.EncodeToString(l.cb):
|
||||
@@ -566,6 +566,7 @@ type scramDialer struct {
|
||||
unbound bool // HTTP logins to a listener behind a TLS-terminating proxy
|
||||
token atomic.Pointer[string]
|
||||
renewAt atomic.Int64 // unix nanoseconds; Prepare logs in again from then
|
||||
expires atomic.Int64 // unix nanoseconds; 0 = lifetime unknown
|
||||
pin atomic.Pointer[[]byte] // HTTP: certificate the token's login was bound to
|
||||
failures atomic.Uint64
|
||||
lastErr atomic.Pointer[string]
|
||||
@@ -722,7 +723,12 @@ func (p *Policy) Token(ctx context.Context, client *http.Client, baseURL string)
|
||||
if step.Token == "" {
|
||||
return "", errors.New("auth: server issued no token")
|
||||
}
|
||||
d.renewAt.Store(renewAt(time.Now(), time.Duration(step.ExpiresIn)*time.Second).UnixNano())
|
||||
now, lifetime := time.Now(), time.Duration(step.ExpiresIn)*time.Second
|
||||
d.renewAt.Store(renewAt(now, lifetime).UnixNano())
|
||||
d.expires.Store(0)
|
||||
if lifetime > 0 {
|
||||
d.expires.Store(now.Add(lifetime).UnixNano())
|
||||
}
|
||||
d.token.Store(&step.Token)
|
||||
return step.Token, nil
|
||||
}
|
||||
@@ -770,6 +776,12 @@ func (d *scramDialer) stats(m map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
// putOff marks a login the server deferred (throttled, busy) rather than
|
||||
// refused: a token still held stays usable meanwhile.
|
||||
type putOff struct{ error }
|
||||
|
||||
func (e putOff) Unwrap() error { return e.error }
|
||||
|
||||
// postStep sends one /auth request; any answer but 200 is a refusal
|
||||
func postStep(ctx context.Context, client *http.Client, url string, body []byte) (authStep, *http.Response, error) {
|
||||
var step authStep
|
||||
@@ -796,7 +808,11 @@ func postStep(ctx context.Context, client *http.Client, url string, body []byte)
|
||||
if msg == "" {
|
||||
msg = resp.Status
|
||||
}
|
||||
return step, resp, fmt.Errorf("%w: server: %s", ErrRefused, msg)
|
||||
err := fmt.Errorf("%w: server: %s", ErrRefused, msg)
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable {
|
||||
err = putOff{err}
|
||||
}
|
||||
return step, resp, err
|
||||
case decodeErr != nil:
|
||||
return step, resp, fmt.Errorf("auth: malformed answer: %w", decodeErr)
|
||||
}
|
||||
@@ -888,13 +904,18 @@ func remoteIP(addr string) string {
|
||||
// --- Throttling ---
|
||||
|
||||
// throttleKey is what the limiter counts: an address, or for IPv6 its /64,
|
||||
// which one host usually holds whole
|
||||
// which one host usually holds whole. Link-local too, per zone: a peer picks
|
||||
// any fe80::/64 address, so per address it would escape its budget.
|
||||
func throttleKey(ip string) string {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil || addr.Is4() {
|
||||
return ip
|
||||
}
|
||||
return netip.PrefixFrom(addr, 64).Masked().String()
|
||||
key := netip.PrefixFrom(addr, 64).Masked().String()
|
||||
if zone := addr.Zone(); zone != "" {
|
||||
key += "%" + zone
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// limiter bounds SCRAM attempts per remote address: failed or abandoned
|
||||
|
||||
@@ -417,6 +417,15 @@ func TestDialerRefusesACheapChallenge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Link-local peers share one budget per link, not one across every link: the
|
||||
// zone names the link and stays in the key.
|
||||
func TestLinkLocalBudgetIsPerLink(t *testing.T) {
|
||||
a, b, other := throttleKey("fe80::1%eth0"), throttleKey("fe80::2%eth0"), throttleKey("fe80::1%eth1")
|
||||
if a != b || a == other {
|
||||
t.Fatalf("keys %q, %q on one link, %q on another", a, b, other)
|
||||
}
|
||||
}
|
||||
|
||||
// Only failed attempts drain an address's budget; beyond it the listener
|
||||
// answers "too many attempts" without running the exchange.
|
||||
func TestOnlyFailedAttemptsAreThrottled(t *testing.T) {
|
||||
@@ -734,3 +743,80 @@ func TestTokenIsRenewedBeforeExpiry(t *testing.T) {
|
||||
t.Fatalf("logins after the renewal point = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A renewal put off (throttled, or the server busy) keeps the link sending on
|
||||
// the token it still holds, and retries soon, but only to the pinned server;
|
||||
// any other refusal, or the token's expiry, ends it.
|
||||
func TestPutOffRenewalKeepsTheValidToken(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{TokenLifetimeMS: MinTokenLifetime.Milliseconds()}, f.serverTLS, RoleListener, HTTP)
|
||||
srv, _ := f.httpListener(t, l, f.serverTLS)
|
||||
// An impostor with another certificate of the same CA
|
||||
var answer, stolen atomic.Int64
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, int(answer.Load()), authStep{Error: "no"})
|
||||
})
|
||||
mux.HandleFunc("GET /protected", func(w http.ResponseWriter, r *http.Request) { stolen.Add(1) })
|
||||
imp := httptest.NewUnstartedServer(mux)
|
||||
imp.TLS = f.serverTLS.Clone()
|
||||
imp.TLS.Certificates = []tls.Certificate{f.serverLeaf(t, "impostor")}
|
||||
imp.StartTLS()
|
||||
t.Cleanup(imp.Close)
|
||||
|
||||
d := f.dialer(t, "edge-01", "edge-01-secret")
|
||||
client := f.httpClient(d)
|
||||
var reroute atomic.Pointer[string]
|
||||
client.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if to := reroute.Load(); to != nil {
|
||||
addr = *to
|
||||
}
|
||||
return (&net.Dialer{}).DialContext(ctx, network, addr)
|
||||
}
|
||||
prepare := func(renew bool) (*http.Request, error) {
|
||||
if renew {
|
||||
d.dialer.renewAt.Store(time.Now().UnixNano())
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/protected", nil)
|
||||
return req, d.Prepare(t.Context(), client, srv.URL, req)
|
||||
}
|
||||
held := func() string {
|
||||
req, err := prepare(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req.Header.Get("Authorization")
|
||||
}
|
||||
|
||||
first := held()
|
||||
client.CloseIdleConnections()
|
||||
to := imp.Listener.Addr().String()
|
||||
reroute.Store(&to)
|
||||
answer.Store(http.StatusTooManyRequests)
|
||||
req, err := prepare(true)
|
||||
if err != nil || req.Header.Get("Authorization") != first {
|
||||
t.Fatalf("put-off renewal: header %q, %v; want the held token", req.Header.Get("Authorization"), err)
|
||||
}
|
||||
if ahead := time.Until(time.Unix(0, d.dialer.renewAt.Load())); ahead <= 0 || ahead > 5*time.Second {
|
||||
t.Fatalf("next renewal %v ahead, want within 5 s", ahead)
|
||||
}
|
||||
if _, err := client.Do(req); !errors.Is(err, errPinMismatch) || stolen.Load() != 0 {
|
||||
t.Fatalf("the held token went to a server the pin never checked: %v, %d requests", err, stolen.Load())
|
||||
}
|
||||
answer.Store(http.StatusUnauthorized)
|
||||
if _, err := prepare(true); err == nil || d.dialer.token.Load() != nil {
|
||||
t.Fatalf("a refused renewal kept the token: %v", err)
|
||||
}
|
||||
|
||||
client.CloseIdleConnections()
|
||||
reroute.Store(nil)
|
||||
first = held()
|
||||
l.Close() // every further login answers busy
|
||||
if req, err := prepare(true); err != nil || req.Header.Get("Authorization") != first {
|
||||
t.Fatalf("busy renewal: %v; want the held token", err)
|
||||
}
|
||||
d.dialer.expires.Store(time.Now().UnixNano())
|
||||
if _, err := prepare(true); err == nil {
|
||||
t.Fatal("an expired token was kept after a put-off renewal")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user