package authz import ( "bufio" "bytes" "context" "crypto/rand" "crypto/sha256" "crypto/tls" "encoding/base64" "encoding/json" "errors" "fmt" "io" "mime" "net" "net/http" "os" "strings" "sync/atomic" "time" "github.com/lixenwraith/logwisp/internal/chain" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/netacl" "github.com/lixenwraith/logwisp/internal/tlsx" "github.com/lixenwraith/auth" "github.com/lixenwraith/toml" ) const ( // ExchangeTimeout bounds a whole SCRAM exchange on either side ExchangeTimeout = 10 * time.Second // DefaultTokenLifetime is how long a bearer token from /auth stays valid DefaultTokenLifetime = 15 * time.Minute maxAuthLine = 4096 // pre-auth lines and /auth bodies come from unauthenticated peers streamBuffer = 64 * 1024 limitBurst = 10 // failed or abandoned exchanges per address before throttling limitRate = 1 // per second limitPending = 4 // unfinished exchanges per address ) // The dialer's floor on the Argon2 cost a server may ask for. A hostile server // could otherwise request a cheaply guessable proof; tests lower it. var minArgonTime, minArgonMemory uint32 = auth.DefaultArgonTime, auth.DefaultArgonMemory var errPinMismatch = errors.New("server certificate differs from the one the SCRAM login was bound to") // authStep is every SCRAM message after the hello: TCP lines and HTTP bodies. // Binding is the client's view of the server certificate, used only to tell // TLS interception apart from a wrong password in the listener's log. type authStep struct { Error string `json:"error,omitempty"` Challenge *auth.ServerFirstMessage `json:"challenge,omitempty"` Proof *auth.ClientFinalRequest `json:"proof,omitempty"` Binding string `json:"binding,omitempty"` Final *auth.ServerFinalMessage `json:"final,omitempty"` Token string `json:"token,omitempty"` ExpiresIn int64 `json:"expires_in,omitempty"` Session string `json:"session,omitempty"` // "cookie": proxy mode, token in a cookie Logout bool `json:"logout,omitempty"` } // Credentials is a parsed credentials file: the verifiers a listener accepts, // never passwords, and the key that keeps unknown-user challenges stable. type Credentials struct { DecoyKey []byte Users []*auth.Credential } var credentialKeys = map[string]bool{ "username": true, "salt": true, "argon_time": true, "argon_memory": true, "argon_threads": true, "stored_key": true, "server_key": true, } // LoadCredentials reads and validates a credentials file func LoadCredentials(path string) (*Credentials, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("auth: credentials_file: %w", err) } c, err := ParseCredentials(data) if err != nil { return nil, fmt.Errorf("auth: credentials_file %s: %w", path, err) } return c, nil } // ParseCredentials validates a whole file: a decoy key, at least one user, // unique names, one shared KDF profile (mixed profiles would reveal which // accounts exist) and well-formed verifiers. func ParseCredentials(data []byte) (*Credentials, error) { root, err := toml.NewParser(data).Parse() if err != nil { return nil, err } for k := range root { if k != "decoy_key" && k != "users" { return nil, fmt.Errorf("unknown key %q", k) } } key, _ := root["decoy_key"].(string) decoy, err := base64.StdEncoding.Strict().DecodeString(key) if err != nil || len(decoy) < 32 { return nil, errors.New("decoy_key must hold at least 32 base64-encoded bytes") } var users []map[string]any switch v := root["users"].(type) { case []map[string]any: users = v case []any: for _, e := range v { if u, ok := e.(map[string]any); ok { users = append(users, u) } } } if len(users) == 0 { return nil, errors.New("no users") } c := &Credentials{DecoyKey: decoy} seen := make(map[string]bool, len(users)) for i, u := range users { for k := range u { if !credentialKeys[k] { return nil, fmt.Errorf("users[%d]: unknown key %q", i, k) } } cred, err := auth.ImportCredential(u) if err != nil { return nil, fmt.Errorf("users[%d]: %w", i, err) } if seen[cred.Username] { return nil, fmt.Errorf("users[%d]: duplicate user %q", i, cred.Username) } seen[cred.Username] = true if f := c.Users; len(f) > 0 && (f[0].ArgonTime != cred.ArgonTime || f[0].ArgonMemory != cred.ArgonMemory || f[0].ArgonThreads != cred.ArgonThreads || len(f[0].Salt) != len(cred.Salt)) { return nil, fmt.Errorf("users[%d] %q: Argon2 profile differs from %q's; all users must share one", i, cred.Username, f[0].Username) } c.Users = append(c.Users, cred) } return c, nil } // Marshal renders the file in its canonical form func (c *Credentials) Marshal() ([]byte, error) { users := make([]map[string]any, len(c.Users)) for i, u := range c.Users { users[i] = u.Export() } body, err := toml.Marshal(map[string]any{ "decoy_key": base64.StdEncoding.EncodeToString(c.DecoyKey), "users": users, }) if err != nil { return nil, err } 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 `lw auth add-user` agree. func ReadPassword(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("auth: password_file: %w", err) } pw := strings.TrimSuffix(strings.TrimSuffix(string(data), "\n"), "\r") if pw == "" || len(pw) > auth.MaxPasswordLen { return "", fmt.Errorf("auth: password_file %s: password must be 1-%d bytes", path, auth.MaxPasswordLen) } return pw, nil } // --- Listener --- type scramListener struct { creds *Credentials cb []byte // SHA-256 of this listener's certificate, the channel binding; nil behind proxies tokens *auth.JWT // HTTP listeners; a per-instance key, so a reload revokes every token lifetime time.Duration proxy *proxyMode server atomic.Pointer[auth.ScramServer] // limit bounds attempts per client: failed or abandoned exchanges drain // its bucket (successes are refunded), and limitPending may be unfinished limit *netacl.Table throttled atomic.Uint64 busy atomic.Uint64 bindingMismatch atomic.Uint64 } func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error { if p.side == config.Dialer { return p.compileSCRAMDialer(o) } if o.Identity != "" { p.identity = o.Identity // binds the certificate to the user } l := &scramListener{limit: netacl.NewTable(limitBurst, limitRate, limitPending)} var err error if len(o.TrustedProxies) > 0 { if l.proxy, err = parseProxies(o.TrustedProxies); err != nil { return err } l.proxy.plaintext = tlsCfg == nil } else { if len(tlsCfg.Certificates) == 0 || len(tlsCfg.Certificates[0].Certificate) == 0 { return errors.New("auth: type scram needs the listener certificate for channel binding") } cb := sha256.Sum256(tlsCfg.Certificates[0].Certificate[0]) l.cb = cb[:] } if l.creds, err = LoadCredentials(o.CredentialsFile); err != nil { return err } if p.transport == HTTP { l.lifetime = DefaultTokenLifetime if o.TokenLifetimeMS > 0 { l.lifetime = time.Duration(o.TokenLifetimeMS) * time.Millisecond } key := make([]byte, 32) rand.Read(key) if l.tokens, err = auth.NewJWT(key, auth.WithIssuer("logwisp"), auth.WithTokenLifetime(l.lifetime), auth.WithLeeway(0)); err != nil { return err } } p.listener = l p.secrets = append(p.secrets, secretFile{"auth.credentials_file", o.CredentialsFile}) return nil } // Start brings up the SCRAM server. Plugins call it from Start, so a policy // that is constructed but never started holds no goroutine. func (p *Policy) Start() error { if p == nil || p.listener == nil { return nil } l := p.listener s, err := auth.NewScramServerWithDecoyKey(l.creds.DecoyKey) if err != nil { return err } for _, c := range l.creds.Users { if err := s.AddCredential(c); err != nil { s.Stop() return fmt.Errorf("auth: user %q: %w", c.Username, err) } } l.server.Store(s) return nil } // Close stops the SCRAM server; exchanges in flight fail func (p *Policy) Close() { if p == nil || p.listener == nil { return } if s := p.listener.server.Swap(nil); s != nil { s.Stop() } } // begin opens an exchange for a peer: throttling, then the challenge. On // failure, status is the HTTP answer and public the only detail sent. func (p *Policy) begin(ip string, first json.RawMessage) (challenge auth.ServerFirstMessage, status int, public string, err error) { l := p.listener var req auth.ClientFirstRequest if err := json.Unmarshal(first, &req); err != nil { p.rejected.Add(1) return challenge, http.StatusBadRequest, "malformed request", fmt.Errorf("%w: malformed client-first message", ErrRefused) } if !l.limit.Take(ip) { l.throttled.Add(1) return challenge, http.StatusTooManyRequests, "too many attempts", fmt.Errorf("%w: %s throttled", ErrRefused, ip) } s := l.server.Load() if s == nil { l.limit.Release(ip) l.busy.Add(1) return challenge, http.StatusServiceUnavailable, "busy", errors.New("auth: scram server is not running") } challenge, err = s.ProcessClientFirstMessage(req.Username, req.ClientNonce) if err != nil { l.limit.Release(ip) } switch { case errors.Is(err, auth.ErrSCRAMTooManyHandshakes), errors.Is(err, auth.ErrSCRAMStopped): l.busy.Add(1) return challenge, http.StatusServiceUnavailable, "busy", err case err != nil: p.rejected.Add(1) return challenge, http.StatusBadRequest, "malformed request", fmt.Errorf("%w: %w", ErrRefused, err) } l.limit.Hold(ip, challenge.FullNonce, time.Now().Add(auth.ScramHandshakeTimeout)) return challenge, 0, "", nil } // finish verifies a proof, binding it to this listener's certificate, then // the client certificate to the user when configured. func (p *Policy) finish(ip, nonce string, step authStep, cs *tls.ConnectionState) (auth.ServerFinalMessage, error) { l := p.listener l.limit.Done(ip, nonce) s := l.server.Load() if s == nil { l.busy.Add(1) return auth.ServerFinalMessage{}, errors.New("auth: scram server is not running") } var bind auth.ExchangeOption // nil, so unbound, behind proxies if l.cb != nil { bind = auth.WithChannelBinding(l.cb) } final, err := s.ProcessClientFinalMessage(nonce, step.Proof.ClientProof, bind) if err == nil { err = p.bindCertificate(cs, final.Username) } if err != nil { 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 (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): l.bindingMismatch.Add(1) err = fmt.Errorf("%w; the client saw another server certificate: TLS interception or a terminating proxy", err) } return auth.ServerFinalMessage{}, fmt.Errorf("%w: %w", ErrRefused, err) } p.allowed.Add(1) l.limit.Refund(ip) return final, nil } func (p *Policy) bindCertificate(cs *tls.ConnectionState, username string) error { if p.identity == "" { return nil } if cs == nil { return errors.New("no client certificate") } if got := tlsx.PeerIdentity(*cs, p.identity); got != username { return fmt.Errorf("certificate %s %q does not match user %q", p.identity, got, username) } return nil } // exchangeTCP runs the listener side after the hello and returns the final // line for Accept. An exchange abandoned after its challenge is released from // the auth table at once rather than holding a slot for its timeout. func (p *Policy) exchangeTCP(a *Admission, cs *tls.ConnectionState) (Identity, []byte, error) { addr, _ := netacl.ParseHost(a.conn.RemoteAddr().String()) ip := netacl.Key(addr) challenge, _, public, err := p.begin(ip, a.Hello.Scram) if err != nil { writeStep(a.conn, authStep{Error: public}) return Identity{}, nil, err } settled := false defer func() { if !settled { p.listener.limit.Done(ip, challenge.FullNonce) if s := p.listener.server.Load(); s != nil { s.ProcessClientFinalMessage(challenge.FullNonce, "") } } }() if err := writeStep(a.conn, authStep{Challenge: &challenge}); err != nil { return Identity{}, nil, err } line, err := readLine(a.Reader) if err != nil { return Identity{}, nil, fmt.Errorf("read proof: %w", err) } var step authStep if err := json.Unmarshal(line, &step); err != nil || step.Proof == nil { p.rejected.Add(1) writeStep(a.conn, authStep{Error: "malformed proof"}) return Identity{}, nil, fmt.Errorf("%w: malformed proof", ErrRefused) } settled = true final, err := p.finish(ip, challenge.FullNonce, step, cs) if err != nil { writeStep(a.conn, authStep{Error: "authentication failed"}) return Identity{}, nil, err } out, err := json.Marshal(authStep{Final: &final}) if err != nil { return Identity{}, nil, err } return Identity{Name: final.Username, Method: MethodSCRAM}, append(out, '\n'), nil } // ServeAuth answers POST /auth on an HTTP scram listener: a hello gets a // challenge, a proof gets the server-final message and a bearer token (in // proxy mode, a session cookie on request), a logout ends a session. It // returns the identity of a completed login, or the refusal, for the // plugin's log; other steps return neither. func (p *Policy) ServeAuth(w http.ResponseWriter, r *http.Request) (Identity, error) { if p == nil || p.listener == nil || p.listener.tokens == nil { http.NotFound(w, r) return Identity{}, nil } l := p.listener client, err := p.ClientAddr(r) if err != nil { writeJSON(w, http.StatusForbidden, authStep{Error: "forbidden"}) return Identity{}, err } addr, _ := netacl.ParseHost(client) ip := netacl.Key(addr) // A cross-origin page cannot send JSON without a preflight nobody answers if mt, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); mt != "application/json" { p.rejected.Add(1) writeJSON(w, http.StatusUnsupportedMediaType, authStep{Error: "malformed request"}) return Identity{}, errors.New("auth request: Content-Type is not application/json") } rc := http.NewResponseController(w) rc.SetReadDeadline(time.Now().Add(ExchangeTimeout)) rc.SetWriteDeadline(time.Now().Add(ExchangeTimeout)) var req struct { chain.Hello authStep } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxAuthLine)).Decode(&req); err != nil { p.rejected.Add(1) var tooLarge *http.MaxBytesError if errors.As(err, &tooLarge) { writeJSON(w, http.StatusRequestEntityTooLarge, authStep{Error: "request too large"}) } else { writeJSON(w, http.StatusBadRequest, authStep{Error: "malformed request"}) } return Identity{}, fmt.Errorf("auth request: %w", err) } cookie := req.Session == "cookie" switch { case cookie && l.proxy == nil, req.Session != "" && !cookie, req.Logout && l.proxy == nil: p.rejected.Add(1) writeJSON(w, http.StatusBadRequest, authStep{Error: "sessions and logout need trusted_proxies"}) return Identity{}, errors.New("auth request: a browser session outside proxy mode") case req.Logout: return Identity{}, p.logout(w, r) case len(req.Scram) > 0 && req.LogWisp == chain.ProtocolVersion: challenge, status, public, err := p.begin(ip, req.Scram) if err != nil { writeJSON(w, status, authStep{Error: public}) return Identity{}, err } writeJSON(w, http.StatusOK, authStep{Challenge: &challenge}) return Identity{}, nil case req.Proof != nil: final, err := p.finish(ip, req.Proof.FullNonce, req.authStep, r.TLS) if err != nil { status := http.StatusUnauthorized if !errors.Is(err, ErrRefused) { status = http.StatusServiceUnavailable } writeJSON(w, status, authStep{Error: "authentication failed"}) return Identity{}, err } token, err := l.tokens.GenerateToken(final.Username, nil) if err != nil { writeJSON(w, http.StatusInternalServerError, authStep{Error: "token unavailable"}) return Identity{}, err } answer := authStep{Final: &final, ExpiresIn: int64(l.lifetime / time.Second)} if cookie { http.SetCookie(w, sessionCookie(token, int(l.lifetime/time.Second))) } else { answer.Token = token } writeJSON(w, http.StatusOK, answer) return Identity{Name: final.Username, Method: MethodSCRAM}, nil } p.rejected.Add(1) writeJSON(w, http.StatusBadRequest, authStep{Error: "malformed request"}) return Identity{}, errors.New("auth request: neither a hello nor a proof") } func (p *Policy) authorizeToken(r *http.Request) (Identity, int, error) { l := p.listener if l.tokens == nil { p.rejected.Add(1) return Identity{}, http.StatusUnauthorized, fmt.Errorf("%w: no token issuer on this listener", ErrRefused) } if _, err := p.ClientAddr(r); err != nil { return Identity{}, http.StatusForbidden, err } token, err := p.presentedToken(r) if err != nil { p.rejected.Add(1) return Identity{}, http.StatusUnauthorized, fmt.Errorf("%w: %w", ErrRefused, err) } user, _, err := l.tokens.ValidateToken(token) if err == nil && l.proxy != nil && l.proxy.isRevoked(token) { err = errors.New("token ended by logout") } if err != nil { p.rejected.Add(1) return Identity{}, http.StatusUnauthorized, fmt.Errorf("%w: bearer %w", ErrRefused, err) } if err := p.bindCertificate(r.TLS, user); err != nil { p.rejected.Add(1) return Identity{}, http.StatusForbidden, fmt.Errorf("%w: %w", ErrRefused, err) } return Identity{Name: user, Method: MethodSCRAM}, 0, nil } func (l *scramListener) stats(d map[string]any) { d["auth_users"] = len(l.creds.Users) d["auth_throttled"] = l.throttled.Load() d["auth_busy"] = l.busy.Load() d["auth_binding_mismatch"] = l.bindingMismatch.Load() if l.tokens != nil { d["auth_token_lifetime_ms"] = l.lifetime.Milliseconds() } if l.proxy != nil { proxies := make([]string, len(l.proxy.trusted)) for i, p := range l.proxy.trusted { proxies[i] = p.String() } d["auth_trusted_proxies"] = proxies } } // --- Dialer --- type scramDialer struct { username string password string 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] } func (p *Policy) compileSCRAMDialer(o *config.AuthOptions) error { password, err := ReadPassword(o.PasswordFile) if err != nil { return err } d := &scramDialer{username: o.Username, password: password} if _, err := d.client().StartAuthentication(); err != nil { return fmt.Errorf("auth: username %q: %w", o.Username, err) } p.dialer = d p.secrets = append(p.secrets, secretFile{"auth.password_file", o.PasswordFile}) return nil } // Unbind makes a dialer's HTTP logins unbound, for an http sink behind a // TLS-terminating proxy whose certificate logwisp never sees func (p *Policy) Unbind() { if p != nil && p.dialer != nil { p.dialer.unbound = true } } func (d *scramDialer) client() *auth.ScramClient { return auth.NewScramClient(d.username, d.password, auth.WithMinArgonCost(minArgonTime, minArgonMemory)) } // exchangeTCP runs the dialer side on conn; nothing is trusted until the // server's final signature proves it holds this user's verifier. func (d *scramDialer) exchangeTCP(conn net.Conn, r *bufio.Reader, node string) (err error) { defer func() { d.record(err) }() tc, ok := conn.(*tls.Conn) if !ok { return errors.New("auth: scram requires TLS") } c := d.client() first, err := c.StartAuthentication() if err != nil { return err } scram, err := json.Marshal(first) if err != nil { return err } line, err := chain.EncodeHello(chain.Hello{Node: node, Scram: scram}) if err != nil { return err } if _, err := conn.Write(line); err != nil { return fmt.Errorf("hello: %w", err) } step, err := readStep(r) if err != nil { var ne net.Error if errors.As(err, &ne) && ne.Timeout() { return fmt.Errorf("%w: no challenge within %v (older logwisp, or not a scram listener)", ErrRefused, ExchangeTimeout) } return err } if step.Challenge == nil { return errors.New("auth: server sent no challenge") } cb := certHash(tc.ConnectionState()) proof, err := c.ProcessServerFirstMessage(*step.Challenge, auth.WithChannelBinding(cb)) if err != nil { return fmt.Errorf("%w: %w", ErrRefused, err) } if err := writeStep(conn, authStep{Proof: &proof, Binding: base64.StdEncoding.EncodeToString(cb)}); err != nil { return err } if step, err = readStep(r); err != nil { return err } return d.verifyFinal(c, step) } func (d *scramDialer) verifyFinal(c *auth.ScramClient, step authStep) error { if step.Final == nil { return errors.New("auth: server sent no final message") } if err := c.VerifyServerFinalMessage(*step.Final); err != nil { return fmt.Errorf("%w: the server could not prove it holds this user's verifier: %w", ErrRefused, err) } return nil } // Token logs in over HTTP and returns a fresh bearer token: for the CLI, and // behind Prepare. The certificate of the first answer is pinned before the // proof is sent, so neither the proof nor the token reaches another server. func (p *Policy) Token(ctx context.Context, client *http.Client, baseURL string) (token string, err error) { if p == nil || p.dialer == nil { return "", fmt.Errorf("auth: tokens need type %q on a dialer", MethodSCRAM) } d := p.dialer defer func() { d.record(err) }() d.dropToken() // a rotated server certificate must be able to bind anew ctx, cancel := context.WithTimeout(ctx, ExchangeTimeout) defer cancel() url := baseURL + chain.AuthPath c := d.client() first, err := c.StartAuthentication() if err != nil { return "", err } scram, err := json.Marshal(first) if err != nil { return "", err } hello, err := json.Marshal(chain.Hello{LogWisp: chain.ProtocolVersion, Scram: scram}) if err != nil { return "", err } step, resp, err := postStep(ctx, client, url, hello) if err != nil { return "", err } if step.Challenge == nil || resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 { return "", errors.New("auth: no challenge over TLS") } cb := certHash(*resp.TLS) d.pin.Store(&cb) // unbound too: both requests must reach the same server var bind auth.ExchangeOption var binding string if !d.unbound { bind, binding = auth.WithChannelBinding(cb), base64.StdEncoding.EncodeToString(cb) } proof, err := c.ProcessServerFirstMessage(*step.Challenge, bind) if err != nil { return "", fmt.Errorf("%w: %w", ErrRefused, err) } body, err := json.Marshal(authStep{Proof: &proof, Binding: binding}) if err != nil { return "", err } if step, _, err = postStep(ctx, client, url, body); err != nil { return "", err } if err := d.verifyFinal(c, step); err != nil { return "", err } if step.Token == "" { return "", errors.New("auth: server issued no token") } 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 } // renewAt schedules a fresh login ahead of expiry, so a healthy link never // pays a refused request each lifetime; 401 stays the fallback for reloads. func renewAt(now time.Time, lifetime time.Duration) time.Time { if lifetime <= 0 { return now.Add(100 * 365 * 24 * time.Hour) // lifetime unknown: renew on 401 only } margin := min(max(5*time.Second, lifetime/10), lifetime/2) return now.Add(lifetime - margin) } func (d *scramDialer) dropToken() { d.token.Store(nil) d.pin.Store(nil) } func (d *scramDialer) verifyPin(cs tls.ConnectionState) error { pin := d.pin.Load() if pin == nil { return nil } if len(cs.PeerCertificates) == 0 || !bytes.Equal(certHash(cs), *pin) { return errPinMismatch } return nil } func (d *scramDialer) record(err error) { msg := "" if err != nil { d.failures.Add(1) msg = err.Error() } d.lastErr.Store(&msg) } func (d *scramDialer) stats(m map[string]any) { m["auth_username"] = d.username m["auth_failures"] = d.failures.Load() if e := d.lastErr.Load(); e != nil { m["last_auth_error"] = *e } } // 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 req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return step, nil, err } req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return step, nil, err } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, maxAuthLine)) if err != nil { return step, resp, err } decodeErr := json.Unmarshal(data, &step) switch { case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed: return step, resp, fmt.Errorf("%w: no auth endpoint at %s (older logwisp, or not a scram listener)", ErrRefused, url) case resp.StatusCode != http.StatusOK: msg := step.Error if msg == "" { msg = resp.Status } 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) } return step, resp, nil } // AwaitClose blocks until a listener ends a link after its admission. Chain // sources never write once a link is up, so a line is a refusal (ErrRefused, // sent to dialers without scram that never read it) and EOF a close. func AwaitClose(r *bufio.Reader) error { line, err := readLine(r) if err != nil { return err } var step authStep if json.Unmarshal(line, &step) == nil && step.Error != "" { return fmt.Errorf("%w: server: %s", ErrRefused, step.Error) } return errors.New("unexpected data from server") } // --- Wire helpers --- // readLine refuses a line over maxAuthLine as soon as it arrives: r buffers // far more, for the stream after the exchange. func readLine(r *bufio.Reader) ([]byte, error) { for { b, _ := r.Peek(r.Buffered()) if i := bytes.IndexByte(b, '\n'); i >= 0 && i < maxAuthLine { line := bytes.Clone(bytes.TrimRight(b[:i+1], "\r\n")) r.Discard(i + 1) return line, nil } if len(b) >= maxAuthLine { return nil, fmt.Errorf("line exceeds %d bytes", maxAuthLine) } if _, err := r.Peek(len(b) + 1); err != nil { return nil, err } } } func readStep(r *bufio.Reader) (authStep, error) { var step authStep line, err := readLine(r) if err != nil { return step, err } if err := json.Unmarshal(line, &step); err != nil { return step, fmt.Errorf("auth: malformed answer: %w", err) } if step.Error != "" { return step, fmt.Errorf("%w: server: %s", ErrRefused, step.Error) } return step, nil } func writeStep(w io.Writer, step authStep) error { line, err := json.Marshal(step) if err != nil { return err } _, err = w.Write(append(line, '\n')) return err } func writeJSON(w http.ResponseWriter, status int, step authStep) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) writeStep(w, step) } func certHash(cs tls.ConnectionState) []byte { if len(cs.PeerCertificates) == 0 { return nil } h := sha256.Sum256(cs.PeerCertificates[0].Raw) return h[:] } func remoteIP(addr string) string { if host, _, err := net.SplitHostPort(addr); err == nil { return host } return addr }