package console // Who sent this X-Forwarded-For (discussion 214, item 5). // // The console listens on 127.0.0.1, behind the edge on its own box, so every // proxied request arrives from loopback and the client's address is the // edge's X-Forwarded-For. But loopback is not the edge alone: any tenant // process on the console's box can connect to 127.0.0.1:8090 and send any // X-Forwarded-For it likes, one fresh address per request, past every per-IP // limit on the device-code login. So a loopback peer's chain is believed only // with the edge's signature over it (internal/edgeproof), checked against the // deployment's mesh key, which tenants cannot read. // // Rollout needs one more rule. An edge that predates the proof sends none, // and refusing its chains would put every client in the deployment into the // single 127.0.0.1 rate-limit bucket — ten login starts a minute for // everybody — until the edge caught up. The console therefore keeps the old // trust until it has seen ONE valid proof, and from then on requires one for // good. Only the real edge can produce that proof, and the moment is stored // (edge_proof_seen) so a restart does not reopen the window. Rolling the edge // back past the proof after that is the one way to land in the shared // bucket; the console logs it once when it happens. // // The latch must only close on a proof from THIS box's edge. Far edges send // none across a backhaul hop (internal/edge's backhaulProxy) for exactly // that reason: an older home edge would pass a far edge's proof straight // through, and the console would latch while its own edge still signed // nothing. The proof also binds the request's Host, so a tenant app on this // box cannot replay the proofs it receives on its own traffic here. import ( "context" "database/sql" "log" "net" "net/http" "sync" "sync/atomic" "time" "github.com/carlosframework/platform/internal/edgeproof" ) // edgeProofKeyTTL is how long one read of the mesh keys stands. The edge // polls the same objects every 30s, so a rotation reaches both sides on the // same scale. const edgeProofKeyTTL = 30 * time.Second // edgeProofKeyTimeout bounds one key read: it runs on the login path, and a // hung store must cost a bounded wait, not a wedged door. const edgeProofKeyTimeout = 5 * time.Second // edgeProofState is the cached key pair and the seen latch. Zero value is // ready to use. type edgeProofState struct { mu sync.Mutex at time.Time current, next []byte // loaded is set once the stored mark has been read; seen is the latch. loaded atomic.Bool seen atomic.Bool warned atomic.Bool } // forwardedByEdge reports whether a loopback peer's X-Forwarded-For chain // (xff, its lines comma-joined) came from the edge. func (s *Server) forwardedByEdge(r *http.Request, xff string) bool { st := &s.edgeProof if proof := r.Header.Get(edgeproof.Header); proof != "" { current, next := s.edgeProofKeys(r.Context()) if edgeproof.Verify(current, next, r.Host, xff, proof, time.Now()) { s.markEdgeProofSeen() return true } } if !s.edgeProofSeen() { return true // no proof yet from this deployment's edge: the old trust } if st.warned.CompareAndSwap(false, true) { log.Printf("console: a loopback request carried X-Forwarded-For with no valid edge proof; counting it against the peer address. Expected now and then from a process on this box; on every request it means the edge no longer signs (rolled back?) and every client shares one rate limit") } return false } // edgeProofSeen answers the latch, reading the stored row once per process. // A read failure counts as seen for this request and is retried on the // next: a database blip must not be the way back to the old trust. func (s *Server) edgeProofSeen() bool { st := &s.edgeProof if st.seen.Load() { return true } if st.loaded.Load() { return false } seen, err := s.store.EdgeProofSeen() if err != nil { log.Printf("console: edge proof: read the seen mark: %v", err) return true } if seen { st.seen.Store(true) } st.loaded.Store(true) return seen } func (s *Server) markEdgeProofSeen() { st := &s.edgeProof if st.seen.Load() { return } if err := s.store.MarkEdgeProofSeen(time.Now()); err != nil { log.Printf("console: edge proof: store the seen mark: %v", err) } else { log.Printf("console: edge proof: first signed request from the edge — X-Forwarded-For from loopback now needs a proof") } st.seen.Store(true) } // edgeProofKeys is the cached mesh key pair. With no deployment bucket there // are no keys, nothing verifies, and the latch never closes — the old trust, // on a console that cannot have an edge signing for it anyway. A failed read // keeps the last good pair. func (s *Server) edgeProofKeys(ctx context.Context) (current, next []byte) { st := &s.edgeProof if s.cfg.Releases == nil { return nil, nil } st.mu.Lock() defer st.mu.Unlock() if !st.at.IsZero() && time.Since(st.at) < edgeProofKeyTTL { return st.current, st.next } ctx, cancel := context.WithTimeout(ctx, edgeProofKeyTimeout) defer cancel() c, n, err := edgeproof.LoadKeys(ctx, s.cfg.Releases) // Stamped either way, so a store that is down is asked once per TTL // rather than on every login request. st.at = time.Now() if err != nil { log.Printf("console: edge proof: %v", err) return st.current, st.next } st.current, st.next = c, n return c, n } // isLoopbackPeer reports whether host (a RemoteAddr's host part) is this box. func isLoopbackPeer(host string) bool { ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } // EdgeProofSeen reports whether this console has ever received a request // carrying a valid edge forwarded-for proof. func (s *Store) EdgeProofSeen() (bool, error) { var at int64 err := s.db.QueryRow(`SELECT seen_at FROM edge_proof_seen WHERE id = 1`).Scan(&at) if err == sql.ErrNoRows { return false, nil } return err == nil, err } // MarkEdgeProofSeen records the first valid proof. Later calls keep the // first moment. func (s *Store) MarkEdgeProofSeen(now time.Time) error { _, err := s.db.Exec(`INSERT OR IGNORE INTO edge_proof_seen (id, seen_at) VALUES (1, ?)`, now.Unix()) return err }