feat(admin): add session-based portal login

This commit is contained in:
phamnazage-jpg
2026-05-28 11:01:29 +08:00
parent 03c4b5236f
commit de33ff3492
15 changed files with 833 additions and 75 deletions

View File

@@ -1,3 +1,6 @@
SUB2API_CRM_LISTEN_ADDR=:8080
SUB2API_CRM_SQLITE_DSN=file:/data/sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000
SUB2API_CRM_ADMIN_TOKEN=change-me-before-production
SUB2API_CRM_ADMIN_USERNAME=admin
SUB2API_CRM_ADMIN_PASSWORD=change-me-before-production
SUB2API_CRM_ADMIN_SESSION_TTL=12h

View File

@@ -412,7 +412,7 @@
<article class="panel">
<h2>发起导入</h2>
<p class="panel-desc">
用 admin token 直接调用当前控制面的 batch-import API。
优先使用管理员登录会话调用当前控制面的 batch-import API;必要时也可以回退到 Bearer token
`entries` 每行一个供应商帐号:`base_url|api_key|model_a,model_b`。
</p>
@@ -426,8 +426,9 @@
</div>
<div class="field-grid two">
<label>Admin Token
<label>Admin Token(可选)
<input id="admin-token" type="password" placeholder="secret-token">
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
</label>
<label>Mode
<select id="mode">
@@ -437,6 +438,21 @@
</label>
</div>
<div class="field-grid two">
<label>管理员用户名
<input id="admin-username" type="text" placeholder="admin">
</label>
<label>管理员密码
<input id="admin-password" type="password" placeholder="请输入管理员密码">
</label>
</div>
<div class="toolbar">
<button id="admin-login-btn" type="button">管理员登录</button>
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
<span id="admin-session-status" class="statusbar">尚未检查管理员会话。</span>
</div>
<div class="field-grid two">
<label>Access Mode
<select id="access-mode">
@@ -568,6 +584,11 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
const apiBaseInput = document.getElementById("api-base");
const hostIDInput = document.getElementById("host-id");
const adminTokenInput = document.getElementById("admin-token");
const adminUsernameInput = document.getElementById("admin-username");
const adminPasswordInput = document.getElementById("admin-password");
const adminLoginButton = document.getElementById("admin-login-btn");
const adminLogoutButton = document.getElementById("admin-logout-btn");
const adminSessionStatus = document.getElementById("admin-session-status");
const modeInput = document.getElementById("mode");
const accessModeInput = document.getElementById("access-mode");
const confirmTimeoutInput = document.getElementById("confirm-timeout");
@@ -615,6 +636,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
apiBase: apiBaseInput.value.trim(),
hostID: hostIDInput.value.trim(),
adminToken: adminTokenInput.value,
adminUsername: adminUsernameInput.value.trim(),
mode: modeInput.value,
accessMode: accessModeInput.value,
confirmTimeoutSec: confirmTimeoutInput.value,
@@ -641,6 +663,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
apiBaseInput.value = payload.apiBase || defaultApiBase();
hostIDInput.value = payload.hostID || "";
adminTokenInput.value = payload.adminToken || "";
adminUsernameInput.value = payload.adminUsername || "";
modeInput.value = payload.mode || "strict";
accessModeInput.value = payload.accessMode || "self_service";
confirmTimeoutInput.value = payload.confirmTimeoutSec || "10";
@@ -673,14 +696,14 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
}
function authHeaders() {
const token = adminTokenInput.value.trim();
if (!token) {
throw new Error("admin token 不能为空");
}
return {
const headers = {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
};
const token = adminTokenInput.value.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function parseEntries() {
@@ -734,7 +757,16 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
}
async function requestJSON(path, options = {}) {
const response = await fetch(`${normalizeApiBase()}${path}`, options);
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { ...headers };
if (!skipAuth) {
Object.assign(finalHeaders, authHeaders(), finalHeaders);
}
const response = await fetch(`${normalizeApiBase()}${path}`, {
...rest,
credentials: "include",
headers: finalHeaders,
});
const text = await response.text();
let payload = {};
try {
@@ -749,6 +781,58 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
return payload;
}
async function refreshAdminSession() {
try {
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
if (payload.username && !adminUsernameInput.value.trim()) {
adminUsernameInput.value = payload.username;
}
if (payload.authenticated) {
setStatus(`管理员已登录:${payload.username}`, "success");
adminSessionStatus.textContent = `已登录:${payload.username}`;
} else if (payload.login_enabled) {
adminSessionStatus.textContent = "未登录,可直接使用管理员用户名密码建立会话。";
} else {
adminSessionStatus.textContent = "当前实例未启用管理员密码登录,只能使用 Bearer token。";
}
return payload;
} catch (error) {
adminSessionStatus.textContent = `管理员会话检查失败:${error.message}`;
throw error;
}
}
async function loginAdminSession() {
const username = adminUsernameInput.value.trim();
const password = adminPasswordInput.value;
if (!username || !password) {
throw new Error("管理员用户名和密码不能为空");
}
const payload = await requestJSON("/api/admin/session/login", {
method: "POST",
skipAuth: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
adminPasswordInput.value = "";
saveConfig();
adminSessionStatus.textContent = `已登录:${payload.username}`;
return payload;
}
async function logoutAdminSession() {
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
method: "POST",
credentials: "include",
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
adminPasswordInput.value = "";
adminSessionStatus.textContent = "管理员会话已退出。";
}
function syncHeaderMetrics() {
metricApiRoot.textContent = normalizeApiBase();
metricRunID.textContent = state.currentRunID || "-";
@@ -926,11 +1010,28 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
document.getElementById("load-sample-btn").addEventListener("click", loadSampleEntries);
document.getElementById("clear-items-btn").addEventListener("click", clearResults);
document.getElementById("apply-filter-btn").addEventListener("click", refreshRun);
adminLoginButton.addEventListener("click", async () => {
try {
await loginAdminSession();
setStatus("管理员会话已建立。", "success");
} catch (error) {
setStatus(error.message, "danger");
}
});
adminLogoutButton.addEventListener("click", async () => {
try {
await logoutAdminSession();
await refreshAdminSession();
} catch (error) {
setStatus(error.message, "danger");
}
});
accessModeInput.addEventListener("change", updateAccessModeFields);
restoreConfig();
updateAccessModeFields();
syncHeaderMetrics();
refreshAdminSession().catch(() => {});
</script>
</body>
</html>

View File

@@ -375,7 +375,7 @@
</p>
<ul class="hero-points">
<li>默认 API Base<code>/portal-admin-api</code></li>
<li>支持同域 Bearer admin token</li>
<li>支持管理员登录会话,也保留 Bearer admin token 兜底</li>
<li>支持 provider 草稿发布到 pack 仓库</li>
</ul>
</article>
@@ -408,11 +408,27 @@
<label>API Base
<input id="api-base" type="text" placeholder="https://sub.tksea.top/portal-admin-api">
</label>
<label>Admin Token
<label>Admin Token(可选)
<input id="admin-token" type="password" placeholder="crm-admin-token">
<span class="hint">优先使用下方管理员登录;这里只保留给脚本联调或紧急兜底。</span>
</label>
</div>
<div class="field-grid two">
<label>管理员用户名
<input id="admin-username" type="text" placeholder="admin">
</label>
<label>管理员密码
<input id="admin-password" type="password" placeholder="请输入管理员密码">
</label>
</div>
<div class="actions">
<button id="admin-login-btn" type="button">管理员登录</button>
<button id="admin-logout-btn" type="button" class="ghost">退出登录</button>
<span id="admin-session-status" class="status">尚未检查管理员会话。</span>
</div>
<div class="field-grid two">
<label>Pack
<select id="pack-id"></select>
@@ -592,6 +608,11 @@
const apiBaseInput = document.getElementById("api-base");
const adminTokenInput = document.getElementById("admin-token");
const adminUsernameInput = document.getElementById("admin-username");
const adminPasswordInput = document.getElementById("admin-password");
const adminLoginButton = document.getElementById("admin-login-btn");
const adminLogoutButton = document.getElementById("admin-logout-btn");
const adminSessionStatus = document.getElementById("admin-session-status");
const packIDInput = document.getElementById("pack-id");
const hostIDInput = document.getElementById("host-id");
const packPathInput = document.getElementById("pack-path");
@@ -641,18 +662,27 @@
}
function authHeaders() {
const token = adminTokenInput.value.trim();
if (!token) {
throw new Error("admin token 不能为空");
}
return {
const headers = {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
};
const token = adminTokenInput.value.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
function requestJSON(path, options = {}) {
return fetch(`${normalizeApiBase()}${path}`, options)
const { skipAuth = false, headers = {}, ...rest } = options;
const finalHeaders = { ...headers };
if (!skipAuth) {
Object.assign(finalHeaders, authHeaders(), finalHeaders);
}
return fetch(`${normalizeApiBase()}${path}`, {
...rest,
credentials: "include",
headers: finalHeaders,
})
.then(async (response) => {
const text = await response.text();
let payload = {};
@@ -679,6 +709,7 @@
localStorage.setItem(storageKey, JSON.stringify({
apiBase: apiBaseInput.value.trim(),
adminToken: adminTokenInput.value,
adminUsername: adminUsernameInput.value.trim(),
packID: packIDInput.value,
hostID: hostIDInput.value,
packPath: packPathInput.value.trim(),
@@ -714,6 +745,7 @@
const payload = JSON.parse(raw);
apiBaseInput.value = payload.apiBase || defaultApiBase();
adminTokenInput.value = payload.adminToken || "";
adminUsernameInput.value = payload.adminUsername || "";
packPathInput.value = payload.packPath || "/app/packs/openai-cn-pack";
providerIDInput.value = payload.providerID || "";
modeInput.value = payload.mode || "strict";
@@ -739,6 +771,57 @@
subscriptionFields.hidden = accessModeInput.value !== "subscription";
}
async function refreshAdminSession() {
try {
const payload = await requestJSON("/api/admin/session", { skipAuth: true });
if (payload.username && !adminUsernameInput.value.trim()) {
adminUsernameInput.value = payload.username;
}
if (payload.authenticated) {
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
} else if (payload.login_enabled) {
setStatus(adminSessionStatus, "未登录。可直接使用管理员用户名密码建立会话。", "note");
} else {
setStatus(adminSessionStatus, "当前实例未启用管理员密码登录,只能使用 Bearer token。", "warning");
}
return payload;
} catch (error) {
setStatus(adminSessionStatus, `管理员会话检查失败:${error.message}`, "danger");
throw error;
}
}
async function loginAdminSession() {
const username = adminUsernameInput.value.trim();
const password = adminPasswordInput.value;
if (!username || !password) {
throw new Error("管理员用户名和密码不能为空");
}
const payload = await requestJSON("/api/admin/session/login", {
method: "POST",
skipAuth: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
adminPasswordInput.value = "";
saveConfig();
setStatus(adminSessionStatus, `已登录:${payload.username}`, "success");
return payload;
}
async function logoutAdminSession() {
const response = await fetch(`${normalizeApiBase()}/api/admin/session/logout`, {
method: "POST",
credentials: "include",
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
adminPasswordInput.value = "";
setStatus(adminSessionStatus, "管理员会话已退出。", "note");
}
function renderSelectOptions(select, values, currentValue, emptyLabel) {
select.innerHTML = "";
if (!values.length) {
@@ -1203,6 +1286,22 @@
document.getElementById("delete-draft-btn").addEventListener("click", deleteDraftOnServer);
document.getElementById("copy-draft-btn").addEventListener("click", copyDraft);
document.getElementById("refresh-drafts-btn").addEventListener("click", loadServerDrafts);
adminLoginButton.addEventListener("click", async () => {
try {
await loginAdminSession();
await loadCatalog();
} catch (error) {
setStatus(adminSessionStatus, error.message, "danger");
}
});
adminLogoutButton.addEventListener("click", async () => {
try {
await logoutAdminSession();
await refreshAdminSession();
} catch (error) {
setStatus(adminSessionStatus, error.message, "danger");
}
});
accessModeInput.addEventListener("change", updateAccessModeFields);
packIDInput.addEventListener("change", loadCatalog);
providerIDInput.addEventListener("input", syncMetrics);
@@ -1211,6 +1310,7 @@
restoreConfig();
updateAccessModeFields();
syncMetrics();
refreshAdminSession().catch(() => {});
renderServerDrafts();
</script>
</body>

View File

@@ -62,6 +62,7 @@ SUB2API_CRM_ADMIN_TOKEN=change-me-before-production SUB2API_CRM_LISTEN_ADDR=127.
- `https://sub.tksea.top/portal/admin/`
- 管理首页
- 统一提供“新增模型 / 供应商目录”和“导入供应商帐号”入口
- 当前已支持管理员用户名 / 密码登录;登录成功后浏览器会持有同域 HttpOnly session cookie
- `https://sub.tksea.top/portal/admin/providers.html`
- provider 目录与 preview/import 管理页
- 当前已支持通过 `provider_drafts` API 把 provider manifest 草稿持久化到 CRM SQLite并直接更新 / 删除 / 发布到 pack 仓库
@@ -77,9 +78,23 @@ SUB2API_CRM_ADMIN_TOKEN=change-me-before-production SUB2API_CRM_LISTEN_ADDR=127.
- `https://sub.tksea.top/portal-admin-api/`
- 反代到 CRM
- 浏览器侧仍需 Bearer admin token
- 浏览器侧优先走管理员 session同时保留 Bearer admin token 兼容脚本与紧急兜底
- 作用是让静态 admin 页面不必直接访问 remote43 的内网 `18173`
管理员登录配置:
- `SUB2API_CRM_ADMIN_TOKEN`
- 必填
- 继续作为服务端管理 API 的 Bearer token同时也是 session cookie 的签名密钥
- `SUB2API_CRM_ADMIN_USERNAME`
- 可选,默认 `admin`
- `SUB2API_CRM_ADMIN_PASSWORD`
- 可选
- 若未配置,当前实现会回退为“使用 `SUB2API_CRM_ADMIN_TOKEN` 作为登录密码”
- `SUB2API_CRM_ADMIN_SESSION_TTL`
- 可选,默认 `12h`
- 控制浏览器管理态 session 的有效期
当前 provider 草稿发布相关 API
- `POST /api/provider-drafts`

View File

@@ -35,7 +35,7 @@
- `https://sub.tksea.top/portal/admin/providers.html`provider 目录 / preview-import / import / manifest 草稿页
- `https://sub.tksea.top/portal/admin/batch-import.html`:结构化 batch-import 入口,当前跳转到 legacy `admin-batch-import.html`
- Nginx 示例与 deploy 脚本已补同域 CRM 反代 `https://sub.tksea.top/portal-admin-api/`
- 目的不是绕过鉴权,而是让浏览器可直接操作 remote43 CRM,同时继续由 Bearer admin token 控制权限
- 目的不是绕过鉴权,而是让浏览器可直接操作 remote43 CRM;当前已继续补成“管理员用户名 / 密码登录 + HttpOnly session cookie”同时保留 Bearer admin token 兼容脚本与紧急兜底
- 2026-05-27 已继续把 provider manifest 草稿从“只存在浏览器”补成真正的服务端能力:
- 新增 `POST /api/provider-drafts`
- 新增 `GET /api/provider-drafts`
@@ -67,6 +67,18 @@
- `GET /portal/` 返回 `200`
- `GET /kimi-portal/` 返回 `302 -> /portal/`
- `GET /portal-proxy/api/v1/keys` 在无效 token 下已命中宿主真实 `INVALID_TOKEN`,说明新的同域代理已生效
- 2026-05-28 已继续把管理态“每次手贴 Bearer token”收口为正式登录流
- 新增 `GET /api/admin/session`
- 新增 `POST /api/admin/session/login`
- 新增 `POST /api/admin/session/logout`
- 管理态受保护接口现已同时接受:
- `Authorization: Bearer <SUB2API_CRM_ADMIN_TOKEN>`
- 或同域管理员 session cookie
- `providers.html``admin-batch-import.html` 现已优先走 sessiontoken 输入框仅保留为兜底
- 当前部署环境可通过以下变量显式配置管理员账号:
- `SUB2API_CRM_ADMIN_USERNAME`
- `SUB2API_CRM_ADMIN_PASSWORD`
- `SUB2API_CRM_ADMIN_SESSION_TTL`
- 2026-05-26 已把“最终用户 -> 公网域名 -> OpenClaw”这一跳补进正式验证口径
- 公网根地址当前统一为 `https://sub.tksea.top`
- OpenClaw 本地 `MiniMax` 运行时故障已定位为 `pi-ai/openai-node` 未继承系统 `HTTP(S)_PROXY`,不是 allowlist 或模型名大小写问题

278
internal/app/admin_auth.go Normal file
View File

@@ -0,0 +1,278 @@
package app
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
)
const (
adminSessionCookieName = "sub2api_crm_admin_session"
defaultAdminUsername = "admin"
defaultAdminSessionTTL = 12 * time.Hour
)
type AdminAuthConfig struct {
Token string
Username string
Password string
SessionTTL time.Duration
Now func() time.Time
}
type adminLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type adminSessionInfo struct {
Username string
ExpiresAt time.Time
}
func (c AdminAuthConfig) normalized() AdminAuthConfig {
c.Token = strings.TrimSpace(c.Token)
c.Username = strings.TrimSpace(c.Username)
c.Password = strings.TrimSpace(c.Password)
if c.Username == "" {
c.Username = defaultAdminUsername
}
if c.Password == "" {
c.Password = c.Token
}
if c.SessionTTL <= 0 {
c.SessionTTL = defaultAdminSessionTTL
}
if c.Now == nil {
c.Now = time.Now
}
return c
}
func (c AdminAuthConfig) loginEnabled() bool {
cfg := c.normalized()
return cfg.Token != "" && cfg.Password != ""
}
func (c AdminAuthConfig) now() time.Time {
return c.normalized().Now()
}
func (c AdminAuthConfig) sessionCookie(r *http.Request) (*http.Cookie, *adminSessionInfo, bool) {
cfg := c.normalized()
cookie, err := r.Cookie(adminSessionCookieName)
if err != nil || cookie == nil || strings.TrimSpace(cookie.Value) == "" {
return nil, nil, false
}
info, ok := verifyAdminSessionValue(cfg.Token, cookie.Value, cfg.now())
if !ok {
return cookie, nil, false
}
return cookie, info, true
}
func (c AdminAuthConfig) requestAuthorized(r *http.Request) bool {
cfg := c.normalized()
if secureCompare(bearerToken(r), cfg.Token) {
return true
}
_, _, ok := cfg.sessionCookie(r)
return ok
}
func secureCompare(left, right string) bool {
if left == "" || right == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
}
func signAdminSessionValue(secret, username string, expiresAt time.Time) string {
payload := strings.TrimSpace(username) + "|" + strconv.FormatInt(expiresAt.Unix(), 10)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + signature
}
func verifyAdminSessionValue(secret, raw string, now time.Time) (*adminSessionInfo, bool) {
if strings.TrimSpace(secret) == "" || strings.TrimSpace(raw) == "" {
return nil, false
}
parts := strings.Split(raw, ".")
if len(parts) != 2 {
return nil, false
}
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, false
}
payload := string(payloadBytes)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(payload))
expectedSignature := mac.Sum(nil)
signatureBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, false
}
if subtle.ConstantTimeCompare(signatureBytes, expectedSignature) != 1 {
return nil, false
}
fields := strings.Split(payload, "|")
if len(fields) != 2 {
return nil, false
}
username := strings.TrimSpace(fields[0])
if username == "" {
return nil, false
}
unixSeconds, err := strconv.ParseInt(fields[1], 10, 64)
if err != nil {
return nil, false
}
expiresAt := time.Unix(unixSeconds, 0)
if !expiresAt.After(now) {
return nil, false
}
return &adminSessionInfo{Username: username, ExpiresAt: expiresAt}, true
}
func issueAdminSessionCookie(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig, username string) *adminSessionInfo {
cfg = cfg.normalized()
expiresAt := cfg.now().Add(cfg.SessionTTL)
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookieName,
Value: signAdminSessionValue(cfg.Token, username, expiresAt),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: requestUsesHTTPS(r),
Expires: expiresAt,
MaxAge: int(cfg.SessionTTL.Seconds()),
})
return &adminSessionInfo{Username: username, ExpiresAt: expiresAt}
}
func clearAdminSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: adminSessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: requestUsesHTTPS(r),
Expires: time.Unix(0, 0),
MaxAge: -1,
})
}
func requestUsesHTTPS(r *http.Request) bool {
if r != nil && r.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
func handleAdminSessionState(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig) {
cfg = cfg.normalized()
payload := map[string]any{
"authenticated": false,
"login_enabled": cfg.loginEnabled(),
"username": cfg.Username,
}
if _, session, ok := cfg.sessionCookie(r); ok {
payload["authenticated"] = true
payload["username"] = session.Username
payload["expires_at"] = session.ExpiresAt.Format(time.RFC3339)
}
writeJSON(w, http.StatusOK, payload)
}
func handleAdminSessionLogin(w http.ResponseWriter, r *http.Request, cfg AdminAuthConfig) {
cfg = cfg.normalized()
if cfg.Token == "" {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"})
return
}
if !cfg.loginEnabled() {
writeHTTPError(w, &httpError{StatusCode: http.StatusServiceUnavailable, Code: "login_disabled", Message: "admin login is not enabled"})
return
}
var req adminLoginRequest
if err := decodeJSON(r, &req); err != nil {
writeHTTPError(w, err)
return
}
if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" {
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "username and password are required"})
return
}
if !secureCompare(strings.TrimSpace(req.Username), cfg.Username) || !secureCompare(strings.TrimSpace(req.Password), cfg.Password) {
writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "invalid admin credentials"})
return
}
session := issueAdminSessionCookie(w, r, cfg, cfg.Username)
writeJSON(w, http.StatusOK, map[string]any{
"authenticated": true,
"username": session.Username,
"expires_at": session.ExpiresAt.Format(time.RFC3339),
})
}
func handleAdminSessionLogout(w http.ResponseWriter, r *http.Request) {
clearAdminSessionCookie(w, r)
w.WriteHeader(http.StatusNoContent)
}
func requireAdminAccess(cfg AdminAuthConfig, next http.Handler) http.Handler {
cfg = cfg.normalized()
if cfg.Token == "" {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"})
})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !cfg.requestAuthorized(r) {
writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "missing or invalid admin credentials"})
return
}
next.ServeHTTP(w, r)
})
}
func adminSessionDebugValue(secret, username string, expiresAt time.Time) string {
return hex.EncodeToString([]byte(signAdminSessionValue(secret, username, expiresAt)))
}
func adminSessionPayload(raw string) map[string]any {
payload := map[string]any{"raw": raw}
parts := strings.Split(raw, ".")
if len(parts) != 2 {
return payload
}
body, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return payload
}
fields := strings.Split(string(body), "|")
payload["payload"] = string(body)
if len(fields) == 2 {
payload["username"] = fields[0]
payload["expires_unix"] = fields[1]
}
return payload
}
func marshalAdminSessionPayload(raw string) string {
body, _ := json.Marshal(adminSessionPayload(raw))
return string(body)
}

View File

@@ -128,6 +128,122 @@ func TestAPIRejectsMissingAdminToken(t *testing.T) {
assertJSONContains(t, response.Body().Bytes(), "error.code", "unauthorized")
}
func TestAPIAdminSessionLoginSetsCookieAndAuthorizesSubsequentRequest(t *testing.T) {
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
SessionTTL: 2 * time.Hour,
Now: func() time.Time {
return time.Unix(1_717_000_000, 0)
},
}, ActionSet{
ListPacks: func(context.Context) ([]PackInfo, error) {
return []PackInfo{{PackID: "openai-cn-pack", Version: "1.1.6"}}, nil
},
})
loginRequest := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
"username": "admin",
"password": "pass-123",
}, "")
loginResponse := httptestRecorder(handler, loginRequest)
assertStatusCode(t, loginResponse, http.StatusOK)
assertJSONContains(t, loginResponse.Body().Bytes(), "authenticated", true)
assertJSONContains(t, loginResponse.Body().Bytes(), "username", "admin")
cookies := loginResponse.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("login cookies = %d, want 1", len(cookies))
}
if cookies[0].Name != adminSessionCookieName {
t.Fatalf("cookie name = %q, want %q", cookies[0].Name, adminSessionCookieName)
}
if !cookies[0].HttpOnly {
t.Fatal("session cookie HttpOnly = false, want true")
}
authorizedRequest := httptestRequest(t, http.MethodGet, "/api/packs", nil, "")
authorizedRequest.AddCookie(cookies[0])
authorizedResponse := httptestRecorder(handler, authorizedRequest)
assertStatusCode(t, authorizedResponse, http.StatusOK)
if !strings.Contains(authorizedResponse.Body().String(), `"pack_id":"openai-cn-pack"`) {
t.Fatalf("authorized response = %s, want pack_id openai-cn-pack", authorizedResponse.Body().String())
}
}
func TestAPIAdminSessionRejectsInvalidPassword(t *testing.T) {
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
}, ActionSet{})
request := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
"username": "admin",
"password": "wrong",
}, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusUnauthorized)
assertJSONContains(t, response.Body().Bytes(), "error.code", "unauthorized")
}
func TestAPIAdminSessionLogoutClearsCookie(t *testing.T) {
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
}, ActionSet{})
request := httptestRequest(t, http.MethodPost, "/api/admin/session/logout", nil, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusNoContent)
cookies := response.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("logout cookies = %d, want 1", len(cookies))
}
if cookies[0].Name != adminSessionCookieName {
t.Fatalf("cookie name = %q, want %q", cookies[0].Name, adminSessionCookieName)
}
if cookies[0].MaxAge != -1 {
t.Fatalf("cookie MaxAge = %d, want -1", cookies[0].MaxAge)
}
}
func TestAPIAdminSessionMeReportsAuthenticationState(t *testing.T) {
now := time.Unix(1_717_000_000, 0)
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: "secret-token",
Username: "admin",
Password: "pass-123",
SessionTTL: time.Hour,
Now: func() time.Time {
return now
},
}, ActionSet{})
request := httptestRequest(t, http.MethodGet, "/api/admin/session", nil, "")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, http.StatusOK)
assertJSONContains(t, response.Body().Bytes(), "authenticated", false)
assertJSONContains(t, response.Body().Bytes(), "login_enabled", true)
loginRequest := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{
"username": "admin",
"password": "pass-123",
}, "")
loginResponse := httptestRecorder(handler, loginRequest)
assertStatusCode(t, loginResponse, http.StatusOK)
meRequest := httptestRequest(t, http.MethodGet, "/api/admin/session", nil, "")
for _, cookie := range loginResponse.Result().Cookies() {
meRequest.AddCookie(cookie)
}
meResponse := httptestRecorder(handler, meRequest)
assertStatusCode(t, meResponse, http.StatusOK)
assertJSONContains(t, meResponse.Body().Bytes(), "authenticated", true)
assertJSONContains(t, meResponse.Body().Bytes(), "username", "admin")
}
func TestAPIInstallPackReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
InstallPack: func(context.Context, InstallPackRequest) (provision.PackInstallResult, error) {
@@ -545,10 +661,26 @@ type responseRecorder struct {
code int
}
func (r *responseRecorder) Header() http.Header { return r.header }
func (r *responseRecorder) Write(body []byte) (int, error) { return r.body.Write(body) }
func (r *responseRecorder) WriteHeader(statusCode int) { r.code = statusCode }
func (r *responseRecorder) Body() *bytes.Buffer { return &r.body }
func (r *responseRecorder) Header() http.Header { return r.header }
func (r *responseRecorder) Write(body []byte) (int, error) {
if r.code == 0 {
r.code = http.StatusOK
}
return r.body.Write(body)
}
func (r *responseRecorder) WriteHeader(statusCode int) { r.code = statusCode }
func (r *responseRecorder) Body() *bytes.Buffer { return &r.body }
func (r *responseRecorder) Result() *http.Response {
statusCode := r.code
if statusCode == 0 {
statusCode = http.StatusOK
}
return &http.Response{
StatusCode: statusCode,
Header: r.header.Clone(),
Body: io.NopCloser(bytes.NewReader(r.body.Bytes())),
}
}
func assertStatusCode(t *testing.T, recorder *responseRecorder, want int) {
t.Helper()

View File

@@ -16,8 +16,17 @@ func Bootstrap(ctx context.Context) (*Server, error) {
if err != nil {
return nil, err
}
adminSession, err := config.LoadAdminSessionFromEnv()
if err != nil {
return nil, err
}
startBackgroundSchedulers(ctx, cfg, defaultBackgroundSchedulers())
handler := NewAPIHandler(adminToken, NewActionSet(cfg.Database.SQLiteDSN))
handler := NewAPIHandlerWithAuth(AdminAuthConfig{
Token: adminToken,
Username: adminSession.Username,
Password: adminSession.Password,
SessionTTL: adminSession.SessionTTL,
}, NewActionSet(cfg.Database.SQLiteDSN))
return NewServer(cfg.Server.ListenAddr, handler, nil), nil
}

View File

@@ -276,102 +276,115 @@ func (e *httpError) Error() string {
}
func NewAPIHandler(adminToken string, actions ActionSet) http.Handler {
return NewAPIHandlerWithAuth(AdminAuthConfig{Token: adminToken}, actions)
}
func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.Handle("POST /api/batch-import/runs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("GET /api/admin/session", func(w http.ResponseWriter, r *http.Request) {
handleAdminSessionState(w, r, adminAuth)
})
mux.HandleFunc("POST /api/admin/session/login", func(w http.ResponseWriter, r *http.Request) {
handleAdminSessionLogin(w, r, adminAuth)
})
mux.HandleFunc("POST /api/admin/session/logout", func(w http.ResponseWriter, r *http.Request) {
handleAdminSessionLogout(w, r)
})
mux.Handle("POST /api/batch-import/runs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleCreateBatchImportRun(w, r, actions.CreateBatchImportRun)
})))
mux.Handle("GET /api/batch-import/runs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/batch-import/runs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListBatchImportRuns(w, r, actions.ListBatchImportRuns)
})))
mux.Handle("GET /api/batch-import/runs/{run_id}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/batch-import/runs/{run_id}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetBatchImportRun(w, r, actions.GetBatchImportRun)
})))
mux.Handle("GET /api/batch-import/runs/{run_id}/items", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/batch-import/runs/{run_id}/items", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListBatchImportRunItems(w, r, actions.ListBatchImportRunItems)
})))
mux.Handle("GET /api/batch-import/runs/{run_id}/items/{item_id}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/batch-import/runs/{run_id}/items/{item_id}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetBatchImportRunItem(w, r, actions.GetBatchImportRunItem)
})))
mux.Handle("POST /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/provider-drafts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleCreateProviderDraft(w, r, actions.CreateProviderDraft)
})))
mux.Handle("GET /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/provider-drafts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListProviderDrafts(w, r, actions.ListProviderDrafts)
})))
mux.Handle("GET /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/provider-drafts/{draftID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetProviderDraft(w, r, actions.GetProviderDraft)
})))
mux.Handle("PUT /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("PUT /api/provider-drafts/{draftID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleUpdateProviderDraft(w, r, actions.UpdateProviderDraft)
})))
mux.Handle("DELETE /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("DELETE /api/provider-drafts/{draftID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleDeleteProviderDraft(w, r, actions.DeleteProviderDraft)
})))
mux.Handle("POST /api/provider-drafts/{draftID}/publish", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/provider-drafts/{draftID}/publish", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlePublishProviderDraft(w, r, actions.PublishProviderDraft)
})))
mux.Handle("GET /api/import-batches/{batchID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/import-batches/{batchID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleBatchDetail(w, r, actions.BatchDetail)
})))
mux.Handle("POST /api/import-batches/{batchID}/rollback", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/import-batches/{batchID}/rollback", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleRollbackBatch(w, r, actions.RollbackBatch)
})))
mux.Handle("GET /api/providers/{providerID}/status", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/providers/{providerID}/status", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProviderStatus(w, r, actions.GetProviderStatus)
})))
mux.Handle("GET /api/providers/{providerID}/resources", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/providers/{providerID}/resources", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProviderResources(w, r, actions.GetProviderResources)
})))
mux.Handle("GET /api/providers/{providerID}/access/status", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/providers/{providerID}/access/status", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProviderAccessStatus(w, r, actions.GetProviderAccessStatus)
})))
mux.Handle("GET /api/providers/{providerID}/import-batches", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/providers/{providerID}/import-batches", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListProviderImportBatches(w, r, actions.ListProviderImportBatches)
})))
mux.Handle("POST /api/providers/{providerID}/access/assign-subscriptions", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/access/assign-subscriptions", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleAssignAccessSubscriptions(w, r, actions.AssignAccessSubscriptions)
})))
mux.Handle("POST /api/providers/{providerID}/access/preview", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/access/preview", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleAccessPreview(w, r, actions.AccessPreview)
})))
mux.Handle("POST /api/packs/install", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/packs/install", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleInstallPack(w, r, actions.InstallPack)
})))
mux.Handle("GET /api/packs", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/packs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListPacks(w, r, actions.ListPacks)
})))
mux.Handle("GET /api/packs/{packID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/packs/{packID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetPack(w, r, actions.GetPack)
})))
mux.Handle("GET /api/packs/{packID}/providers", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/packs/{packID}/providers", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListPackProviders(w, r, actions.ListPackProviders)
})))
mux.Handle("POST /api/providers/{providerID}/preview-import", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/preview-import", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlePreviewProvider(w, r, actions.PreviewProvider)
})))
mux.Handle("POST /api/providers/{providerID}/import", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/import", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleImportProvider(w, r, actions.ImportProvider)
})))
mux.Handle("POST /api/providers/{providerID}/rollback", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/rollback", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleRollbackProvider(w, r, actions.RollbackProvider)
})))
mux.Handle("POST /api/providers/{providerID}/reconcile", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/providers/{providerID}/reconcile", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleReconcileProvider(w, r, actions.ReconcileProvider)
})))
mux.Handle("GET /api/hosts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/hosts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleListHosts(w, r, actions.ListHosts)
})))
mux.Handle("GET /api/hosts/{hostID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("GET /api/hosts/{hostID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleGetHost(w, r, actions.GetHost)
})))
mux.Handle("POST /api/hosts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/hosts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleCreateHost(w, r, actions.CreateHost)
})))
mux.Handle("POST /api/hosts/{hostID}/probe", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("POST /api/hosts/{hostID}/probe", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleProbeHost(w, r, actions.ProbeHost)
})))
mux.Handle("DELETE /api/hosts/{hostID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux.Handle("DELETE /api/hosts/{hostID}", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleDeleteHost(w, r, actions.DeleteHost)
})))
return mux
@@ -501,21 +514,6 @@ func handlePublishProviderDraft(w http.ResponseWriter, r *http.Request, fn func(
writeJSON(w, http.StatusOK, map[string]any{"publish": result})
}
func requireAdminToken(token string, next http.Handler) http.Handler {
if strings.TrimSpace(token) == "" {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "admin token is not configured"})
})
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bearerToken(r) != token {
writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "missing or invalid admin token"})
return
}
next.ServeHTTP(w, r)
})
}
func bearerToken(r *http.Request) string {
header := strings.TrimSpace(r.Header.Get("Authorization"))
if !strings.HasPrefix(strings.ToLower(header), "bearer ") {

View File

@@ -11,12 +11,17 @@ const (
EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR"
EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN"
EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN"
EnvAdminUsername = "SUB2API_CRM_ADMIN_USERNAME"
EnvAdminPassword = "SUB2API_CRM_ADMIN_PASSWORD"
EnvAdminSessionTTL = "SUB2API_CRM_ADMIN_SESSION_TTL"
EnvRepoRoot = "SUB2API_CRM_REPO_ROOT"
EnvReconcileWorkerEnabled = "SUB2API_CRM_RECONCILE_WORKER_ENABLED"
EnvReconcilePollInterval = "SUB2API_CRM_RECONCILE_POLL_INTERVAL"
DefaultListenAddr = ":8080"
DefaultSQLiteDSN = "file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000"
DefaultAdminUsername = "admin"
DefaultAdminSessionTTL = 12 * time.Hour
DefaultReconcilePollInterval = 10 * time.Minute
)
@@ -44,6 +49,12 @@ type StartupConfig struct {
Reconcile ReconcileConfig
}
type AdminSessionConfig struct {
Username string
Password string
SessionTTL time.Duration
}
func LoadStartupFromEnv() (StartupConfig, error) {
return loadStartupFromLookupEnv(os.LookupEnv)
}
@@ -76,6 +87,10 @@ func LoadAdminTokenFromEnv() (string, error) {
return loadAdminTokenFromLookupEnv(os.LookupEnv)
}
func LoadAdminSessionFromEnv() (AdminSessionConfig, error) {
return loadAdminSessionFromLookupEnv(os.LookupEnv)
}
func loadAdminTokenFromLookupEnv(lookup func(string) (string, bool)) (string, error) {
token := strings.TrimSpace(readRequiredEnv(lookup, EnvAdminToken))
if token == "" {
@@ -85,6 +100,18 @@ func loadAdminTokenFromLookupEnv(lookup func(string) (string, bool)) (string, er
return token, nil
}
func loadAdminSessionFromLookupEnv(lookup func(string) (string, bool)) (AdminSessionConfig, error) {
ttl, err := readOptionalDurationEnv(lookup, EnvAdminSessionTTL, DefaultAdminSessionTTL)
if err != nil {
return AdminSessionConfig{}, err
}
return AdminSessionConfig{
Username: readOptionalEnv(lookup, EnvAdminUsername, DefaultAdminUsername),
Password: strings.TrimSpace(readOptionalEnv(lookup, EnvAdminPassword, "")),
SessionTTL: ttl,
}, nil
}
func readOptionalEnv(lookup func(string) (string, bool), key string, defaultValue string) string {
value, ok := lookup(key)
if !ok {

View File

@@ -163,6 +163,65 @@ func TestLoadAdminTokenFromLookupEnv(t *testing.T) {
})
}
func TestLoadAdminSessionFromLookupEnv(t *testing.T) {
t.Run("uses defaults", func(t *testing.T) {
cfg, err := loadAdminSessionFromLookupEnv(func(string) (string, bool) {
return "", false
})
if err != nil {
t.Fatal(err)
}
if cfg.Username != DefaultAdminUsername {
t.Fatalf("Username = %q, want %q", cfg.Username, DefaultAdminUsername)
}
if cfg.Password != "" {
t.Fatalf("Password = %q, want empty", cfg.Password)
}
if cfg.SessionTTL != DefaultAdminSessionTTL {
t.Fatalf("SessionTTL = %s, want %s", cfg.SessionTTL, DefaultAdminSessionTTL)
}
})
t.Run("loads custom values", func(t *testing.T) {
cfg, err := loadAdminSessionFromLookupEnv(func(key string) (string, bool) {
switch key {
case EnvAdminUsername:
return " portal-admin ", true
case EnvAdminPassword:
return " super-secret ", true
case EnvAdminSessionTTL:
return "4h", true
default:
return "", false
}
})
if err != nil {
t.Fatal(err)
}
if cfg.Username != "portal-admin" {
t.Fatalf("Username = %q, want portal-admin", cfg.Username)
}
if cfg.Password != "super-secret" {
t.Fatalf("Password = %q, want super-secret", cfg.Password)
}
if cfg.SessionTTL != 4*time.Hour {
t.Fatalf("SessionTTL = %s, want 4h", cfg.SessionTTL)
}
})
t.Run("rejects invalid ttl", func(t *testing.T) {
_, err := loadAdminSessionFromLookupEnv(func(key string) (string, bool) {
if key == EnvAdminSessionTTL {
return "bad", true
}
return "", false
})
if err == nil {
t.Fatal("expected error for invalid session ttl")
}
})
}
// Verify exported wrappers call the lookup versions.
// We can't easily test LoadStartupFromEnv / LoadAdminTokenFromEnv
// since they depend on os.LookupEnv, but we verify they compile and don't panic.

View File

@@ -73,15 +73,22 @@ render_remote43_crm_env() {
local sqlite_dsn="$2"
local admin_token="$3"
local repo_root="${4:-}"
local sqlite_dsn_q admin_token_q repo_root_q
local admin_username="${5:-admin}"
local admin_password="${6:-$admin_token}"
local sqlite_dsn_q admin_token_q repo_root_q admin_username_q admin_password_q
printf -v sqlite_dsn_q '%q' "$sqlite_dsn"
printf -v admin_token_q '%q' "$admin_token"
printf -v repo_root_q '%q' "$repo_root"
printf -v admin_username_q '%q' "$admin_username"
printf -v admin_password_q '%q' "$admin_password"
cat <<EOF
SUB2API_CRM_LISTEN_ADDR=127.0.0.1:$crm_port
SUB2API_CRM_SQLITE_DSN=$sqlite_dsn_q
SUB2API_CRM_ADMIN_TOKEN=$admin_token_q
SUB2API_CRM_ADMIN_USERNAME=$admin_username_q
SUB2API_CRM_ADMIN_PASSWORD=$admin_password_q
SUB2API_CRM_ADMIN_SESSION_TTL=12h
SUB2API_CRM_REPO_ROOT=$repo_root_q
SUB2API_CRM_RECONCILE_WORKER_ENABLED=false
EOF

View File

@@ -23,6 +23,8 @@ ADMIN_PASSWORD="${ADMIN_PASSWORD:-Sub2API-Remote43-Temp-Admin-20260525}"
JWT_SECRET="${JWT_SECRET:-$(remote43_random_hex 24)}"
TOTP_ENCRYPTION_KEY="${TOTP_ENCRYPTION_KEY:-$(remote43_random_hex 32)}"
CRM_ADMIN_TOKEN="${CRM_ADMIN_TOKEN:-$(remote43_random_hex 24)}"
CRM_ADMIN_USERNAME="${CRM_ADMIN_USERNAME:-admin}"
CRM_ADMIN_PASSWORD="${CRM_ADMIN_PASSWORD:-$CRM_ADMIN_TOKEN}"
HOST_NAME="${HOST_NAME:-remote43-patched-${HOST_PORT}}"
HOST_BINARY="${HOST_BINARY:-}"
CRM_BINARY="${CRM_BINARY:-$ROOT_DIR/server}"
@@ -172,7 +174,9 @@ main() {
"$CRM_PORT" \
"file:${REMOTE_CRM_DB_FILE}?_foreign_keys=on&_busy_timeout=5000" \
"$CRM_ADMIN_TOKEN" \
"$REMOTE_REPO_ROOT" > "$crm_env_file"
"$REMOTE_REPO_ROOT" \
"$CRM_ADMIN_USERNAME" \
"$CRM_ADMIN_PASSWORD" > "$crm_env_file"
render_remote43_bootstrap_script \
"$REMOTE_ROOT" \
"$REMOTE_HOST_ENV_FILE" \

View File

@@ -658,7 +658,7 @@ run_test_remote43_patched_stack_renderers() {
local host_env crm_env bootstrap
host_env="$(render_remote43_host_env "stack-pg" "stack-redis" "db-pass" "sub2api" "admin@sub2api.local" "admin-pass" "jwt-secret" "totp-secret")"
crm_env="$(render_remote43_crm_env "18143" "file:/tmp/sub2api.db?_foreign_keys=on" "crm-token" "/home/ubuntu/sub2api-cn-relay-manager-git-current")"
crm_env="$(render_remote43_crm_env "18143" "file:/tmp/sub2api.db?_foreign_keys=on" "crm-token" "/home/ubuntu/sub2api-cn-relay-manager-git-current" "portal-admin" "portal-pass")"
bootstrap="$(render_remote43_bootstrap_script \
"/home/ubuntu/test-stack" \
"/home/ubuntu/test-stack/.env.host" \
@@ -690,6 +690,9 @@ run_test_remote43_patched_stack_renderers() {
assert_contains "$crm_env" "SUB2API_CRM_LISTEN_ADDR=127.0.0.1:18143"
assert_contains "$crm_env" "SUB2API_CRM_SQLITE_DSN="
assert_contains "$crm_env" "SUB2API_CRM_ADMIN_TOKEN=crm-token"
assert_contains "$crm_env" "SUB2API_CRM_ADMIN_USERNAME=portal-admin"
assert_contains "$crm_env" "SUB2API_CRM_ADMIN_PASSWORD=portal-pass"
assert_contains "$crm_env" "SUB2API_CRM_ADMIN_SESSION_TTL=12h"
assert_contains "$crm_env" "SUB2API_CRM_REPO_ROOT=/home/ubuntu/sub2api-cn-relay-manager-git-current"
local sourced_dsn
sourced_dsn="$(bash -lc 'set -a; source /dev/stdin; set +a; printf "%s" "$SUB2API_CRM_SQLITE_DSN"' <<<"$crm_env")"

View File

@@ -71,6 +71,10 @@ assert_contains_file "$ADMIN_HOME_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_HOME_FILE" "浏览器提交到 CRM"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "管理员登录"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/packs"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/hosts"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/providers/"
@@ -86,8 +90,14 @@ assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Manifest 草稿"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/publish"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "发布 Commit Message"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "credentials: \"include\""
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
assert_contains_file "$ADMIN_HTML_FILE" "管理员登录"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session"
assert_contains_file "$ADMIN_HTML_FILE" "credentials: \"include\""
assert_contains_file "$NGINX_FILE" "location = /portal"
assert_contains_file "$NGINX_FILE" "location = /portal/admin"