mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): protect launcher dashboard with token and SPA login (#1953)
Add token-based authentication for the Launcher's embedded Web Dashboard. - Ephemeral token generated in-memory each run (or via PICOCLAW_LAUNCHER_TOKEN env var) - HMAC-SHA256 session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS) - Bearer token support for API/script access - Rate limiting on login (10 attempts/IP/min) - Referrer-Policy: no-referrer on all responses - POST-only logout with JSON content-type (CSRF-safe) - System tray "Copy dashboard token" action - Login page shows contextual help (console/tray/log file path) - Path traversal protection via path.Clean - X-Forwarded-Host/Port/Proto support for reverse proxy deployments - Full i18n support (English, Chinese) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,18 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
|||||||
|
|
||||||
> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.
|
> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.
|
||||||
|
|
||||||
|
### 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**.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
### Skill Sources
|
### Skill Sources
|
||||||
|
|
||||||
By default, skills are loaded from:
|
By default, skills are loaded from:
|
||||||
|
|||||||
+1
-1
@@ -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.
|
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> The web console does not yet support authentication. Avoid exposing it to the public internet.
|
> 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.
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
|||||||
|
|
||||||
> **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。
|
> **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。
|
||||||
|
|
||||||
|
### Web 启动器控制台
|
||||||
|
|
||||||
|
用 **picoclaw-launcher** 打开浏览器控制台前需要先登录。**访问口令**与 **会话签名密钥**默认在**每次启动时在内存中生成**(重启后随机口令会变)。若设置环境变量 **`PICOCLAW_LAUNCHER_TOKEN`**,则该进程使用固定口令(启动日志中不会打印具体口令值)。
|
||||||
|
|
||||||
|
**到哪里找口令**:**控制台模式**(`-console`)请看启动时的终端输出;**托盘 / GUI 模式**可使用托盘菜单中的「复制控制台口令」,并在 **`$PICOCLAW_HOME/logs/launcher.log`**(未设置 `PICOCLAW_HOME` 时一般为 `~/.picoclaw/logs/launcher.log`)中查看本次启动写入的随机口令。登录页在未登录时会根据当前运行方式展示提示(含日志文件绝对路径等;**接口与页面均不会返回口令本身**)。
|
||||||
|
|
||||||
|
- **配置文件**:与 `config.json` 同一目录(若设置了 `PICOCLAW_CONFIG`,则与它所指的文件同目录)。启动器专用文件名为 `launcher-config.json`。
|
||||||
|
- **登录与链接**:在登录页输入口令;自动打开浏览器时可在 URL 上使用 `?token=`。全站响应携带 **`Referrer-Policy: no-referrer`**,减轻 `token` 经 `Referer` 头泄露的风险。
|
||||||
|
- **退出登录**:应使用 **`POST /api/auth/logout`**,且请求头为 **`Content-Type: application/json`**(请求体可为 `{}`),勿使用可被第三方页面触发的 GET 链接登出。
|
||||||
|
- **暴力尝试**:`POST /api/auth/login` 对同一远程地址有 **每分钟尝试次数上限**(超限返回 HTTP 429)。
|
||||||
|
- **会话时长**:登录后的 HttpOnly 会话 Cookie 默认约 **7 天**有效,到期需重新用口令登录。
|
||||||
|
|
||||||
### 技能来源 (Skill Sources)
|
### 技能来源 (Skill Sources)
|
||||||
|
|
||||||
默认情况下,技能会按以下顺序加载:
|
默认情况下,技能会按以下顺序加载:
|
||||||
|
|||||||
+2
-2
@@ -42,10 +42,10 @@ docker compose -f docker/docker-compose.yml --profile gateway down
|
|||||||
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
docker compose -f docker/docker-compose.yml --profile launcher up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
在浏览器中打开 http://localhost:18800。Launcher 会自动管理 Gateway 进程。
|
在浏览器中打开 <http://localhost:18800>。Launcher 会自动管理 Gateway 进程。
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Web 控制台尚不支持身份验证。请勿将其暴露到公网。
|
> Web 控制台通过 dashboard 令牌鉴权(默认每次启动在内存中生成;可用 `PICOCLAW_LAUNCHER_TOKEN` 固定)。**不要**将启动器暴露到不可信网络或公网。完整说明见 [配置指南](configuration.md) 中的「Web 启动器控制台」一节。
|
||||||
|
|
||||||
### Agent 模式 (一次性运行)
|
### Agent 模式 (一次性运行)
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
|||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Test JSON unmarshal of private fields
|
// Test JSON unmarshal of private fields (unexported fields are never filled, with or without json tag).
|
||||||
func TestJSONUnmarshalPrivateFields(t *testing.T) {
|
func TestJSONUnmarshalPrivateFields(t *testing.T) {
|
||||||
type testStruct struct {
|
type testStruct struct {
|
||||||
PublicField string `json:"public"`
|
PublicField string `json:"public"`
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LauncherAuthRouteOpts configures dashboard token login handlers.
|
||||||
|
type LauncherAuthRouteOpts struct {
|
||||||
|
DashboardToken string
|
||||||
|
SessionCookie string
|
||||||
|
SecureCookie func(*http.Request) bool
|
||||||
|
// TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets).
|
||||||
|
TokenHelp LauncherAuthTokenHelp
|
||||||
|
}
|
||||||
|
|
||||||
|
// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token.
|
||||||
|
type LauncherAuthTokenHelp struct {
|
||||||
|
EnvVarName string `json:"env_var_name"`
|
||||||
|
LogFileAbs string `json:"log_file,omitempty"`
|
||||||
|
TrayCopyMenu bool `json:"tray_copy_menu"`
|
||||||
|
ConsoleStdout bool `json:"console_stdout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type launcherAuthLoginBody struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type launcherAuthStatusResponse struct {
|
||||||
|
Authenticated bool `json:"authenticated"`
|
||||||
|
TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status.
|
||||||
|
func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) {
|
||||||
|
secure := opts.SecureCookie
|
||||||
|
if secure == nil {
|
||||||
|
secure = middleware.DefaultLauncherDashboardSecureCookie
|
||||||
|
}
|
||||||
|
h := &launcherAuthHandlers{
|
||||||
|
token: opts.DashboardToken,
|
||||||
|
sessionCookie: opts.SessionCookie,
|
||||||
|
secureCookie: secure,
|
||||||
|
tokenHelp: opts.TokenHelp,
|
||||||
|
loginLimit: newLoginRateLimiter(),
|
||||||
|
}
|
||||||
|
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
|
||||||
|
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
|
||||||
|
mux.HandleFunc("GET /api/auth/status", h.handleStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
type launcherAuthHandlers struct {
|
||||||
|
token string
|
||||||
|
sessionCookie string
|
||||||
|
secureCookie func(*http.Request) bool
|
||||||
|
tokenHelp LauncherAuthTokenHelp
|
||||||
|
loginLimit *loginRateLimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
var body launcherAuthLoginBody
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"invalid JSON"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip := clientIPForLimiter(r)
|
||||||
|
if !h.loginLimit.allow(ip) {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"too many login attempts"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in := strings.TrimSpace(body.Token)
|
||||||
|
if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"invalid token"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware.SetLauncherDashboardSessionCookie(w, r, h.sessionCookie, h.secureCookie)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"method not allowed"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
|
||||||
|
if !strings.HasPrefix(ct, "application/json") {
|
||||||
|
w.WriteHeader(http.StatusUnsupportedMediaType)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"Content-Type must be application/json"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, logoutBodyMaxBytes))
|
||||||
|
if err := dec.Decode(&struct{}{}); err != nil && err != io.EOF {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"invalid JSON body"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := dec.Decode(&struct{}{}); err != io.EOF {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"invalid JSON body"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware.ClearLauncherDashboardSessionCookie(w, r, h.secureCookie)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
ok := false
|
||||||
|
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
|
||||||
|
ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
_, _ = w.Write([]byte(`{"authenticated":true}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := launcherAuthStatusResponse{
|
||||||
|
Authenticated: false,
|
||||||
|
TokenHelp: &h.tokenHelp,
|
||||||
|
}
|
||||||
|
enc, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(enc)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
loginAttemptsPerIP = 10
|
||||||
|
loginAttemptWindow = time.Minute
|
||||||
|
logoutBodyMaxBytes = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// loginRateLimiter limits POST /api/auth/login attempts per IP per minute.
|
||||||
|
type loginRateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
now func() time.Time
|
||||||
|
byIP map[string][]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newLoginRateLimiter() *loginRateLimiter {
|
||||||
|
return &loginRateLimiter{
|
||||||
|
now: time.Now,
|
||||||
|
byIP: make(map[string][]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow reserves a slot for this request; false means rate limit exceeded.
|
||||||
|
func (l *loginRateLimiter) allow(ip string) bool {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
now := l.now()
|
||||||
|
cutoff := now.Add(-loginAttemptWindow)
|
||||||
|
times := l.byIP[ip]
|
||||||
|
var kept []time.Time
|
||||||
|
for _, ts := range times {
|
||||||
|
if ts.After(cutoff) {
|
||||||
|
kept = append(kept, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) >= loginAttemptsPerIP {
|
||||||
|
l.byIP[ip] = kept
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
kept = append(kept, now)
|
||||||
|
l.byIP[ip] = kept
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIPForLimiter(r *http.Request) string {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimSpace(r.RemoteAddr)
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = 0x55
|
||||||
|
}
|
||||||
|
const tok = "dashboard-test-token-9"
|
||||||
|
sess := middleware.SessionCookieValue(key, tok)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||||
|
DashboardToken: tok,
|
||||||
|
SessionCookie: sess,
|
||||||
|
TokenHelp: LauncherAuthTokenHelp{
|
||||||
|
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
|
||||||
|
LogFileAbs: "/tmp/launcher.log",
|
||||||
|
TrayCopyMenu: true,
|
||||||
|
ConsoleStdout: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("status_unauthenticated", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status code = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Authenticated bool `json:"authenticated"`
|
||||||
|
TokenHelp *LauncherAuthTokenHelp `json:"token_help"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body.Authenticated || body.TokenHelp == nil {
|
||||||
|
t.Fatalf("unexpected body: %+v", body)
|
||||||
|
}
|
||||||
|
if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" {
|
||||||
|
t.Fatalf("token_help = %+v", body.TokenHelp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("login_ok", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = "127.0.0.1:12345"
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
cookies := rec.Result().Cookies()
|
||||||
|
if len(cookies) != 1 || cookies[0].Name != middleware.LauncherDashboardCookieName {
|
||||||
|
t.Fatalf("cookies = %#v", cookies)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("status_authenticated", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess})
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status code = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte(`"authenticated":true`)) {
|
||||||
|
t.Fatalf("body = %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "token_help") {
|
||||||
|
t.Fatalf("authenticated response should omit token_help: %s", 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,
|
||||||
|
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/logout", nil))
|
||||||
|
if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("GET logout: code = %d (expected 404 or 405)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
||||||
|
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
mux.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Fatalf("wrong content-type: code = %d body=%s", rec2.Code, rec2.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec3 := httptest.NewRecorder()
|
||||||
|
req3 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}`))
|
||||||
|
req3.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec3, req3)
|
||||||
|
if rec3.Code != http.StatusOK {
|
||||||
|
t.Fatalf("POST json logout: code = %d", rec3.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLauncherAuthLoginRateLimit(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
const tok = "rate-limit-tok-xxxxxxxx"
|
||||||
|
sess := middleware.SessionCookieValue(key, tok)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||||
|
DashboardToken: tok,
|
||||||
|
SessionCookie: sess,
|
||||||
|
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
|
||||||
|
wrongBody := `{"token":"wrong"}`
|
||||||
|
for i := 0; i < loginAttemptsPerIP; i++ {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = "192.168.5.5:9999"
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("iter %d: want 401 got %d %s", i, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.RemoteAddr = "192.168.5.5:9999"
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("11th attempt: want 429 got %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRateLimiterWindow(t *testing.T) {
|
||||||
|
l := newLoginRateLimiter()
|
||||||
|
t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
l.now = func() time.Time { return t0 }
|
||||||
|
for i := 0; i < loginAttemptsPerIP; i++ {
|
||||||
|
if !l.allow("ip") {
|
||||||
|
t.Fatalf("want allow at %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.allow("ip") {
|
||||||
|
t.Fatal("want deny on 11th")
|
||||||
|
}
|
||||||
|
l.now = func() time.Time { return t0.Add(loginAttemptWindow + time.Second) }
|
||||||
|
if !l.allow("ip") {
|
||||||
|
t.Fatal("want allow after window")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReferrerPolicyMiddleware(t *testing.T) {
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
h := middleware.ReferrerPolicyNoReferrer(next)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
|
||||||
|
t.Fatalf("Referrer-Policy = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
sess := middleware.SessionCookieValue(key, "tok")
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||||
|
DashboardToken: "tok",
|
||||||
|
SessionCookie: sess,
|
||||||
|
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Body = http.NoBody
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
sess := middleware.SessionCookieValue(key, "tok")
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||||
|
DashboardToken: "tok",
|
||||||
|
SessionCookie: sess,
|
||||||
|
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400 got %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,16 +93,120 @@ func requestWSScheme(r *http.Request) string {
|
|||||||
return "ws"
|
return "ws"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
|
// requestHTTPScheme returns http or https for URLs that are not WebSockets (e.g. SSE).
|
||||||
host := h.effectiveGatewayBindHost(cfg)
|
func requestHTTPScheme(r *http.Request) string {
|
||||||
if host == "" || host == "0.0.0.0" {
|
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
|
||||||
host = requestHostName(r)
|
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
|
||||||
|
if proto == "https" || proto == "wss" {
|
||||||
|
return "https"
|
||||||
|
}
|
||||||
|
if proto == "http" || proto == "ws" {
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Use web server port instead of gateway port to avoid exposing extra ports
|
if r.TLS != nil {
|
||||||
// The WebSocket connection will be proxied by the backend to the gateway
|
return "https"
|
||||||
|
}
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedHostFirst returns the client-visible host from reverse-proxy / tunnel headers
|
||||||
|
// (e.g. VS Code port forwarding, nginx). Empty if unset.
|
||||||
|
func forwardedHostFirst(r *http.Request) string {
|
||||||
|
raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
||||||
|
if raw == "" {
|
||||||
|
raw = forwardedRFC7239Host(r)
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(raw, ','); i >= 0 {
|
||||||
|
raw = strings.TrimSpace(raw[:i])
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedRFC7239Host parses host= from the first Forwarded header element (RFC 7239).
|
||||||
|
func forwardedRFC7239Host(r *http.Request) string {
|
||||||
|
v := strings.TrimSpace(r.Header.Get("Forwarded"))
|
||||||
|
if v == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
first := strings.TrimSpace(strings.Split(v, ",")[0])
|
||||||
|
for _, part := range strings.Split(first, ";") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
low := strings.ToLower(part)
|
||||||
|
if !strings.HasPrefix(low, "host=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val := strings.TrimSpace(part[strings.IndexByte(part, '=')+1:])
|
||||||
|
if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {
|
||||||
|
val = val[1 : len(val)-1]
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedPortFirst returns the first X-Forwarded-Port value, or empty.
|
||||||
|
func forwardedPortFirst(r *http.Request) string {
|
||||||
|
raw := strings.TrimSpace(r.Header.Get("X-Forwarded-Port"))
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if i := strings.IndexByte(raw, ','); i >= 0 {
|
||||||
|
raw = strings.TrimSpace(raw[:i])
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientVisiblePort picks the TCP port the browser uses to reach this app (after proxies).
|
||||||
|
// Used by picoWebUIAddr → buildWsURL / buildPicoEventsURL / buildPicoSendURL so WebSocket and
|
||||||
|
// HTTP URLs match the dashboard page origin (cookies / token flow behind tunnels and reverse proxies).
|
||||||
|
func clientVisiblePort(r *http.Request, serverListenPort int) string {
|
||||||
|
if p := forwardedPortFirst(r); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if _, port, err := net.SplitHostPort(r.Host); err == nil && port != "" {
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
if requestHTTPScheme(r) == "https" {
|
||||||
|
return "443"
|
||||||
|
}
|
||||||
|
return strconv.Itoa(serverListenPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinClientVisibleHostPort builds host:port for absolute URLs returned to the browser.
|
||||||
|
func joinClientVisibleHostPort(r *http.Request, host string, serverListenPort int) string {
|
||||||
|
if h, p, err := net.SplitHostPort(host); err == nil {
|
||||||
|
return net.JoinHostPort(h, p)
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, clientVisiblePort(r, serverListenPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
// picoWebUIAddr is host:port for URLs returned to the browser (/pico/ws, /pico/events, /pico/send).
|
||||||
|
// It must match the HTTP Host the client used (or X-Forwarded-*), not cfg.Gateway.Host — otherwise
|
||||||
|
// e.g. page on localhost with ws_url 127.0.0.1 omits cookies and the dashboard auth handshake fails.
|
||||||
|
func (h *Handler) picoWebUIAddr(r *http.Request) string {
|
||||||
wsPort := h.serverPort
|
wsPort := h.serverPort
|
||||||
if wsPort == 0 {
|
if wsPort == 0 {
|
||||||
wsPort = 18800 // default web server port
|
wsPort = 18800 // default web server port
|
||||||
}
|
}
|
||||||
return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws"
|
if fwdHost := forwardedHostFirst(r); fwdHost != "" {
|
||||||
|
return joinClientVisibleHostPort(r, fwdHost, wsPort)
|
||||||
|
}
|
||||||
|
host := requestHostName(r)
|
||||||
|
return net.JoinHostPort(host, strconv.Itoa(wsPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) buildWsURL(r *http.Request) string {
|
||||||
|
return requestWSScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/ws"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) buildPicoEventsURL(r *http.Request) string {
|
||||||
|
return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/events"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) buildPicoSendURL(r *http.Request) string {
|
||||||
|
return requestHTTPScheme(r) + "://" + h.picoWebUIAddr(r) + "/pico/send"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,9 +51,16 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
|
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
|
||||||
req.Host = "192.168.1.9:18800"
|
req.Host = "192.168.1.9:18800"
|
||||||
|
|
||||||
if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" {
|
if got := h.buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" {
|
||||||
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws")
|
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if got := h.buildPicoEventsURL(req); got != "http://192.168.1.9:18800/pico/events" {
|
||||||
|
t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/events")
|
||||||
|
}
|
||||||
|
if got := h.buildPicoSendURL(req); got != "http://192.168.1.9:18800/pico/send" {
|
||||||
|
t.Fatalf("buildPicoSendURL() = %q, want %q", got, "http://192.168.1.9:18800/pico/send")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
|
func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) {
|
||||||
@@ -147,7 +154,7 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) {
|
|||||||
req.Host = "chat.example.com"
|
req.Host = "chat.example.com"
|
||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
|
||||||
if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18800/pico/ws" {
|
if got := h.buildWsURL(req); got != "wss://chat.example.com:18800/pico/ws" {
|
||||||
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws")
|
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,11 +171,45 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) {
|
|||||||
req.Host = "secure.example.com"
|
req.Host = "secure.example.com"
|
||||||
req.TLS = &tls.ConnectionState{}
|
req.TLS = &tls.ConnectionState{}
|
||||||
|
|
||||||
if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18800/pico/ws" {
|
if got := h.buildWsURL(req); got != "wss://secure.example.com:18800/pico/ws" {
|
||||||
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws")
|
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildPicoURLsPreferXForwardedHost(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
launcherPath := launcherconfig.PathForAppConfig(configPath)
|
||||||
|
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
|
||||||
|
Port: 18800,
|
||||||
|
Public: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("launcherconfig.Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
h.SetServerOptions(18800, false, false, nil)
|
||||||
|
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Gateway.Host = "0.0.0.0"
|
||||||
|
cfg.Gateway.Port = 18790
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "http://127.0.0.1:18800/api/pico/token", nil)
|
||||||
|
req.Host = "127.0.0.1:18800"
|
||||||
|
req.Header.Set("X-Forwarded-Host", "vscode-tunnel.example.com")
|
||||||
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
req.Header.Set("X-Forwarded-Port", "443")
|
||||||
|
|
||||||
|
if got := h.buildPicoEventsURL(req); got != "https://vscode-tunnel.example.com:443/pico/events" {
|
||||||
|
t.Fatalf("buildPicoEventsURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/events")
|
||||||
|
}
|
||||||
|
if got := h.buildPicoSendURL(req); got != "https://vscode-tunnel.example.com:443/pico/send" {
|
||||||
|
t.Fatalf("buildPicoSendURL() = %q, want %q", got, "https://vscode-tunnel.example.com:443/pico/send")
|
||||||
|
}
|
||||||
|
if got := h.buildWsURL(req); got != "wss://vscode-tunnel.example.com:443/pico/ws" {
|
||||||
|
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://vscode-tunnel.example.com:443/pico/ws")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
|
func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
@@ -182,7 +223,20 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
|
|||||||
req.TLS = &tls.ConnectionState{}
|
req.TLS = &tls.ConnectionState{}
|
||||||
req.Header.Set("X-Forwarded-Proto", "http")
|
req.Header.Set("X-Forwarded-Proto", "http")
|
||||||
|
|
||||||
if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18800/pico/ws" {
|
if got := h.buildWsURL(req); got != "ws://chat.example.com:18800/pico/ws" {
|
||||||
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws")
|
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildWsURLUsesRequestHostNotGatewayBindLoopback(t *testing.T) {
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
h.SetServerOptions(18800, false, false, nil)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "http://localhost:18800/api/pico/token", nil)
|
||||||
|
req.Host = "localhost:18800"
|
||||||
|
|
||||||
|
if got := h.buildWsURL(req); got != "ws://localhost:18800/pico/ws" {
|
||||||
|
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://localhost:18800/pico/ws")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL := h.buildWsURL(r, cfg)
|
wsURL := h.buildWsURL(r)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
@@ -81,7 +81,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL := h.buildWsURL(r, cfg)
|
wsURL := h.buildWsURL(r)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
@@ -146,7 +146,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL := h.buildWsURL(r, cfg)
|
wsURL := h.buildWsURL(r)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
|||||||
@@ -55,8 +55,12 @@ func shutdownApp() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func openBrowser() error {
|
func openBrowser() error {
|
||||||
if serverAddr == "" {
|
target := browserLaunchURL
|
||||||
|
if target == "" {
|
||||||
|
target = serverAddr
|
||||||
|
}
|
||||||
|
if target == "" {
|
||||||
return fmt.Errorf("server address not set")
|
return fmt.Errorf("server address not set")
|
||||||
}
|
}
|
||||||
return utils.OpenBrowser(serverAddr)
|
return utils.OpenBrowser(target)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const (
|
|||||||
AppTooltip TranslationKey = "AppTooltip"
|
AppTooltip TranslationKey = "AppTooltip"
|
||||||
MenuOpen TranslationKey = "MenuOpen"
|
MenuOpen TranslationKey = "MenuOpen"
|
||||||
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
|
MenuOpenTooltip TranslationKey = "MenuOpenTooltip"
|
||||||
|
MenuCopyToken TranslationKey = "MenuCopyToken"
|
||||||
|
MenuCopyTokenHint TranslationKey = "MenuCopyTokenHint"
|
||||||
MenuAbout TranslationKey = "MenuAbout"
|
MenuAbout TranslationKey = "MenuAbout"
|
||||||
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
|
MenuAboutTooltip TranslationKey = "MenuAboutTooltip"
|
||||||
MenuVersion TranslationKey = "MenuVersion"
|
MenuVersion TranslationKey = "MenuVersion"
|
||||||
@@ -47,6 +49,8 @@ var translations = map[Language]map[TranslationKey]string{
|
|||||||
AppTooltip: "%s - Web Console",
|
AppTooltip: "%s - Web Console",
|
||||||
MenuOpen: "Open Console",
|
MenuOpen: "Open Console",
|
||||||
MenuOpenTooltip: "Open PicoClaw console in browser",
|
MenuOpenTooltip: "Open PicoClaw console in browser",
|
||||||
|
MenuCopyToken: "Copy dashboard token",
|
||||||
|
MenuCopyTokenHint: "Copy the current web console access token to the clipboard",
|
||||||
MenuAbout: "About",
|
MenuAbout: "About",
|
||||||
MenuAboutTooltip: "About PicoClaw",
|
MenuAboutTooltip: "About PicoClaw",
|
||||||
MenuVersion: "Version: %s",
|
MenuVersion: "Version: %s",
|
||||||
@@ -64,6 +68,8 @@ var translations = map[Language]map[TranslationKey]string{
|
|||||||
AppTooltip: "%s - Web Console",
|
AppTooltip: "%s - Web Console",
|
||||||
MenuOpen: "打开控制台",
|
MenuOpen: "打开控制台",
|
||||||
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
|
MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台",
|
||||||
|
MenuCopyToken: "复制控制台口令",
|
||||||
|
MenuCopyTokenHint: "将当前 Web 控制台访问口令复制到剪贴板",
|
||||||
MenuAbout: "关于",
|
MenuAbout: "关于",
|
||||||
MenuAboutTooltip: "关于 PicoClaw",
|
MenuAboutTooltip: "关于 PicoClaw",
|
||||||
MenuVersion: "版本: %s",
|
MenuVersion: "版本: %s",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package launcherconfig
|
package launcherconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -14,6 +16,11 @@ const (
|
|||||||
FileName = "launcher-config.json"
|
FileName = "launcher-config.json"
|
||||||
// DefaultPort is the default port for the web launcher.
|
// DefaultPort is the default port for the web launcher.
|
||||||
DefaultPort = 18800
|
DefaultPort = 18800
|
||||||
|
|
||||||
|
// 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
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config stores launch parameters for the web backend service.
|
// Config stores launch parameters for the web backend service.
|
||||||
@@ -41,6 +48,34 @@ func Validate(cfg Config) error {
|
|||||||
return nil
|
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 the environment
|
||||||
|
// variable PICOCLAW_LAUNCHER_TOKEN when set, otherwise a new random token.
|
||||||
|
func EnsureDashboardSecrets() (effectiveToken string, signingKey []byte, newRandomDashboardToken bool, err error) {
|
||||||
|
signingKey = make([]byte, dashboardSigningKeyBytes)
|
||||||
|
if _, err = rand.Read(signingKey); err != nil {
|
||||||
|
return "", nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveToken = strings.TrimSpace(os.Getenv("PICOCLAW_LAUNCHER_TOKEN"))
|
||||||
|
if effectiveToken != "" {
|
||||||
|
return effectiveToken, signingKey, false, nil
|
||||||
|
}
|
||||||
|
tok, genErr := randomDashboardToken()
|
||||||
|
if genErr != nil {
|
||||||
|
return "", nil, false, genErr
|
||||||
|
}
|
||||||
|
return tok, signingKey, true, 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.
|
// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs.
|
||||||
func NormalizeCIDRs(cidrs []string) []string {
|
func NormalizeCIDRs(cidrs []string) []string {
|
||||||
if len(cidrs) == 0 {
|
if len(cidrs) == 0 {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
|
func TestLoadReturnsFallbackWhenMissing(t *testing.T) {
|
||||||
@@ -75,6 +77,51 @@ func TestValidateRejectsInvalidCIDR(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureDashboardSecrets_GeneratesEphemeral(t *testing.T) {
|
||||||
|
t.Setenv("PICOCLAW_LAUNCHER_TOKEN", "")
|
||||||
|
|
||||||
|
tok, key, newTok, err := EnsureDashboardSecrets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if !newTok || tok == "" || len(key) != dashboardSigningKeyBytes {
|
||||||
|
t.Fatalf("unexpected first call: newTok=%v tok=%q keyLen=%d", newTok, tok, len(key))
|
||||||
|
}
|
||||||
|
mac := middleware.SessionCookieValue(key, tok)
|
||||||
|
if mac == "" {
|
||||||
|
t.Fatal("empty session mac")
|
||||||
|
}
|
||||||
|
|
||||||
|
tok2, key2, newTok2, err := EnsureDashboardSecrets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureDashboardSecrets() second error = %v", err)
|
||||||
|
}
|
||||||
|
if !newTok2 {
|
||||||
|
t.Fatal("second call without env should generate another random token")
|
||||||
|
}
|
||||||
|
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, _, newTok, err := EnsureDashboardSecrets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureDashboardSecrets() error = %v", err)
|
||||||
|
}
|
||||||
|
if tok != "env-only-token-override" {
|
||||||
|
t.Fatalf("token = %q, want env value", tok)
|
||||||
|
}
|
||||||
|
if newTok {
|
||||||
|
t.Fatal("newRandomDashboardToken should be false when env is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeCIDRs(t *testing.T) {
|
func TestNormalizeCIDRs(t *testing.T) {
|
||||||
got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"})
|
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"}
|
want := []string{"192.168.1.0/24", "10.0.0.0/8"}
|
||||||
|
|||||||
+60
-7
@@ -16,6 +16,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -44,7 +45,12 @@ var (
|
|||||||
|
|
||||||
server *http.Server
|
server *http.Server
|
||||||
serverAddr string
|
serverAddr string
|
||||||
apiHandler *api.Handler
|
// 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
|
||||||
|
// launcherDashboardTokenForClipboard is read by the system tray "copy token" action (GUI mode).
|
||||||
|
launcherDashboardTokenForClipboard string
|
||||||
|
|
||||||
noBrowser *bool
|
noBrowser *bool
|
||||||
)
|
)
|
||||||
@@ -57,7 +63,7 @@ func main() {
|
|||||||
console := flag.Bool("console", false, "Console mode, no GUI")
|
console := flag.Bool("console", false, "Console mode, no GUI")
|
||||||
|
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
|
fmt.Fprintf(os.Stderr, "%s Launcher - A web-based configuration editor\n\n", appName)
|
||||||
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
||||||
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
||||||
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
||||||
@@ -98,8 +104,8 @@ func main() {
|
|||||||
defer logger.DisableFileLogging()
|
defer logger.DisableFileLogging()
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoC("web", fmt.Sprintf("%s Launcher %s starting...", appName, appVersion))
|
logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
|
||||||
logger.InfoC("web", fmt.Sprintf("PicoClaw Home: %s", picoHome))
|
logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
|
||||||
|
|
||||||
// Set language from command line or auto-detect
|
// Set language from command line or auto-detect
|
||||||
if *lang != "" {
|
if *lang != "" {
|
||||||
@@ -118,7 +124,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
err = utils.EnsureOnboarded(absPath)
|
err = utils.EnsureOnboarded(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Warning: Failed to initialize PicoClaw config automatically: %v", err)
|
logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var explicitPort bool
|
var explicitPort bool
|
||||||
@@ -156,6 +162,13 @@ func main() {
|
|||||||
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
|
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dashboardToken, dashboardSigningKey, newDashTok, dashErr := launcherconfig.EnsureDashboardSecrets()
|
||||||
|
if dashErr != nil {
|
||||||
|
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
|
||||||
|
}
|
||||||
|
dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
|
||||||
|
launcherDashboardTokenForClipboard = dashboardToken
|
||||||
|
|
||||||
// Determine listen address
|
// Determine listen address
|
||||||
var addr string
|
var addr string
|
||||||
if effectivePublic {
|
if effectivePublic {
|
||||||
@@ -167,6 +180,21 @@ func main() {
|
|||||||
// Initialize Server components
|
// Initialize Server components
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
tokenLogFileAbs := ""
|
||||||
|
if !enableConsole {
|
||||||
|
tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
|
||||||
|
}
|
||||||
|
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
|
||||||
|
DashboardToken: dashboardToken,
|
||||||
|
SessionCookie: dashboardSessionCookie,
|
||||||
|
TokenHelp: api.LauncherAuthTokenHelp{
|
||||||
|
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
|
||||||
|
LogFileAbs: tokenLogFileAbs,
|
||||||
|
TrayCopyMenu: trayOffersDashboardTokenCopy(),
|
||||||
|
ConsoleStdout: enableConsole,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// API Routes (e.g. /api/status)
|
// API Routes (e.g. /api/status)
|
||||||
apiHandler = api.NewHandler(absPath)
|
apiHandler = api.NewHandler(absPath)
|
||||||
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
||||||
@@ -183,14 +211,21 @@ func main() {
|
|||||||
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{
|
||||||
|
ExpectedCookie: dashboardSessionCookie,
|
||||||
|
Token: dashboardToken,
|
||||||
|
}, accessControlledMux)
|
||||||
|
|
||||||
// Apply middleware stack
|
// Apply middleware stack
|
||||||
handler := middleware.Recoverer(
|
handler := middleware.Recoverer(
|
||||||
middleware.Logger(
|
middleware.Logger(
|
||||||
middleware.JSONContentType(accessControlledMux),
|
middleware.ReferrerPolicyNoReferrer(
|
||||||
|
middleware.JSONContentType(dashAuth),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Print startup banner (only in console mode)
|
// Print startup banner and token (console mode only).
|
||||||
if enableConsole {
|
if enableConsole {
|
||||||
fmt.Print(utils.Banner)
|
fmt.Print(utils.Banner)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
@@ -203,6 +238,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
if newDashTok {
|
||||||
|
fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken)
|
||||||
|
} else if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" {
|
||||||
|
fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.Getenv("PICOCLAW_LAUNCHER_TOKEN") != "" {
|
||||||
|
logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN")
|
||||||
|
}
|
||||||
|
if !enableConsole && newDashTok {
|
||||||
|
logger.InfoC("web", "Dashboard token (this run): "+dashboardToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log startup info to file
|
// Log startup info to file
|
||||||
@@ -215,6 +263,11 @@ func main() {
|
|||||||
|
|
||||||
// Share the local URL with the launcher runtime.
|
// Share the local URL with the launcher runtime.
|
||||||
serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort)
|
serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort)
|
||||||
|
if dashboardToken != "" {
|
||||||
|
browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken)
|
||||||
|
} else {
|
||||||
|
browserLaunchURL = serverAddr
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-open browser will be handled by the launcher runtime.
|
// Auto-open browser will be handled by the launcher runtime.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LauncherDashboardCookieName is the HttpOnly cookie set after a successful token login.
|
||||||
|
const LauncherDashboardCookieName = "picoclaw_launcher_auth"
|
||||||
|
|
||||||
|
// launcherDashboardSessionMaxAgeSec is the session cookie lifetime (7 days).
|
||||||
|
const launcherDashboardSessionMaxAgeSec = 7 * 24 * 3600
|
||||||
|
|
||||||
|
const launcherSessionMACLabel = "picoclaw-launcher-v1"
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LauncherDashboardAuthConfig holds runtime material for dashboard access checks.
|
||||||
|
type LauncherDashboardAuthConfig struct {
|
||||||
|
ExpectedCookie string
|
||||||
|
Token string
|
||||||
|
// SecureCookie sets the session cookie's Secure flag. If nil, DefaultLauncherDashboardSecureCookie is used.
|
||||||
|
SecureCookie func(*http.Request) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLauncherDashboardSecureCookie mirrors typical production HTTPS detection (TLS or X-Forwarded-Proto).
|
||||||
|
func DefaultLauncherDashboardSecureCookie(r *http.Request) bool {
|
||||||
|
if r.TLS != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLauncherDashboardSessionCookie writes the HttpOnly session cookie after successful dashboard token login.
|
||||||
|
func SetLauncherDashboardSessionCookie(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
sessionValue string,
|
||||||
|
secure func(*http.Request) bool,
|
||||||
|
) {
|
||||||
|
if secure == nil {
|
||||||
|
secure = DefaultLauncherDashboardSecureCookie
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: LauncherDashboardCookieName,
|
||||||
|
Value: sessionValue,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: launcherDashboardSessionMaxAgeSec,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: secure(r),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearLauncherDashboardSessionCookie clears the dashboard session (e.g. logout).
|
||||||
|
func ClearLauncherDashboardSessionCookie(w http.ResponseWriter, r *http.Request, secure func(*http.Request) bool) {
|
||||||
|
if secure == nil {
|
||||||
|
secure = DefaultLauncherDashboardSecureCookie
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: LauncherDashboardCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: secure(r),
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LauncherDashboardAuth requires a valid session cookie or Authorization: Bearer <token>
|
||||||
|
// before calling next. Public paths are login page 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 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isPublicLauncherDashboardPath(r.Method, p) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if validLauncherDashboardAuth(r, cfg) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rejectLauncherDashboardAuth(w, r, p)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
if canonicalPath == "/api" || strings.HasPrefix(canonicalPath, "/api/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
qToken := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||||
|
if qToken == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(qToken) != len(cfg.Token) || subtle.ConstantTimeCompare([]byte(qToken), []byte(cfg.Token)) != 1 {
|
||||||
|
rejectLauncherDashboardAuth(w, r, canonicalPath)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
SetLauncherDashboardSessionCookie(w, r, cfg.ExpectedCookie, cfg.SecureCookie)
|
||||||
|
http.Redirect(w, r, redirectAfterQueryTokenLogin(r, canonicalPath), http.StatusSeeOther)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectAfterQueryTokenLogin(r *http.Request, canonicalPath string) string {
|
||||||
|
if canonicalPath == "/launcher-login" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
q.Del("token")
|
||||||
|
enc := q.Encode()
|
||||||
|
if enc != "" {
|
||||||
|
return canonicalPath + "?" + enc
|
||||||
|
}
|
||||||
|
return canonicalPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalAuthPath(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
c := path.Clean(raw)
|
||||||
|
switch c {
|
||||||
|
case ".", "":
|
||||||
|
return "/"
|
||||||
|
default:
|
||||||
|
if c[0] != '/' {
|
||||||
|
return "/" + c
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPublicLauncherDashboardPath(method, p string) bool {
|
||||||
|
if isPublicLauncherDashboardStatic(method, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch p {
|
||||||
|
case "/api/auth/login":
|
||||||
|
return method == http.MethodPost
|
||||||
|
case "/api/auth/logout":
|
||||||
|
return method == http.MethodPost
|
||||||
|
case "/api/auth/status":
|
||||||
|
return method == http.MethodGet
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPublicLauncherDashboardStatic allows the SPA login route and embedded
|
||||||
|
// frontend assets without a session (GET/HEAD only).
|
||||||
|
func isPublicLauncherDashboardStatic(method, p string) bool {
|
||||||
|
if method != http.MethodGet && method != http.MethodHead {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p == "/launcher-login" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(p, "/assets/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch p {
|
||||||
|
case "/favicon.ico", "/favicon.svg", "/favicon-96x96.png",
|
||||||
|
"/apple-touch-icon.png", "/site.webmanifest", "/robots.txt":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validLauncherDashboardAuth(r *http.Request, cfg LauncherDashboardAuthConfig) bool {
|
||||||
|
if c, err := r.Cookie(LauncherDashboardCookieName); err == nil {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(c.Value), []byte(cfg.ExpectedCookie)) == 1 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func rejectLauncherDashboardAuth(w http.ResponseWriter, r *http.Request, canonicalPath string) {
|
||||||
|
if strings.HasPrefix(canonicalPath, "/api/") {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/launcher-login", http.StatusFound)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionCookieValue_Deterministic(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte(i)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
c := SessionCookieValue(key, "tok-b")
|
||||||
|
if c == a {
|
||||||
|
t.Fatal("SessionCookieValue should differ for different tokens")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLauncherDashboardAuth_AllowsPublicPaths(t *testing.T) {
|
||||||
|
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
})
|
||||||
|
h := LauncherDashboardAuth(cfg, next)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{http.MethodGet, "/launcher-login", 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/logout", http.StatusTeapot},
|
||||||
|
{http.MethodGet, "/api/auth/logout", http.StatusUnauthorized},
|
||||||
|
{http.MethodGet, "/api/config", http.StatusUnauthorized},
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != tc.want {
|
||||||
|
t.Fatalf("%s %s: status = %d, want %d", tc.method, tc.path, rec.Code, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLauncherDashboardAuth_URLTokenBootstrapGET(t *testing.T) {
|
||||||
|
const tok = "secret"
|
||||||
|
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: tok}
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
})
|
||||||
|
h := LauncherDashboardAuth(cfg, next)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/?token="+tok, nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("GET /?token=valid: status = %d, want %d", rec.Code, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Location"); got != "/" {
|
||||||
|
t.Fatalf("Location = %q, want %q", got, "/")
|
||||||
|
}
|
||||||
|
if c := rec.Result().Cookies(); len(c) != 1 || c[0].Name != LauncherDashboardCookieName {
|
||||||
|
t.Fatalf("expected one session cookie, got %#v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLauncherDashboardAuth_DotDotCannotBypass(t *testing.T) {
|
||||||
|
cfg := LauncherDashboardAuthConfig{ExpectedCookie: "deadbeef", Token: "x"}
|
||||||
|
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Fatal("next handler should not run without auth")
|
||||||
|
})
|
||||||
|
h := LauncherDashboardAuth(cfg, next)
|
||||||
|
|
||||||
|
for _, p := range []string{
|
||||||
|
"/assets/../api/config",
|
||||||
|
"/launcher-login/../api/config",
|
||||||
|
"/./api/config",
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("%q: status = %d, want %d", p, rec.Code, http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
h := LauncherDashboardAuth(cfg, next)
|
||||||
|
|
||||||
|
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: status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req2.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
h.ServeHTTP(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bearer auth: status = %d", rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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.
|
||||||
|
func ReferrerPolicyNoReferrer(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"fyne.io/systray"
|
"fyne.io/systray"
|
||||||
|
"github.com/atotto/clipboard"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/web/backend/utils"
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
||||||
@@ -23,6 +24,7 @@ func onReady() {
|
|||||||
|
|
||||||
// Create menu items
|
// Create menu items
|
||||||
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
|
mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip))
|
||||||
|
mCopyTok := systray.AddMenuItem(T(MenuCopyToken), T(MenuCopyTokenHint))
|
||||||
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
|
mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip))
|
||||||
|
|
||||||
// Add version info under About menu
|
// Add version info under About menu
|
||||||
@@ -50,6 +52,17 @@ func onReady() {
|
|||||||
logger.Errorf("Failed to open browser: %v", err)
|
logger.Errorf("Failed to open browser: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case <-mCopyTok.ClickedCh:
|
||||||
|
if launcherDashboardTokenForClipboard == "" {
|
||||||
|
logger.WarnC("web", "Dashboard token is empty; cannot copy")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := clipboard.WriteAll(launcherDashboardTokenForClipboard); err != nil {
|
||||||
|
logger.Errorf("Failed to copy dashboard token: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.InfoC("web", "Dashboard token copied to clipboard")
|
||||||
|
}
|
||||||
|
|
||||||
case <-mVersion.ClickedCh:
|
case <-mVersion.ClickedCh:
|
||||||
// Version info - do nothing, just shows current version
|
// Version info - do nothing, just shows current version
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build (!darwin && !freebsd) || cgo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func trayOffersDashboardTokenCopy() bool { return true }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build (darwin || freebsd) && !cgo
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func trayOffersDashboardTokenCopy() bool { return false }
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
// API client for channels navigation and channel-specific config flows.
|
// API client for channels navigation and channel-specific config flows.
|
||||||
|
|
||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
export type ChannelConfig = Record<string, unknown>
|
export type ChannelConfig = Record<string, unknown>
|
||||||
export type AppConfig = Record<string, unknown>
|
export type AppConfig = Record<string, unknown>
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ interface ConfigActionResponse {
|
|||||||
const BASE_URL = ""
|
const BASE_URL = ""
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = `API error: ${res.status} ${res.statusText}`
|
let message = `API error: ${res.status} ${res.statusText}`
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
// API client for gateway process management.
|
// API client for gateway process management.
|
||||||
|
|
||||||
interface GatewayStatusResponse {
|
interface GatewayStatusResponse {
|
||||||
@@ -27,7 +29,7 @@ interface GatewayActionResponse {
|
|||||||
const BASE_URL = ""
|
const BASE_URL = ""
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
|
||||||
|
|
||||||
|
function isLauncherLoginPath(): boolean {
|
||||||
|
if (typeof globalThis.location === "undefined") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (isLauncherLoginPathname(globalThis.location.pathname || "/")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return isLauncherLoginPathname(
|
||||||
|
new URL(globalThis.location.href).pathname || "/",
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same-origin fetch that sends cookies; redirects to launcher login on 401 JSON responses.
|
||||||
|
* Skips redirect while already on the login page to avoid reload loops (e.g. gateway poll).
|
||||||
|
*/
|
||||||
|
export async function launcherFetch(
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> {
|
||||||
|
const res = await fetch(input, {
|
||||||
|
credentials: "same-origin",
|
||||||
|
...init,
|
||||||
|
})
|
||||||
|
if (res.status === 401) {
|
||||||
|
const ct = res.headers.get("content-type") || ""
|
||||||
|
if (
|
||||||
|
ct.includes("application/json") &&
|
||||||
|
typeof globalThis.location !== "undefined" &&
|
||||||
|
!isLauncherLoginPath()
|
||||||
|
) {
|
||||||
|
globalThis.location.assign("/launcher-login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard launcher token login. Uses plain fetch (not launcherFetch) to avoid
|
||||||
|
* redirect loops on 401 while on the login page.
|
||||||
|
*/
|
||||||
|
export async function postLauncherDashboardLogin(
|
||||||
|
token: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify({ token: token.trim() }),
|
||||||
|
})
|
||||||
|
return res.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LauncherAuthTokenHelp = {
|
||||||
|
env_var_name: string
|
||||||
|
log_file?: string
|
||||||
|
tray_copy_menu: boolean
|
||||||
|
console_stdout: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LauncherAuthStatus = {
|
||||||
|
authenticated: boolean
|
||||||
|
token_help?: LauncherAuthTokenHelp
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLauncherAuthStatus(): Promise<LauncherAuthStatus> {
|
||||||
|
const res = await fetch("/api/auth/status", {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`status ${res.status}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as LauncherAuthStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postLauncherDashboardLogout(): Promise<boolean> {
|
||||||
|
const res = await fetch("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: "{}",
|
||||||
|
})
|
||||||
|
return res.ok
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
// API client for model list management.
|
// API client for model list management.
|
||||||
@@ -39,7 +40,7 @@ interface ModelActionResponse {
|
|||||||
const BASE_URL = ""
|
const BASE_URL = ""
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
export type OAuthProvider = "openai" | "anthropic" | "google-antigravity"
|
export type OAuthProvider = "openai" | "anthropic" | "google-antigravity"
|
||||||
export type OAuthMethod = "browser" | "device_code" | "token"
|
export type OAuthMethod = "browser" | "device_code" | "token"
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ interface OAuthProvidersResponse {
|
|||||||
const BASE_URL = ""
|
const BASE_URL = ""
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message = await res.text()
|
const message = await res.text()
|
||||||
throw new Error(message || `API error: ${res.status} ${res.statusText}`)
|
throw new Error(message || `API error: ${res.status} ${res.statusText}`)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
// API client for Pico Channel configuration.
|
// API client for Pico Channel configuration.
|
||||||
|
|
||||||
interface PicoTokenResponse {
|
interface PicoTokenResponse {
|
||||||
@@ -16,7 +18,7 @@ interface PicoSetupResponse {
|
|||||||
const BASE_URL = ""
|
const BASE_URL = ""
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${BASE_URL}${path}`, options)
|
const res = await launcherFetch(`${BASE_URL}${path}`, options)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
throw new Error(`API error: ${res.status} ${res.statusText}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Sessions API — list and retrieve chat session history
|
// Sessions API — list and retrieve chat session history
|
||||||
|
|
||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
export interface SessionSummary {
|
export interface SessionSummary {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -26,7 +28,7 @@ export async function getSessions(
|
|||||||
limit: limit.toString(),
|
limit: limit.toString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const res = await fetch(`/api/sessions?${params.toString()}`)
|
const res = await launcherFetch(`/api/sessions?${params.toString()}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to fetch sessions: ${res.status}`)
|
throw new Error(`Failed to fetch sessions: ${res.status}`)
|
||||||
}
|
}
|
||||||
@@ -34,7 +36,7 @@ export async function getSessions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSessionHistory(id: string): Promise<SessionDetail> {
|
export async function getSessionHistory(id: string): Promise<SessionDetail> {
|
||||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`)
|
const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to fetch session ${id}: ${res.status}`)
|
throw new Error(`Failed to fetch session ${id}: ${res.status}`)
|
||||||
}
|
}
|
||||||
@@ -42,7 +44,7 @@ export async function getSessionHistory(id: string): Promise<SessionDetail> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSession(id: string): Promise<void> {
|
export async function deleteSession(id: string): Promise<void> {
|
||||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
|
const res = await launcherFetch(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
export interface SkillSupportItem {
|
export interface SkillSupportItem {
|
||||||
name: string
|
name: string
|
||||||
path: string
|
path: string
|
||||||
@@ -22,7 +24,7 @@ interface SkillActionResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, options)
|
const res = await launcherFetch(path, options)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(await extractErrorMessage(res))
|
throw new Error(await extractErrorMessage(res))
|
||||||
}
|
}
|
||||||
@@ -41,7 +43,7 @@ export async function importSkill(file: File): Promise<SkillActionResponse> {
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.set("file", file)
|
formData.set("file", file)
|
||||||
|
|
||||||
const res = await fetch("/api/skills/import", {
|
const res = await launcherFetch("/api/skills/import", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
export interface AutoStartStatus {
|
export interface AutoStartStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
supported: boolean
|
supported: boolean
|
||||||
@@ -12,7 +14,7 @@ export interface LauncherConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, options)
|
const res = await launcherFetch(path, options)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = `API error: ${res.status} ${res.statusText}`
|
let message = `API error: ${res.status} ${res.statusText}`
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
|
|
||||||
export interface ToolSupportItem {
|
export interface ToolSupportItem {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
@@ -16,7 +18,7 @@ interface ToolActionResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(path, options)
|
const res = await launcherFetch(path, options)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = `API error: ${res.status} ${res.statusText}`
|
let message = `API error: ${res.status} ${res.statusText}`
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { patchAppConfig } from "@/api/channels"
|
import { patchAppConfig } from "@/api/channels"
|
||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
import {
|
import {
|
||||||
getAutoStartStatus,
|
getAutoStartStatus,
|
||||||
getLauncherConfig,
|
getLauncherConfig,
|
||||||
@@ -50,7 +51,7 @@ export function ConfigPage() {
|
|||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["config"],
|
queryKey: ["config"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch("/api/config")
|
const res = await launcherFetch("/api/config")
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Failed to load config")
|
throw new Error("Failed to load config")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState } from "react"
|
|||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { launcherFetch } from "@/api/http"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -28,7 +29,7 @@ export function RawConfigPage() {
|
|||||||
const { data: config, isLoading } = useQuery({
|
const { data: config, isLoading } = useQuery({
|
||||||
queryKey: ["config"],
|
queryKey: ["config"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await fetch("/api/config")
|
const res = await launcherFetch("/api/config")
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Failed to fetch config")
|
throw new Error("Failed to fetch config")
|
||||||
}
|
}
|
||||||
@@ -38,7 +39,7 @@ export function RawConfigPage() {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (newConfig: string) => {
|
mutationFn: async (newConfig: string) => {
|
||||||
const res = await fetch("/api/config", {
|
const res = await launcherFetch("/api/config", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: newConfig,
|
body: newConfig,
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ export function normalizeWsUrlForBrowser(wsUrl: string): string {
|
|||||||
if (isLocalHost && !isBrowserLocal) {
|
if (isLocalHost && !isBrowserLocal) {
|
||||||
parsedUrl.hostname = window.location.hostname
|
parsedUrl.hostname = window.location.hostname
|
||||||
finalWsUrl = parsedUrl.toString()
|
finalWsUrl = parsedUrl.toString()
|
||||||
|
} else if (
|
||||||
|
isLocalHost &&
|
||||||
|
isBrowserLocal &&
|
||||||
|
parsedUrl.hostname !== window.location.hostname &&
|
||||||
|
(parsedUrl.hostname === "127.0.0.1" ||
|
||||||
|
parsedUrl.hostname === "localhost") &&
|
||||||
|
(window.location.hostname === "127.0.0.1" ||
|
||||||
|
window.location.hostname === "localhost")
|
||||||
|
) {
|
||||||
|
// Same machine, but cookies are host-specific; match the page origin.
|
||||||
|
parsedUrl.hostname = window.location.hostname
|
||||||
|
finalWsUrl = parsedUrl.toString()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Could not parse ws_url:", error)
|
console.warn("Could not parse ws_url:", error)
|
||||||
|
|||||||
@@ -14,6 +14,20 @@
|
|||||||
"config": "Config",
|
"config": "Config",
|
||||||
"logs": "Logs"
|
"logs": "Logs"
|
||||||
},
|
},
|
||||||
|
"launcherLogin": {
|
||||||
|
"title": "Launcher access",
|
||||||
|
"description": "Sign in with the dashboard access token for this launcher process (it may change after each restart unless you pin it with an environment variable).",
|
||||||
|
"tokenLabel": "Token",
|
||||||
|
"tokenPlaceholder": "Enter access token",
|
||||||
|
"submit": "Continue to Dashboard",
|
||||||
|
"errorInvalid": "Invalid token. Please try again.",
|
||||||
|
"errorNetwork": "Network error. Please try again.",
|
||||||
|
"helpTitle": "Where to find the token",
|
||||||
|
"helpConsole": "Console mode: printed in the terminal when the launcher starts.",
|
||||||
|
"helpTray": "Tray mode: menu «Copy dashboard token».",
|
||||||
|
"helpLogFile": "Log file (startup line includes the token): {{path}}",
|
||||||
|
"helpEnv": "Stable token: set {{env}}."
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"welcome": "How can I help you today?",
|
"welcome": "How can I help you today?",
|
||||||
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.",
|
"welcomeDesc": "Ask me about weather, settings, or any other tasks. I'm here to assist you.",
|
||||||
|
|||||||
@@ -14,6 +14,20 @@
|
|||||||
"config": "配置",
|
"config": "配置",
|
||||||
"logs": "日志"
|
"logs": "日志"
|
||||||
},
|
},
|
||||||
|
"launcherLogin": {
|
||||||
|
"title": "Launcher 访问验证",
|
||||||
|
"description": "请使用当前 Launcher 进程的访问口令登录(每次重启可能变化,除非用环境变量固定)。",
|
||||||
|
"tokenLabel": "令牌",
|
||||||
|
"tokenPlaceholder": "输入访问令牌",
|
||||||
|
"submit": "进入 Dashboard",
|
||||||
|
"errorInvalid": "令牌错误,请重试。",
|
||||||
|
"errorNetwork": "网络错误,请重试。",
|
||||||
|
"helpTitle": "口令在哪里",
|
||||||
|
"helpConsole": "控制台模式:启动时在终端输出。",
|
||||||
|
"helpTray": "托盘模式:菜单「复制控制台口令」。",
|
||||||
|
"helpLogFile": "日志文件(启动时会写入口令):{{path}}",
|
||||||
|
"helpEnv": "固定口令:设置环境变量 {{env}}。"
|
||||||
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"welcome": "今天我能为您做些什么?",
|
"welcome": "今天我能为您做些什么?",
|
||||||
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
|
"welcomeDesc": "您可以询问我天气、设置或其他任何任务,我随时为您效劳。",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Normalize URL pathname for comparisons (trailing slashes, empty). */
|
||||||
|
export function normalizePathname(p: string): string {
|
||||||
|
const t = p.replace(/\/+$/, "")
|
||||||
|
return t === "" ? "/" : t
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLauncherLoginPathname(pathname: string): boolean {
|
||||||
|
return normalizePathname(pathname) === "/launcher-login"
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as ModelsRouteImport } from './routes/models'
|
import { Route as ModelsRouteImport } from './routes/models'
|
||||||
import { Route as LogsRouteImport } from './routes/logs'
|
import { Route as LogsRouteImport } from './routes/logs'
|
||||||
|
import { Route as LauncherLoginRouteImport } from './routes/launcher-login'
|
||||||
import { Route as CredentialsRouteImport } from './routes/credentials'
|
import { Route as CredentialsRouteImport } from './routes/credentials'
|
||||||
import { Route as ConfigRouteImport } from './routes/config'
|
import { Route as ConfigRouteImport } from './routes/config'
|
||||||
import { Route as AgentRouteImport } from './routes/agent'
|
import { Route as AgentRouteImport } from './routes/agent'
|
||||||
@@ -31,6 +32,11 @@ const LogsRoute = LogsRouteImport.update({
|
|||||||
path: '/logs',
|
path: '/logs',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LauncherLoginRoute = LauncherLoginRouteImport.update({
|
||||||
|
id: '/launcher-login',
|
||||||
|
path: '/launcher-login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const CredentialsRoute = CredentialsRouteImport.update({
|
const CredentialsRoute = CredentialsRouteImport.update({
|
||||||
id: '/credentials',
|
id: '/credentials',
|
||||||
path: '/credentials',
|
path: '/credentials',
|
||||||
@@ -83,6 +89,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/agent': typeof AgentRouteWithChildren
|
'/agent': typeof AgentRouteWithChildren
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
|
'/launcher-login': typeof LauncherLoginRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
@@ -96,6 +103,7 @@ export interface FileRoutesByTo {
|
|||||||
'/agent': typeof AgentRouteWithChildren
|
'/agent': typeof AgentRouteWithChildren
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
|
'/launcher-login': typeof LauncherLoginRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
@@ -110,6 +118,7 @@ export interface FileRoutesById {
|
|||||||
'/agent': typeof AgentRouteWithChildren
|
'/agent': typeof AgentRouteWithChildren
|
||||||
'/config': typeof ConfigRouteWithChildren
|
'/config': typeof ConfigRouteWithChildren
|
||||||
'/credentials': typeof CredentialsRoute
|
'/credentials': typeof CredentialsRoute
|
||||||
|
'/launcher-login': typeof LauncherLoginRoute
|
||||||
'/logs': typeof LogsRoute
|
'/logs': typeof LogsRoute
|
||||||
'/models': typeof ModelsRoute
|
'/models': typeof ModelsRoute
|
||||||
'/agent/skills': typeof AgentSkillsRoute
|
'/agent/skills': typeof AgentSkillsRoute
|
||||||
@@ -125,6 +134,7 @@ export interface FileRouteTypes {
|
|||||||
| '/agent'
|
| '/agent'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
|
| '/launcher-login'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
@@ -138,6 +148,7 @@ export interface FileRouteTypes {
|
|||||||
| '/agent'
|
| '/agent'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
|
| '/launcher-login'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
@@ -151,6 +162,7 @@ export interface FileRouteTypes {
|
|||||||
| '/agent'
|
| '/agent'
|
||||||
| '/config'
|
| '/config'
|
||||||
| '/credentials'
|
| '/credentials'
|
||||||
|
| '/launcher-login'
|
||||||
| '/logs'
|
| '/logs'
|
||||||
| '/models'
|
| '/models'
|
||||||
| '/agent/skills'
|
| '/agent/skills'
|
||||||
@@ -165,6 +177,7 @@ export interface RootRouteChildren {
|
|||||||
AgentRoute: typeof AgentRouteWithChildren
|
AgentRoute: typeof AgentRouteWithChildren
|
||||||
ConfigRoute: typeof ConfigRouteWithChildren
|
ConfigRoute: typeof ConfigRouteWithChildren
|
||||||
CredentialsRoute: typeof CredentialsRoute
|
CredentialsRoute: typeof CredentialsRoute
|
||||||
|
LauncherLoginRoute: typeof LauncherLoginRoute
|
||||||
LogsRoute: typeof LogsRoute
|
LogsRoute: typeof LogsRoute
|
||||||
ModelsRoute: typeof ModelsRoute
|
ModelsRoute: typeof ModelsRoute
|
||||||
}
|
}
|
||||||
@@ -185,6 +198,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LogsRouteImport
|
preLoaderRoute: typeof LogsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/launcher-login': {
|
||||||
|
id: '/launcher-login'
|
||||||
|
path: '/launcher-login'
|
||||||
|
fullPath: '/launcher-login'
|
||||||
|
preLoaderRoute: typeof LauncherLoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/credentials': {
|
'/credentials': {
|
||||||
id: '/credentials'
|
id: '/credentials'
|
||||||
path: '/credentials'
|
path: '/credentials'
|
||||||
@@ -292,6 +312,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
AgentRoute: AgentRouteWithChildren,
|
AgentRoute: AgentRouteWithChildren,
|
||||||
ConfigRoute: ConfigRouteWithChildren,
|
ConfigRoute: ConfigRouteWithChildren,
|
||||||
CredentialsRoute: CredentialsRoute,
|
CredentialsRoute: CredentialsRoute,
|
||||||
|
LauncherLoginRoute: LauncherLoginRoute,
|
||||||
LogsRoute: LogsRoute,
|
LogsRoute: LogsRoute,
|
||||||
ModelsRoute: ModelsRoute,
|
ModelsRoute: ModelsRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,56 @@
|
|||||||
import { Outlet, createRootRoute } from "@tanstack/react-router"
|
import {
|
||||||
|
Outlet,
|
||||||
|
createRootRoute,
|
||||||
|
useRouterState,
|
||||||
|
} from "@tanstack/react-router"
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
|
||||||
import { AppLayout } from "@/components/app-layout"
|
import { AppLayout } from "@/components/app-layout"
|
||||||
import { initializeChatStore } from "@/features/chat/controller"
|
import { initializeChatStore } from "@/features/chat/controller"
|
||||||
|
import { isLauncherLoginPathname } from "@/lib/launcher-login-path"
|
||||||
|
|
||||||
const RootLayout = () => {
|
const RootLayout = () => {
|
||||||
|
// Prefer the real address bar path: stale embedded bundles may not register
|
||||||
|
// /launcher-login in the route tree, which would otherwise keep AppLayout +
|
||||||
|
// gateway polling → 401 → launcherFetch redirect loop.
|
||||||
|
const routerState = useRouterState({
|
||||||
|
select: (s) => ({
|
||||||
|
pathname: s.location.pathname,
|
||||||
|
matches: s.matches,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const windowPath =
|
||||||
|
typeof globalThis.location !== "undefined"
|
||||||
|
? globalThis.location.pathname || "/"
|
||||||
|
: routerState.pathname
|
||||||
|
|
||||||
|
const isLauncherLogin =
|
||||||
|
isLauncherLoginPathname(windowPath) ||
|
||||||
|
isLauncherLoginPathname(routerState.pathname) ||
|
||||||
|
routerState.matches.some((m) => m.routeId === "/launcher-login")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isLauncherLogin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
initializeChatStore()
|
initializeChatStore()
|
||||||
}, [])
|
}, [isLauncherLogin])
|
||||||
|
|
||||||
|
if (isLauncherLogin) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Outlet />
|
||||||
|
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackRouterDevtools />
|
{import.meta.env.DEV ? <TanStackRouterDevtools /> : null}
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { IconLanguage, IconMoon, IconSun } from "@tabler/icons-react"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import * as React from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
import {
|
||||||
|
getLauncherAuthStatus,
|
||||||
|
postLauncherDashboardLogin,
|
||||||
|
type LauncherAuthTokenHelp,
|
||||||
|
} from "@/api/launcher-auth"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { useTheme } from "@/hooks/use-theme"
|
||||||
|
|
||||||
|
function LauncherLoginPage() {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const [token, setToken] = React.useState("")
|
||||||
|
const [submitting, setSubmitting] = React.useState(false)
|
||||||
|
const [error, setError] = React.useState("")
|
||||||
|
const [tokenHelp, setTokenHelp] = React.useState<LauncherAuthTokenHelp | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void getLauncherAuthStatus()
|
||||||
|
.then((s) => {
|
||||||
|
if (cancelled || s.authenticated || !s.token_help) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTokenHelp(s.token_help)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* ignore; login form still usable */
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loginWithToken = React.useCallback(
|
||||||
|
async (tokenValue: string) => {
|
||||||
|
setError("")
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const ok = await postLauncherDashboardLogin(tokenValue)
|
||||||
|
if (ok) {
|
||||||
|
globalThis.location.assign("/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(t("launcherLogin.errorInvalid"))
|
||||||
|
} catch {
|
||||||
|
setError(t("launcherLogin.errorNetwork"))
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[t],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
await loginWithToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background text-foreground flex min-h-dvh flex-col">
|
||||||
|
<header className="border-border/50 flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" aria-label="Language">
|
||||||
|
<IconLanguage className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => i18n.changeLanguage("en")}>
|
||||||
|
English
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => i18n.changeLanguage("zh")}>
|
||||||
|
简体中文
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTheme()}
|
||||||
|
aria-label={theme === "dark" ? "Light mode" : "Dark mode"}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<IconSun className="size-4" />
|
||||||
|
) : (
|
||||||
|
<IconMoon className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md" size="sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("launcherLogin.title")}</CardTitle>
|
||||||
|
<CardDescription>{t("launcherLogin.description")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="launcher-token">
|
||||||
|
{t("launcherLogin.tokenLabel")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="launcher-token"
|
||||||
|
name="token"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder={t("launcherLogin.tokenPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? t("labels.loading") : t("launcherLogin.submit")}
|
||||||
|
</Button>
|
||||||
|
{error ? (
|
||||||
|
<p className="text-destructive text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
{tokenHelp ? (
|
||||||
|
<div className="border-border/60 mt-6 border-t pt-4">
|
||||||
|
<p className="text-muted-foreground mb-2 text-sm font-medium">
|
||||||
|
{t("launcherLogin.helpTitle")}
|
||||||
|
</p>
|
||||||
|
<ul className="text-muted-foreground list-inside list-disc space-y-1.5 text-sm">
|
||||||
|
{tokenHelp.console_stdout ? (
|
||||||
|
<li>{t("launcherLogin.helpConsole")}</li>
|
||||||
|
) : null}
|
||||||
|
{tokenHelp.tray_copy_menu ? (
|
||||||
|
<li>{t("launcherLogin.helpTray")}</li>
|
||||||
|
) : null}
|
||||||
|
{tokenHelp.log_file ? (
|
||||||
|
<li>
|
||||||
|
{t("launcherLogin.helpLogFile", {
|
||||||
|
path: tokenHelp.log_file,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
{tokenHelp.env_var_name ? (
|
||||||
|
<li>
|
||||||
|
{t("launcherLogin.helpEnv", {
|
||||||
|
env: tokenHelp.env_var_name,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/launcher-login")({
|
||||||
|
component: LauncherLoginPage,
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user