Gitfed
bastien-mrq/gitfed / cmd / gitfed-renew-cert / main.go
// 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
}