190 lines
7.2 KiB
Go
190 lines
7.2 KiB
Go
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/<id>/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.<unix>`, 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.<unix>` 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)
|
|
}
|