From 4ecc4159de55911cb977eb904913b2c824de7b74 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 13 Jul 2026 13:54:19 +0200 Subject: [PATCH] oai: cert TLS-ALPN-01 par le tunnel (on-demand, sans DNS) + demux agent (flux TLS brut vs HTTP au 1er octet) --- go.mod | 1 - go.sum | 2 - link.go | 56 ++++++++++++++++-- oai.go | 173 +++++++++++++++++++++++++++++++++----------------------- 4 files changed, 152 insertions(+), 80 deletions(-) diff --git a/go.mod b/go.mod index 69f2f14..dd05e5f 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ 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 ( diff --git a/go.sum b/go.sum index f24fa22..992e78b 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,6 @@ github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh 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= diff --git a/link.go b/link.go index 2f323c3..5fd92dd 100644 --- a/link.go +++ b/link.go @@ -14,11 +14,14 @@ package main // connexion. import ( + "bufio" "context" "crypto/rand" + "crypto/tls" "encoding/hex" "fmt" "io" + "net" "net/http" "net/http/httputil" "net/url" @@ -219,10 +222,16 @@ func runLinkForeground() error { // On construit le handler une seule fois ; il est servi à travers chaque tunnel. handler := newLinkHandler() + // Front TLS de l'accès OpenAI public (nil si JEAN_LINK_ALLOW_OAI != 1). Le + // certificat est obtenu à la demande via TLS-ALPN-01 à travers le tunnel. + oaiTLS := oaiTLSConfig() + if oaiTLS != nil { + fmt.Printf("%s accès OpenAI public activé (TLS terminé ici, cert Let's Encrypt à la demande)\n", green("[oai]")) + } backoff := time.Second for { - err := runLinkSession(token, handler) + err := runLinkSession(token, handler, oaiTLS) if err != nil { fmt.Printf("%s lien perdu: %v — reconnexion dans %s\n", yellow("[link]"), err, backoff) } @@ -347,7 +356,7 @@ func withLocalAuth(next http.Handler) http.Handler { // runLinkSession opens one WebSocket→yamux session and serves it until it dies. // It blocks for the lifetime of the connection and returns the error that ended // it (so the caller can reconnect). -func runLinkSession(token string, handler http.Handler) error { +func runLinkSession(token string, handler http.Handler, oaiTLS *tls.Config) error { ctx := context.Background() dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() @@ -390,9 +399,46 @@ func runLinkSession(token string, handler http.Handler) error { fmt.Printf("%s lien établi ✓\n", green("[link]")) - // sess implémente net.Listener : chaque Accept() = un stream = une requête. - // On sert directement l'UI Jean (le handler gère lui-même le flush SSE). + // Chaque Accept() = un stream. Deux natures possibles : + // - requête HTTP normale (UI Jean / E2E) → servie par `handler` ; + // - session TLS brute (accès OpenAI public) → terminée par le front TLS. + // On les distingue au 1er octet (0x16 = handshake TLS). Sans OAI activé, tout + // va au HTTP (comportement historique inchangé). + httpLn := newChanListener(sess.Addr()) + defer httpLn.Close() srv := &http.Server{Handler: handler} - return srv.Serve(sess) + go srv.Serve(httpLn) + + var oaiLn *chanListener + if oaiTLS != nil { + oaiLn = newChanListener(sess.Addr()) + defer oaiLn.Close() + go runOAIFront(oaiLn, oaiTLS) + } + + for { + stream, err := sess.Accept() + if err != nil { + return fmt.Errorf("accept: %w", err) + } + go demuxTunnelStream(stream, httpLn, oaiLn) + } +} + +// demuxTunnelStream aiguille un stream du tunnel selon son 1er octet : 0x16 (TLS) +// → front OAI ; sinon → HTTP. On consulte l'octet sans le consommer (peekedConn). +func demuxTunnelStream(stream net.Conn, httpLn, oaiLn *chanListener) { + br := bufio.NewReader(stream) + b, err := br.Peek(1) + if err != nil { + stream.Close() + return + } + pc := &peekedConn{Conn: stream, r: br} + if oaiLn != nil && b[0] == 0x16 { + oaiLn.push(pc) + return + } + httpLn.push(pc) } diff --git a/oai.go b/oai.go index 671fd4e..54fbcad 100644 --- a/oai.go +++ b/oai.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "crypto/ecdsa" "crypto/elliptic" @@ -21,7 +22,6 @@ import ( "time" "github.com/caddyserver/certmagic" - "github.com/libdns/cloudflare" ) // oai.go — front TLS de l'accès OpenAI public "VPS aveugle" (SNI passthrough). @@ -32,9 +32,13 @@ 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é. // -// 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é. +// Certificat : Let's Encrypt via challenge TLS-ALPN-01 servi À TRAVERS LE TUNNEL +// (l'agent est derrière CGNAT, mais le VPS forwarde la validation jusqu'à lui). +// Aucun secret DNS nulle part : le seul DNS est un wildcard *.oai.ajean.link +// statique posé une fois par l'opérateur. + +// oaiSuffix est le domaine sous lequel on autorise l'émission de certificats. +const oaiSuffix = ".oai.ajean.link" // 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 @@ -68,48 +72,99 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error { return srv.Serve(tls.NewListener(rawLn, tlsCfg)) } -// 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")) +// oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via +// Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et +// répond elle-même aux challenges ACME. La clé privée est stockée dans +// $JEAN_HOME/certs et ne quitte jamais la machine. Renvoie nil si l'accès OpenAI +// public n'est pas autorisé (JEAN_LINK_ALLOW_OAI != 1). +func oaiTLSConfig() *tls.Config { + if os.Getenv("JEAN_LINK_ALLOW_OAI") != "1" { + return nil } 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}, + certmagic.DefaultACME.DisableHTTPChallenge = true // pas de :80 accessible (CGNAT) → TLS-ALPN uniquement + magic := certmagic.NewDefault() + magic.OnDemand = &certmagic.OnDemandConfig{ + DecisionFunc: func(_ context.Context, name string) error { + if strings.HasSuffix(name, oaiSuffix) { + return nil + } + return fmt.Errorf("nom non autorisé pour l'accès OpenAI: %s", name) }, } - return certmagic.NewDefault(), nil + cfg := magic.TLSConfig() // GetCertificate (on-demand) + gère l'ALPN acme-tls/1 + cfg.MinVersion = tls.VersionTLS12 + return cfg } -// 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 +// --- démultiplexeur du tunnel ------------------------------------------------ +// Le relais ouvre soit un stream HTTP normal (UI / E2E), soit un stream "brut" +// qui porte une session TLS de bout en bout (accès OpenAI). On les distingue au +// 1er octet : un enregistrement TLS commence par 0x16 (handshake), une requête +// HTTP par une lettre ASCII (GET/POST/…). Voir demuxTunnelStream dans link.go. + +// peekedConn rend un net.Conn dont on a déjà consulté le début, sans perdre ces +// octets (ils restent dans le bufio.Reader). +type peekedConn struct { + net.Conn + r *bufio.Reader } +func (p *peekedConn) Read(b []byte) (int, error) { return p.r.Read(b) } + +// chanListener est un net.Listener alimenté à la main (push), pour injecter dans +// http.Server / tls.NewListener des conns déjà acceptées ailleurs (les streams +// démultiplexés du tunnel). +type chanListener struct { + ch chan net.Conn + done chan struct{} + addr net.Addr +} + +func newChanListener(addr net.Addr) *chanListener { + return &chanListener{ch: make(chan net.Conn), done: make(chan struct{}), addr: addr} +} + +func (l *chanListener) push(c net.Conn) { + select { + case l.ch <- c: + case <-l.done: + c.Close() + } +} + +func (l *chanListener) Accept() (net.Conn, error) { + select { + case c := <-l.ch: + return c, nil + case <-l.done: + return nil, net.ErrClosed + } +} + +func (l *chanListener) Close() error { + select { + case <-l.done: + default: + close(l.done) + } + return nil +} + +func (l *chanListener) Addr() net.Addr { + if l.addr != nil { + return l.addr + } + return dummyAddr{} +} + +type dummyAddr struct{} + +func (dummyAddr) Network() string { return "tunnel" } +func (dummyAddr) String() string { return "tunnel" } + // 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) { @@ -142,11 +197,8 @@ func selfSignedTLSConfig(host string) (*tls.Config, error) { // cmdOAI pilote l'accès OpenAI public côté agent. // -// 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é. +// jean oai serve [port] [host] (test local) termine le TLS sur :port avec un +// cert auto-signé et proxifie vers llama /v1. func cmdOAI(args []string) error { sub := "" if len(args) > 0 { @@ -154,17 +206,6 @@ 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] != "" { @@ -174,23 +215,11 @@ func cmdOAI(args []string) error { } port = n } - 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) + host := "localhost" + if len(args) > 1 && args[1] != "" { + host = args[1] } + tlsCfg, err := selfSignedTLSConfig(host) if err != nil { return err } @@ -198,12 +227,12 @@ func cmdOAI(args []string) error { if err != nil { return err } - fmt.Printf("[jean oai] front TLS :%d [%s] → llama /v1 :%d\n", port, mode, LLMPort()) + fmt.Printf("[jean oai] front TLS (test, auto-signé) https://%s:%d/v1 → llama :%d\n", host, port, LLMPort()) return runOAIFront(ln, tlsCfg) default: - 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é)") + fmt.Println("usage: jean oai serve [port] [host] (front TLS de test → llama /v1)") + fmt.Println(" en prod, le front TLS est servi automatiquement dans le tunnel (jean link)") + fmt.Println(" quand JEAN_LINK_ALLOW_OAI=1 ; cert Let's Encrypt via TLS-ALPN-01.") return nil } }