refactor(web): switch dashboard auth from tokens to passwords (#2608)

- replace token-based launcher auth with password-based login and sessions
- migrate legacy launcher_token values into bcrypt-backed password storage
- add one-shot local auto-login bootstrap
- update config UI, i18n strings, docs, and auth-related tests
This commit is contained in:
wenjie
2026-04-21 18:04:15 +08:00
committed by GitHub
parent a5379d5fff
commit a17a43cfcc
34 changed files with 1188 additions and 585 deletions
+6 -5
View File
@@ -71,15 +71,16 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
### Web launcher dashboard
**picoclaw-launcher** serves a browser UI that requires sign-in first. By default, the **dashboard token** and **session signing key** are **generated in memory on each start** (a new random token after every restart). Set **`PICOCLAW_LAUNCHER_TOKEN`** to pin a fixed token for that process (startup logs do not print the secret when this env var is used).
**Where to read the token**: In **console mode** (`-console`), it is printed at startup. In **tray / GUI mode**, use the tray action **Copy dashboard token**, and check **`$PICOCLAW_HOME/logs/launcher.log`** (typically `~/.picoclaw/logs/launcher.log` if `PICOCLAW_HOME` is unset) for the random token logged on startup. The login page shows hints that match how the launcher is running (including the absolute log path); **responses do not include the token itself**.
**picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`.
- **Config file**: Same directory as `config.json` (or the file pointed to by `PICOCLAW_CONFIG`). The launcher-specific file is `launcher-config.json`.
- **Sign-in and links**: Enter the token on the login page, or open with `?token=` when the browser is launched automatically. All responses include **`Referrer-Policy: no-referrer`** to reduce leakage of `token` via the `Referer` header.
- **Password storage**: On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`. On platforms where the SQLite password store is unavailable, the bcrypt hash is stored in `launcher-config.json`.
- **Legacy migration**: Older `launcher_token` values are migrated once into password login and removed from saved launcher config.
- **Local auto-login**: When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically.
- **Unsupported auth paths**: URL token login (`?token=...`), `PICOCLAW_LAUNCHER_TOKEN`, and `Authorization: Bearer` dashboard auth are no longer supported.
- **Sign-out**: Use **`POST /api/auth/logout`** with **`Content-Type: application/json`** (body may be `{}`). Do not rely on a GET URL for logout (CSRF-safe pattern).
- **Brute-force**: **`POST /api/auth/login`** is **rate-limited per client IP per minute** (HTTP 429 when exceeded).
- **Session lifetime**: The HttpOnly session cookie lasts about **7 days** by default; sign in again with the token after it expires.
- **Session lifetime**: The HttpOnly session cookie lasts about **31 days** by default, but sessions are invalidated when the launcher process restarts.
### Skill Sources
+6 -5
View File
@@ -69,15 +69,16 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
### Web 启动器控制台
**picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)
**到哪里找口令****控制台模式**`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。
**picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`
- **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`
- **登录与链接**:在登录页输入口令;自动打开浏览器时可在 URL 上使用 `?token=`。全站响应携带 **`Referrer-Policy: no-referrer`**,减轻 `token``Referer` 头泄露的风险
- **密码存储**:支持的平台会把 bcrypt 后的密码哈希存入 `launcher-auth.db`。如果当前平台不支持 SQLite 密码存储,则把 bcrypt 哈希存入 `launcher-config.json`
- **旧配置迁移**:旧版 `launcher_token` 会一次性迁移为密码登录,并从保存后的 launcher 配置中移除。
- **本地自动登录**:launcher 启动后自动打开本地浏览器时,会使用仅允许 loopback 访问的一次性引导入口自动设置会话 Cookie。
- **不再支持的鉴权方式**:不再支持 URL token 登录(`?token=...`)、`PICOCLAW_LAUNCHER_TOKEN``Authorization: Bearer` dashboard 鉴权。
- **退出登录**:应使用 **`POST /api/auth/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。
- **暴力尝试**`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429)。
- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **7 天**有效,到期需重新用口令登录
- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **31 天**有效,但 launcher 进程重启后已有会话会失效
### 技能来源 (Skill Sources)
+1 -1
View File
@@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
Ouvrez http://localhost:18800 dans votre navigateur. Le launcher gère automatiquement le processus gateway.
> [!WARNING]
> La console web ne prend pas encore en charge l'authentification. Évitez de l'exposer sur Internet public.
> La console web est protégée par un mot de passe de connexion au dashboard. Ne l'exposez pas à des réseaux non fiables ni à Internet public.
### Mode Agent (One-shot)
+1 -1
View File
@@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
ブラウザで http://localhost:18800 を開いてください。Launcher が Gateway プロセスを自動管理します。
> [!WARNING]
> Web コンソールはまだ認証をサポートしていません。公開インターネットに公開しないでください。
> Web コンソールは dashboard ログインパスワードで保護されます。信頼できないネットワークや公開インターネットに公開しないでください。
### Agent モード (ワンショット)
+1 -1
View File
@@ -48,7 +48,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
> [!WARNING]
> The web console uses a dashboard token (in-memory per run unless `PICOCLAW_LAUNCHER_TOKEN` is set). **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide.
> The web console is protected by dashboard password login. **Do not** expose the launcher to untrusted networks or the public internet. See [Web launcher dashboard](configuration.md#web-launcher-dashboard) in the Configuration Guide.
### Agent Mode (One-shot)
+1 -1
View File
@@ -44,7 +44,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
Buka http://localhost:18800 dalam pelayar anda. Launcher mengurus proses gateway secara automatik.
> [!WARNING]
> Konsol web belum menyokong autentikasi. Elakkan mendedahkannya ke internet awam.
> Konsol web dilindungi oleh kata laluan log masuk dashboard. Jangan dedahkannya kepada rangkaian tidak dipercayai atau internet awam.
### Mod Agent (One-shot)
+1 -1
View File
@@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
Abra http://localhost:18800 no seu navegador. O launcher gerencia o processo do gateway automaticamente.
> [!WARNING]
> O console web ainda não suporta autenticação. Evite expô-lo na internet pública.
> O console web é protegido por senha de login do dashboard. Não exponha o launcher a redes não confiáveis nem à internet pública.
### Modo Agent (One-shot)
+1 -1
View File
@@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
Mở http://localhost:18800 trong trình duyệt. Launcher tự động quản lý tiến trình gateway.
> [!WARNING]
> Web console chưa hỗ trợ xác thực. Tránh để lộ ra internet công cộng.
> Web console được bảo vệ bằng mật khẩu đăng nhập dashboard. Không để lộ launcher ra mạng không tin cậy hoặc internet công cộng.
### Chế Độ Agent (One-shot)
+1 -1
View File
@@ -45,7 +45,7 @@ docker compose -f docker/docker-compose.yml --profile launcher up -d
在浏览器中打开 <http://localhost:18800>。Launcher 会自动管理 Gateway 进程。
> [!WARNING]
> Web 控制台通过 dashboard 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。
> Web 控制台通过 dashboard 登录密码保护。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。
### Agent 模式 (一次性运行)
+13 -29
View File
@@ -121,23 +121,18 @@ When a gateway process is started by the launcher, the launcher:
### Launcher Authentication
The dashboard is protected by a launcher access token.
The dashboard is protected by password login.
- If `PICOCLAW_LAUNCHER_TOKEN` is set, that token is used.
- Otherwise a random token is generated for each launcher process.
- The browser auto-open URL includes `?token=...` so local launches can sign in automatically.
- First run uses `/launcher-setup` to create the dashboard password.
- Manual login uses `/launcher-login`.
- API clients may also authenticate with `Authorization: Bearer <token>`.
Where users can retrieve the token depends on launch mode:
- Console mode: printed to stdout
- GUI mode: available through the tray menu on supported builds
- GUI mode without stdout:
- random per-run tokens are written to the launcher log
- default log path: `~/.picoclaw/logs/launcher.log`
- if `PICOCLAW_HOME` is set, use `$PICOCLAW_HOME/logs/launcher.log`
- env-pinned tokens are not reprinted there; the log only notes that `PICOCLAW_LAUNCHER_TOKEN` is in use
- Successful login sets an HttpOnly session cookie.
- Existing sessions are invalidated when the launcher process restarts; otherwise the browser cookie expires after 31 days.
- When the launcher auto-opens a local browser after startup, it uses a one-shot loopback-only bootstrap endpoint to set the session cookie automatically.
- On supported platforms, the password is stored as a bcrypt hash in `launcher-auth.db`.
- On platforms where the SQLite password store is unavailable, the launcher stores the bcrypt hash in `launcher-config.json`.
- Legacy `launcher_token` values are migrated once into password login and are removed from saved launcher config.
- `PICOCLAW_LAUNCHER_TOKEN` is deprecated and ignored; after upgrading from env-token auth, open `/launcher-setup` to create a password.
- URL token login and `Authorization: Bearer` dashboard auth are not supported.
### Network Exposure
@@ -155,7 +150,7 @@ With `-public` or `public: true`, it listens on all interfaces:
When public access is enabled:
- the launcher can still protect the dashboard with the access token
- the launcher still protects the dashboard with password login
- optional `allowed_cidrs` can restrict which client IP ranges may connect
- the gateway host is overridden so remote clients can still use the launcher-managed proxy paths
@@ -336,19 +331,8 @@ web/
### You have to sign in again after the launcher restarts
Existing dashboard sessions do not survive launcher restarts.
That is expected: each launcher process generates a new signed session value, so old cookies become invalid.
To make re-login easier, set a stable token:
```bash
export PICOCLAW_LAUNCHER_TOKEN="replace-with-a-long-random-token"
```
Notes:
- a stable token does not preserve the old cookie-based session by itself
- when the launcher opens the browser automatically, it appends `?token=...` and signs in again automatically
- if you reopen the dashboard manually, use the same stable token on `/launcher-login`
That is expected: each launcher process generates a new session value, so old cookies become invalid.
Sign in again with the dashboard password on `/launcher-login`.
### "Start Gateway" stays disabled
+30 -62
View File
@@ -12,9 +12,8 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
// PasswordStore is the interface for bcrypt-backed dashboard password persistence.
// Implemented by dashboardauth.Store; a nil value falls back to the legacy
// static-token comparison.
// PasswordStore is the interface for dashboard password persistence.
// Implemented by dashboardauth.Store and launcherconfig.PasswordStore.
type PasswordStore interface {
IsInitialized(ctx context.Context) (bool, error)
SetPassword(ctx context.Context, plain string) error
@@ -23,18 +22,13 @@ type PasswordStore interface {
// LauncherAuthRouteOpts configures dashboard auth handlers.
type LauncherAuthRouteOpts struct {
// DashboardToken is the fallback plaintext token used when PasswordStore is
// nil or not yet initialized (env-var / config-file source, and ?token= auto-login).
DashboardToken string
SessionCookie string
SecureCookie func(*http.Request) bool
// PasswordStore enables bcrypt-backed password persistence. When non-nil and
// initialized, web-form login verifies against the stored hash instead of
// the plaintext DashboardToken.
SessionCookie string
SecureCookie func(*http.Request) bool
// PasswordStore enables password login. It must be non-nil for auth to work.
PasswordStore PasswordStore
// StoreError holds the error returned when opening the password store. When
// non-nil and PasswordStore is nil, the auth endpoints surface a recovery
// message instead of an opaque 501/503.
// non-nil and PasswordStore is nil, auth endpoints fail closed with a
// recovery message.
StoreError error
}
@@ -59,7 +53,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
secure = middleware.DefaultLauncherDashboardSecureCookie
}
h := &launcherAuthHandlers{
token: opts.DashboardToken,
sessionCookie: opts.SessionCookie,
secureCookie: secure,
store: opts.PasswordStore,
@@ -73,7 +66,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
}
type launcherAuthHandlers struct {
token string
sessionCookie string
secureCookie func(*http.Request) bool
store PasswordStore
@@ -81,29 +73,18 @@ type launcherAuthHandlers struct {
loginLimit *loginRateLimiter
}
func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool {
return h.store == nil && h.storeErr == nil && h.token != ""
}
// isStoreInitialized safely queries the store.
// Returns (true, nil) when legacy token auth is active without a password store.
// Returns (false, nil) when no store/token fallback is configured.
// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
// "uninitialized", to keep auth fail-closed.
// Exception: handleLogin swallows storeErr and falls back to token auth so
// that a corrupt DB does not lock out all access.
func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) {
if h.store == nil {
if h.storeErr != nil {
return false, fmt.Errorf(
"password store unavailable (%w); "+
"to recover, stop the application, delete the database file and restart ",
"to recover, stop the application, reset dashboard password storage, and restart",
h.storeErr)
}
if h.usesLegacyTokenAuth() {
return true, nil
}
return false, nil
return false, fmt.Errorf("password store not configured")
}
return h.store.IsInitialized(ctx)
}
@@ -123,35 +104,25 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
return
}
in := strings.TrimSpace(body.Password)
var ok bool
initialized, initErr := h.isStoreInitialized(r.Context())
if initErr != nil {
if h.storeErr != nil {
// Store failed to open at startup — token login remains available.
initialized = false
} else {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "%v", initErr)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
writeErrorf(w, "%v", initErr)
return
}
if !initialized {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":"password has not been set"}`))
return
}
if initialized && h.store != nil {
// Bcrypt path: verify against the stored hash.
var err error
ok, err = h.store.VerifyPassword(r.Context(), in)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "password verification failed: %v", err)
return
}
} else {
// Fallback: constant-time compare against the plaintext token.
ok = len(in) == len(h.token) &&
subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1
ok, err := h.store.VerifyPassword(r.Context(), in)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "password verification failed: %v", err)
return
}
if !ok {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid password"}`))
@@ -221,22 +192,19 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
// handleSetup sets or changes the dashboard password.
//
// Rules:
// - If the store has no password yet, the endpoint is open (no session required).
// - If the store has no password yet, anyone who can reach the setup endpoint
// may initialize the password.
// - If a password is already set, the caller must hold a valid session cookie.
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if h.usesLegacyTokenAuth() {
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write(
[]byte(`{"error":"password setup is unavailable on this platform; use the dashboard token instead"}`),
)
return
}
if h.store == nil {
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
w.WriteHeader(http.StatusServiceUnavailable)
if h.storeErr != nil {
writeErrorf(w, "password store unavailable: %v", h.storeErr)
} else {
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
}
return
}
+142 -44
View File
@@ -2,7 +2,9 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -12,17 +14,43 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
func TestLauncherAuthLoginAndStatus(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = 0x55
type fakePasswordStore struct {
initialized bool
password string
err error
}
func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) {
if s.err != nil {
return false, s.err
}
const tok = "dashboard-test-token-9"
sess := middleware.SessionCookieValue(key, tok)
return s.initialized, nil
}
func (s *fakePasswordStore) SetPassword(_ context.Context, plain string) error {
if s.err != nil {
return s.err
}
s.password = plain
s.initialized = true
return nil
}
func (s *fakePasswordStore) VerifyPassword(_ context.Context, plain string) (bool, error) {
if s.err != nil {
return false, s.err
}
return s.initialized && plain == s.password, nil
}
func TestLauncherAuthLoginAndStatus(t *testing.T) {
const password = "dashboard-test-password"
const sess = "session-cookie-value"
store := &fakePasswordStore{initialized: true, password: password}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
SessionCookie: sess,
PasswordStore: store,
})
t.Run("status_unauthenticated", func(t *testing.T) {
@@ -45,7 +73,7 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+password+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req)
@@ -75,14 +103,13 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
})
}
func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
key := make([]byte, 32)
const tok = "legacy-fallback-token"
sess := middleware.SessionCookieValue(key, tok)
func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) {
const sess = "session-cookie-value"
store := &fakePasswordStore{}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
SessionCookie: sess,
PasswordStore: store,
})
rec := httptest.NewRecorder()
@@ -98,29 +125,80 @@ func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if !body.Initialized {
t.Fatalf("initialized = false, want true in legacy token fallback mode")
if body.Initialized {
t.Fatalf("initialized = true, want false before setup")
}
if body.Authenticated {
t.Fatalf("unexpected authenticated=true: %+v", body)
}
rec = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"not-set-yet"}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("login before setup code = %d body=%s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(
http.MethodPost,
"/api/auth/setup",
strings.NewReader(`{"password":"12345678","confirm":"12345678"}`),
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"12345678"}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login after setup code = %d body=%s", rec.Code, rec.Body.String())
}
}
func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "legacy-token")
func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) {
const sess = "session-cookie-value"
store := &fakePasswordStore{initialized: true, password: "old-password"}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "legacy-token",
SessionCookie: sess,
SessionCookie: sess,
PasswordStore: store,
})
body := strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("setup without session code = %d body=%s", rec.Code, rec.Body.String())
}
body = strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
req = httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess})
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("setup with session code = %d body=%s", rec.Code, rec.Body.String())
}
if store.password != "new-password" {
t.Fatalf("password = %q, want new-password", store.password)
}
}
func TestLauncherAuthInitialSetupAllowsDirectSetup(t *testing.T) {
store := &fakePasswordStore{}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
SessionCookie: "session-cookie-value",
PasswordStore: store,
})
rec := httptest.NewRecorder()
@@ -131,18 +209,46 @@ func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
)
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("setup without grant code = %d body=%s", rec.Code, rec.Body.String())
}
}
func TestLauncherAuthStoreUnavailableFailsClosed(t *testing.T) {
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
SessionCookie: "session-cookie-value",
StoreError: errors.New("open auth store"),
})
for _, tc := range []struct {
name string
method string
path string
body string
}{
{name: "status", method: http.MethodGet, path: "/api/auth/status"},
{name: "login", method: http.MethodPost, path: "/api/auth/login", body: `{"password":"password"}`},
{name: "setup", method: http.MethodPost, path: "/api/auth/setup", body: `{"password":"12345678","confirm":"12345678"}`},
} {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d body=%s", rec.Code, rec.Body.String())
}
})
}
}
func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
@@ -169,16 +275,14 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
}
func TestLauncherAuthLoginRateLimit(t *testing.T) {
key := make([]byte, 32)
const tok = "rate-limit-tok-xxxxxxxx"
sess := middleware.SessionCookieValue(key, tok)
store := &fakePasswordStore{initialized: true, password: "correct-password"}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
SessionCookie: "session-cookie-value",
PasswordStore: store,
})
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
// 11 failing logins by wrong password; each consumes allow() slot after valid JSON.
wrongBody := `{"password":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder()
@@ -231,12 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) {
}
func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
@@ -249,12 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
}
func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
+17 -18
View File
@@ -4,16 +4,14 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
type launcherConfigPayload struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"`
LauncherToken string `json:"launcher_token"`
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"`
}
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
@@ -50,10 +48,9 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
LauncherToken: cfg.LauncherToken,
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
})
}
@@ -64,12 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
return
}
cfg := launcherconfig.Config{
Port: payload.Port,
Public: payload.Public,
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
LauncherToken: strings.TrimSpace(payload.LauncherToken),
cfg, err := h.loadLauncherConfig()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
return
}
cfg.Port = payload.Port
cfg.Public = payload.Public
cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
cfg.LegacyLauncherToken = ""
if err := launcherconfig.Validate(cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -82,9 +82,8 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
LauncherToken: cfg.LauncherToken,
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
})
}
+15 -7
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -34,9 +35,6 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
if got.Port != 19999 || !got.Public {
t.Fatalf("response = %+v, want port=19999 public=true", got)
}
if got.LauncherToken != "" {
t.Fatalf("response launcher_token = %q, want empty", got.LauncherToken)
}
if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs)
}
@@ -44,6 +42,14 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
func TestPutLauncherConfigPersists(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
path := launcherconfig.PathForAppConfig(configPath)
if err := os.WriteFile(
path,
[]byte(`{"port":18800,"public":false,"dashboard_password_hash":"saved-hash","launcher_token":"legacy-token"}`),
0o600,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
@@ -54,7 +60,7 @@ func TestPutLauncherConfigPersists(t *testing.T) {
http.MethodPut,
"/api/system/launcher-config",
strings.NewReader(
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"launcher_token":"saved-token"}`,
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`,
),
)
req.Header.Set("Content-Type", "application/json")
@@ -64,7 +70,6 @@ func TestPutLauncherConfigPersists(t *testing.T) {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
path := launcherconfig.PathForAppConfig(configPath)
cfg, err := launcherconfig.Load(path, launcherconfig.Default())
if err != nil {
t.Fatalf("launcherconfig.Load() error = %v", err)
@@ -72,8 +77,11 @@ func TestPutLauncherConfigPersists(t *testing.T) {
if cfg.Port != 18080 || !cfg.Public {
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
}
if cfg.LauncherToken != "saved-token" {
t.Fatalf("saved launcher_token = %q, want %q", cfg.LauncherToken, "saved-token")
if cfg.DashboardPasswordHash != "saved-hash" {
t.Fatalf("saved dashboard_password_hash = %q, want saved-hash", cfg.DashboardPasswordHash)
}
if cfg.LegacyLauncherToken != "" {
t.Fatalf("saved legacy launcher_token = %q, want empty", cfg.LegacyLauncherToken)
}
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)
+11 -58
View File
@@ -1,8 +1,6 @@
package launcherconfig
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net"
@@ -16,31 +14,19 @@ const (
FileName = "launcher-config.json"
// DefaultPort is the default port for the web launcher.
DefaultPort = 18800
// EnvLauncherToken overrides launcher dashboard token.
EnvLauncherToken = "PICOCLAW_LAUNCHER_TOKEN"
// EnvLauncherHost overrides launcher listen host.
EnvLauncherHost = "PICOCLAW_LAUNCHER_HOST"
// dashboardSigningKeyBytes is the HMAC-SHA256 key size (256 bits).
dashboardSigningKeyBytes = 32
// dashboardTokenEntropyBytes is CSPRNG length before base64 for the per-run dashboard token (256 bits).
dashboardTokenEntropyBytes = 32
)
type DashboardTokenSource string
const (
DashboardTokenSourceEnv DashboardTokenSource = "env"
DashboardTokenSourceConfig DashboardTokenSource = "config"
DashboardTokenSourceRandom DashboardTokenSource = "random"
)
// Config stores launch parameters for the web backend service.
type Config struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
LauncherToken string `json:"launcher_token,omitempty"`
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs,omitempty"`
DashboardPasswordHash string `json:"dashboard_password_hash,omitempty"`
// LegacyLauncherToken is read only for one-time migration from the removed
// token login flow. Save always clears it so new configs do not persist it.
LegacyLauncherToken string `json:"launcher_token,omitempty"`
}
// Default returns default launcher settings.
@@ -61,41 +47,6 @@ func Validate(cfg Config) error {
return nil
}
// EnsureDashboardSecrets returns signing key bytes and the effective dashboard token for this
// process. The signing key is freshly random each call; the token comes from
// EnvLauncherToken when set, otherwise launcher-config.json launcher_token,
// otherwise a new random token.
func EnsureDashboardSecrets(
cfg Config,
) (effectiveToken string, signingKey []byte, source DashboardTokenSource, err error) {
signingKey = make([]byte, dashboardSigningKeyBytes)
if _, err = rand.Read(signingKey); err != nil {
return "", nil, "", err
}
effectiveToken = strings.TrimSpace(os.Getenv(EnvLauncherToken))
if effectiveToken != "" {
return effectiveToken, signingKey, DashboardTokenSourceEnv, nil
}
effectiveToken = strings.TrimSpace(cfg.LauncherToken)
if effectiveToken != "" {
return effectiveToken, signingKey, DashboardTokenSourceConfig, nil
}
tok, genErr := randomDashboardToken()
if genErr != nil {
return "", nil, "", genErr
}
return tok, signingKey, DashboardTokenSourceRandom, nil
}
func randomDashboardToken() (string, error) {
buf := make([]byte, dashboardTokenEntropyBytes)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
func NormalizeCIDRs(cidrs []string) []string {
if len(cidrs) == 0 {
@@ -144,7 +95,8 @@ func Load(path string, fallback Config) (Config, error) {
return Config{}, err
}
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken)
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
cfg.LegacyLauncherToken = strings.TrimSpace(cfg.LegacyLauncherToken)
if err := Validate(cfg); err != nil {
return Config{}, err
}
@@ -154,7 +106,8 @@ func Load(path string, fallback Config) (Config, error) {
// Save writes launcher settings to disk.
func Save(path string, cfg Config) error {
cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs)
cfg.LauncherToken = strings.TrimSpace(cfg.LauncherToken)
cfg.DashboardPasswordHash = strings.TrimSpace(cfg.DashboardPasswordHash)
cfg.LegacyLauncherToken = ""
if err := Validate(cfg); err != nil {
return err
}
+65 -68
View File
@@ -1,11 +1,10 @@
package launcherconfig
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/web/backend/middleware"
)
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
@@ -25,10 +24,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "launcher-config.json")
want := Config{
Port: 18080,
Public: true,
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
LauncherToken: "saved-launcher-token",
Port: 18080,
Public: true,
AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"},
DashboardPasswordHash: "$2a$12$saved-dashboard-password-hash",
LegacyLauncherToken: "legacy-token-should-not-persist",
}
if err := Save(path, want); err != nil {
@@ -41,8 +41,11 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
if got.Port != want.Port || got.Public != want.Public {
t.Fatalf("Load() = %+v, want %+v", got, want)
}
if got.LauncherToken != want.LauncherToken {
t.Fatalf("launcher_token = %q, want %q", got.LauncherToken, want.LauncherToken)
if got.DashboardPasswordHash != want.DashboardPasswordHash {
t.Fatalf("dashboard_password_hash = %q, want %q", got.DashboardPasswordHash, want.DashboardPasswordHash)
}
if got.LegacyLauncherToken != "" {
t.Fatalf("legacy launcher_token = %q, want empty after Save", got.LegacyLauncherToken)
}
if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) {
t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs))
@@ -62,6 +65,21 @@ func TestSaveAndLoadRoundTrip(t *testing.T) {
}
}
func TestLoadReadsLegacyLauncherTokenForMigration(t *testing.T) {
path := filepath.Join(t.TempDir(), "launcher-config.json")
if err := os.WriteFile(path, []byte(`{"port":18800,"launcher_token":"legacy-token"}`), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
got, err := Load(path, Default())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if got.LegacyLauncherToken != "legacy-token" {
t.Fatalf("legacy launcher_token = %q, want legacy-token", got.LegacyLauncherToken)
}
}
func TestValidateRejectsInvalidPort(t *testing.T) {
if err := Validate(Config{Port: 0, Public: false}); err == nil {
t.Fatal("Validate() expected error for port 0")
@@ -81,66 +99,6 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) {
}
}
func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) {
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
tok, key, source, err := EnsureDashboardSecrets(Default())
if err != nil {
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
}
if source != DashboardTokenSourceRandom || tok == "" || len(key) != dashboardSigningKeyBytes {
t.Fatalf("unexpected first call: source=%q tok=%q keyLen=%d", source, tok, len(key))
}
mac := middleware.SessionCookieValue(key, tok)
if mac == "" {
t.Fatal("empty session mac")
}
tok2, key2, source2, err := EnsureDashboardSecrets(Default())
if err != nil {
t.Fatalf("EnsureDashboardSecrets() second error = %v", err)
}
if source2 != DashboardTokenSourceRandom {
t.Fatalf("second call source = %q, want %q", source2, DashboardTokenSourceRandom)
}
if tok2 == tok {
t.Fatal("expected a new random dashboard token")
}
if string(key2) == string(key) {
t.Fatal("expected a new signing key")
}
}
func TestEnsureDashboardSecrets_EnvOverridesGenerated(t *testing.T) {
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "env-only-token-override")
tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"})
if err != nil {
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
}
if tok != "env-only-token-override" {
t.Fatalf("token = %q, want env value", tok)
}
if source != DashboardTokenSourceEnv {
t.Fatalf("source = %q, want %q", source, DashboardTokenSourceEnv)
}
}
func TestEnsureDashboardSecrets_ConfigOverridesGenerated(t *testing.T) {
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
tok, _, source, err := EnsureDashboardSecrets(Config{LauncherToken: "config-token"})
if err != nil {
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
}
if tok != "config-token" {
t.Fatalf("token = %q, want config value", tok)
}
if source != DashboardTokenSourceConfig {
t.Fatalf("source = %q, want %q", source, DashboardTokenSourceConfig)
}
}
func TestNormalizeCIDRs(t *testing.T) {
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
@@ -153,3 +111,42 @@ func TestNormalizeCIDRs(t *testing.T) {
}
}
}
func TestPasswordStoreSetAndVerify(t *testing.T) {
path := filepath.Join(t.TempDir(), "launcher-config.json")
store := NewPasswordStore(path, Default())
ctx := context.Background()
initialized, err := store.IsInitialized(ctx)
if err != nil {
t.Fatalf("IsInitialized() error = %v", err)
}
if initialized {
t.Fatal("IsInitialized() = true, want false before SetPassword")
}
if err = store.SetPassword(ctx, "dashboard-password"); err != nil {
t.Fatalf("SetPassword() error = %v", err)
}
initialized, err = store.IsInitialized(ctx)
if err != nil {
t.Fatalf("IsInitialized() after SetPassword error = %v", err)
}
if !initialized {
t.Fatal("IsInitialized() = false, want true after SetPassword")
}
ok, err := store.VerifyPassword(ctx, "dashboard-password")
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if !ok {
t.Fatal("VerifyPassword(correct) = false, want true")
}
ok, err = store.VerifyPassword(ctx, "wrong-password")
if err != nil {
t.Fatalf("VerifyPassword(wrong) error = %v", err)
}
if ok {
t.Fatal("VerifyPassword(wrong) = true, want false")
}
}
+62
View File
@@ -0,0 +1,62 @@
package launcherconfig
import (
"context"
"strings"
)
var (
loadConfigForMigration = Load
saveConfigForMigration = Save
)
type dashboardPasswordStore interface {
IsInitialized(ctx context.Context) (bool, error)
SetPassword(ctx context.Context, plain string) error
}
// LegacyLauncherTokenMigrationResult reports the outcome of converting a
// removed launcher_token value into the current password-based auth flow.
type LegacyLauncherTokenMigrationResult struct {
Migrated bool
// CleanupErr is non-nil when password migration succeeded (or was already in
// place) but removing launcher_token from launcher-config.json failed.
CleanupErr error
}
// MigrateLegacyLauncherToken converts the removed launcher_token setting into
// the current password-login store, then removes launcher_token from config.
func MigrateLegacyLauncherToken(
ctx context.Context,
store dashboardPasswordStore,
launcherPath string,
fallback Config,
) (LegacyLauncherTokenMigrationResult, error) {
legacyToken := strings.TrimSpace(fallback.LegacyLauncherToken)
if legacyToken == "" || store == nil {
return LegacyLauncherTokenMigrationResult{}, nil
}
result := LegacyLauncherTokenMigrationResult{}
initialized, err := store.IsInitialized(ctx)
if err != nil {
return result, err
}
if !initialized {
if err = store.SetPassword(ctx, legacyToken); err != nil {
return result, err
}
result.Migrated = true
}
result.CleanupErr = cleanupLegacyLauncherTokenConfig(launcherPath, fallback)
return result, nil
}
func cleanupLegacyLauncherTokenConfig(launcherPath string, fallback Config) error {
cfg, err := loadConfigForMigration(launcherPath, fallback)
if err != nil {
return err
}
cfg.LegacyLauncherToken = ""
return saveConfigForMigration(launcherPath, cfg)
}
@@ -0,0 +1,135 @@
package launcherconfig
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
)
type stubMigrationPasswordStore struct {
initialized bool
password string
}
func (s *stubMigrationPasswordStore) IsInitialized(context.Context) (bool, error) {
return s.initialized, nil
}
func (s *stubMigrationPasswordStore) SetPassword(_ context.Context, plain string) error {
s.password = plain
s.initialized = true
return nil
}
func TestMigrateLegacyLauncherToken(t *testing.T) {
dir := t.TempDir()
launcherPath := filepath.Join(dir, FileName)
cfg := Config{
Port: DefaultPort,
LegacyLauncherToken: "legacy-password",
}
if err := os.WriteFile(
launcherPath,
[]byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"),
0o600,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
store := NewPasswordStore(launcherPath, Default())
result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg)
if err != nil {
t.Fatalf("MigrateLegacyLauncherToken() error = %v", err)
}
if !result.Migrated {
t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true")
}
if result.CleanupErr != nil {
t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr)
}
loaded, err := Load(launcherPath, Default())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if loaded.LegacyLauncherToken != "" {
t.Fatalf("legacy launcher token = %q, want empty", loaded.LegacyLauncherToken)
}
if loaded.DashboardPasswordHash == "" {
t.Fatal("dashboard password hash should be set after migration")
}
ok, err := store.VerifyPassword(context.Background(), "legacy-password")
if err != nil {
t.Fatalf("VerifyPassword() error = %v", err)
}
if !ok {
t.Fatal("VerifyPassword() = false, want true")
}
}
func TestMigrateLegacyLauncherTokenCleanupFailureIsNonFatal(t *testing.T) {
dir := t.TempDir()
launcherPath := filepath.Join(dir, FileName)
cfg := Config{
Port: DefaultPort,
LegacyLauncherToken: "legacy-password",
}
if err := os.WriteFile(
launcherPath,
[]byte("{\n \"port\": 18800,\n \"launcher_token\": \"legacy-password\"\n}\n"),
0o600,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
store := &stubMigrationPasswordStore{}
origSave := saveConfigForMigration
saveConfigForMigration = func(string, Config) error {
return errors.New("write launcher config")
}
t.Cleanup(func() {
saveConfigForMigration = origSave
})
result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, cfg)
if err != nil {
t.Fatalf("MigrateLegacyLauncherToken() error = %v, want nil", err)
}
if !result.Migrated {
t.Fatal("MigrateLegacyLauncherToken().Migrated = false, want true")
}
if result.CleanupErr == nil {
t.Fatal("MigrateLegacyLauncherToken().CleanupErr = nil, want non-nil")
}
if store.password != "legacy-password" {
t.Fatalf("password = %q, want legacy-password", store.password)
}
loaded, err := Load(launcherPath, Default())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if loaded.LegacyLauncherToken != "legacy-password" {
t.Fatalf(
"legacy launcher token = %q, want legacy-password after cleanup failure",
loaded.LegacyLauncherToken,
)
}
}
func TestMigrateLegacyLauncherTokenNoopWithoutToken(t *testing.T) {
launcherPath := filepath.Join(t.TempDir(), FileName)
store := NewPasswordStore(launcherPath, Default())
result, err := MigrateLegacyLauncherToken(context.Background(), store, launcherPath, Default())
if err != nil {
t.Fatalf("MigrateLegacyLauncherToken() error = %v", err)
}
if result.Migrated {
t.Fatal("MigrateLegacyLauncherToken().Migrated = true, want false")
}
if result.CleanupErr != nil {
t.Fatalf("MigrateLegacyLauncherToken().CleanupErr = %v, want nil", result.CleanupErr)
}
}
@@ -0,0 +1,92 @@
package launcherconfig
import (
"context"
"errors"
"strings"
"sync"
"golang.org/x/crypto/bcrypt"
)
const passwordBcryptCost = 12
// PasswordStore keeps the dashboard bcrypt hash in launcher-config.json.
// It is used on platforms where the SQLite-backed dashboard auth store is not
// available.
type PasswordStore struct {
path string
fallback Config
mu sync.Mutex
}
// NewPasswordStore returns a config-backed password store.
func NewPasswordStore(path string, fallback Config) *PasswordStore {
return &PasswordStore{
path: path,
fallback: fallback,
}
}
// IsInitialized reports whether a dashboard password hash exists in config.
func (s *PasswordStore) IsInitialized(ctx context.Context) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
cfg, err := s.load()
if err != nil {
return false, err
}
return strings.TrimSpace(cfg.DashboardPasswordHash) != "", nil
}
// SetPassword hashes plain with bcrypt and writes it to launcher-config.json.
func (s *PasswordStore) SetPassword(ctx context.Context, plain string) error {
if err := ctx.Err(); err != nil {
return err
}
if len([]rune(plain)) == 0 {
return errors.New("password must not be empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(plain), passwordBcryptCost)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
cfg, err := Load(s.path, s.fallback)
if err != nil {
return err
}
cfg.DashboardPasswordHash = string(hash)
cfg.LegacyLauncherToken = ""
return Save(s.path, cfg)
}
// VerifyPassword returns true iff plain matches the stored bcrypt hash.
func (s *PasswordStore) VerifyPassword(ctx context.Context, plain string) (bool, error) {
if err := ctx.Err(); err != nil {
return false, err
}
cfg, err := s.load()
if err != nil {
return false, err
}
hash := strings.TrimSpace(cfg.DashboardPasswordHash)
if hash == "" {
return false, nil
}
err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain))
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, nil
}
return err == nil, err
}
func (s *PasswordStore) load() (Config, error) {
s.mu.Lock()
defer s.mu.Unlock()
return Load(s.path, s.fallback)
}
+85 -42
View File
@@ -12,12 +12,12 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
@@ -51,7 +51,6 @@ var (
servers []*http.Server
serverAddr string
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
browserLaunchURL string
apiHandler *api.Handler
@@ -62,11 +61,34 @@ func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
return !enableConsole || debug
}
func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, launcherPath string) string {
if source != launcherconfig.DashboardTokenSourceConfig {
return ""
func shouldEnableLocalAutoLogin(noBrowser bool, probeHost string) bool {
return !noBrowser && isLoopbackLaunchHost(probeHost)
}
func isLoopbackLaunchHost(host string) bool {
host = strings.TrimSpace(host)
if strings.EqualFold(host, "localhost") {
return true
}
return launcherPath
host = strings.Trim(host, "[]")
if i := strings.LastIndex(host, "%"); i >= 0 {
host = host[:i]
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func launcherBrowserLaunchSuffix(
needsSetup bool,
localAutoLogin *middleware.LauncherDashboardLocalAutoLogin,
) string {
if needsSetup {
return middleware.LauncherDashboardSetupPath
}
if localAutoLogin != nil {
return localAutoLogin.URLPath()
}
return ""
}
func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) {
@@ -318,24 +340,6 @@ func firstNonEmpty(values ...string) string {
return ""
}
// maskSecret masks a secret for display. It always shows up to the first 3
// runes. The last 4 runes are only appended when at least 5 runes remain
// hidden in the middle (i.e. string length >= 12), so an 8-char minimum
// password never exposes its tail. Strings of 3 chars or fewer are fully
// masked.
func maskSecret(s string) string {
runes := []rune(s)
n := len(runes)
const prefixLen, suffixLen, minHidden = 3, 4, 5
if n < prefixLen+suffixLen+minHidden {
if n <= prefixLen {
return "**********"
}
return string(runes[:prefixLen]) + "**********"
}
return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:])
}
func main() {
port := flag.String("port", "18800", "Port to listen on")
host := flag.String("host", "", "Host to listen on (overrides -public when set)")
@@ -503,15 +507,11 @@ func main() {
}
listeners := openResult.Listeners
dashboardToken, dashboardSigningKey, _, dashErr := launcherconfig.EnsureDashboardSecrets(
launcherCfg,
)
dashboardSessionCookie, dashErr := middleware.NewLauncherDashboardSessionCookie()
if dashErr != nil {
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
}
dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
fmt.Println("dashboardToken: ", dashboardToken)
// Open the bcrypt password store (creates the DB file on first run).
authStore, authStoreErr := dashboardauth.New(picoHome)
var passwordStore api.PasswordStore
@@ -522,23 +522,62 @@ func main() {
logger.InfoC(
"web",
fmt.Sprintf(
"Dashboard password store unavailable on this platform; falling back to token login: %v",
"Dashboard SQLite password store unavailable on this platform; using launcher-config password storage: %v",
authStoreErr,
),
)
passwordStore = launcherconfig.NewPasswordStore(launcherPath, launcherCfg)
authStoreErr = nil
} else {
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
}
migrationResult, migrationErr := launcherconfig.MigrateLegacyLauncherToken(
context.Background(),
passwordStore,
launcherPath,
launcherCfg,
)
if migrationErr != nil {
logger.Fatalf("Failed to migrate legacy launcher token to password login: %v", migrationErr)
}
if migrationResult.Migrated {
logger.InfoC("web", "Migrated legacy launcher token to dashboard password login")
}
if migrationResult.CleanupErr != nil {
logger.WarnC(
"web",
fmt.Sprintf(
"Legacy launcher token password migration succeeded, but failed to remove launcher_token from %s: %v",
launcherPath,
migrationResult.CleanupErr,
),
)
}
var localAutoLogin *middleware.LauncherDashboardLocalAutoLogin
needsInitialSetup := false
if passwordStore != nil {
initialized, initErr := passwordStore.IsInitialized(context.Background())
if initErr != nil {
logger.ErrorC("web", fmt.Sprintf("Warning: could not check dashboard password state: %v", initErr))
} else if !initialized {
needsInitialSetup = true
} else if shouldEnableLocalAutoLogin(*noBrowser, openResult.ProbeHost) {
localAutoLogin, err = middleware.NewLauncherDashboardLocalAutoLogin(5 * time.Minute)
if err != nil {
logger.Fatalf("Failed to create local auto-login grant: %v", err)
}
}
}
// Initialize Server components
mux := http.NewServeMux()
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
DashboardToken: dashboardToken,
SessionCookie: dashboardSessionCookie,
PasswordStore: passwordStore,
StoreError: authStoreErr,
SessionCookie: dashboardSessionCookie,
PasswordStore: passwordStore,
StoreError: authStoreErr,
})
// API Routes (e.g. /api/status)
@@ -561,7 +600,7 @@ func main() {
dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{
ExpectedCookie: dashboardSessionCookie,
Token: dashboardToken,
LocalAutoLogin: localAutoLogin,
}, accessControlledMux)
// Apply middleware stack
@@ -573,13 +612,21 @@ func main() {
),
)
// Print startup banner and token (console mode only).
// Print startup banner (console mode only).
if enableConsole || debug {
consoleHosts := launcherConsoleHosts(hostInput, effectivePublic)
fmt.Print(utils.Banner)
fmt.Println()
fmt.Println(" Open the following URL in your browser:")
if needsInitialSetup {
if *noBrowser {
fmt.Println(" First-time setup: open /launcher-setup to create the dashboard password.")
} else {
fmt.Println(" Launcher will open /launcher-setup automatically.")
}
fmt.Println()
}
fmt.Println(" Dashboard address:")
fmt.Println()
for _, host := range consoleHosts {
fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
@@ -599,11 +646,7 @@ func main() {
// Share the local URL with the launcher runtime.
serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort))
if dashboardToken != "" {
browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken)
} else {
browserLaunchURL = serverAddr
}
browserLaunchURL = serverAddr + launcherBrowserLaunchSuffix(needsInitialSetup, localAutoLogin)
// Auto-open browser will be handled by the launcher runtime.
+32 -42
View File
@@ -12,7 +12,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/netbind"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
)
func TestShouldEnableLauncherFileLogging(t *testing.T) {
@@ -43,60 +43,50 @@ func TestShouldEnableLauncherFileLogging(t *testing.T) {
}
}
func TestDashboardTokenConfigHelpPath(t *testing.T) {
const launcherPath = "/tmp/launcher-config.json"
func TestShouldEnableLocalAutoLogin(t *testing.T) {
tests := []struct {
name string
source launcherconfig.DashboardTokenSource
want string
name string
noBrowser bool
probeHost string
wantEnable bool
}{
{
name: "env token does not expose config path",
source: launcherconfig.DashboardTokenSourceEnv,
want: "",
},
{
name: "config token exposes config path",
source: launcherconfig.DashboardTokenSourceConfig,
want: launcherPath,
},
{
name: "random token does not expose config path",
source: launcherconfig.DashboardTokenSourceRandom,
want: "",
},
{name: "loopback localhost", probeHost: "localhost", wantEnable: true},
{name: "loopback ipv4", probeHost: "127.0.0.1", wantEnable: true},
{name: "loopback ipv6", probeHost: "::1", wantEnable: true},
{name: "browser disabled", noBrowser: true, probeHost: "localhost", wantEnable: false},
{name: "non-loopback host", probeHost: "192.168.1.50", wantEnable: false},
{name: "non-loopback hostname", probeHost: "example.com", wantEnable: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := dashboardTokenConfigHelpPath(tt.source, launcherPath); got != tt.want {
t.Fatalf("dashboardTokenConfigHelpPath(%q, %q) = %q, want %q", tt.source, launcherPath, got, tt.want)
if got := shouldEnableLocalAutoLogin(tt.noBrowser, tt.probeHost); got != tt.wantEnable {
t.Fatalf(
"shouldEnableLocalAutoLogin(%t, %q) = %t, want %t",
tt.noBrowser,
tt.probeHost,
got,
tt.wantEnable,
)
}
})
}
}
func TestMaskSecret(t *testing.T) {
tests := []struct {
input string
want string
}{
{"sdhjflsjdflksdf", "sdh**********ksdf"},
{"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"},
{"abcdefghijkl", "abc**********ijkl"},
{"abcdefgh", "abc**********"},
{"abcdefghijk", "abc**********"},
{"abcdefg", "abc**********"},
{"abcd", "abc**********"},
{"abc", "**********"},
{"", "**********"},
func TestLauncherBrowserLaunchSuffix(t *testing.T) {
autoLogin, err := middleware.NewLauncherDashboardLocalAutoLogin(time.Minute)
if err != nil {
t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err)
}
for _, tt := range tests {
if got := maskSecret(tt.input); got != tt.want {
t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want)
}
if got := launcherBrowserLaunchSuffix(true, autoLogin); got != middleware.LauncherDashboardSetupPath {
t.Fatalf("setup suffix = %q", got)
}
if got := launcherBrowserLaunchSuffix(false, autoLogin); !strings.HasPrefix(got, "/launcher-auto-login?nonce=") {
t.Fatalf("auto-login suffix = %q", got)
}
if got := launcherBrowserLaunchSuffix(false, nil); got != "" {
t.Fatalf("empty suffix = %q, want empty", got)
}
}
+137 -58
View File
@@ -1,41 +1,88 @@
package middleware
import (
"crypto/hmac"
"crypto/sha256"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/base64"
"errors"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
)
// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login.
// LauncherDashboardCookieName is the HttpOnly cookie set after a successful password login.
const LauncherDashboardCookieName = "picoclaw_launcher_auth"
// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days).
const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600
// launcherDashboardSessionMaxAgeSec is the dashboard session cookie lifetime (31 days).
const launcherDashboardSessionMaxAgeSec = 31 * 24 * 3600
const launcherSessionMACLabel = "picoclaw-launcher-v1"
const (
launcherSessionCookieBytes = 32
launcherGrantNonceBytes = 32
// LauncherDashboardLocalAutoLoginPath is the one-shot local browser
// bootstrap endpoint used by the launcher-managed auto-open flow.
LauncherDashboardLocalAutoLoginPath = "/launcher-auto-login"
// LauncherDashboardSetupPath is the setup page used before the dashboard
// password is initialized.
LauncherDashboardSetupPath = "/launcher-setup"
)
// SessionCookieValue is the expected cookie value for the given signing key and dashboard token.
func SessionCookieValue(signingKey []byte, dashboardToken string) string {
mac := hmac.New(sha256.New, signingKey)
_, _ = mac.Write([]byte(launcherSessionMACLabel))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(dashboardToken))
return hex.EncodeToString(mac.Sum(nil))
// NewLauncherDashboardSessionCookie creates the per-process session cookie value.
func NewLauncherDashboardSessionCookie() (string, error) {
return randomURLToken(launcherSessionCookieBytes)
}
func randomURLToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// LauncherDashboardAuthConfig holds runtime material for dashboard access checks.
type LauncherDashboardAuthConfig struct {
ExpectedCookie string
Token string
// LocalAutoLogin enables one-shot startup auto-login.
LocalAutoLogin *LauncherDashboardLocalAutoLogin
// SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used.
SecureCookie func(*http.Request) bool
}
// LauncherDashboardLocalAutoLogin is an in-memory, one-shot startup grant.
// It is not a reusable credential; it only lets the launcher-opened browser
// receive the current process session cookie.
type LauncherDashboardLocalAutoLogin struct {
grant *launcherDashboardOneTimeGrant
}
type launcherDashboardOneTimeGrant struct {
mu sync.Mutex
expires time.Time
consumed bool
nonce string
now func() time.Time
}
// NewLauncherDashboardLocalAutoLogin creates a one-shot local auto-login grant.
func NewLauncherDashboardLocalAutoLogin(ttl time.Duration) (*LauncherDashboardLocalAutoLogin, error) {
grant, err := newLauncherDashboardOneTimeGrant(ttl)
if err != nil {
return nil, err
}
return &LauncherDashboardLocalAutoLogin{
grant: grant,
}, nil
}
// URLPath returns the one-shot local auto-login URL path including its nonce.
func (a *LauncherDashboardLocalAutoLogin) URLPath() string {
return launcherGrantQueryPath(LauncherDashboardLocalAutoLoginPath, a.grant)
}
// DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto).
func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
if r.TLS != nil {
@@ -44,7 +91,7 @@ func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login.
// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard password login.
func SetLauncherDashboardSessionCookie(
w http.ResponseWriter,
r *http.Request,
@@ -82,12 +129,13 @@ func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request,
})
}
// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer <token>
// before calling next. Public paths are login page and /api/auth/* handlers.
// LauncherDashboardAuth requires a valid session cookie before calling next.
// Public paths are login/setup pages and /api/auth/* handlers.
func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := canonicalAuthPath(r.URL.Path)
if handled := tryLauncherQueryTokenLogin(w, r, p, cfg); handled {
if p == LauncherDashboardLocalAutoLoginPath {
handleLauncherLocalAutoLogin(w, r, cfg)
return
}
if isPublicLauncherDashboardPath(r.Method, p) {
@@ -105,45 +153,84 @@ func LauncherDashboardAuth(cfg LauncherDashboardAuthConfig, next http.Handler) h
// canonicalAuthPath matches path cleaning used for routing decisions so
// prefixes like /assets/../ cannot bypass auth (CVE-class traversal).
// tryLauncherQueryTokenLogin validates ?token= on GET only (non-/api), sets the session
// cookie when correct, and redirects with 303 so the follow-up is a plain GET without side effects.
// Invalid token is rejected like any other unauthenticated browser request.
func tryLauncherQueryTokenLogin(
w http.ResponseWriter,
r *http.Request,
canonicalPath string,
cfg LauncherDashboardAuthConfig,
) bool {
if r.Method != http.MethodGet {
return false
func handleLauncherLocalAutoLogin(w http.ResponseWriter, r *http.Request, cfg LauncherDashboardAuthConfig) {
if validLauncherDashboardAuth(r, cfg) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") {
return false
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
_, _ = w.Write([]byte("method not allowed"))
return
}
qToken := strings.TrimSpace(r.URL.Query().Get("token"))
if qToken == "" {
return false
if r.Method == http.MethodHead {
rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath)
return
}
if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 {
rejectLauncherDashboardAuth(w, r, canonicalPath)
return true
if cfg.LocalAutoLogin != nil && cfg.LocalAutoLogin.consume(r.URL.Query().Get("nonce")) {
SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther)
return true
rejectLauncherDashboardAuth(w, r, LauncherDashboardLocalAutoLoginPath)
}
func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string {
if canonicalPath == "/launcher-login" {
return "/"
func (a *LauncherDashboardLocalAutoLogin) consume(nonce string) bool {
if a == nil || a.grant == nil {
return false
}
q := r.URL.Query()
q.Del("token")
enc := q.Encode()
if enc != "" {
return canonicalPath + "?" + enc
return a.grant.use(nonce, nil) == nil
}
func newLauncherDashboardOneTimeGrant(ttl time.Duration) (*launcherDashboardOneTimeGrant, error) {
nonce, err := randomURLToken(launcherGrantNonceBytes)
if err != nil {
return nil, err
}
return canonicalPath
return &launcherDashboardOneTimeGrant{
expires: time.Now().Add(ttl),
nonce: nonce,
now: time.Now,
}, nil
}
func launcherGrantQueryPath(basePath string, grant *launcherDashboardOneTimeGrant) string {
if grant == nil {
return basePath
}
return basePath + "?nonce=" + url.QueryEscape(grant.nonce)
}
// ErrInvalidLauncherDashboardGrant reports that an auto-login grant is missing,
// expired, already consumed, or otherwise invalid.
var ErrInvalidLauncherDashboardGrant = errors.New("invalid launcher dashboard grant")
func (g *launcherDashboardOneTimeGrant) use(nonce string, fn func() error) error {
if g == nil {
return ErrInvalidLauncherDashboardGrant
}
if len(nonce) != len(g.nonce) ||
subtle.ConstantTimeCompare([]byte(nonce), []byte(g.nonce)) != 1 {
return ErrInvalidLauncherDashboardGrant
}
g.mu.Lock()
defer g.mu.Unlock()
now := time.Now
if g.now != nil {
now = g.now
}
if g.consumed || !now().Before(g.expires) {
return ErrInvalidLauncherDashboardGrant
}
if fn != nil {
if err := fn(); err != nil {
return err
}
}
g.consumed = true
return nil
}
func canonicalAuthPath(raw string) string {
@@ -206,14 +293,6 @@ func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig
return true
}
}
auth := r.Header.Get("Authorization")
const prefix = "Bearer "
if strings.HasPrefix(auth, prefix) {
token := strings.TrimSpace(auth[len(prefix):])
if len(token) == len(cfg.Token) && subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) == 1 {
return true
}
}
return false
}
@@ -4,26 +4,37 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestSessionCookieValue_Deterministic(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
func TestNewLauncherDashboardSessionCookie(t *testing.T) {
a, err := NewLauncherDashboardSessionCookie()
if err != nil {
t.Fatalf("NewLauncherDashboardSessionCookie() error = %v", err)
}
a := SessionCookieValue(key, "tok-a")
b := SessionCookieValue(key, "tok-a")
if a != b || a == "" {
t.Fatalf("SessionCookieValue mismatch or empty: %q vs %q", a, b)
b, err := NewLauncherDashboardSessionCookie()
if err != nil {
t.Fatalf("NewLauncherDashboardSessionCookie() second error = %v", err)
}
c := SessionCookieValue(key, "tok-b")
if c == a {
t.Fatal("SessionCookieValue should differ for different tokens")
if a == "" || b == "" {
t.Fatalf("session cookie values should be non-empty: %q %q", a, b)
}
if a == b {
t.Fatal("session cookie values should be random")
}
}
func mustLocalAutoLogin(t *testing.T, ttl time.Duration) *LauncherDashboardLocalAutoLogin {
t.Helper()
autoLogin, err := NewLauncherDashboardLocalAutoLogin(ttl)
if err != nil {
t.Fatalf("NewLauncherDashboardLocalAutoLogin() error = %v", err)
}
return autoLogin
}
func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
@@ -34,9 +45,11 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
want int
}{
{http.MethodGet, "/launcher-login", http.StatusTeapot},
{http.MethodGet, "/launcher-setup", http.StatusTeapot},
{http.MethodGet, "/assets/index.js", http.StatusTeapot},
{http.MethodPost, "/api/auth/login", http.StatusTeapot},
{http.MethodGet, "/api/auth/status", http.StatusTeapot},
{http.MethodPost, "/api/auth/setup", http.StatusTeapot},
{http.MethodPost, "/api/auth/logout", http.StatusTeapot},
{http.MethodGet, "/api/auth/logout", http.StatusUnauthorized},
{http.MethodGet, "/api/config", http.StatusUnauthorized},
@@ -51,68 +64,143 @@ func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
}
}
func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) {
const tok = "secret"
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok}
func TestLauncherDashboardAuth_QueryTokenDoesNotAuthenticate(t *testing.T) {
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
t.Fatal("next handler should not run without session cookie")
})
h := LauncherDashboardAuth(cfg, next)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil)
req := httptest.NewRequest(http.MethodGet, "/?token=secret", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" {
t.Fatalf("GET /?token=secret: code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
}
if got := rec.Header().Get("Location"); got != "/" {
t.Fatalf("Location = %q, want %q", got, "/")
}
func TestLauncherDashboardAuth_LocalAutoLogin(t *testing.T) {
const cookieVal = "session-cookie-value"
autoLogin := mustLocalAutoLogin(t, time.Minute)
cfg := LauncherDashboardAuthConfig{
ExpectedCookie: cookieVal,
LocalAutoLogin: autoLogin,
}
if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName {
t.Fatalf("expected one session cookie, got %#v", c)
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := LauncherDashboardAuth(cfg, next)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath, nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
len(rec.Result().Cookies()) != 0 {
t.Fatalf(
"auto-login without nonce code=%d loc=%q cookies=%#v",
rec.Code,
rec.Header().Get("Location"),
rec.Result().Cookies(),
)
}
rec1b := httptest.NewRecorder()
req1b := httptest.NewRequest(http.MethodGet, "/config?token="+tok+"&keep=1", nil)
h.ServeHTTP(rec1b, req1b)
if rec1b.Code != http.StatusSeeOther {
t.Fatalf("GET /config?token=valid: status = %d", rec1b.Code)
}
if got := rec1b.Header().Get("Location"); got != "/config?keep=1" {
t.Fatalf("Location = %q, want /config?keep=1", got)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, LauncherDashboardLocalAutoLoginPath+"?nonce=wrong", nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
len(rec.Result().Cookies()) != 0 {
t.Fatalf(
"auto-login with wrong nonce code=%d loc=%q cookies=%#v",
rec.Code,
rec.Header().Get("Location"),
rec.Result().Cookies(),
)
}
recBad := httptest.NewRecorder()
reqBad := httptest.NewRequest(http.MethodGet, "/?token=wrong", nil)
h.ServeHTTP(recBad, reqBad)
if recBad.Code != http.StatusFound || recBad.Header().Get("Location") != "/launcher-login" {
t.Fatalf("GET /?token=invalid: code=%d loc=%q", recBad.Code, recBad.Header().Get("Location"))
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodHead, autoLogin.URLPath(), nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" ||
len(rec.Result().Cookies()) != 0 {
t.Fatalf(
"auto-login HEAD code=%d loc=%q cookies=%#v",
rec.Code,
rec.Header().Get("Location"),
rec.Result().Cookies(),
)
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/config?token="+tok, nil)
h.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("GET /api with token query: status = %d, want %d", rec2.Code, http.StatusUnauthorized)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
t.Fatalf("local auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != LauncherDashboardCookieName || cookies[0].Value != cookieVal {
t.Fatalf("cookies = %#v", cookies)
}
if cookies[0].MaxAge != 31*24*3600 {
t.Fatalf("session cookie MaxAge = %d, want 31 days", cookies[0].MaxAge)
}
rec3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodGet, "/?token=", nil)
h.ServeHTTP(rec3, req3)
if rec3.Code != http.StatusFound {
t.Fatalf("GET /?token=empty: status = %d, want redirect", rec3.Code)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("cookie auth after auto-login status = %d", rec.Code)
}
recLogin := httptest.NewRecorder()
reqLogin := httptest.NewRequest(http.MethodGet, "/launcher-login?token="+tok, nil)
h.ServeHTTP(recLogin, reqLogin)
if recLogin.Code != http.StatusSeeOther || recLogin.Header().Get("Location") != "/" {
t.Fatalf("GET /launcher-login?token=valid: code=%d loc=%q", recLogin.Code, recLogin.Header().Get("Location"))
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
req.AddCookie(&http.Cookie{Name: LauncherDashboardCookieName, Value: cookieVal})
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
t.Fatalf("auto-login path with existing session code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/launcher-login" {
t.Fatalf("consumed auto-login code=%d loc=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestLauncherDashboardAuth_LocalAutoLoginRequiresValidNonceAndUnexpired(t *testing.T) {
const cookieVal = "session-cookie-value"
newHandler := func(autoLogin *LauncherDashboardLocalAutoLogin) http.Handler {
return LauncherDashboardAuth(LauncherDashboardAuthConfig{
ExpectedCookie: cookieVal,
LocalAutoLogin: autoLogin,
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
}
autoLogin := mustLocalAutoLogin(t, time.Minute)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, autoLogin.URLPath(), nil)
req.RemoteAddr = "192.168.1.50:12345"
req.Host = "192.168.1.50:18800"
newHandler(autoLogin).ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || len(rec.Result().Cookies()) != 1 {
t.Fatalf("capability auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies())
}
expired := mustLocalAutoLogin(t, -time.Second)
h := newHandler(expired)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, expired.URLPath(), nil)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound || len(rec.Result().Cookies()) != 0 {
t.Fatalf("expired auto-login code=%d cookies=%#v", rec.Code, rec.Result().Cookies())
}
}
func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) {
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("next handler should not run without auth")
})
@@ -132,14 +220,9 @@ func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) {
}
}
func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = 0xab
}
token := "dashboard-secret-9"
cookieVal := SessionCookieValue(key, token)
cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal, Token: token}
func TestLauncherDashboardAuth_CookieOnly(t *testing.T) {
cookieVal := "session-cookie-value"
cfg := LauncherDashboardAuthConfig{ExpectedCookie: cookieVal}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
@@ -154,16 +237,16 @@ func TestLauncherDashboardAuth_CookieAndBearer(t *testing.T) {
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.Header.Set("Authorization", "Bearer "+token)
req2 := httptest.NewRequest(http.MethodGet, "/api/config", nil)
req2.Header.Set("Authorization", "Bearer dashboard-secret-9")
h.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("bearer auth: status = %d", rec2.Code)
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("bearer auth should not be accepted: status = %d", rec2.Code)
}
}
func TestLauncherDashboardAuth_WebSocketUnauthorizedDoesNotRedirect(t *testing.T) {
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef"}
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Fatal("next handler should not run without auth")
})
+2 -2
View File
@@ -2,8 +2,8 @@ package middleware
import "net/http"
// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response so sensitive
// query parameters (e.g. ?token= for dashboard bootstrap) are not leaked via the Referer header.
// ReferrerPolicyNoReferrer sets Referrer-Policy: no-referrer on every response
// so sensitive paths and query parameters are not leaked via the Referer header.
func ReferrerPolicyNoReferrer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Referrer-Policy", "no-referrer")
+18 -4
View File
@@ -2,16 +2,26 @@
* Dashboard launcher auth API.
* Uses plain fetch (not launcherFetch) to avoid redirect loops on auth pages.
*/
export type LoginResult =
| { ok: true }
| { ok: false; status: number; error: string }
export async function postLauncherDashboardLogin(
password: string,
): Promise<boolean> {
): Promise<LoginResult> {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ password: password.trim() }),
})
return res.ok
if (res.ok) return { ok: true }
return {
ok: false,
status: res.status,
error: await readLauncherAuthError(res),
}
}
export type LauncherAuthStatus = {
@@ -57,12 +67,16 @@ export async function postLauncherDashboardSetup(
}),
})
if (res.ok) return { ok: true }
let msg = "Unknown error"
return { ok: false, error: await readLauncherAuthError(res) }
}
async function readLauncherAuthError(res: Response): Promise<string> {
let msg = `Request failed with status ${res.status}`
try {
const j = (await res.json()) as { error?: string }
if (j.error) msg = j.error
} catch {
/* ignore */
}
return { ok: false, error: msg }
return msg
}
-1
View File
@@ -11,7 +11,6 @@ export interface LauncherConfig {
port: number
public: boolean
allowed_cidrs: string[]
launcher_token: string
}
export interface SystemVersionInfo {
+16 -13
View File
@@ -295,6 +295,22 @@ export function AppHeader() {
</DropdownMenu>
{/* Theme Toggle */}
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={toggleTheme}
>
{theme === "dark" ? (
<IconSun className="size-4.5" />
) : (
<IconMoon className="size-4.5" />
)}
</Button>
<Separator className="mx-2 my-2" orientation="vertical" />
{/* Logout */}
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<Button
@@ -309,19 +325,6 @@ export function AppHeader() {
</TooltipTrigger>
<TooltipContent>{t("header.logout.tooltip")}</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={toggleTheme}
>
{theme === "dark" ? (
<IconSun className="size-4.5" />
) : (
<IconMoon className="size-4.5" />
)}
</Button>
</div>
</header>
)
@@ -7,6 +7,7 @@ import { toast } from "sonner"
import { patchAppConfig } from "@/api/channels"
import { launcherFetch } from "@/api/http"
import { postLauncherDashboardSetup } from "@/api/launcher-auth"
import {
getAutoStartStatus,
getLauncherConfig,
@@ -94,7 +95,8 @@ export function ConfigPage() {
port: String(launcherConfig.port),
publicAccess: launcherConfig.public,
allowedCIDRsText: (launcherConfig.allowed_cidrs ?? []).join("\n"),
launcherToken: launcherConfig.launcher_token ?? "",
dashboardPassword: "",
dashboardPasswordConfirm: "",
}
setLauncherForm(parsed)
setLauncherBaseline(parsed)
@@ -107,8 +109,14 @@ export function ConfigPage() {
}, [autoStartStatus])
const configDirty = JSON.stringify(form) !== JSON.stringify(baseline)
const launcherDirty =
JSON.stringify(launcherForm) !== JSON.stringify(launcherBaseline)
const launcherSettingsDirty =
launcherForm.port !== launcherBaseline.port ||
launcherForm.publicAccess !== launcherBaseline.publicAccess ||
launcherForm.allowedCIDRsText !== launcherBaseline.allowedCIDRsText
const launcherPasswordDirty =
launcherForm.dashboardPassword.trim() !== "" ||
launcherForm.dashboardPasswordConfirm.trim() !== ""
const launcherDirty = launcherSettingsDirty || launcherPasswordDirty
const autoStartDirty = autoStartEnabled !== autoStartBaseline
const isDirty = configDirty || launcherDirty || autoStartDirty
@@ -143,6 +151,19 @@ export function ConfigPage() {
const handleSave = async () => {
try {
setSaving(true)
const password = launcherForm.dashboardPassword.trim()
const confirm = launcherForm.dashboardPasswordConfirm.trim()
if (launcherPasswordDirty) {
if (!password) {
throw new Error(t("pages.config.dashboard_password_required"))
}
if (password !== confirm) {
throw new Error(t("pages.config.dashboard_password_mismatch"))
}
if (Array.from(password).length < 8) {
throw new Error(t("pages.config.dashboard_password_min_length"))
}
}
if (configDirty) {
const workspace = form.workspace.trim()
@@ -255,7 +276,8 @@ export function ConfigPage() {
queryClient.invalidateQueries({ queryKey: ["config"] })
}
if (launcherDirty) {
let savedLauncherForm: LauncherForm | null = null
if (launcherSettingsDirty) {
const port = parseIntField(launcherForm.port, "Service port", {
min: 1,
max: 65535,
@@ -265,7 +287,6 @@ export function ConfigPage() {
port,
public: launcherForm.publicAccess,
allowed_cidrs: allowedCIDRs,
launcher_token: launcherForm.launcherToken.trim(),
})
const parsedLauncher: LauncherForm = {
port: String(savedLauncherConfig.port),
@@ -273,8 +294,10 @@ export function ConfigPage() {
allowedCIDRsText: (savedLauncherConfig.allowed_cidrs ?? []).join(
"\n",
),
launcherToken: savedLauncherConfig.launcher_token ?? "",
dashboardPassword: "",
dashboardPasswordConfirm: "",
}
savedLauncherForm = parsedLauncher
setLauncherForm(parsedLauncher)
setLauncherBaseline(parsedLauncher)
queryClient.setQueryData(
@@ -283,6 +306,23 @@ export function ConfigPage() {
)
}
if (launcherPasswordDirty) {
const result = await postLauncherDashboardSetup(password, confirm)
if (!result.ok) {
throw new Error(result.error)
}
const clearedLauncherForm = savedLauncherForm ?? {
...launcherForm,
dashboardPassword: "",
dashboardPasswordConfirm: "",
}
setLauncherForm(clearedLauncherForm)
if (savedLauncherForm) {
setLauncherBaseline(savedLauncherForm)
}
}
if (autoStartDirty) {
if (!autoStartSupported) {
throw new Error(t("pages.config.autostart_unsupported"))
@@ -304,6 +344,22 @@ export function ConfigPage() {
}
}
const actionButtons = (
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleReset}
disabled={!isDirty || saving}
>
{t("common.reset")}
</Button>
<Button onClick={handleSave} disabled={!isDirty || saving}>
<IconDeviceFloppy className="size-4" />
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
)
return (
<div className="flex h-full flex-col">
<PageHeader
@@ -340,12 +396,6 @@ export function ConfigPage() {
</div>
) : (
<div className="space-y-6">
{isDirty && (
<div className="bg-yellow-50 px-3 py-2 text-sm text-yellow-700">
{t("pages.config.unsaved_changes")}
</div>
)}
<LauncherSection
launcherForm={launcherForm}
onFieldChange={updateLauncherField}
@@ -374,23 +424,21 @@ export function ConfigPage() {
onAutoStartChange={setAutoStartEnabled}
/>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={handleReset}
disabled={!isDirty || saving}
>
{t("common.reset")}
</Button>
<Button onClick={handleSave} disabled={!isDirty || saving}>
<IconDeviceFloppy className="size-4" />
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
{!isDirty && actionButtons}
</div>
)}
</div>
</div>
{isDirty && (
<div className="border-border/70 bg-background/95 supports-backdrop-filter:bg-background/80 shrink-0 border-t px-3 py-3 shadow-[0_-12px_30px_rgba(15,23,42,0.10)] backdrop-blur lg:px-6">
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-muted-foreground/70 text-xs">
{t("pages.config.unsaved_changes")}
</div>
{actionButtons}
</div>
</div>
)}
</div>
)
}
@@ -519,23 +519,48 @@ export function LauncherSection({
return (
<ConfigSectionCard
title={t("pages.config.sections.launcher")}
description={t("pages.config.launcher_token_section_hint")}
description={t("pages.config.launcher_section_hint")}
>
<Field
label={t("pages.config.launcher_token")}
hint={t("pages.config.launcher_token_hint")}
label={t("pages.config.dashboard_password")}
hint={t("pages.config.dashboard_password_hint")}
layout="setting-row"
controlClassName="md:max-w-md"
>
<Input
type="password"
value={launcherForm.launcherToken}
value={launcherForm.dashboardPassword}
disabled={disabled}
autoComplete="off"
placeholder={t("pages.config.launcher_token_placeholder")}
onChange={(e) => onFieldChange("launcherToken", e.target.value)}
autoComplete="new-password"
placeholder={t("pages.config.dashboard_password_placeholder")}
onChange={(e) =>
onFieldChange("dashboardPassword", e.target.value)
}
/>
</Field>
{launcherForm.dashboardPassword.trim() !== "" && (
<Field
label={t("pages.config.dashboard_password_confirm")}
hint={t("pages.config.dashboard_password_confirm_hint")}
layout="setting-row"
controlClassName="md:max-w-md"
>
<Input
type="password"
value={launcherForm.dashboardPasswordConfirm}
disabled={disabled}
autoComplete="new-password"
placeholder={t(
"pages.config.dashboard_password_confirm_placeholder",
)}
onChange={(e) =>
onFieldChange("dashboardPasswordConfirm", e.target.value)
}
/>
</Field>
)}
<SwitchCardField
label={t("pages.config.lan_access")}
hint={t("pages.config.lan_access_hint")}
@@ -30,7 +30,8 @@ export interface LauncherForm {
port: string
publicAccess: boolean
allowedCIDRsText: string
launcherToken: string
dashboardPassword: string
dashboardPasswordConfirm: string
}
export const DM_SCOPE_OPTIONS = [
@@ -94,7 +95,8 @@ export const EMPTY_LAUNCHER_FORM: LauncherForm = {
port: "18800",
publicAccess: false,
allowedCIDRsText: "",
launcherToken: "",
dashboardPassword: "",
dashboardPasswordConfirm: "",
}
function asRecord(value: unknown): JsonRecord {
+10 -4
View File
@@ -663,10 +663,16 @@
"autostart_load_error": "Failed to load launch-at-login status.",
"server_port": "Service Port",
"server_port_hint": "HTTP port used by PicoClaw Web.",
"launcher_token": "Login Token",
"launcher_token_section_hint": "Changes in this section take effect after the launcher restarts.",
"launcher_token_hint": "Used to sign in on the launcher login page.",
"launcher_token_placeholder": "Enter login token",
"launcher_section_hint": "Changes in this section take effect after the launcher restarts.",
"dashboard_password": "Login Password",
"dashboard_password_hint": "Set a new login password.",
"dashboard_password_placeholder": "At least 8 characters",
"dashboard_password_confirm": "Confirm New Password",
"dashboard_password_confirm_hint": "Enter the new login password again.",
"dashboard_password_confirm_placeholder": "Repeat password",
"dashboard_password_required": "Enter and confirm the new login password.",
"dashboard_password_mismatch": "The login passwords do not match.",
"dashboard_password_min_length": "Login password must be at least 8 characters.",
"lan_access": "Enable LAN Access",
"lan_access_hint": "Allow access from other devices on your local network.",
"allowed_cidrs": "Allowed Network CIDRs",
+10 -4
View File
@@ -663,10 +663,16 @@
"autostart_load_error": "加载开机自启状态失败",
"server_port": "服务端口",
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
"launcher_token": "登录令牌",
"launcher_token_section_hint": "此分组中的改动需要在重启 launcher 后生效",
"launcher_token_hint": "用于在 launcher 登录页进行登录",
"launcher_token_placeholder": "输入登录令牌",
"launcher_section_hint": "此分组中的改动需要在重启 launcher 后生效",
"dashboard_password": "登录密码",
"dashboard_password_hint": "设置新的登录密码",
"dashboard_password_placeholder": "至少 8 个字符",
"dashboard_password_confirm": "确认新密码",
"dashboard_password_confirm_hint": "再次输入新的登录密码",
"dashboard_password_confirm_placeholder": "再次输入密码",
"dashboard_password_required": "请输入并确认新的登录密码",
"dashboard_password_mismatch": "两次输入的登录密码不一致",
"dashboard_password_min_length": "登录密码至少需要 8 个字符",
"lan_access": "启用局域网访问",
"lan_access_hint": "允许局域网中的其他设备访问当前服务",
"allowed_cidrs": "允许访问网段",
+1 -2
View File
@@ -33,7 +33,6 @@ const RootLayout = () => {
const [authError, setAuthError] = useState<string | null>(null)
// Session guard: proactively check auth status on every page load.
// This catches the case where ?token= auto-login bypassed the login/setup UI.
useEffect(() => {
if (isAuthPage) return
void getLauncherAuthStatus()
@@ -55,7 +54,7 @@ const RootLayout = () => {
setAuthError(
err instanceof Error
? err.message
: "Auth service unavailable, please try to delete the launcher-auth.db at picoclaw home directory and restart the application.",
: "Auth service unavailable. Reset dashboard password storage and restart the application.",
)
}
})
+19 -11
View File
@@ -28,7 +28,7 @@ import { useTheme } from "@/hooks/use-theme"
function LauncherLoginPage() {
const { t, i18n } = useTranslation()
const { theme, toggleTheme } = useTheme()
const [token, setToken] = React.useState("")
const [password, setPassword] = React.useState("")
const [submitting, setSubmitting] = React.useState(false)
const [error, setError] = React.useState("")
@@ -45,17 +45,25 @@ function LauncherLoginPage() {
})
}, [])
const loginWithToken = React.useCallback(
async (tokenValue: string) => {
const loginWithPassword = React.useCallback(
async (passwordValue: string) => {
setError("")
setSubmitting(true)
try {
const ok = await postLauncherDashboardLogin(tokenValue)
if (ok) {
const result = await postLauncherDashboardLogin(passwordValue)
if (result.ok) {
globalThis.location.assign("/")
return
}
setError(t("launcherLogin.errorInvalid"))
if (result.status === 409) {
globalThis.location.assign("/launcher-setup")
return
}
if (result.status === 401) {
setError(t("launcherLogin.errorInvalid"))
return
}
setError(result.error)
} catch {
setError(t("launcherLogin.errorNetwork"))
} finally {
@@ -67,7 +75,7 @@ function LauncherLoginPage() {
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
await loginWithToken(token)
await loginWithPassword(password)
}
return (
@@ -112,17 +120,17 @@ function LauncherLoginPage() {
<CardContent>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="launcher-token">
<Label htmlFor="launcher-password">
{t("launcherLogin.passwordLabel")}
</Label>
<Input
id="launcher-token"
id="launcher-password"
name="password"
type="password"
autoComplete="current-password"
required
value={token}
onChange={(e) => setToken(e.target.value)}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("launcherLogin.passwordPlaceholder")}
/>
</div>