Solo para leer y verificar. No puedes copiar, usar, modificar ni redistribuir este código. Aviso completo en la página principal.
tunnel/selfcheck.go
| 1 | package main |
| 2 | |
| 3 | // Self-check (ALL 2.0, WP26; the design plan, section 6 point 4, the migration notes): before a computer is told to reconnect, somebody OUTSIDE the |
| 4 | // computer's own process dials its public name the way ChatGPT will, and says whether it works. The computer asks it of a node (the node is on the other side of |
| 5 | // the internet from the computer's own network), and the same function runs from the computer itself (the device's enrolment code) and from the operator's canary. |
| 6 | // |
| 7 | // dial the public address, SNI = the computer's host, TLS 1.2 offered and 1.3 required |
| 8 | // verify the chain against the public roots, the name and the validity at this moment |
| 9 | // compare the leaf's SubjectPublicKeyInfo hash with the one the computer expects (a certificate that is not the computer's own key is not the computer) |
| 10 | // GET /healthz through the same connection and read {"ok": true} |
| 11 | // |
| 12 | // The answer is contracts/schemas/selfcheck-result.schema.json: ok, the FIRST step that failed as one closed word (chain, expired, spki, name, tls_version, healthz, |
| 13 | // unreachable, timeout), the TLS version, the leaf's SPKI hash and its not_after. No host, no address, no free text: it can be shown, stored and sent as it is. |
| 14 | // |
| 15 | // What this file does NOT do: it does not decide who may ask, it has no listener of its own and it does not know the label registry. NewSelfCheckHandler is the |
| 16 | // HTTP shape (a POST with {v, label, expect}) for the operator's loopback port, with the per-label spacing and the cap on checks in flight that keep it from being |
| 17 | // a way to make the node dial itself; the route in server.go and the policy numbers (selfcheck.timeout_s, selfcheck.min_interval_s) are the node integration's (WP17). |
| 18 | |
| 19 | import ( |
| 20 | "bufio" |
| 21 | "context" |
| 22 | "crypto/sha256" |
| 23 | "crypto/tls" |
| 24 | "crypto/x509" |
| 25 | "encoding/base64" |
| 26 | "encoding/json" |
| 27 | "errors" |
| 28 | "io" |
| 29 | "net" |
| 30 | "net/http" |
| 31 | "os" |
| 32 | "strings" |
| 33 | "sync" |
| 34 | "time" |
| 35 | ) |
| 36 | |
| 37 | // The reasons, in the order the steps run. They are policy.enums.selfcheck_reason (the test pins that they are the same list). |
| 38 | const ( |
| 39 | selfCheckChain = "chain" |
| 40 | selfCheckExpired = "expired" |
| 41 | selfCheckSPKI = "spki" |
| 42 | selfCheckName = "name" |
| 43 | selfCheckTLSVersion = "tls_version" |
| 44 | selfCheckHealthz = "healthz" |
| 45 | selfCheckUnreachable = "unreachable" |
| 46 | selfCheckTimeout = "timeout" |
| 47 | ) |
| 48 | |
| 49 | // SelfCheckResult is the contract's selfcheck-result. |
| 50 | type SelfCheckResult struct { |
| 51 | V int `json:"v"` |
| 52 | OK bool `json:"ok"` |
| 53 | Reason *string `json:"reason"` |
| 54 | TLSVersion *string `json:"tls_version"` |
| 55 | SPKISHA256 *string `json:"spki_sha256"` |
| 56 | NotAfter *int64 `json:"not_after"` |
| 57 | Healthz bool `json:"healthz"` |
| 58 | } |
| 59 | |
| 60 | // SelfCheckConfig is what the check needs from outside. Nothing here has a default that is a number: the timeout is the caller's (the policy's). |
| 61 | type SelfCheckConfig struct { |
| 62 | // Dial opens the transport to the public address; nil is a plain TCP dial. |
| 63 | Dial func(ctx context.Context, network, addr string) (net.Conn, error) |
| 64 | // Roots are the public roots the chain must lead to; nil is the system's. |
| 65 | Roots *x509.CertPool |
| 66 | // Now is the moment validity is judged at; nil is the clock. |
| 67 | Now func() time.Time |
| 68 | // Timeout bounds the whole check (dial, handshake and healthz); zero leaves it to the context. |
| 69 | Timeout time.Duration |
| 70 | } |
| 71 | |
| 72 | func selfCheckFail(res *SelfCheckResult, reason string) SelfCheckResult { |
| 73 | res.OK = false |
| 74 | res.Reason = &reason |
| 75 | return *res |
| 76 | } |
| 77 | |
| 78 | // SelfCheck dials addr ("host:port", the public address of the node or of the computer's name), speaks TLS as `host`, and judges what it finds. `expect` is the set |
| 79 | // of SPKI hashes (SHA-256, base64url, no padding) the computer may be serving; empty means "any" (the operator's canary asks only whether the chain is good). |
| 80 | func SelfCheck(ctx context.Context, cfg SelfCheckConfig, addr, host string, expect []string) SelfCheckResult { |
| 81 | res := SelfCheckResult{V: 1} |
| 82 | if cfg.Timeout > 0 { |
| 83 | var cancel context.CancelFunc |
| 84 | ctx, cancel = context.WithTimeout(ctx, cfg.Timeout) |
| 85 | defer cancel() |
| 86 | } |
| 87 | dial := cfg.Dial |
| 88 | if dial == nil { |
| 89 | dial = (&net.Dialer{}).DialContext |
| 90 | } |
| 91 | conn, err := dial(ctx, "tcp", addr) |
| 92 | if err != nil { |
| 93 | return selfCheckFail(&res, networkReason(ctx, err)) |
| 94 | } |
| 95 | defer conn.Close() |
| 96 | if deadline, ok := ctx.Deadline(); ok { |
| 97 | _ = conn.SetDeadline(deadline) |
| 98 | } |
| 99 | now := time.Now |
| 100 | if cfg.Now != nil { |
| 101 | now = cfg.Now |
| 102 | } |
| 103 | |
| 104 | // Every step that can fail inside the handshake records its reason here before it refuses the connection: the standard library would otherwise report them all as one |
| 105 | // "certificate" error, and an operator needs to know which of the four it was. |
| 106 | var found string |
| 107 | verify := func(state tls.ConnectionState) error { |
| 108 | if len(state.PeerCertificates) == 0 { |
| 109 | found = selfCheckChain |
| 110 | return errors.New("no certificate") |
| 111 | } |
| 112 | leaf := state.PeerCertificates[0] |
| 113 | sum := sha256.Sum256(leaf.RawSubjectPublicKeyInfo) |
| 114 | spki := base64.RawURLEncoding.EncodeToString(sum[:]) |
| 115 | notAfter := leaf.NotAfter.Unix() |
| 116 | res.SPKISHA256, res.NotAfter = &spki, ¬After |
| 117 | version := tlsVersionText(state.Version) |
| 118 | res.TLSVersion = &version |
| 119 | if state.Version != tls.VersionTLS13 { |
| 120 | found = selfCheckTLSVersion |
| 121 | return errors.New("tls version") |
| 122 | } |
| 123 | intermediates := x509.NewCertPool() |
| 124 | for _, extra := range state.PeerCertificates[1:] { |
| 125 | intermediates.AddCert(extra) |
| 126 | } |
| 127 | if _, verr := leaf.Verify(x509.VerifyOptions{DNSName: host, Roots: cfg.Roots, Intermediates: intermediates, CurrentTime: now()}); verr != nil { |
| 128 | found = verifyReason(verr) |
| 129 | return verr |
| 130 | } |
| 131 | if len(expect) > 0 && !containsText(expect, spki) { |
| 132 | found = selfCheckSPKI |
| 133 | return errors.New("spki") |
| 134 | } |
| 135 | return nil |
| 136 | } |
| 137 | secure := tls.Client(conn, &tls.Config{ |
| 138 | ServerName: host, |
| 139 | MinVersion: tls.VersionTLS12, |
| 140 | NextProtos: []string{"http/1.1"}, |
| 141 | // The verification is VerifyConnection above, so that every failure has its own reason; nothing is accepted that it refuses. |
| 142 | InsecureSkipVerify: true, |
| 143 | VerifyConnection: verify, |
| 144 | }) |
| 145 | if err := secure.HandshakeContext(ctx); err != nil { |
| 146 | if found != "" { |
| 147 | return selfCheckFail(&res, found) |
| 148 | } |
| 149 | if strings.Contains(err.Error(), "protocol version") { |
| 150 | return selfCheckFail(&res, selfCheckTLSVersion) |
| 151 | } |
| 152 | return selfCheckFail(&res, networkReason(ctx, err)) |
| 153 | } |
| 154 | |
| 155 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+host+"/healthz", nil) |
| 156 | if err != nil { |
| 157 | return selfCheckFail(&res, selfCheckHealthz) |
| 158 | } |
| 159 | req.Header.Set("User-Agent", "all-tunnel-selfcheck") |
| 160 | req.Close = true |
| 161 | if err := req.Write(secure); err != nil { |
| 162 | return selfCheckFail(&res, networkReason(ctx, err)) |
| 163 | } |
| 164 | response, err := http.ReadResponse(bufio.NewReader(io.LimitReader(secure, 64<<10)), req) |
| 165 | if err != nil { |
| 166 | return selfCheckFail(&res, networkReasonOr(ctx, err, selfCheckHealthz)) |
| 167 | } |
| 168 | defer response.Body.Close() |
| 169 | body, err := io.ReadAll(io.LimitReader(response.Body, 4<<10)) |
| 170 | if err != nil { |
| 171 | return selfCheckFail(&res, networkReasonOr(ctx, err, selfCheckHealthz)) |
| 172 | } |
| 173 | var health struct { |
| 174 | OK *bool `json:"ok"` |
| 175 | } |
| 176 | if response.StatusCode != http.StatusOK || json.Unmarshal(body, &health) != nil || health.OK == nil || !*health.OK { |
| 177 | return selfCheckFail(&res, selfCheckHealthz) |
| 178 | } |
| 179 | res.Healthz = true |
| 180 | res.OK = true |
| 181 | return res |
| 182 | } |
| 183 | |
| 184 | func tlsVersionText(version uint16) string { |
| 185 | if version == tls.VersionTLS13 { |
| 186 | return "1.3" |
| 187 | } |
| 188 | return "1.2" |
| 189 | } |
| 190 | |
| 191 | func containsText(list []string, text string) bool { |
| 192 | for _, item := range list { |
| 193 | if item == text { |
| 194 | return true |
| 195 | } |
| 196 | } |
| 197 | return false |
| 198 | } |
| 199 | |
| 200 | // verifyReason is the first of the three things x509 checks that is wrong: the validity, the name, or the chain (an unknown authority, a bad signature, a certificate |
| 201 | // that is not for a server, anything else). |
| 202 | func verifyReason(err error) string { |
| 203 | var invalid x509.CertificateInvalidError |
| 204 | if errors.As(err, &invalid) && invalid.Reason == x509.Expired { |
| 205 | return selfCheckExpired |
| 206 | } |
| 207 | var wrongName x509.HostnameError |
| 208 | if errors.As(err, &wrongName) { |
| 209 | return selfCheckName |
| 210 | } |
| 211 | return selfCheckChain |
| 212 | } |
| 213 | |
| 214 | func networkReason(ctx context.Context, err error) string { |
| 215 | return networkReasonOr(ctx, err, selfCheckUnreachable) |
| 216 | } |
| 217 | |
| 218 | // networkReasonOr is `timeout` when the time ran out (the context, a deadline on the connection, a net.Error that says so) and `otherwise` for every other way a |
| 219 | // connection can end. |
| 220 | func networkReasonOr(ctx context.Context, err error, otherwise string) string { |
| 221 | var netErr net.Error |
| 222 | if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, os.ErrDeadlineExceeded) || ctx.Err() != nil || (errors.As(err, &netErr) && netErr.Timeout()) { |
| 223 | return selfCheckTimeout |
| 224 | } |
| 225 | return otherwise |
| 226 | } |
| 227 | |
| 228 | // ---- the HTTP shape -------------------------------------------------------------------------------------------------------------------------- |
| 229 | |
| 230 | // selfCheckRequest is {"v": 1, "label": "...", "expect": ["<spki>", ...]}; nothing else is accepted. |
| 231 | type selfCheckRequest struct { |
| 232 | V int `json:"v"` |
| 233 | Label string `json:"label"` |
| 234 | Expect []string `json:"expect"` |
| 235 | } |
| 236 | |
| 237 | const ( |
| 238 | selfCheckBodyMax = 1024 |
| 239 | selfCheckExpectMax = 4 |
| 240 | selfCheckSPKILength = 43 |
| 241 | selfCheckInFlight = 4 |
| 242 | ) |
| 243 | |
| 244 | // NewSelfCheckHandler is POST {v, label, expect} -> selfcheck-result, for a port that is not public (the operator's loopback listener). `lookup` says which host a label |
| 245 | // stands for on this node and whether its computer is connected (an unknown or offline label is an `unreachable` answer, never an error that tells which); `labelOK` |
| 246 | // is the label pattern of the policy. A label is checked at most once per `minInterval` and at most selfCheckInFlight checks run at once: a loop of requests is a |
| 247 | // 429, never a flood of dials. |
| 248 | func NewSelfCheckHandler(cfg SelfCheckConfig, addr string, lookup func(label string) (host string, ok bool), labelOK func(string) bool, minInterval time.Duration) http.Handler { |
| 249 | var mu sync.Mutex |
| 250 | last := map[string]time.Time{} |
| 251 | slots := make(chan struct{}, selfCheckInFlight) |
| 252 | reply := func(w http.ResponseWriter, status int, value any) { |
| 253 | w.Header().Set("Content-Type", "application/json") |
| 254 | w.Header().Set("Cache-Control", "no-store") |
| 255 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 256 | w.WriteHeader(status) |
| 257 | _ = json.NewEncoder(w).Encode(value) |
| 258 | } |
| 259 | refuse := func(w http.ResponseWriter, status int, word string) { |
| 260 | reply(w, status, map[string]any{"ok": false, "error": word}) |
| 261 | } |
| 262 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 263 | if r.Method != http.MethodPost { |
| 264 | refuse(w, http.StatusMethodNotAllowed, "invalid_request") |
| 265 | return |
| 266 | } |
| 267 | decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, selfCheckBodyMax)) |
| 268 | decoder.DisallowUnknownFields() |
| 269 | var asked selfCheckRequest |
| 270 | if err := decoder.Decode(&asked); err != nil || decoder.More() || asked.V != 1 || !labelOK(asked.Label) || len(asked.Expect) > selfCheckExpectMax { |
| 271 | refuse(w, http.StatusBadRequest, "invalid_request") |
| 272 | return |
| 273 | } |
| 274 | for _, spki := range asked.Expect { |
| 275 | if len(spki) != selfCheckSPKILength { |
| 276 | refuse(w, http.StatusBadRequest, "invalid_request") |
| 277 | return |
| 278 | } |
| 279 | } |
| 280 | mu.Lock() |
| 281 | if at, seen := last[asked.Label]; seen && time.Since(at) < minInterval { |
| 282 | mu.Unlock() |
| 283 | refuse(w, http.StatusTooManyRequests, "busy") |
| 284 | return |
| 285 | } |
| 286 | select { |
| 287 | case slots <- struct{}{}: |
| 288 | last[asked.Label] = time.Now() |
| 289 | if len(last) > 4096 { |
| 290 | for label, at := range last { |
| 291 | if time.Since(at) >= minInterval { |
| 292 | delete(last, label) |
| 293 | } |
| 294 | } |
| 295 | } |
| 296 | mu.Unlock() |
| 297 | default: |
| 298 | mu.Unlock() |
| 299 | refuse(w, http.StatusTooManyRequests, "busy") |
| 300 | return |
| 301 | } |
| 302 | defer func() { <-slots }() |
| 303 | host, online := lookup(asked.Label) |
| 304 | if !online { |
| 305 | reason := selfCheckUnreachable |
| 306 | reply(w, http.StatusOK, SelfCheckResult{V: 1, Reason: &reason}) |
| 307 | return |
| 308 | } |
| 309 | reply(w, http.StatusOK, SelfCheck(r.Context(), cfg, addr, host, asked.Expect)) |
| 310 | }) |
| 311 | } |