diff --git a/go.mod b/go.mod index 06fc5ea..69f2f14 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,29 @@ module github.com/nathaninline/jean -go 1.23 +go 1.25.0 require ( + github.com/caddyserver/certmagic v0.25.4 github.com/coder/websocket v1.8.15 github.com/hashicorp/yamux v0.1.2 + github.com/libdns/cloudflare v0.2.2 +) + +require ( + github.com/caddyserver/zerossl v0.1.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/libdns/libdns v1.1.1 // indirect + github.com/mholt/acmez/v3 v3.1.6 // indirect + github.com/miekg/dns v1.1.72 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect ) diff --git a/go.sum b/go.sum index c5d316b..f24fa22 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,64 @@ +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= +github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg= +github.com/caddyserver/certmagic v0.25.4/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= +github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/oai.go b/oai.go index 45aea86..671fd4e 100644 --- a/oai.go +++ b/oai.go @@ -1,6 +1,7 @@ package main import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" @@ -14,9 +15,13 @@ import ( "net/http/httputil" "net/url" "os" + "path/filepath" "strconv" "strings" "time" + + "github.com/caddyserver/certmagic" + "github.com/libdns/cloudflare" ) // oai.go — front TLS de l'accès OpenAI public "VPS aveugle" (SNI passthrough). @@ -27,9 +32,9 @@ import ( // la clé privée ne quitte JAMAIS cette machine — puis proxifié vers llama-server // local (/v1). Un attaquant qui possède le VPS ne voit donc que du chiffré. // -// Ce fichier fournit la terminaison TLS + le proxy. La source du certificat -// (auto-signé pour les tests locaux ; Let's Encrypt via DNS-01 en prod) et -// l'alimentation par le tunnel (streams "raw") sont branchées séparément. +// Le certificat est obtenu par Let's Encrypt via challenge DNS-01 Cloudflare +// (l'agent est derrière CGNAT → pas de HTTP-01). En test local, on retombe sur +// un certificat auto-signé. // oaiHandler construit le reverse-proxy vers llama-server, restreint à la surface // compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le SaaS envoie @@ -51,30 +56,66 @@ func oaiHandler() http.Handler { }) } -// runOAIFront termine le TLS sur rawLn (avec cert) puis sert oaiHandler dessus. +// runOAIFront termine le TLS sur rawLn (avec tlsCfg) puis sert oaiHandler dessus. // rawLn peut être un vrai listener TCP (test local) ou un listener alimenté par // les streams "raw" du tunnel (prod). Bloquant. -func runOAIFront(rawLn net.Listener, cert tls.Certificate) error { - tlsCfg := &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, - } +func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error { srv := &http.Server{ - Handler: oaiHandler(), - ReadTimeout: 0, // completions longues : pas de timeout de lecture - WriteTimeout: 0, - IdleTimeout: 120 * time.Second, + Handler: oaiHandler(), + IdleTimeout: 120 * time.Second, + // completions longues : pas de Read/Write timeout. } return srv.Serve(tls.NewListener(rawLn, tlsCfg)) } -// selfSignedCert fabrique en mémoire un certificat auto-signé pour host, valable -// 1 an. Sert UNIQUEMENT aux tests locaux (curl -k) avant de brancher Let's -// Encrypt. En prod le cert vient de certmagic/ACME (phase 2). -func selfSignedCert(host string) (tls.Certificate, error) { +// cfToken lit le token API Cloudflare (permission DNS edit sur la zone ajean.link) +// depuis $JEAN_CF_TOKEN ou $JEAN_HOME/.cf_token. Sert au challenge DNS-01. +func cfToken() string { + if t := strings.TrimSpace(os.Getenv("JEAN_CF_TOKEN")); t != "" { + return t + } + b, _ := os.ReadFile(filepath.Join(JeanHome(), ".cf_token")) + return strings.TrimSpace(string(b)) +} + +// newCertmagic configure certmagic pour émettre/renouveler via Cloudflare DNS-01, +// avec stockage persistant dans $JEAN_HOME/certs (la clé privée reste locale). +func newCertmagic(token string) (*certmagic.Config, error) { + if token == "" { + return nil, fmt.Errorf("token Cloudflare absent (JEAN_CF_TOKEN ou %s)", filepath.Join(JeanHome(), ".cf_token")) + } + certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(JeanHome(), "certs")} + certmagic.DefaultACME.Agreed = true + certmagic.DefaultACME.Email = strings.TrimSpace(os.Getenv("JEAN_ACME_EMAIL")) + certmagic.DefaultACME.DNS01Solver = &certmagic.DNS01Solver{ + DNSManager: certmagic.DNSManager{ + DNSProvider: &cloudflare.Provider{APIToken: token}, + }, + } + return certmagic.NewDefault(), nil +} + +// acmeTLSConfig émet (ou charge le cache) le cert LE pour domain via DNS-01 +// Cloudflare et renvoie un *tls.Config qui le sert. +func acmeTLSConfig(domain string) (*tls.Config, error) { + cfg, err := newCertmagic(cfToken()) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + if err := cfg.ManageSync(ctx, []string{domain}); err != nil { + return nil, err + } + return &tls.Config{GetCertificate: cfg.GetCertificate, MinVersion: tls.VersionTLS12}, nil +} + +// selfSignedTLSConfig fabrique un *tls.Config auto-signé pour host. Tests locaux +// uniquement (curl -k) avant de brancher Let's Encrypt. +func selfSignedTLSConfig(host string) (*tls.Config, error) { priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { - return tls.Certificate{}, err + return nil, err } serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) tmpl := x509.Certificate{ @@ -93,16 +134,19 @@ func selfSignedCert(host string) (tls.Certificate, error) { } der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) if err != nil { - return tls.Certificate{}, err + return nil, err } - return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}, nil + cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv} + return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil } // cmdOAI pilote l'accès OpenAI public côté agent. // -// jean oai serve [port] [host] (test local) termine le TLS sur :port avec un -// cert auto-signé et proxifie vers llama /v1. -// défaut port=8443, host=localhost. +// jean oai cert émet/charge le cert Let's Encrypt (DNS-01 CF) +// et s'arrête — pour tester l'émission. +// jean oai serve [port] [domaine] termine le TLS sur :port et proxifie vers +// llama /v1. Avec un domaine réel + token CF → +// cert Let's Encrypt ; sinon cert auto-signé. func cmdOAI(args []string) error { sub := "" if len(args) > 0 { @@ -110,6 +154,17 @@ func cmdOAI(args []string) error { args = args[1:] } switch sub { + case "cert": + if len(args) == 0 || args[0] == "" { + return fmt.Errorf("usage: jean oai cert ") + } + domain := args[0] + fmt.Printf("[jean oai] émission du certificat pour %s (Let's Encrypt, DNS-01 Cloudflare)…\n", bold(domain)) + if _, err := acmeTLSConfig(domain); err != nil { + return err + } + fmt.Printf("%s certificat prêt (stocké dans %s)\n", green("[ok]"), filepath.Join(JeanHome(), "certs")) + return nil case "serve": port := 8443 if len(args) > 0 && args[0] != "" { @@ -119,11 +174,23 @@ func cmdOAI(args []string) error { } port = n } - host := "localhost" - if len(args) > 1 && args[1] != "" { - host = args[1] + domain := "" + if len(args) > 1 { + domain = args[1] + } + var tlsCfg *tls.Config + var err error + mode := "auto-signé (test)" + if domain != "" && net.ParseIP(domain) == nil && cfToken() != "" { + tlsCfg, err = acmeTLSConfig(domain) + mode = "Let's Encrypt " + domain + } else { + host := domain + if host == "" { + host = "localhost" + } + tlsCfg, err = selfSignedTLSConfig(host) } - cert, err := selfSignedCert(host) if err != nil { return err } @@ -131,14 +198,12 @@ func cmdOAI(args []string) error { if err != nil { return err } - fmt.Printf("[jean oai] front TLS (test) https://%s:%d/v1 → llama :%d\n", host, port, LLMPort()) - fmt.Printf("%s cert auto-signé — teste avec: %s\n", yellow("[info]"), - bold(fmt.Sprintf("curl -k https://%s:%d/v1/models -H 'Authorization: Bearer '", host, port))) - return runOAIFront(ln, cert) + fmt.Printf("[jean oai] front TLS :%d [%s] → llama /v1 :%d\n", port, mode, LLMPort()) + return runOAIFront(ln, tlsCfg) default: - fmt.Println("usage: jean oai serve [port] [host] (front TLS de test → llama /v1)") + fmt.Println("usage:") + fmt.Println(" jean oai cert émet le cert Let's Encrypt (DNS-01 Cloudflare) et s'arrête") + fmt.Println(" jean oai serve [port] [domaine] front TLS → llama /v1 (domaine réel = Let's Encrypt, sinon auto-signé)") return nil } } - -var _ = os.Getenv // réservé : flags d'activation en phase suivante