mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 モード (ワンショット)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(`{}{}`))
|
||||
|
||||
@@ -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...),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface LauncherConfig {
|
||||
port: number
|
||||
public: boolean
|
||||
allowed_cidrs: string[]
|
||||
launcher_token: string
|
||||
}
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "允许访问网段",
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user