| 1 | package console |
| 2 | |
| 3 | // Who sent this X-Forwarded-For (discussion 214, item 5). |
| 4 | // |
| 5 | // The console listens on 127.0.0.1, behind the edge on its own box, so every |
| 6 | // proxied request arrives from loopback and the client's address is the |
| 7 | // edge's X-Forwarded-For. But loopback is not the edge alone: any tenant |
| 8 | // process on the console's box can connect to 127.0.0.1:8090 and send any |
| 9 | // X-Forwarded-For it likes, one fresh address per request, past every per-IP |
| 10 | // limit on the device-code login. So a loopback peer's chain is believed only |
| 11 | // with the edge's signature over it (internal/edgeproof), checked against the |
| 12 | // deployment's mesh key, which tenants cannot read. |
| 13 | // |
| 14 | // Rollout needs one more rule. An edge that predates the proof sends none, |
| 15 | // and refusing its chains would put every client in the deployment into the |
| 16 | // single 127.0.0.1 rate-limit bucket — ten login starts a minute for |
| 17 | // everybody — until the edge caught up. The console therefore keeps the old |
| 18 | // trust until it has seen ONE valid proof, and from then on requires one for |
| 19 | // good. Only the real edge can produce that proof, and the moment is stored |
| 20 | // (edge_proof_seen) so a restart does not reopen the window. Rolling the edge |
| 21 | // back past the proof after that is the one way to land in the shared |
| 22 | // bucket; the console logs it once when it happens. |
| 23 | // |
| 24 | // The latch must only close on a proof from THIS box's edge. Far edges send |
| 25 | // none across a backhaul hop (internal/edge's backhaulProxy) for exactly |
| 26 | // that reason: an older home edge would pass a far edge's proof straight |
| 27 | // through, and the console would latch while its own edge still signed |
| 28 | // nothing. The proof also binds the request's Host, so a tenant app on this |
| 29 | // box cannot replay the proofs it receives on its own traffic here. |
| 30 | |
| 31 | import ( |
| 32 | "context" |
| 33 | "database/sql" |
| 34 | "log" |
| 35 | "net" |
| 36 | "net/http" |
| 37 | "sync" |
| 38 | "sync/atomic" |
| 39 | "time" |
| 40 | |
| 41 | "github.com/carlosframework/platform/internal/edgeproof" |
| 42 | ) |
| 43 | |
| 44 | // edgeProofKeyTTL is how long one read of the mesh keys stands. The edge |
| 45 | // polls the same objects every 30s, so a rotation reaches both sides on the |
| 46 | // same scale. |
| 47 | const edgeProofKeyTTL = 30 * time.Second |
| 48 | |
| 49 | // edgeProofKeyTimeout bounds one key read: it runs on the login path, and a |
| 50 | // hung store must cost a bounded wait, not a wedged door. |
| 51 | const edgeProofKeyTimeout = 5 * time.Second |
| 52 | |
| 53 | // edgeProofState is the cached key pair and the seen latch. Zero value is |
| 54 | // ready to use. |
| 55 | type edgeProofState struct { |
| 56 | mu sync.Mutex |
| 57 | at time.Time |
| 58 | current, next []byte |
| 59 | |
| 60 | // loaded is set once the stored mark has been read; seen is the latch. |
| 61 | loaded atomic.Bool |
| 62 | seen atomic.Bool |
| 63 | warned atomic.Bool |
| 64 | } |
| 65 | |
| 66 | // forwardedByEdge reports whether a loopback peer's X-Forwarded-For chain |
| 67 | // (xff, its lines comma-joined) came from the edge. |
| 68 | func (s *Server) forwardedByEdge(r *http.Request, xff string) bool { |
| 69 | st := &s.edgeProof |
| 70 | if proof := r.Header.Get(edgeproof.Header); proof != "" { |
| 71 | current, next := s.edgeProofKeys(r.Context()) |
| 72 | if edgeproof.Verify(current, next, r.Host, xff, proof, time.Now()) { |
| 73 | s.markEdgeProofSeen() |
| 74 | return true |
| 75 | } |
| 76 | } |
| 77 | if !s.edgeProofSeen() { |
| 78 | return true // no proof yet from this deployment's edge: the old trust |
| 79 | } |
| 80 | if st.warned.CompareAndSwap(false, true) { |
| 81 | 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") |
| 82 | } |
| 83 | return false |
| 84 | } |
| 85 | |
| 86 | // edgeProofSeen answers the latch, reading the stored row once per process. |
| 87 | // A read failure counts as seen for this request and is retried on the |
| 88 | // next: a database blip must not be the way back to the old trust. |
| 89 | func (s *Server) edgeProofSeen() bool { |
| 90 | st := &s.edgeProof |
| 91 | if st.seen.Load() { |
| 92 | return true |
| 93 | } |
| 94 | if st.loaded.Load() { |
| 95 | return false |
| 96 | } |
| 97 | seen, err := s.store.EdgeProofSeen() |
| 98 | if err != nil { |
| 99 | log.Printf("console: edge proof: read the seen mark: %v", err) |
| 100 | return true |
| 101 | } |
| 102 | if seen { |
| 103 | st.seen.Store(true) |
| 104 | } |
| 105 | st.loaded.Store(true) |
| 106 | return seen |
| 107 | } |
| 108 | |
| 109 | func (s *Server) markEdgeProofSeen() { |
| 110 | st := &s.edgeProof |
| 111 | if st.seen.Load() { |
| 112 | return |
| 113 | } |
| 114 | if err := s.store.MarkEdgeProofSeen(time.Now()); err != nil { |
| 115 | log.Printf("console: edge proof: store the seen mark: %v", err) |
| 116 | } else { |
| 117 | log.Printf("console: edge proof: first signed request from the edge — X-Forwarded-For from loopback now needs a proof") |
| 118 | } |
| 119 | st.seen.Store(true) |
| 120 | } |
| 121 | |
| 122 | // edgeProofKeys is the cached mesh key pair. With no deployment bucket there |
| 123 | // are no keys, nothing verifies, and the latch never closes — the old trust, |
| 124 | // on a console that cannot have an edge signing for it anyway. A failed read |
| 125 | // keeps the last good pair. |
| 126 | func (s *Server) edgeProofKeys(ctx context.Context) (current, next []byte) { |
| 127 | st := &s.edgeProof |
| 128 | if s.cfg.Releases == nil { |
| 129 | return nil, nil |
| 130 | } |
| 131 | st.mu.Lock() |
| 132 | defer st.mu.Unlock() |
| 133 | if !st.at.IsZero() && time.Since(st.at) < edgeProofKeyTTL { |
| 134 | return st.current, st.next |
| 135 | } |
| 136 | ctx, cancel := context.WithTimeout(ctx, edgeProofKeyTimeout) |
| 137 | defer cancel() |
| 138 | c, n, err := edgeproof.LoadKeys(ctx, s.cfg.Releases) |
| 139 | // Stamped either way, so a store that is down is asked once per TTL |
| 140 | // rather than on every login request. |
| 141 | st.at = time.Now() |
| 142 | if err != nil { |
| 143 | log.Printf("console: edge proof: %v", err) |
| 144 | return st.current, st.next |
| 145 | } |
| 146 | st.current, st.next = c, n |
| 147 | return c, n |
| 148 | } |
| 149 | |
| 150 | // isLoopbackPeer reports whether host (a RemoteAddr's host part) is this box. |
| 151 | func isLoopbackPeer(host string) bool { |
| 152 | ip := net.ParseIP(host) |
| 153 | return ip != nil && ip.IsLoopback() |
| 154 | } |
| 155 | |
| 156 | // EdgeProofSeen reports whether this console has ever received a request |
| 157 | // carrying a valid edge forwarded-for proof. |
| 158 | func (s *Store) EdgeProofSeen() (bool, error) { |
| 159 | var at int64 |
| 160 | err := s.db.QueryRow(`SELECT seen_at FROM edge_proof_seen WHERE id = 1`).Scan(&at) |
| 161 | if err == sql.ErrNoRows { |
| 162 | return false, nil |
| 163 | } |
| 164 | return err == nil, err |
| 165 | } |
| 166 | |
| 167 | // MarkEdgeProofSeen records the first valid proof. Later calls keep the |
| 168 | // first moment. |
| 169 | func (s *Store) MarkEdgeProofSeen(now time.Time) error { |
| 170 | _, err := s.db.Exec(`INSERT OR IGNORE INTO edge_proof_seen (id, seen_at) VALUES (1, ?)`, now.Unix()) |
| 171 | return err |
| 172 | } |
| 173 | |