GHOSTWIRE 0.1.2: WireGuard server manager with web UI and API
Single Go binary that manages a WireGuard server based on pivpn's defaults: - config.json as the single source of truth, reconciled to the kernel via netlink, wgctrl and its own nftables table (NAT, forward, input) - web interface (dashboard, peers, peer detail, add peer, server, settings) and a JSON API for the future iOS app, with session and API-token auth - client private keys are never stored; configs and QR codes shown once - per-peer traffic statistics in stats.json, logs in GHOSTWIRE.jsonl - HTTPS via Let's Encrypt, self-signed, certificate files or off - self-managing: install, update (restores the old binary on failure), uninstall and passwd subcommands; systemd unit generated by the binary Tested end to end on Ubuntu 26.04 (kernel 7.0) at dev.redetzke.aero. Claude-Session: https://claude.ai/code/session_01RAnLbyQZ5ZTA7KqwXP98nw
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/acme"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
const letsEncryptStaging = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
|
||||
// webTLS is what the HTTP server needs for the configured TLS mode.
|
||||
type webTLS struct {
|
||||
Config *tls.Config // nil when mode is "off"
|
||||
ACME *autocert.Manager // set in mode "acme"
|
||||
leaf func() *x509.Certificate
|
||||
}
|
||||
|
||||
// Fingerprint is the SHA-256 of the served certificate, for pinning in the
|
||||
// iOS app. Empty for Let's Encrypt, whose certificate is trusted anyway.
|
||||
func (w *webTLS) Fingerprint() string {
|
||||
if w == nil || w.leaf == nil {
|
||||
return ""
|
||||
}
|
||||
c := w.leaf()
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(c.Raw)
|
||||
parts := make([]string, len(sum))
|
||||
for i, b := range sum {
|
||||
parts[i] = fmt.Sprintf("%02X", b)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func setupTLS(c *Config, dataDir string) (*webTLS, error) {
|
||||
t := c.Web.TLS
|
||||
switch t.Mode {
|
||||
case "off":
|
||||
return &webTLS{}, nil
|
||||
|
||||
case "acme":
|
||||
m := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Cache: autocert.DirCache(filepath.Join(dataDir, "acme")),
|
||||
HostPolicy: autocert.HostWhitelist(t.Domain),
|
||||
Email: t.Email,
|
||||
}
|
||||
if t.Staging {
|
||||
m.Client = &acme.Client{DirectoryURL: letsEncryptStaging}
|
||||
}
|
||||
cfg := m.TLSConfig() // answers tls-alpn-01 challenges on :443
|
||||
cfg.MinVersion = tls.VersionTLS12
|
||||
return &webTLS{Config: cfg, ACME: m}, nil
|
||||
|
||||
case "files":
|
||||
r := &certReloader{certFile: t.CertFile, keyFile: t.KeyFile}
|
||||
if _, err := r.get(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &webTLS{
|
||||
Config: &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return r.get() }},
|
||||
leaf: r.leaf,
|
||||
}, nil
|
||||
|
||||
case "selfsigned":
|
||||
cert, err := loadOrCreateSelfSigned(filepath.Join(dataDir, "tls"), c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &webTLS{
|
||||
Config: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}},
|
||||
leaf: func() *x509.Certificate { return cert.Leaf },
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown tls mode %q", t.Mode)
|
||||
}
|
||||
|
||||
// certReloader re-reads certificate files when they change on disk, so an
|
||||
// external renewal (e.g. certbot) is picked up without a restart.
|
||||
type certReloader struct {
|
||||
certFile, keyFile string
|
||||
mu sync.Mutex
|
||||
cert *tls.Certificate
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
func (r *certReloader) get() (*tls.Certificate, error) {
|
||||
st, err := os.Stat(r.certFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.cert != nil && st.ModTime().Equal(r.mtime) {
|
||||
return r.cert, nil
|
||||
}
|
||||
c, err := tls.LoadX509KeyPair(r.certFile, r.keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.cert, r.mtime = &c, st.ModTime()
|
||||
return r.cert, nil
|
||||
}
|
||||
|
||||
func (r *certReloader) leaf() *x509.Certificate {
|
||||
c, err := r.get()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return c.Leaf
|
||||
}
|
||||
|
||||
func loadOrCreateSelfSigned(dir string, c *Config) (tls.Certificate, error) {
|
||||
certPath, keyPath := filepath.Join(dir, "selfsigned.crt"), filepath.Join(dir, "selfsigned.key")
|
||||
if cert, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil {
|
||||
return cert, nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127))
|
||||
name := c.Server.Endpoint
|
||||
if name == "" {
|
||||
name, _ = os.Hostname()
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: name, Organization: []string{appName}},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
|
||||
}
|
||||
for _, n := range []string{c.Server.Endpoint, c.Web.TLS.Domain} {
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(n); ip != nil {
|
||||
tmpl.IPAddresses = append(tmpl.IPAddresses, ip)
|
||||
} else {
|
||||
tmpl.DNSNames = append(tmpl.DNSNames, n)
|
||||
}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
return tls.LoadX509KeyPair(certPath, keyPath)
|
||||
}
|
||||
Reference in New Issue
Block a user