From 126f4be2b916269107774ef3ddc4e078359ba52d Mon Sep 17 00:00:00 2001 From: OpenClaw Dev Date: Sun, 1 Nov 2026 04:09:09 +0100 Subject: [PATCH 1/6] =?UTF-8?q?server:=20trusted=5Fproxies=20=E2=80=93=20X?= =?UTF-8?q?-Forwarded-For=20f=C3=BCr=20Rate-Limit=20hinter=20Reverse-Proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/byteclaw/main.go | 7 +++- config.example.yaml | 1 + internal/config/config.go | 4 +- internal/server/auth.go | 65 ++++++++++++++++++++++++++++++-- internal/server/clientip_test.go | 26 +++++++++++++ internal/server/server.go | 5 ++- 6 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 internal/server/clientip_test.go diff --git a/cmd/byteclaw/main.go b/cmd/byteclaw/main.go index 0a34729..23a1b40 100644 --- a/cmd/byteclaw/main.go +++ b/cmd/byteclaw/main.go @@ -81,7 +81,12 @@ func run(cfgPath string, log *slog.Logger) error { p, _, err := pm.ForSession(ctx, sid) return p, err }} - srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), TokenTTL: time.Duration(cfg.TokenTTLHours) * time.Hour, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm, + trusted, err := server.ParseCIDRs(cfg.TrustedProxies) + if err != nil { + log.Error("invalid trusted_proxies", "err", err) + os.Exit(1) + } + srv := &server.Server{TrustedProxies: trusted, Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), TokenTTL: time.Duration(cfg.TokenTTLHours) * time.Hour, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm, Info: server.Info{Version: version, Provider: cfg.LLM.Provider, Model: cfg.LLM.Model, BaseURL: cfg.LLM.BaseURL}, RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second} diff --git a/config.example.yaml b/config.example.yaml index d179374..e3eb03d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -5,6 +5,7 @@ listen: 127.0.0.1:8787 # bind to 0.0.0.0 only behind TLS (reverse proxy auth_token: "" # optional static admin token (>= 16 chars, sees all sessions). Generate: openssl rand -hex 32 allow_registration: true # users sign up via POST /api/auth/register; set false after creating your account token_ttl_hours: 720 # login token lifetime +trusted_proxies: [] # reverse proxy IPs/CIDRs whose X-Forwarded-For is trusted (empty = loopback only) allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it data_dir: ./data workspace: "" # default: /workspace; file tools cannot leave it diff --git a/internal/config/config.go b/internal/config/config.go index 794b94a..abbddee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -75,7 +75,7 @@ type Tools struct { } type Config struct { - Listen string `yaml:"listen" json:"listen"` + Listen string `yaml:"listen" json:"listen"` // AuthToken is an optional static admin token (sees all sessions). Users log in via /api/auth. AuthToken string `yaml:"auth_token" json:"auth_token"` // AllowRegistration enables self-service sign-up (default true). @@ -84,6 +84,8 @@ type Config struct { TokenTTLHours int `yaml:"token_ttl_hours" json:"token_ttl_hours"` // AllowedOrigins for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly. AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"` + // TrustedProxies (IPs/CIDRs) whose X-Forwarded-For is used for rate limiting. Empty = loopback only. + TrustedProxies []string `yaml:"trusted_proxies" json:"trusted_proxies"` DataDir string `yaml:"data_dir" json:"data_dir"` Workspace string `yaml:"workspace" json:"workspace"` LLM LLM `yaml:"llm" json:"llm"` diff --git a/internal/server/auth.go b/internal/server/auth.go index bd481cc..c2521da 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -103,14 +103,71 @@ func (l *limiter) Reset(key string) { delete(l.hits, key) } -func clientIP(r *http.Request) string { +// clientIP returns the peer address. If the peer is a trusted proxy, the +// right-most X-Forwarded-For entry that is not itself a trusted proxy is used. +func (s *Server) clientIP(r *http.Request) string { h, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { - return r.RemoteAddr + h = r.RemoteAddr + } + if !s.isTrustedProxy(h) { + return h + } + var parts []string + for _, v := range r.Header.Values("X-Forwarded-For") { + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + parts = append(parts, p) + } + } + } + for i := len(parts) - 1; i >= 0; i-- { + if net.ParseIP(parts[i]) == nil { + break + } + if !s.isTrustedProxy(parts[i]) { + return parts[i] + } } return h } +func (s *Server) isTrustedProxy(ip string) bool { + p := net.ParseIP(ip) + if p == nil { + return false + } + if len(s.TrustedProxies) == 0 { + return p.IsLoopback() + } + for _, c := range s.TrustedProxies { + if c.Contains(p) { + return true + } + } + return false +} + +// ParseCIDRs turns IPs or CIDRs into networks (bare IPs become /32 or /128). +func ParseCIDRs(in []string) ([]*net.IPNet, error) { + var out []*net.IPNet + for _, v := range in { + if !strings.Contains(v, "/") { + if ip := net.ParseIP(v); ip != nil && ip.To4() != nil { + v += "/32" + } else { + v += "/128" + } + } + _, n, err := net.ParseCIDR(v) + if err != nil { + return nil, err + } + out = append(out, n) + } + return out, nil +} + func (s *Server) initAuth() { s.authOnce.Do(func() { if s.limiter == nil { @@ -194,7 +251,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusForbidden, "registration disabled") return } - ip := clientIP(r) + ip := s.clientIP(r) if s.limiter.Blocked(ip) { writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later") return @@ -232,7 +289,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { func (s *Server) login(w http.ResponseWriter, r *http.Request) { s.initAuth() - ip := clientIP(r) + ip := s.clientIP(r) if s.limiter.Blocked(ip) { writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later") return diff --git a/internal/server/clientip_test.go b/internal/server/clientip_test.go new file mode 100644 index 0000000..ae6379e --- /dev/null +++ b/internal/server/clientip_test.go @@ -0,0 +1,26 @@ +package server + +import ( + "net/http/httptest" + "testing" +) + +func TestClientIPTrustedProxy(t *testing.T) { + s := &Server{} + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "127.0.0.1:1234" + r.Header.Set("X-Forwarded-For", "6.6.6.6, 1.2.3.4") + if got := s.clientIP(r); got != "1.2.3.4" { + t.Fatalf("loopback proxy: got %s", got) + } + r.RemoteAddr = "9.9.9.9:1" + if got := s.clientIP(r); got != "9.9.9.9" { + t.Fatalf("untrusted peer: got %s", got) + } + n, _ := ParseCIDRs([]string{"10.0.1.0/24"}) + s.TrustedProxies = n + r.RemoteAddr = "10.0.1.6:1" + if got := s.clientIP(r); got != "1.2.3.4" { + t.Fatalf("cidr proxy: got %s", got) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 5e2afb9..3868a19 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,9 +3,10 @@ package server import ( "context" - "encoding/json" + "encoding/json" "errors" "log/slog" + "net" "net/http" "net/url" "strings" @@ -38,6 +39,8 @@ type Server struct { AllowRegistration bool // TokenTTL is the lifetime of user tokens (default 30 days). TokenTTL time.Duration + // TrustedProxies whose X-Forwarded-For is honoured (empty = loopback only). + TrustedProxies []*net.IPNet authOnce sync.Once limiter *limiter From 161bc02bb9b1fcbc9365ced3f694f0d9cb71310b Mon Sep 17 00:00:00 2001 From: OpenClaw Dev Date: Sun, 1 Nov 2026 04:09:09 +0100 Subject: [PATCH 2/6] app: Standard-Server https://byteclaw.jodabreit-ai.de; README --- README.md | 2 ++ .../java/dev/byteclaw/client/data/SettingsStore.kt | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3723a45..9bb8f23 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,8 @@ Adaptive Icon als Vektor (`res/drawable/ic_launcher_{background,foreground,monoc In der App unter Einstellungen Server-URL (`https://…`; im Debug-Build auch `http://10.0.2.2:8787` für den Emulator) und Token eintragen, „Verbindung testen", speichern. +Öffentlicher Server: Die App schlägt standardmäßig `https://byteclaw.jodabreit-ai.de` vor (Registrierung offen). Hinter einem Reverse-Proxy dessen IP/CIDR in `trusted_proxies` eintragen, damit das Login-Rate-Limit die echte Client-IP aus `X-Forwarded-For` nutzt. + ## Nächste Schritte - CI (GitHub Actions) für Go + Android diff --git a/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt b/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt index 8105c73..f48ff42 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt @@ -20,7 +20,7 @@ class SettingsStore(context: Context) { ) fun load() = ServerSettings( - serverUrl = prefs.getString(KEY_URL, "") ?: "", + serverUrl = prefs.getString(KEY_URL, null)?.takeIf { it.isNotBlank() } ?: DEFAULT_SERVER_URL, token = prefs.getString(KEY_TOKEN, "") ?: "", username = prefs.getString(KEY_USER, "") ?: "", ) @@ -30,9 +30,11 @@ class SettingsStore(context: Context) { .putString(KEY_USER, s.username.trim()).apply() } - private companion object { - const val KEY_URL = "server_url" - const val KEY_TOKEN = "token" - const val KEY_USER = "username" + companion object { + /** Public ByteClaw server, prefilled on the login screen (user can change it). */ + const val DEFAULT_SERVER_URL = "https://byteclaw.jodabreit-ai.de" + private const val KEY_URL = "server_url" + private const val KEY_TOKEN = "token" + private const val KEY_USER = "username" } } From 70d316851ad0302eb6d7a942a6ca6820b201167f Mon Sep 17 00:00:00 2001 From: OpenClaw Dev Date: Sun, 1 Nov 2026 04:17:53 +0100 Subject: [PATCH 3/6] =?UTF-8?q?Provider-Wechsel=20nur=20f=C3=BCr=20Admin?= =?UTF-8?q?=20(403=20f=C3=BCr=20Nutzer);=20App=20blendet=20Provider-Auswah?= =?UTF-8?q?l=20f=C3=BCr=20Nicht-Admins=20aus;=20v0.3.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../.kotlin/errors/errors-1793502564485.log | 4 ++ android/app/build.gradle.kts | 4 +- .../dev/byteclaw/client/ui/ChatViewModel.kt | 3 ++ .../java/dev/byteclaw/client/ui/Screens.kt | 11 ++++- .../client/preview/PreviewScreenshots.kt | 18 ++++---- internal/server/admin_test.go | 43 +++++++++++++++++++ internal/server/extra.go | 18 ++++++++ internal/server/extra_test.go | 2 +- internal/server/server.go | 7 +++ site/.netlify/state.json | 3 ++ site/_headers | 2 +- site/index.html | 4 +- 12 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 android/.kotlin/errors/errors-1793502564485.log create mode 100644 internal/server/admin_test.go create mode 100644 site/.netlify/state.json diff --git a/android/.kotlin/errors/errors-1793502564485.log b/android/.kotlin/errors/errors-1793502564485.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/android/.kotlin/errors/errors-1793502564485.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 39e2a6b..a0b2532 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -31,8 +31,8 @@ android { applicationId = "dev.byteclaw.client" minSdk = 26 targetSdk = 35 - versionCode = 3 - versionName = "0.3.0" + versionCode = 4 + versionName = "0.3.1" } signingConfigs { diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt index 780339a..feb1bd4 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt @@ -44,6 +44,8 @@ data class UiState( /** Login screen state. */ val authBusy: Boolean = false, val authError: String? = null, + /** From /api/auth/me: only the admin may change providers. */ + val isAdmin: Boolean = false, ) { /** What the current chat effectively uses: session override, else the global active provider. */ val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active @@ -266,6 +268,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { } fun loadProviders() = viewModelScope.launch { + runCatching { api().me() }.onSuccess { u -> _state.update { it.copy(isAdmin = u.admin) } } runCatching { api().providers() } .onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } } .onFailure { e -> if (e is UnauthorizedException) fail(e) else _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } } diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt index 74e7c73..6021bfb 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt @@ -296,7 +296,14 @@ fun SettingsScreen( } status?.let { item { Text(it) } } if (state.settings.isConfigured) { - item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) } + if (state.isAdmin) { + item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) } + } else { + item { + Text("LLM-Provider: " + (state.provider.active?.let { ProviderPresets.label(it.provider) + " · " + it.model } ?: "–") + + "\nNur der Server-Admin kann den Provider ändern.", style = MaterialTheme.typography.bodySmall) + } + } } } } @@ -370,7 +377,7 @@ private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initia Box { AssistChip( onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) }, - enabled = state.settings.isConfigured && !state.busy, + enabled = state.settings.isConfigured && !state.busy && state.isAdmin, modifier = Modifier.height(28.dp).widthIn(max = 230.dp), ) DropdownMenu(expanded = open, onDismissRequest = { open = false }) { diff --git a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt index 65a1d81..6cbe174 100644 --- a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt +++ b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt @@ -43,7 +43,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun chat() = snap("chat") { ChatScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, sessionId = "demo", busy = true, lines = listOf( ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"), @@ -68,7 +68,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun chatProvider() = snap("chat_provider") { ChatScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, sessionId = "demo", provider = demoProviders, sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"), lines = listOf( @@ -83,7 +83,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun chatMarkdown() = snap("chat_markdown") { ChatScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, sessionId = "demo", online = true, lines = listOf( ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"), @@ -96,7 +96,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun sessionsSearch() = snap("sessions_search") { SessionsScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, sessions = listOf( Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"), @@ -110,7 +110,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun sessions() = snap("sessions") { SessionsScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, sessions = listOf( Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"), @@ -126,14 +126,14 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun settings() = snap("settings") { SettingsScreen( - state = UiState(settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {}, + state = UiState(isAdmin = true, settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {}, initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]", ) } @Test fun providers() = snap("providers") { SettingsScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, provider = ProviderUi( active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"), @@ -152,12 +152,12 @@ class PreviewScreenshots(private val dark: Boolean) { } @Test fun login() = snap("login") { - LoginScreen(state = UiState(settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> }) + LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> }) } @Test fun loginRegister() = snap("login_register") { LoginScreen( - state = UiState(settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"), + state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"), onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto", ) } diff --git a/internal/server/admin_test.go b/internal/server/admin_test.go new file mode 100644 index 0000000..544e89a --- /dev/null +++ b/internal/server/admin_test.go @@ -0,0 +1,43 @@ +package server + +import ( + "encoding/json" + "testing" +) + +func TestProviderChangesAdminOnly(t *testing.T) { + ts, _ := setupFull(t, echo{}) + ut := registerUser(t, ts.URL, "alice", "correct-horse-1") + uh := bearerHdr(ut) + if r := do(t, "GET", ts.URL+"/api/providers", "", uh); r.StatusCode != 200 { + t.Fatal("users may list providers", r.StatusCode) + } + if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, uh); r.StatusCode != 403 { + t.Fatal("user must not change global provider", r.StatusCode) + } + var me map[string]any + json.NewDecoder(do(t, "GET", ts.URL+"/api/auth/me", "", uh).Body).Decode(&me) + if me["admin"] != false { + t.Fatalf("%v", me) + } + // own session: chat ok, provider change forbidden + r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, uh) + if r.StatusCode != 200 { + t.Fatal("user chat", r.StatusCode) + } + var cr struct { + SessionID string `json:"session_id"` + } + json.NewDecoder(r.Body).Decode(&cr) + if cr.SessionID != "" { + if r := do(t, "PUT", ts.URL+"/api/sessions/"+cr.SessionID+"/provider", `{"provider":"openai"}`, uh); r.StatusCode != 403 { + t.Fatal("session provider", r.StatusCode) + } + } + if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi","provider":"dahl"}`, uh); r.StatusCode != 403 { + t.Fatal("chat with provider", r.StatusCode) + } + if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, authH); r.StatusCode != 200 { + t.Fatal("admin allowed", r.StatusCode) + } +} diff --git a/internal/server/extra.go b/internal/server/extra.go index 1594c14..8da25f3 100644 --- a/internal/server/extra.go +++ b/internal/server/extra.go @@ -48,6 +48,9 @@ func (s *Server) listProviders(w http.ResponseWriter, _ *http.Request) { } func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) { + return + } if !s.needProviders(w) { return } @@ -82,6 +85,9 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) { // PUT /api/sessions/{id}/provider: per-session override; empty provider clears it. func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) { + return + } if !s.needProviders(w) { return } @@ -148,3 +154,15 @@ func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, sm) } + +// errAdminOnly is returned when a non-admin tries to change provider settings. +var errAdminOnly = errors.New("admin only: provider settings can only be changed by the server admin") + +// requireAdmin writes 403 unless the caller is the admin (static auth_token). +func requireAdmin(w http.ResponseWriter, r *http.Request) bool { + if principal(r.Context()).Admin { + return true + } + writeErr(w, http.StatusForbidden, errAdminOnly.Error()) + return false +} diff --git a/internal/server/extra_test.go b/internal/server/extra_test.go index 37ad78a..bf9fdc3 100644 --- a/internal/server/extra_test.go +++ b/internal/server/extra_test.go @@ -50,7 +50,7 @@ func setupFull(t *testing.T, p llm.Provider) (*httptest.Server, *store.Store) { pr, _, err := pm.ForSession(ctx, sid) return pr, err } - s := &Server{Store: st, Token: tok, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm} + s := &Server{Store: st, Token: tok, AllowRegistration: true, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm} ts := httptest.NewServer(s.Handler()) t.Cleanup(ts.Close) return ts, st diff --git a/internal/server/server.go b/internal/server/server.go index 3868a19..6357b84 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -253,6 +253,9 @@ type chatReq struct { func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) { var a providers.Active if req.SessionID == "" && req.Provider != "" && s.Providers != nil { + if !principal(ctx).Admin { + return "", errAdminOnly + } var err error if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil { return "", err @@ -288,6 +291,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { return } id, err := s.startSession(r.Context(), req) + if errors.Is(err, errAdminOnly) { + writeErr(w, http.StatusForbidden, err.Error()) + return + } if err != nil { if req.SessionID == "" && req.Provider != "" { providerErr(w, err) diff --git a/site/.netlify/state.json b/site/.netlify/state.json new file mode 100644 index 0000000..d1ce675 --- /dev/null +++ b/site/.netlify/state.json @@ -0,0 +1,3 @@ +{ + "siteId": "eda745c3-d798-4dab-907e-aac0a7d94433" +} \ No newline at end of file diff --git a/site/_headers b/site/_headers index cc11c9f..b935310 100644 --- a/site/_headers +++ b/site/_headers @@ -1,3 +1,3 @@ /downloads/*.apk Content-Type: application/vnd.android.package-archive - Content-Disposition: attachment; filename="byteclaw-0.3.0.apk" + Content-Disposition: attachment; filename="byteclaw-0.3.1.apk" diff --git a/site/index.html b/site/index.html index d3677b0..ab9d553 100644 --- a/site/index.html +++ b/site/index.html @@ -32,7 +32,7 @@

Selbst gehosteter, schlanker KI-Assistent – Go-Backend + Android-App.

Ein einzelnes Go-Binary mit SQLite spricht mit jedem OpenAI-kompatiblen Anbieter, nutzt Tools und streamt Antworten live in die native Android-App. Deine Schlüssel bleiben auf deinem Server.

@@ -89,7 +89,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" \

ByteClaw · selbst gehostet, Go + Android

-

Screenshots zeigen Demo-Daten. APK v0.3.0 · Android 8+ · signiert.

+

Screenshots zeigen Demo-Daten. APK v0.3.1 · Android 8+ · signiert.