package wgtunnel import ( "context" "crypto/rand" "encoding/base64" "fmt" "os" "path/filepath" "sort" "strings" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // D30 (operator 2026-10-22, R-1204): the tunnel heals itself after the box ran with its clock ahead. // // WireGuard refuses a handshake whose timestamp is not newer than the last one it accepted from that key (replay // protection). A box whose clock ran ahead handshook with a future timestamp; ep0 keeps it, and once the clock is // right again ep0 refuses every new handshake from that key — until the box's clock passes the stored stamp. The // sign on the box: its own latest handshake stays stamped in the future (R-1204's handshake_future_s). // // The repair: when that sign stays for rekeyAfter, the agent makes a NEW key pair and registers the new public key on // the existing path (POST /hosts//wg → the hub re-keys the peer IN PLACE: same /32, new key, generation bump, // and the wgsync push of the FULL peer list to ep0 drops the old key and adds the new one). A new key is a new peer // for ep0, with no stored timestamp — the next handshake is accepted. Nothing new runs on ep0. // // The previous private key is kept as private.key.prev (0600): the escrow ceremony may hold it (a host-loss DR that // installs it registers it again, and the hub re-keys in place back to it — the same path). // Pinned by TestD30_* (rekey_test.go). const ( // rekeyAfter: how long the latest handshake must stay in the future before the re-key. A working tunnel // handshakes every two minutes, which ends the sign within minutes of the clock being right. rekeyAfterDefault = 15 * time.Minute // rekeyMinGap bounds the re-key to once an hour, whatever the clock does. rekeyMinGap = time.Hour prevKeyName = "private.key.prev" ) // clockAheadRekeyLocked watches for a handshake stamped in the future and re-keys when it stays. It reports whether // it re-keyed (the caller then skips the rest of its tick). Caller holds m.mu. func (m *Manager) clockAheadRekeyLocked(ctx context.Context, block *hub.WireWireguard) bool { if m.keyBroken || !m.isActive(ctx) { m.futureSince = time.Time{} return false } out, _, err := m.runner.Run(ctx, "wg", "show", Iface, "latest-handshakes") if err != nil { return false } now := m.now() fut, ok := handshakeFuture(string(out), now) if !ok { if !m.futureSince.IsZero() { m.logger.Info("wgtunnel: the handshake is no longer stamped in the future — no re-key needed") } m.futureSince = time.Time{} return false } if m.futureSince.IsZero() { m.futureSince = now m.logger.Warn("wgtunnel: the latest handshake is stamped in the future — this box's clock ran ahead, and the endpoint refuses its newer handshakes; re-keying if it stays", "future_s", fut, "rekey_after", m.rekeyAfter.String()) return false } // A clock that jumps backwards must not stretch either wait (commit security review 2026-10-23 night): a start or a // last re-key stamped AFTER now is from a wrong clock and is dropped — this whole feature exists because clocks jump. if now.Before(m.futureSince) { m.futureSince = now } if now.Sub(m.futureSince) < m.rekeyAfter { return false } if m.lastRekey.IsZero() { m.lastRekey = m.loadLastRekey() } if now.Before(m.lastRekey) { m.lastRekey = time.Time{} } if !m.lastRekey.IsZero() && now.Sub(m.lastRekey) < rekeyMinGap { return false } pub, _, err := EnsureKey(m.stateDir) if err != nil || block.Pubkey != pub { return false // a re-register is already in flight, or the key is not readable — never re-key over that } newPub, err := rotateKey(m.stateDir) if err != nil { m.logger.Error("wgtunnel: re-key after a future handshake FAILED — the old key stays", "err", err) m.lastRekey = now // not again before rekeyMinGap m.saveLastRekey(now) return false } m.lastRekey, m.futureSince = now, time.Time{} m.saveLastRekey(now) m.logger.Warn("wgtunnel: RE-KEYED after a handshake stamped in the future (D30) — registering the new key; the hub re-keys the peer in place and the endpoint takes the new key", "old_pubkey", pub, "new_pubkey", newPub, "future_s", fut) m.nextRegAt = time.Time{} // register now, not after an older backoff m.registerLocked(ctx, newPub) return true } // rotateKey makes a new key pair, never leaving the box without a readable key (review 2026-10-23 night, N6): the new // key is written and fsynced to a temp file; the current key gets a second name by a HARD LINK (the escrow may hold // it — `private.key.prev` keeps the first such key, later ones `private.key.prev.`, the newest three kept, N7); // then the temp file is renamed OVER the current name (atomic) and the directory fsynced. At every instant // `private.key` is a complete key. Returns the new PUBLIC key only. func rotateKey(stateDir string) (string, error) { dir := filepath.Join(stateDir, "wg") cur := filepath.Join(dir, keyFileName) if _, err := os.Stat(cur); err != nil { return "", fmt.Errorf("no current key: %w", err) } priv := make([]byte, 32) if _, err := rand.Read(priv); err != nil { return "", fmt.Errorf("entropy: %w", err) } clampPrivateKey(priv) pub, err := derivePublic(priv) if err != nil { return "", err } tmp := filepath.Join(dir, keyFileName+".new") f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return "", fmt.Errorf("writing the new key: %w", err) } _, werr := f.Write([]byte(base64.StdEncoding.EncodeToString(priv) + "\n")) serr := f.Sync() cerr := f.Close() if werr != nil || serr != nil || cerr != nil { os.Remove(tmp) return "", fmt.Errorf("writing the new key: %v %v %v", werr, serr, cerr) } prev := filepath.Join(dir, prevKeyName) if _, err := os.Lstat(prev); err == nil { prev = filepath.Join(dir, fmt.Sprintf("%s.%d", prevKeyName, rekeyNow().Unix())) } if err := os.Link(cur, prev); err != nil { os.Remove(tmp) return "", fmt.Errorf("keeping the old key: %w", err) } if err := os.Rename(tmp, cur); err != nil { os.Remove(tmp) return "", fmt.Errorf("installing the new key: %w", err) } if d, err := os.Open(dir); err == nil { d.Sync() d.Close() } prunePrevKeys(dir, 3) return pub, nil } // rekeyNow is the clock for the kept-key names (a seam). var rekeyNow = time.Now // prunePrevKeys keeps the newest `keep` of the `private.key.prev.` files (`private.key.prev` itself always stays). func prunePrevKeys(dir string, keep int) { m, _ := filepath.Glob(filepath.Join(dir, prevKeyName+".*")) if len(m) <= keep { return } sort.Strings(m) // the same digit count until 2286: lexical = time order for _, p := range m[:len(m)-keep] { os.Remove(p) } } // lastRekeyFile records the last re-key's time across agent restarts (N7: the once-an-hour bound was in memory only). const lastRekeyFile = "last-rekey" func (m *Manager) loadLastRekey() time.Time { b, err := os.ReadFile(filepath.Join(m.wgDir(), lastRekeyFile)) if err != nil { return time.Time{} } var u int64 if _, err := fmt.Sscanf(strings.TrimSpace(string(b)), "%d", &u); err != nil { return time.Time{} } return time.Unix(u, 0) } func (m *Manager) saveLastRekey(t time.Time) { _ = os.WriteFile(filepath.Join(m.wgDir(), lastRekeyFile), []byte(fmt.Sprintf("%d\n", t.Unix())), 0o600) }