oai: toggle UI exposer en public (drapeau .oai_public lu en direct au demux, sans redemarrage) + endpoint /api/oai/public

This commit is contained in:
nathaninline committed 2026-07-13 14:30:52 +02:00
1 parent 6767a816a9
commit 4d9dcb2d6e
4 files changed
+72 -13

No files matched your search

+10 -7
View File
@@ -222,12 +222,11 @@ 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.
// Front TLS de l'accès OpenAI public. Toujours prêt ; c'est le drapeau
// oaiPublicEnabled (piloté par l'UI, lu en direct au démux) qui autorise ou non
// le trafic. Cert 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]"))
}
fmt.Printf("%s front OpenAI public prêt (activation en direct via l'UI ; état: %v)\n", green("[oai]"), oaiPublicEnabled())
backoff := time.Second
for {
@@ -435,8 +434,12 @@ func demuxTunnelStream(stream net.Conn, httpLn, oaiLn *chanListener) {
return
}
pc := &peekedConn{Conn: stream, r: br}
if oaiLn != nil && b[0] == 0x16 {
oaiLn.push(pc)
if b[0] == 0x16 { // handshake TLS = accès OpenAI public
if oaiLn != nil && oaiPublicEnabled() {
oaiLn.push(pc)
} else {
stream.Close() // public désactivé → on refuse (fail-closed)
}
return
}
httpLn.push(pc)
+25 -5
View File
@@ -72,15 +72,35 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error {
return srv.Serve(tls.NewListener(rawLn, tlsCfg))
}
// oaiPublicPath est le drapeau qui active l'accès OpenAI public (piloté par l'UI,
// lu en direct → activable/coupable sans redémarrer le service de lien).
func oaiPublicPath() string { return filepath.Join(JeanHome(), ".oai_public") }
// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette machine.
func oaiPublicEnabled() bool {
if _, err := os.Stat(oaiPublicPath()); err == nil {
return true
}
return os.Getenv("JEAN_LINK_ALLOW_OAI") == "1" // rétro-compat (ancien drapeau env)
}
// setOAIPublic active (on) ou coupe (off) l'accès OpenAI public.
func setOAIPublic(on bool) error {
if on {
return os.WriteFile(oaiPublicPath(), []byte("1\n"), 0o600)
}
if err := os.Remove(oaiPublicPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// 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).
// $JEAN_HOME/certs et ne quitte jamais la machine. Toujours construite ; c'est le
// démux (oaiPublicEnabled, lu en direct) qui décide de router ou non le trafic.
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"))
+9
View File
@@ -331,6 +331,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
</details>
<details><summary>Accès OpenAI</summary>
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branche n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur cette URL. Modèle : <code>jean</code>. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer &lt;clé&gt;</code>.</span></span></div>
<label class="switchrow" style="margin-top:6px"><span class="switch"><input type="checkbox" id="oai-public-toggle" onchange="toggleOAIPublic()"><span class="slider"></span></span> exposer en public (ajean.link)<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Rend ton IA joignable depuis <b>n'importe où</b> via <code>https://&lt;machine&gt;.oai.ajean.link/v1</code> (pour brancher un SaaS). Le trafic est chiffré <b>de bout en bout</b> jusqu'à ce serveur — le relais ajean.link ne voit rien. Nécessite un abonnement ajean.link actif.</span></span></label>
<div id="oai-public-wrap" style="display:none;margin-top:4px">
<div class="muted" style="font-size:11px;margin-bottom:2px">🌍 public (ajean.link) — accessible partout, TLS de bout en bout jusqu'à ce serveur</div>
<div class="row">
@@ -773,6 +774,8 @@ function renderApiKey(d){
const host = d.host || location.hostname;
document.getElementById('oai-url').value = 'http://'+host+':'+d.port+'/v1';
// Endpoint PUBLIC (ajean.link) : affiché seulement si l'accès public est activé.
const tg = document.getElementById('oai-public-toggle');
if(tg) tg.checked = !!d.oai_public;
const pubWrap = document.getElementById('oai-public-wrap');
if(d.oai_public && d.machine){
document.getElementById('oai-public-url').value = 'https://'+d.machine+'.oai.ajean.link/v1';
@@ -794,6 +797,12 @@ async function apiKeyAction(action){
toast('application…');
renderApiKey(await jpost('/api/apikey', {action}));
}
async function toggleOAIPublic(){
const on = document.getElementById('oai-public-toggle').checked;
await jpost('/api/oai/public', {enabled:on});
toast(on ? 'accès public activé' : 'accès public coupé');
loadApiKey();
}
async function apiKeySet(){
const k = await askPrompt('Colle ta clé API (ou laisse vide pour annuler) :', {title:'Définir la clé API', placeholder:'sk-…'});
if(!k || !k.trim()) return;
+28 -1
View File
@@ -87,6 +87,7 @@ func newWebMux() *http.ServeMux {
api("/api/agent/toggle", handleAgentToggle)
api("/api/agent/tool-limit", handleToolLimitToggle)
api("/api/apikey", handleAPIKey)
api("/api/oai/public", handleOAIPublic)
api("/api/internet", handleInternet)
api("/api/memory", handleMemoryMode)
// Alias rétro-compat : l'ancien portail ajean.link (dépôt jean-relay) pilote
@@ -550,11 +551,37 @@ func handleAPIKey(w http.ResponseWriter, r *http.Request) {
"host": localIP(),
// Accès OpenAI PUBLIC via ajean.link (passthrough SNI, VPS aveugle) : si
// activé, l'URL publique est https://<machine>.oai.ajean.link/v1.
"oai_public": os.Getenv("JEAN_LINK_ALLOW_OAI") == "1",
"oai_public": oaiPublicEnabled(),
"machine": machineID(),
})
}
// handleOAIPublic pilote le drapeau d'accès OpenAI public (exposition via
// ajean.link). GET renvoie l'état ; POST {enabled} l'active/coupe en direct
// (aucun redémarrage : le démux du tunnel relit le drapeau à chaque connexion).
func handleOAIPublic(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if req.Enabled != nil {
if err := setOAIPublic(*req.Enabled); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
}
}
sendJSON(w, 200, map[string]any{
"ok": true,
"enabled": oaiPublicEnabled(),
"machine": machineID(),
})
}
// localIP best-effort renvoie l'IPv4 LAN primaire de la machine (l'IP source du
// trafic sortant), ou "localhost" à défaut. Sert à annoncer l'endpoint OpenAI
// avec une adresse correcte sur le réseau local MÊME quand l'UI est atteinte via