Multi-host phase 1: API tokens, auto TLS with pinned fingerprints, hub-side node manager + reverse proxy (WebSockets included), machine switcher in the rail, Machines page
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
fc42675e9f
commit
7dcdb5dc98
@@ -0,0 +1,125 @@
|
||||
// Package tlsauto makes a self-signed certificate for a node so hub↔node traffic is
|
||||
// encrypted without any CA. The hub pins the certificate's fingerprint.
|
||||
package tlsauto
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Fingerprint formats a certificate's SHA-256 the way the UI shows it.
|
||||
func Fingerprint(der []byte) string {
|
||||
sum := sha256.Sum256(der)
|
||||
h := hex.EncodeToString(sum[:])
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(h); i += 2 {
|
||||
if i > 0 {
|
||||
b.WriteByte(':')
|
||||
}
|
||||
b.WriteString(strings.ToUpper(h[i : i+2]))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Ensure returns the cert/key paths and fingerprint, generating them on first use.
|
||||
func Ensure(dir string) (certPath, keyPath, fingerprint string, err error) {
|
||||
certPath, keyPath = filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem")
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
c, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err == nil && len(c.Certificate) > 0 {
|
||||
return certPath, keyPath, Fingerprint(c.Certificate[0]), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "hamlab@" + host, Organization: []string{"Ham Lab"}},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{host, "localhost"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||
for _, a := range addrs {
|
||||
if ipn, ok := a.(*net.IPNet); ok && !ipn.IP.IsLoopback() && ipn.IP.To4() != nil {
|
||||
tmpl.IPAddresses = append(tmpl.IPAddresses, ipn.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
return certPath, keyPath, Fingerprint(der), nil
|
||||
}
|
||||
|
||||
// PinnedConfig returns a client TLS config that accepts exactly one certificate.
|
||||
func PinnedConfig(fingerprint string) *tls.Config {
|
||||
return &tls.Config{
|
||||
InsecureSkipVerify: true, // we verify by fingerprint below, not by CA
|
||||
VerifyPeerCertificate: func(raw [][]byte, _ [][]*x509.Certificate) error {
|
||||
if len(raw) == 0 {
|
||||
return errors.New("no certificate presented")
|
||||
}
|
||||
if Fingerprint(raw[0]) != fingerprint {
|
||||
return errors.New("certificate fingerprint changed: " + Fingerprint(raw[0]))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Peek connects and returns the fingerprint the server presents (trust on first use).
|
||||
func Peek(addr string) (string, error) {
|
||||
d := &net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{InsecureSkipVerify: true})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
certs := conn.ConnectionState().PeerCertificates
|
||||
if len(certs) == 0 {
|
||||
return "", errors.New("no certificate presented")
|
||||
}
|
||||
return Fingerprint(certs[0].Raw), nil
|
||||
}
|
||||
Reference in New Issue
Block a user