oai: cert Let's Encrypt via DNS-01 Cloudflare (phase 2) — certmagic + libdns/cloudflare, cle privee locale (/certs); jean oai cert <domaine>

This commit is contained in:
nathaninline committed 2026-07-13 13:23:10 +02:00
1 parent 28b6e33b2c
commit daefa8932c
3 files changed
+181 -35

No files matched your search

+99 -34
View File
@@ -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 <domaine> é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 <domaine>")
}
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 <clé>'", 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 <domaine> é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