Confirm changes automatically from the client that made them
A change made over the network answers with a one-time confirm_token and closes its connection (HTTP/2: a GOAWAY). The web UI and hc send the token straight back to /confirm, which accepts it only for the newest change, from the same user and API token, the same address, and over a new connection: proof that the client can still reach the API through the new ruleset (an established connection would get through anyway). A change that cuts its client off can't be confirmed so, and rolls back by itself. The setting manual_confirm (hc settings set --auto-confirm off) and hc --no-confirm leave confirming to a person. Changes through the local socket still need no confirmation. e2e: 136 checks, among them a lockout change rolled back without anyone confirming. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc390eb57c
commit
e4efdf9c78
19 files changed
+384
-50
No files matched your search
@@ -2,8 +2,10 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +33,21 @@ type probation struct {
|
||||
timer *time.Timer
|
||||
// restores are nft scripts that bring back tables a takeover deleted.
|
||||
restores []string
|
||||
// check is the latest change's confirmation token (reachCheck): only
|
||||
// it can confirm, since the ruleset in force is the one it tests.
|
||||
check *reachCheck
|
||||
}
|
||||
|
||||
// reachCheck is what a client must show to confirm its change by itself:
|
||||
// the token, from the same user (and API token), the same address, and a
|
||||
// new connection (another remote address and port than the change's).
|
||||
type reachCheck struct {
|
||||
token string
|
||||
userID int64
|
||||
username string
|
||||
tokenID int64
|
||||
ip string
|
||||
remote string
|
||||
}
|
||||
|
||||
// system is the principal of rollbacks.
|
||||
@@ -88,6 +105,60 @@ func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyI
|
||||
s.saveProbation(ctx)
|
||||
}
|
||||
|
||||
// issueCheck gives the client that made a network change on probation a
|
||||
// token to confirm it by itself, unless the settings want a person to
|
||||
// confirm. The answer then closes its connection (HTTP/2: a GOAWAY), so
|
||||
// the confirmation has to open a new one through the new ruleset; an
|
||||
// established connection would get through whatever the change did.
|
||||
func (s *Server) issueCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal, res *engine.Result) string {
|
||||
if p == nil || p.Method == "socket" || (res != nil && !res.Applied) || s.eng.State().Settings.ManualConfirm {
|
||||
return ""
|
||||
}
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
if len(pr.pending) == 0 && len(pr.restores) == 0 {
|
||||
return ""
|
||||
}
|
||||
c := &reachCheck{token: auth.RandomString(24), username: p.Name(), ip: s.clientIP(r), remote: r.RemoteAddr}
|
||||
if p.User != nil {
|
||||
c.userID = p.User.ID
|
||||
}
|
||||
if p.Token != nil {
|
||||
c.tokenID = p.Token.ID
|
||||
}
|
||||
pr.check = c
|
||||
w.Header().Set("Connection", "close")
|
||||
return c.token
|
||||
}
|
||||
|
||||
// confirmToken confirms with a change's token: the proof that the client
|
||||
// that made it still reaches the API, from where it made it.
|
||||
func (s *Server) confirmToken(r *http.Request, p *auth.Principal, token string) (int, error) {
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
c := pr.check
|
||||
pending := len(pr.pending) + len(pr.restores)
|
||||
pr.mu.Unlock()
|
||||
var tokenID int64
|
||||
if p.Token != nil {
|
||||
tokenID = p.Token.ID
|
||||
}
|
||||
switch {
|
||||
case pending == 0:
|
||||
return 0, nil
|
||||
case c == nil || subtle.ConstantTimeCompare([]byte(c.token), []byte(token)) != 1:
|
||||
return 0, conflict("this confirmation is for an earlier change: a newer one is on probation, and only its own client (or a person) can confirm it")
|
||||
case p.User == nil || p.User.ID != c.userID || p.Name() != c.username || tokenID != c.tokenID:
|
||||
return 0, forbidden("only the client that made the change can confirm it this way")
|
||||
case s.clientIP(r) != c.ip:
|
||||
return 0, forbidden("the confirmation comes from %s, the change from %s: it must come from the same place", s.clientIP(r), c.ip)
|
||||
case r.RemoteAddr == c.remote:
|
||||
return 0, conflict("the confirmation came over the change's own connection, which proves nothing; it must open a new one")
|
||||
}
|
||||
return s.confirmWith(r.Context(), p, fmt.Sprintf("confirmed automatically: %s reached the API again over a new connection", c.ip))
|
||||
}
|
||||
|
||||
func (s *Server) saveBoot() {
|
||||
if err := s.fw.SaveBoot(s.eng.State()); err != nil {
|
||||
s.log.Error("saving the ruleset for the next boot failed", "err", err)
|
||||
@@ -110,8 +181,12 @@ func (s *Server) probationStatus() api.Probation {
|
||||
return out
|
||||
}
|
||||
|
||||
// confirm makes the pending changes permanent.
|
||||
// confirm makes the pending changes permanent (a person's confirmation).
|
||||
func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
|
||||
return s.confirmWith(ctx, p, "")
|
||||
}
|
||||
|
||||
func (s *Server) confirmWith(ctx context.Context, p *auth.Principal, how string) (int, error) {
|
||||
pr := &s.probation
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
@@ -122,10 +197,14 @@ func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
|
||||
if pr.timer != nil {
|
||||
pr.timer.Stop()
|
||||
}
|
||||
pr.pending, pr.restores, pr.deadline, pr.timer = nil, nil, time.Time{}, nil
|
||||
pr.pending, pr.restores, pr.deadline, pr.timer, pr.check = nil, nil, time.Time{}, nil, nil
|
||||
s.saveProbation(ctx)
|
||||
s.saveBoot()
|
||||
s.eng.Audit(ctx, p, "config.confirm", "", "success", fmt.Sprintf("%d changes confirmed", n))
|
||||
detail := fmt.Sprintf("%d changes confirmed", n)
|
||||
if how != "" {
|
||||
detail += "; " + how
|
||||
}
|
||||
s.eng.Audit(ctx, p, "config.confirm", "", "success", detail)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -136,7 +215,7 @@ func (s *Server) rollback(why string) {
|
||||
pr.mu.Lock()
|
||||
defer pr.mu.Unlock()
|
||||
pending, restores := pr.pending, pr.restores
|
||||
pr.pending, pr.restores, pr.deadline, pr.timer = nil, nil, time.Time{}, nil
|
||||
pr.pending, pr.restores, pr.deadline, pr.timer, pr.check = nil, nil, time.Time{}, nil, nil
|
||||
for i := len(restores) - 1; i >= 0; i-- {
|
||||
if err := s.fw.ApplyScript(ctx, restores[i]); err != nil {
|
||||
s.log.Error("bringing back a table a takeover deleted failed", "err", err)
|
||||
|
||||
Reference in new issue
Block a user