// gitfed-renew-cert is a client-side helper, meant to run from cron/a
// systemd timer on a user's machine: it checks whether the user's gitfed
// certificate is close to expiry and, if so, requests a fresh one from the
// user's home instance over SSH (the "gitfed-cert" exec command implemented
// in internal/ssh/session.go). This is the client half of DESIGN.md §9
// Phase 3 "Renouvellement de certificat (TTL court côté client)".
package main
import (
"bytes"
"flag"
"fmt"
"os"
"time"
gossh "golang.org/x/crypto/ssh"
)
func main() {
host := flag.String("host", "", "home instance SSH address, e.g. instancea.example:22")
keyPath := flag.String("key", "", "path to the private key to authenticate with")
certPath := flag.String("cert", "", "path to read/write the certificate (default: <key>-cert.pub)")
hostKey := flag.String("host-key", "", "expected server host key (authorized_keys format); required unless -insecure is set")
insecure := flag.Bool("insecure", false, "skip host-key verification (UNSAFE: allows a man-in-the-middle to intercept renewal)")
minTTL := flag.Duration("min-ttl", 6*time.Hour, "renew if less than this much validity remains")
force := flag.Bool("force", false, "renew even if the current certificate is still comfortably valid")
flag.Parse()
if *host == "" || *keyPath == "" {
fmt.Fprintln(os.Stderr, "usage: gitfed-renew-cert -host <instance:port> -key <private-key-path> -host-key <authorized_keys line> [-cert <path>] [-min-ttl 6h]")
os.Exit(2)
}
// Fail closed: without a pinned host key we can't tell the real instance
// from a MITM. Verification must be explicitly waived with -insecure.
if *hostKey == "" && !*insecure {
fmt.Fprintln(os.Stderr, "gitfed-renew-cert: -host-key is required (or pass -insecure to skip verification, which is unsafe)")
os.Exit(2)
}
if *certPath == "" {
*certPath = *keyPath + "-cert.pub"
}
if !*force {
if remaining, ok := certRemainingTTL(*certPath); ok {
if remaining > *minTTL {
fmt.Printf("certificate still valid for %s, no renewal needed\n", remaining.Round(time.Second))
return
}
fmt.Printf("certificate expires in %s, renewing\n", remaining.Round(time.Second))
} else {
fmt.Println("no usable certificate found, requesting one")
}
}
if err := renew(*host, *keyPath, *certPath, *hostKey, *insecure); err != nil {
fmt.Fprintln(os.Stderr, "gitfed-renew-cert:", err)
os.Exit(1)
}
fmt.Println("wrote", *certPath)
}
// certRemainingTTL returns how much longer the certificate at path remains
// valid. ok is false if the file is missing or unparseable.
func certRemainingTTL(path string) (time.Duration, bool) {
data, err := os.ReadFile(path)
if err != nil {
return 0, false
}
pub, _, _, _, err := gossh.ParseAuthorizedKey(data)
if err != nil {
return 0, false
}
cert, ok := pub.(*gossh.Certificate)
if !ok {
return 0, false
}
validBefore := time.Unix(int64(cert.ValidBefore), 0)
return time.Until(validBefore), true
}
func renew(host, keyPath, certPath, hostKeyAuthorized string, insecure bool) error {
keyData, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("read private key: %w", err)
}
signer, err := gossh.ParsePrivateKey(keyData)
if err != nil {
return fmt.Errorf("parse private key: %w", err)
}
var hostKeyCallback gossh.HostKeyCallback
if hostKeyAuthorized != "" {
expected, _, _, _, err := gossh.ParseAuthorizedKey([]byte(hostKeyAuthorized))
if err != nil {
return fmt.Errorf("parse -host-key: %w", err)
}
hostKeyCallback = gossh.FixedHostKey(expected)
} else if insecure {
fmt.Fprintln(os.Stderr, "warning: -insecure set, the server's host key will NOT be verified")
hostKeyCallback = gossh.InsecureIgnoreHostKey()
} else {
return fmt.Errorf("no host key to verify against (this should have been caught earlier)")
}
client, err := gossh.Dial("tcp", host, &gossh.ClientConfig{
User: "git",
Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)},
HostKeyCallback: hostKeyCallback,
Timeout: 10 * time.Second,
})
if err != nil {
return fmt.Errorf("dial %s: %w", host, err)
}
defer client.Close()
session, err := client.NewSession()
if err != nil {
return fmt.Errorf("open session: %w", err)
}
defer session.Close()
var stdout, stderr bytes.Buffer
session.Stdout = &stdout
session.Stderr = &stderr
if err := session.Run("gitfed-cert"); err != nil {
return fmt.Errorf("gitfed-cert: %w (%s)", err, stderr.String())
}
if err := os.WriteFile(certPath, stdout.Bytes(), 0644); err != nil {
return fmt.Errorf("write %s: %w", certPath, err)
}
return nil
}