Refactor admin frontend build
Some checks failed
Docker Image / docker (push) Has been cancelled

This commit is contained in:
2026-06-25 21:16:11 +08:00
parent bf6db8c455
commit ecb682bb41
34 changed files with 1474 additions and 692 deletions

View File

@@ -3,6 +3,8 @@
.codex .codex
.omc .omc
.vscode .vscode
node_modules
cmd/gateway/admin_static/js
logs logs
data data

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
.vscode .vscode
node_modules/
cmd/gateway/admin_static/js/
config.toml config.toml
/gateway* /gateway*

View File

@@ -1,5 +1,17 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM node:24.11.1-alpine AS admin-frontend
WORKDIR /src
COPY package.json package-lock.json tsconfig.admin.json ./
RUN --mount=type=cache,id=mc-gateway-npm,target=/root/.npm,sharing=locked \
npm ci
COPY cmd/gateway/admin_frontend ./cmd/gateway/admin_frontend
COPY cmd/gateway/admin_static/index.html cmd/gateway/admin_static/app.css ./cmd/gateway/admin_static/
RUN npm run build:admin
FROM --platform=$BUILDPLATFORM golang:1.24.4-alpine AS build FROM --platform=$BUILDPLATFORM golang:1.24.4-alpine AS build
ARG TARGETOS ARG TARGETOS
@@ -12,6 +24,7 @@ RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \
go mod download go mod download
COPY . . COPY . .
RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \ RUN --mount=type=cache,id=mc-gateway-go-mod,target=/go/pkg/mod,sharing=locked \
--mount=type=cache,id=mc-gateway-go-build-${TARGETOS}-${TARGETARCH},target=/root/.cache/go-build,sharing=locked \ --mount=type=cache,id=mc-gateway-go-build-${TARGETOS}-${TARGETARCH},target=/root/.cache/go-build,sharing=locked \
mkdir -p /out \ mkdir -p /out \
@@ -22,8 +35,10 @@ FROM alpine:3.22
WORKDIR /data WORKDIR /data
COPY --from=build /out/mc-gateway /usr/local/bin/mc-gateway COPY --from=build /out/mc-gateway /usr/local/bin/mc-gateway
COPY --from=admin-frontend /src/cmd/gateway/admin_static /usr/share/mc-gateway/admin_static
ENV MC_GATEWAY_DB=/data/mc-gateway.sqlite3 ENV MC_GATEWAY_DB=/data/mc-gateway.sqlite3
ENV MC_GATEWAY_ADMIN_STATIC_DIR=/usr/share/mc-gateway/admin_static
EXPOSE 25565/tcp EXPOSE 25565/tcp

View File

@@ -19,6 +19,7 @@ mc-gateway 默认不依赖配置文件。直接启动后会在 `25565` 端口同
| `MC_GATEWAY_TCP_ADMIN_PORT` | `25565` | TCP/Admin 共享监听端口 | | `MC_GATEWAY_TCP_ADMIN_PORT` | `25565` | TCP/Admin 共享监听端口 |
| `MC_GATEWAY_ADMIN_PATH` | `/admin/` | Admin 页面路径 | | `MC_GATEWAY_ADMIN_PATH` | `/admin/` | Admin 页面路径 |
| `MC_GATEWAY_ADMIN_API_PREFIX` | `/admin/api` | Admin API 前缀 | | `MC_GATEWAY_ADMIN_API_PREFIX` | `/admin/api` | Admin API 前缀 |
| `MC_GATEWAY_ADMIN_STATIC_DIR` | `cmd/gateway/admin_static` | Admin 前端静态文件目录Docker 镜像中为 `/usr/share/mc-gateway/admin_static` |
| `MC_GATEWAY_DB` | `mc-gateway.sqlite3` | SQLite 数据库路径 | | `MC_GATEWAY_DB` | `mc-gateway.sqlite3` | SQLite 数据库路径 |
| `MC_GATEWAY_ADMIN_PASSWORD` | 空 | 首次启动时创建默认管理员密码 | | `MC_GATEWAY_ADMIN_PASSWORD` | 空 | 首次启动时创建默认管理员密码 |
@@ -56,9 +57,21 @@ MC_GATEWAY_ADMIN_PASSWORD=change-me
MC_GATEWAY_TCP_ADMIN_PORT=25565 MC_GATEWAY_TCP_ADMIN_PORT=25565
MC_GATEWAY_ADMIN_PATH=/admin/ MC_GATEWAY_ADMIN_PATH=/admin/
MC_GATEWAY_ADMIN_API_PREFIX=/admin/api MC_GATEWAY_ADMIN_API_PREFIX=/admin/api
MC_GATEWAY_ADMIN_STATIC_DIR=/usr/share/mc-gateway/admin_static
MC_GATEWAY_DB=/data/mc-gateway.sqlite3 MC_GATEWAY_DB=/data/mc-gateway.sqlite3
``` ```
### Admin 前端开发
Admin 前端源码位于 `cmd/gateway/admin_frontend/src`,使用 TypeScript 拆分为原生 ES modules。构建产物输出到 `cmd/gateway/admin_static/js`Go 服务不会 embed 前端文件,而是从 `MC_GATEWAY_ADMIN_STATIC_DIR` 指向的目录透传静态响应。
修改前端后运行:
```sh
npm install
npm run build:admin
```
`master` 分支和 `v*` tag 会通过 GitHub Actions 构建并推送 Docker 镜像到 GitHub Container Registry `master` 分支和 `v*` tag 会通过 GitHub Actions 构建并推送 Docker 镜像到 GitHub Container Registry
```text ```text

View File

@@ -122,8 +122,16 @@ func TestAdminCustomPathAndAPIPrefixFromEnv(t *testing.T) {
if resp.Code != http.StatusOK { if resp.Code != http.StatusOK {
t.Fatalf("custom admin page status = %d, body=%s", resp.Code, resp.Body.String()) t.Fatalf("custom admin page status = %d, body=%s", resp.Code, resp.Body.String())
} }
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) { if !strings.Contains(resp.Body.String(), `script src="config.js"`) {
t.Fatalf("custom admin page does not contain API prefix: %s", resp.Body.String()) t.Fatalf("custom admin page does not reference runtime config: %s", resp.Body.String())
}
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/config.js", "", nil)
if resp.Code != http.StatusOK {
t.Fatalf("custom admin config status = %d, body=%s", resp.Code, resp.Body.String())
}
if strings.TrimSpace(resp.Body.String()) != `window.MCGatewayAdmin={"apiPrefix":"/ops/api"};` {
t.Fatalf("custom admin config body = %q", resp.Body.String())
} }
resp = adminTestRequest(t, handler, http.MethodGet, "/ops/api/setup", "", nil) resp = adminTestRequest(t, handler, http.MethodGet, "/ops/api/setup", "", nil)

View File

@@ -0,0 +1,8 @@
import { el } from "./dom.js";
import { localizeMessage } from "./i18n.js";
export function showAlert(message: unknown): void {
const box = el("alert");
box.textContent = localizeMessage(message);
box.classList.toggle("hidden", !message);
}

View File

@@ -0,0 +1,46 @@
import { state } from "./state.js";
interface APIOptions {
method?: string;
body?: unknown;
}
export async function api<T>(path: string, options: APIOptions = {}): Promise<T> {
const headers: Record<string, string> = { Accept: "application/json" };
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
}
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const res = await fetch(state.apiBase + path, {
method: options.method || "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
let data: unknown = {};
const text = await res.text();
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { error: text };
}
}
if (!res.ok) {
throw new Error(apiErrorMessage(data, res.statusText));
}
return data as T;
}
function apiErrorMessage(data: unknown, fallback: string): string {
if (data && typeof data === "object" && "error" in data) {
const error = (data as { error?: unknown }).error;
if (typeof error === "string" && error) {
return error;
}
}
return fallback;
}

View File

@@ -0,0 +1,13 @@
import type { RuntimeConfig } from "./types.js";
declare global {
interface Window {
MCGatewayAdmin?: Partial<RuntimeConfig>;
}
}
export function runtimeConfig(): RuntimeConfig {
return {
apiPrefix: window.MCGatewayAdmin?.apiPrefix || "/admin/api",
};
}

View File

@@ -0,0 +1,53 @@
export function el<T extends HTMLElement = HTMLElement>(id: string): T {
const node = document.getElementById(id);
if (!node) {
throw new Error(`missing element #${id}`);
}
return node as T;
}
export function escapeHTML(value: unknown): string {
return String(value).replace(/[&<>"']/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
}[ch] || ch));
}
export function escapeAttr(value: unknown): string {
return escapeHTML(value).replace(/`/g, "&#96;");
}
export function stat(label: string, value: unknown): string {
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
}
export function badge(text: string, off = false): string {
return `<span class="badge ${off ? "off" : ""}">${escapeHTML(text)}</span>`;
}
export function debounce<T extends (...args: unknown[]) => void>(fn: T, wait: number): T {
let id = 0;
return ((...args: Parameters<T>) => {
clearTimeout(id);
id = window.setTimeout(() => fn(...args), wait);
}) as T;
}
export function getFormInput(form: HTMLFormElement, name: string): HTMLInputElement {
const field = form.elements.namedItem(name);
if (!(field instanceof HTMLInputElement)) {
throw new Error(`missing input ${name}`);
}
return field;
}
export function getFormSelect(form: HTMLFormElement, name: string): HTMLSelectElement {
const field = form.elements.namedItem(name);
if (!(field instanceof HTMLSelectElement)) {
throw new Error(`missing select ${name}`);
}
return field;
}

View File

@@ -0,0 +1,293 @@
import { el } from "./dom.js";
import { languageStorageKey, state } from "./state.js";
const defaultLanguage = "en";
const translations = {
en: {
activeConnections: "Active",
activeUser: "Active",
actions: "Actions",
actor: "Actor",
adminSubtitle: "Admin",
audit: "Audit",
cancel: "Cancel",
createAdmin: "Create admin",
dataShards: "Data shards",
delete: "Delete",
deleteDefaultRouteConfirm: "Delete default route?",
deleteUserConfirm: "Delete user {username}?",
dialErrors: "Dial errors",
disabled: "Disabled",
edit: "Edit",
enabled: "Enabled",
failed: "Failed",
host: "Host",
initialAdmin: "Initial admin",
language: "Language",
languageChinese: "中文",
languageEnglish: "English",
login: "Login",
logout: "Logout",
message: "Message",
metrics: "Metrics",
misses: "Misses",
newRoute: "New route",
newUser: "New user",
noHits: "No hits",
note: "Note",
parityShards: "Parity shards",
password: "Password",
path: "Path",
port: "Port",
protocols: "Protocols",
restart: "Restart",
restartRequired: "restart required",
result: "Result",
role: "Role",
roleAdmin: "Admin",
roleGuest: "Guest",
roleMember: "Member",
route: "Route",
routeHits: "Route hits",
routeSearch: "Search host, upstream, note",
routes: "Routes",
save: "Save",
services: "Services",
setupSubtitle: "Setup",
success: "Success",
target: "Target",
time: "Time",
total: "Total",
upstream: "Upstream",
uptime: "Uptime",
user: "User",
username: "Username",
users: "Users",
},
zh: {
activeConnections: "活跃连接",
activeUser: "启用",
actions: "操作",
actor: "操作者",
adminSubtitle: "管理后台",
audit: "审计",
cancel: "取消",
createAdmin: "创建管理员",
dataShards: "数据分片",
delete: "删除",
deleteDefaultRouteConfirm: "确认删除默认路由?",
deleteUserConfirm: "确认删除用户 {username}",
dialErrors: "连接上游失败",
disabled: "禁用",
edit: "编辑",
enabled: "启用",
failed: "失败",
host: "主机",
initialAdmin: "初始化管理员",
language: "语言",
languageChinese: "中文",
languageEnglish: "English",
login: "登录",
logout: "退出登录",
message: "消息",
metrics: "指标",
misses: "未命中",
newRoute: "新建路由",
newUser: "新建用户",
noHits: "暂无命中",
note: "备注",
parityShards: "校验分片",
password: "密码",
path: "路径",
port: "端口",
protocols: "协议",
restart: "重启",
restartRequired: "需要重启",
result: "结果",
role: "角色",
roleAdmin: "管理员",
roleGuest: "访客",
roleMember: "成员",
route: "路由",
routeHits: "路由命中",
routeSearch: "搜索主机、上游、备注",
routes: "路由",
save: "保存",
services: "服务",
setupSubtitle: "初始化",
success: "成功",
target: "目标",
time: "时间",
total: "总数",
upstream: "上游",
uptime: "运行时间",
user: "用户",
username: "用户名",
users: "用户",
},
} as const;
type TranslationKey = keyof typeof translations.en;
type Language = keyof typeof translations;
const localizedMessages: Partial<Record<Language, Record<string, string>>> = {
zh: {
"admin API prefix cannot be under static asset path": "管理 API 前缀不能位于静态资源路径下",
"admin API prefix cannot equal admin page path": "管理 API 前缀不能等于管理页面路径",
"cannot delete the last enabled admin": "不能删除最后一个启用的管理员",
"cannot remove the last enabled admin": "不能移除最后一个启用的管理员",
"created initial admin": "已创建初始管理员",
"host is required": "主机不能为空",
"host must not contain /": "主机不能包含 /",
"host must not contain whitespace": "主机不能包含空白字符",
"initial admin has already been created": "初始管理员已创建",
"invalid JSON": "JSON 无效",
"invalid path segment": "路径片段无效",
"invalid username or password": "用户名或密码无效",
"login required": "需要登录",
"login success": "登录成功",
"logout success": "退出登录成功",
"method not allowed": "方法不允许",
"not found": "未找到",
"password hash is required": "密码哈希不能为空",
"password is required": "密码不能为空",
"permission denied": "没有权限",
"port must be an integer from 1 to 65535": "端口必须是 1 到 65535 之间的整数",
"restart is not implemented": "暂未实现重启",
"route deleted": "路由已删除",
"route saved": "路由已保存",
"service restart is not implemented; restart the gateway process": "暂未实现服务重启;请重启网关进程",
"service saved": "服务已保存",
"tcp_admin cannot be disabled": "tcp_admin 不能被禁用",
"upstream host is required": "上游主机不能为空",
"upstream is required": "上游不能为空",
"upstream must be host:port": "上游必须是 host:port 格式",
"upstream port must be an integer from 1 to 65535": "上游端口必须是 1 到 65535 之间的整数",
"user created": "用户已创建",
"user deleted": "用户已删除",
"user is disabled": "用户已禁用",
"user not found": "用户不存在",
"user updated": "用户已更新",
"username is required": "用户名不能为空",
"username must not contain whitespace or /": "用户名不能包含空白字符或 /",
},
};
const localizedPrefixes: Partial<Record<Language, Array<[string, string]>>> = {
zh: [
["invalid JSON: ", "JSON 无效:"],
["invalid role ", "无效角色 "],
["unknown service ", "未知服务 "],
],
};
export const auditActions: Partial<Record<Language, Record<string, string>>> = {
zh: {
login: "登录",
logout: "退出登录",
route_delete: "删除路由",
route_upsert: "保存路由",
service_restart: "重启服务",
service_update: "更新服务",
setup: "初始化",
user_create: "创建用户",
user_delete: "删除用户",
user_patch: "更新用户",
},
};
export const targetTypes: Partial<Record<Language, Record<string, string>>> = {
zh: {
route: "路由",
service: "服务",
session: "会话",
user: "用户",
},
};
export function initializeLanguage(): void {
state.language = resolveInitialLanguage();
applyLanguage();
}
export function changeLanguage(next: string, rerender: () => void): void {
if (!isLanguage(next) || next === state.language) {
return;
}
state.language = next;
localStorage.setItem(languageStorageKey, next);
applyLanguage();
rerender();
}
export function applyLanguage(): void {
document.documentElement.lang = state.language === "zh" ? "zh-CN" : "en";
el<HTMLSelectElement>("languageSelect").value = state.language;
document.querySelectorAll<HTMLElement>("[data-i18n]").forEach((node) => {
node.textContent = t(node.dataset.i18n as TranslationKey);
});
document.querySelectorAll<HTMLInputElement>("[data-i18n-placeholder]").forEach((node) => {
node.placeholder = t(node.dataset.i18nPlaceholder as TranslationKey);
});
if (!el("setupView").classList.contains("hidden")) {
setSubtitle("setupSubtitle");
} else if (!el("loginView").classList.contains("hidden")) {
setSubtitle("login");
} else {
setSubtitle("adminSubtitle");
}
}
export function setSubtitle(key: TranslationKey): void {
el("subtitle").textContent = t(key);
}
export function t(key: TranslationKey, values: Record<string, string> = {}): string {
const lang = isLanguage(state.language) ? state.language : defaultLanguage;
let text: string = translations[lang][key] || translations[defaultLanguage][key] || key;
for (const [name, value] of Object.entries(values)) {
text = text.replaceAll(`{${name}}`, value);
}
return text;
}
export function localizeMessage(message: unknown): string {
if (!message) {
return "";
}
const lang = isLanguage(state.language) ? state.language : defaultLanguage;
const text = String(message);
const messages = localizedMessages[lang] || {};
if (messages[text]) {
return messages[text];
}
const prefixes = localizedPrefixes[lang] || [];
for (const [source, replacement] of prefixes) {
if (text.startsWith(source)) {
return replacement + text.slice(source.length);
}
}
return text;
}
export function formatAuditAction(action: string): string {
return auditActions[state.language as Language]?.[action] || action;
}
export function formatTargetType(targetType: string): string {
return targetTypes[state.language as Language]?.[targetType] || targetType;
}
function resolveInitialLanguage(): Language {
const stored = localStorage.getItem(languageStorageKey);
if (isLanguage(stored)) {
return stored;
}
const browserLanguage = (navigator.language || "").toLowerCase();
return browserLanguage.startsWith("zh") ? "zh" : defaultLanguage;
}
function isLanguage(value: unknown): value is Language {
return typeof value === "string" && value in translations;
}

View File

@@ -0,0 +1,188 @@
import { api } from "./api.js";
import { showAlert } from "./alerts.js";
import { runtimeConfig } from "./config.js";
import { debounce, el } from "./dom.js";
import { changeLanguage, initializeLanguage, setSubtitle } from "./i18n.js";
import { isAdmin, isMember, renderSessionUser } from "./session.js";
import { setToken, state } from "./state.js";
import type { LoginResponse, SetupStatus, User } from "./types.js";
import { loadAudit } from "./views/audit.js";
import { loadMetrics } from "./views/metrics.js";
import { loadRoutes, openRouteDialog, renderRoutes, saveRoute } from "./views/routes.js";
import { loadServices, renderServices } from "./views/services.js";
import { loadStatus } from "./views/status.js";
import { loadUsers, openUserDialog, renderUsers, saveUser } from "./views/users.js";
async function boot(): Promise<void> {
state.apiBase = runtimeConfig().apiPrefix;
initializeLanguage();
bindEvents();
try {
const setup = await api<SetupStatus>("/setup");
if (setup.required) {
setView("setupView");
setSubtitle("setupSubtitle");
return;
}
} catch (err) {
showAlert((err as Error).message);
}
if (!state.token) {
setView("loginView");
setSubtitle("login");
return;
}
try {
state.user = await api<User>("/me");
await showApp();
} catch {
setToken("");
setView("loginView");
setSubtitle("login");
}
}
function bindEvents(): void {
el<HTMLSelectElement>("languageSelect").addEventListener("change", (event) => {
changeLanguage((event.currentTarget as HTMLSelectElement).value, rerenderCurrentView);
});
el<HTMLFormElement>("setupForm").addEventListener("submit", submitSetup);
el<HTMLFormElement>("loginForm").addEventListener("submit", submitLogin);
el("logoutBtn").addEventListener("click", logout);
el("routeSearch").addEventListener("input", debounce(loadRoutes, 180));
el("newRouteBtn").addEventListener("click", () => openRouteDialog());
el("newUserBtn").addEventListener("click", () => openUserDialog());
el<HTMLFormElement>("routeForm").addEventListener("submit", saveRoute);
el<HTMLFormElement>("userForm").addEventListener("submit", saveUser);
document.querySelectorAll<HTMLButtonElement>("[data-close]").forEach((button) => {
button.addEventListener("click", () => button.closest("dialog")?.close());
});
document.querySelectorAll<HTMLButtonElement>(".tabs button").forEach((button) => {
button.addEventListener("click", () => {
if (button.dataset.tab) {
selectTab(button.dataset.tab);
}
});
});
}
async function submitSetup(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = new FormData(event.currentTarget as HTMLFormElement);
try {
await api("/setup", {
method: "POST",
body: {
username: form.get("username"),
password: form.get("password"),
},
});
showAlert("");
setView("loginView");
setSubtitle("login");
} catch (err) {
showAlert((err as Error).message);
}
}
async function submitLogin(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = new FormData(event.currentTarget as HTMLFormElement);
try {
const data = await api<LoginResponse>("/auth/login", {
method: "POST",
body: {
username: form.get("username"),
password: form.get("password"),
},
});
setToken(data.token);
state.user = data.user;
showAlert("");
await showApp();
} catch (err) {
showAlert((err as Error).message);
}
}
async function logout(): Promise<void> {
try {
await api("/auth/logout", { method: "POST", body: {} });
} catch {
}
setToken("");
state.user = null;
setView("loginView");
setSubtitle("login");
renderSessionUser();
el("logoutBtn").classList.add("hidden");
}
async function showApp(): Promise<void> {
setView("appView");
setSubtitle("adminSubtitle");
renderSessionUser();
el("logoutBtn").classList.remove("hidden");
applyRoleVisibility();
await loadRoutes();
if (isMember()) {
await loadStatus();
await loadServices();
await loadMetrics();
}
if (isAdmin()) {
await loadUsers();
await loadAudit();
}
}
function applyRoleVisibility(): void {
const member = isMember();
const admin = isAdmin();
el("statusGrid").classList.toggle("hidden", !member);
el("newRouteBtn").classList.toggle("hidden", !member);
toggleTab("services", member);
toggleTab("metrics", member);
toggleTab("users", admin);
toggleTab("audit", admin);
selectTab("routes");
}
function toggleTab(name: string, visible: boolean): void {
document.querySelector(`[data-tab="${name}"]`)?.classList.toggle("hidden", !visible);
}
function selectTab(name: string): void {
document.querySelectorAll<HTMLButtonElement>(".tabs button").forEach((button) => {
button.classList.toggle("active", button.dataset.tab === name);
});
document.querySelectorAll<HTMLElement>(".tab-panel").forEach((panel) => {
panel.classList.add("hidden");
});
el(`${name}Tab`).classList.remove("hidden");
}
function setView(name: string): void {
for (const id of ["setupView", "loginView", "appView"]) {
el(id).classList.toggle("hidden", id !== name);
}
}
function rerenderCurrentView(): void {
renderSessionUser();
renderRoutes();
renderServices();
renderUsers();
if (isMember()) {
loadStatus();
loadMetrics();
}
if (isAdmin()) {
loadAudit();
}
}
boot();

View File

@@ -0,0 +1,26 @@
import { el } from "./dom.js";
import { t } from "./i18n.js";
import { state } from "./state.js";
import type { Role } from "./types.js";
export function isAdmin(): boolean {
return state.user?.role === "admin";
}
export function isMember(): boolean {
return state.user?.role === "admin" || state.user?.role === "member";
}
export function renderSessionUser(): void {
el("sessionUser").textContent = state.user ? `${state.user.username} (${formatRole(state.user.role)})` : "";
}
export function formatRole(role: Role | string): string {
const keys: Partial<Record<string, "roleAdmin" | "roleMember" | "roleGuest">> = {
admin: "roleAdmin",
member: "roleMember",
guest: "roleGuest",
};
const key = keys[role];
return key ? t(key) : role;
}

View File

@@ -0,0 +1,33 @@
import type { RouteRecord, ServiceRecord, User } from "./types.js";
export const tokenStorageKey = "mcGatewayAdminToken";
export const languageStorageKey = "mcGatewayAdminLanguage";
export interface AppState {
apiBase: string;
language: string;
token: string;
user: User | null;
routes: RouteRecord[];
services: ServiceRecord[];
users: User[];
}
export const state: AppState = {
apiBase: "/admin/api",
language: "",
token: sessionStorage.getItem(tokenStorageKey) || "",
user: null,
routes: [],
services: [],
users: [],
};
export function setToken(token: string): void {
state.token = token;
if (token) {
sessionStorage.setItem(tokenStorageKey, token);
} else {
sessionStorage.removeItem(tokenStorageKey);
}
}

View File

@@ -0,0 +1,55 @@
export type Role = "admin" | "member" | "guest";
export interface User {
username: string;
role: Role;
disabled: boolean;
}
export interface RouteRecord {
host: string;
upstream: string;
enabled: boolean;
note?: string;
}
export interface ServiceRecord {
name: string;
enabled: boolean;
port: number;
options?: Record<string, unknown>;
restart_required?: boolean;
}
export interface Metrics {
total_connections?: number;
active_connections?: number;
tcp_connections?: number;
websocket_connections?: number;
route_misses?: number;
upstream_dial_errors?: number;
route_hits?: Record<string, number>;
}
export interface AuditLog {
created_at: number;
actor: string;
action: string;
target_type: string;
target_id: string;
success: boolean;
message?: string;
}
export interface SetupStatus {
required: boolean;
}
export interface LoginResponse {
token: string;
user: User;
}
export interface RuntimeConfig {
apiPrefix: string;
}

View File

@@ -0,0 +1,27 @@
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeHTML } from "../dom.js";
import { formatAuditAction, formatTargetType, localizeMessage, t } from "../i18n.js";
import type { AuditLog } from "../types.js";
interface AuditResponse {
audit_logs?: AuditLog[];
}
export async function loadAudit(): Promise<void> {
try {
const data = await api<AuditResponse>("/audit-logs");
el("auditBody").innerHTML = (data.audit_logs || []).map((item) => `
<tr>
<td>${new Date(item.created_at * 1000).toLocaleString()}</td>
<td>${escapeHTML(item.actor)}</td>
<td>${escapeHTML(formatAuditAction(item.action))}</td>
<td>${escapeHTML(formatTargetType(item.target_type))}:${escapeHTML(item.target_id)}</td>
<td>${badge(item.success ? t("success") : t("failed"), !item.success)}</td>
<td>${escapeHTML(localizeMessage(item.message || ""))}</td>
</tr>
`).join("");
} catch (err) {
showAlert((err as Error).message);
}
}

View File

@@ -0,0 +1,25 @@
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { el, escapeHTML, stat } from "../dom.js";
import { t } from "../i18n.js";
import type { Metrics } from "../types.js";
export async function loadMetrics(): Promise<void> {
try {
const data = await api<Metrics>("/metrics");
el("metricsGrid").innerHTML = [
stat(t("total"), data.total_connections),
stat(t("activeConnections"), data.active_connections),
stat("TCP", data.tcp_connections),
stat("WebSocket", data.websocket_connections),
stat(t("misses"), data.route_misses),
stat(t("dialErrors"), data.upstream_dial_errors),
].join("");
const hits = data.route_hits || {};
el("routeHits").innerHTML = Object.keys(hits).length
? Object.entries(hits).map(([host, count]) => `<span class="chip">${escapeHTML(host)}: ${count}</span>`).join("")
: `<span class="chip">${escapeHTML(t("noHits"))}</span>`;
} catch (err) {
showAlert((err as Error).message);
}
}

View File

@@ -0,0 +1,108 @@
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
import { t } from "../i18n.js";
import { isMember } from "../session.js";
import { state } from "../state.js";
import type { RouteRecord } from "../types.js";
interface RoutesResponse {
routes?: RouteRecord[];
}
export async function loadRoutes(): Promise<void> {
try {
const q = encodeURIComponent(el<HTMLInputElement>("routeSearch").value || "");
const data = await api<RoutesResponse>(q ? `/routes?q=${q}` : "/routes");
state.routes = data.routes || [];
renderRoutes();
} catch (err) {
showAlert((err as Error).message);
}
}
export function renderRoutes(): void {
const canWrite = isMember();
el("routesBody").innerHTML = state.routes.map((route) => `
<tr>
<td>${escapeHTML(route.host)}</td>
<td>${escapeHTML(route.upstream)}</td>
<td>${badge(route.enabled ? t("enabled") : t("disabled"), !route.enabled)}</td>
<td>${escapeHTML(route.note || "")}</td>
<td class="actions">${canWrite ? routeActions(route) : ""}</td>
</tr>
`).join("");
document.querySelectorAll<HTMLButtonElement>("[data-edit-route]").forEach((button) => {
button.addEventListener("click", () => {
const route = state.routes.find((item) => item.host === button.dataset.editRoute);
openRouteDialog(route || null);
});
});
document.querySelectorAll<HTMLButtonElement>("[data-delete-route]").forEach((button) => {
button.addEventListener("click", () => {
const host = button.dataset.deleteRoute;
if (host) {
removeRoute(host);
}
});
});
}
export function openRouteDialog(route: RouteRecord | null = null): void {
const form = el<HTMLFormElement>("routeForm");
form.reset();
form.dataset.originalHost = route ? route.host : "";
getFormInput(form, "host").disabled = Boolean(route);
if (route) {
getFormInput(form, "host").value = route.host;
getFormInput(form, "upstream").value = route.upstream;
getFormInput(form, "enabled").checked = route.enabled;
getFormInput(form, "note").value = route.note || "";
} else {
getFormInput(form, "enabled").checked = true;
}
el<HTMLDialogElement>("routeDialog").showModal();
}
export async function saveRoute(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const host = form.dataset.originalHost || getFormInput(form, "host").value;
try {
await api(`/routes/${encodeURIComponent(host)}`, {
method: "PUT",
body: {
upstream: getFormInput(form, "upstream").value,
enabled: getFormInput(form, "enabled").checked,
note: getFormInput(form, "note").value,
},
});
el<HTMLDialogElement>("routeDialog").close();
await loadRoutes();
showAlert("");
} catch (err) {
showAlert((err as Error).message);
}
}
async function removeRoute(host: string): Promise<void> {
if (host === "default" && !confirm(t("deleteDefaultRouteConfirm"))) {
return;
}
try {
await api(`/routes/${encodeURIComponent(host)}`, { method: "DELETE" });
await loadRoutes();
} catch (err) {
showAlert((err as Error).message);
}
}
function routeActions(route: RouteRecord): string {
return `
<div class="row-actions">
<button class="secondary" type="button" data-edit-route="${escapeAttr(route.host)}">${escapeHTML(t("edit"))}</button>
<button class="danger" type="button" data-delete-route="${escapeAttr(route.host)}">${escapeHTML(t("delete"))}</button>
</div>
`;
}

View File

@@ -0,0 +1,161 @@
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { el, escapeAttr, escapeHTML, getFormInput } from "../dom.js";
import { t } from "../i18n.js";
import { isAdmin } from "../session.js";
import { state } from "../state.js";
import type { ServiceRecord } from "../types.js";
interface ServicesResponse {
services?: ServiceRecord[];
}
const serviceNames: Record<string, string> = {
tcp_admin: "TCP/Admin",
kcp: "KCP",
quic: "QUIC",
websocket: "WebSocket",
};
export async function loadServices(): Promise<void> {
try {
const data = await api<ServicesResponse>("/services");
state.services = data.services || [];
renderServices();
} catch (err) {
showAlert((err as Error).message);
}
}
export function renderServices(): void {
el("servicesGrid").innerHTML = state.services.map((service) => `
<article class="service">
<div>
<span>${escapeHTML(formatServiceName(service.name))}</span>
<strong>${serviceStatusText(service)}</strong>
</div>
${isAdmin() ? serviceForm(service) : serviceSummary(service)}
</article>
`).join("");
document.querySelectorAll<HTMLFormElement>("[data-service-form]").forEach((form) => {
form.addEventListener("submit", saveService);
});
document.querySelectorAll<HTMLButtonElement>("[data-restart-service]").forEach((button) => {
button.addEventListener("click", () => {
const name = button.dataset.restartService;
if (name) {
restartService(name);
}
});
});
}
function serviceSummary(service: ServiceRecord): string {
return `<div><span>${escapeHTML(t("port"))}</span><strong>${service.port}</strong></div>`;
}
function serviceForm(service: ServiceRecord): string {
const disabled = service.name === "tcp_admin" ? "disabled" : "";
const optionFields = serviceOptionFields(service);
return `
<form data-service-form="${escapeAttr(service.name)}">
<label class="inline">
<input name="enabled" type="checkbox" ${service.enabled ? "checked" : ""} ${disabled}>
${escapeHTML(t("enabled"))}
</label>
<label>
${escapeHTML(t("port"))}
<input name="port" type="number" min="1" max="65535" value="${service.port}">
</label>
${optionFields}
<div class="row-actions">
<button type="submit">${escapeHTML(t("save"))}</button>
<button class="secondary" type="button" data-restart-service="${escapeAttr(service.name)}">${escapeHTML(t("restart"))}</button>
</div>
</form>
`;
}
function serviceOptionFields(service: ServiceRecord): string {
const options = service.options || {};
if (service.name === "kcp") {
return `
<label>${escapeHTML(t("dataShards"))}<input name="data_shards" type="number" min="1" value="${numberOption(options, "data_shards", 10)}"></label>
<label>${escapeHTML(t("parityShards"))}<input name="parity_shards" type="number" min="1" value="${numberOption(options, "parity_shards", 3)}"></label>
`;
}
if (service.name === "quic") {
const protocols = arrayOption(options, "application_protocols").join(",");
return `<label>${escapeHTML(t("protocols"))}<input name="application_protocols" value="${escapeAttr(protocols)}"></label>`;
}
if (service.name === "websocket") {
return `<label>${escapeHTML(t("path"))}<input name="path" value="${escapeAttr(stringOption(options, "path", "/"))}"></label>`;
}
return "";
}
async function saveService(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const name = form.dataset.serviceForm;
if (!name) {
return;
}
const options: Record<string, unknown> = {};
if (name === "kcp") {
options.data_shards = Number(getFormInput(form, "data_shards").value);
options.parity_shards = Number(getFormInput(form, "parity_shards").value);
} else if (name === "quic") {
options.application_protocols = getFormInput(form, "application_protocols").value.split(",").map((item) => item.trim()).filter(Boolean);
} else if (name === "websocket") {
options.path = getFormInput(form, "path").value;
}
try {
await api(`/services/${encodeURIComponent(name)}`, {
method: "PUT",
body: {
enabled: name === "tcp_admin" ? true : getFormInput(form, "enabled").checked,
port: Number(getFormInput(form, "port").value),
options,
},
});
await loadServices();
} catch (err) {
showAlert((err as Error).message);
}
}
async function restartService(name: string): Promise<void> {
try {
await api(`/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: {} });
await loadServices();
} catch (err) {
showAlert((err as Error).message);
}
}
function formatServiceName(name: string): string {
return serviceNames[name] || name;
}
function serviceStatusText(service: ServiceRecord): string {
const status = service.enabled ? t("enabled") : t("disabled");
return escapeHTML(service.restart_required ? `${status} / ${t("restartRequired")}` : status);
}
function numberOption(options: Record<string, unknown>, key: string, fallback: number): number {
const value = options[key];
return typeof value === "number" ? value : fallback;
}
function stringOption(options: Record<string, unknown>, key: string, fallback: string): string {
const value = options[key];
return typeof value === "string" ? value : fallback;
}
function arrayOption(options: Record<string, unknown>, key: string): string[] {
const value = options[key];
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
}

View File

@@ -0,0 +1,25 @@
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { el, stat } from "../dom.js";
import { t } from "../i18n.js";
interface StatusResponse {
pid?: number;
uptime_seconds?: number;
db_path?: string;
tcp_admin_port?: number;
}
export async function loadStatus(): Promise<void> {
try {
const status = await api<StatusResponse>("/status");
el("statusGrid").innerHTML = [
stat("PID", status.pid),
stat(t("uptime"), `${status.uptime_seconds}s`),
stat("SQLite", status.db_path),
stat("TCP/Admin", status.tcp_admin_port),
].join("");
} catch (err) {
showAlert((err as Error).message);
}
}

View File

@@ -0,0 +1,106 @@
import { api } from "../api.js";
import { showAlert } from "../alerts.js";
import { badge, el, escapeAttr, escapeHTML, getFormInput, getFormSelect } from "../dom.js";
import { t } from "../i18n.js";
import { formatRole } from "../session.js";
import { state } from "../state.js";
import type { User } from "../types.js";
interface UsersResponse {
users?: User[];
}
export async function loadUsers(): Promise<void> {
try {
const data = await api<UsersResponse>("/users");
state.users = data.users || [];
renderUsers();
} catch (err) {
showAlert((err as Error).message);
}
}
export function renderUsers(): void {
el("usersBody").innerHTML = state.users.map((user) => `
<tr>
<td>${escapeHTML(user.username)}</td>
<td>${escapeHTML(formatRole(user.role))}</td>
<td>${badge(user.disabled ? t("disabled") : t("activeUser"), user.disabled)}</td>
<td class="actions">
<div class="row-actions">
<button class="secondary" type="button" data-edit-user="${escapeAttr(user.username)}">${escapeHTML(t("edit"))}</button>
<button class="danger" type="button" data-delete-user="${escapeAttr(user.username)}">${escapeHTML(t("delete"))}</button>
</div>
</td>
</tr>
`).join("");
document.querySelectorAll<HTMLButtonElement>("[data-edit-user]").forEach((button) => {
button.addEventListener("click", () => {
const user = state.users.find((item) => item.username === button.dataset.editUser);
openUserDialog(user || null);
});
});
document.querySelectorAll<HTMLButtonElement>("[data-delete-user]").forEach((button) => {
button.addEventListener("click", () => {
const username = button.dataset.deleteUser;
if (username) {
removeUser(username);
}
});
});
}
export function openUserDialog(user: User | null = null): void {
const form = el<HTMLFormElement>("userForm");
form.reset();
form.dataset.originalUsername = user ? user.username : "";
getFormInput(form, "username").disabled = Boolean(user);
getFormInput(form, "password").required = !user;
if (user) {
getFormInput(form, "username").value = user.username;
getFormSelect(form, "role").value = user.role;
getFormInput(form, "disabled").checked = user.disabled;
} else {
getFormSelect(form, "role").value = "member";
}
el<HTMLDialogElement>("userDialog").showModal();
}
export async function saveUser(event: SubmitEvent): Promise<void> {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const username = form.dataset.originalUsername || getFormInput(form, "username").value;
const body: Record<string, unknown> = {
role: getFormSelect(form, "role").value,
disabled: getFormInput(form, "disabled").checked,
};
if (getFormInput(form, "password").value) {
body.password = getFormInput(form, "password").value;
}
try {
if (form.dataset.originalUsername) {
await api(`/users/${encodeURIComponent(username)}`, { method: "PATCH", body });
} else {
body.username = username;
await api("/users", { method: "POST", body });
}
el<HTMLDialogElement>("userDialog").close();
await loadUsers();
} catch (err) {
showAlert((err as Error).message);
}
}
async function removeUser(username: string): Promise<void> {
if (!confirm(t("deleteUserConfirm", { username }))) {
return;
}
try {
await api(`/users/${encodeURIComponent(username)}`, { method: "DELETE" });
await loadUsers();
} catch (err) {
showAlert((err as Error).message);
}
}

View File

@@ -1,20 +1,15 @@
package main package main
import ( import (
"embed"
"net/http" "net/http"
"github.com/tursom/mc-gateway/internal/adminhttp" "github.com/tursom/mc-gateway/internal/adminhttp"
) )
//go:embed admin_static/index.html admin_static/app.css admin_static/app.js
var adminStaticFS embed.FS
func newGatewayHTTPHandler() http.Handler { func newGatewayHTTPHandler() http.Handler {
return adminhttp.NewGatewayHandler(adminhttp.GatewayHandlerOptions{ return adminhttp.NewGatewayHandler(adminhttp.GatewayHandlerOptions{
AdminPath: adminStartup.AdminPath, AdminPath: adminStartup.AdminPath,
AdminAPIPrefix: adminStartup.AdminAPIPrefix, AdminAPIPrefix: adminStartup.AdminAPIPrefix,
Assets: adminStaticFS,
APIHandler: newAdminAPIHandler(), APIHandler: newAdminAPIHandler(),
WebSocketEnabled: config.WebSocket.Enable, WebSocketEnabled: config.WebSocket.Enable,
WebSocketPath: normalizedWebSocketPath(), WebSocketPath: normalizedWebSocketPath(),

View File

@@ -126,6 +126,18 @@ label.inline input {
color: var(--muted); color: var(--muted);
} }
.language-field {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.language-field select {
width: auto;
min-width: 104px;
}
.auth-view { .auth-view {
min-height: calc(100vh - 120px); min-height: calc(100vh - 120px);
display: grid; display: grid;
@@ -355,6 +367,10 @@ dialog h2 {
flex-direction: column; flex-direction: column;
} }
.session {
flex-wrap: wrap;
}
.toolbar input { .toolbar input {
max-width: none; max-width: none;
} }

View File

@@ -1,579 +0,0 @@
const apiBase = document.body.dataset.apiPrefix || "/admin/api";
const state = {
token: sessionStorage.getItem("mcGatewayAdminToken") || "",
user: null,
routes: [],
services: [],
users: [],
};
const el = (id) => document.getElementById(id);
function showAlert(message) {
const box = el("alert");
box.textContent = message;
box.classList.toggle("hidden", !message);
}
function setView(name) {
for (const id of ["setupView", "loginView", "appView"]) {
el(id).classList.toggle("hidden", id !== name);
}
}
async function api(path, options = {}) {
const headers = { "Accept": "application/json" };
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
}
if (state.token) {
headers.Authorization = `Bearer ${state.token}`;
}
const res = await fetch(apiBase + path, {
method: options.method || "GET",
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
let data = {};
const text = await res.text();
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { error: text };
}
}
if (!res.ok) {
throw new Error(data.error || res.statusText);
}
return data;
}
async function boot() {
bindEvents();
try {
const setup = await api("/setup");
if (setup.required) {
setView("setupView");
el("subtitle").textContent = "Setup";
return;
}
} catch (err) {
showAlert(err.message);
}
if (!state.token) {
setView("loginView");
el("subtitle").textContent = "Login";
return;
}
try {
state.user = await api("/me");
await showApp();
} catch {
sessionStorage.removeItem("mcGatewayAdminToken");
state.token = "";
setView("loginView");
el("subtitle").textContent = "Login";
}
}
function bindEvents() {
el("setupForm").addEventListener("submit", submitSetup);
el("loginForm").addEventListener("submit", submitLogin);
el("logoutBtn").addEventListener("click", logout);
el("routeSearch").addEventListener("input", debounce(loadRoutes, 180));
el("newRouteBtn").addEventListener("click", () => openRouteDialog());
el("newUserBtn").addEventListener("click", () => openUserDialog());
el("routeForm").addEventListener("submit", saveRoute);
el("userForm").addEventListener("submit", saveUser);
for (const button of document.querySelectorAll("[data-close]")) {
button.addEventListener("click", () => button.closest("dialog").close());
}
for (const button of document.querySelectorAll(".tabs button")) {
button.addEventListener("click", () => selectTab(button.dataset.tab));
}
}
async function submitSetup(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await api("/setup", {
method: "POST",
body: {
username: form.get("username"),
password: form.get("password"),
},
});
showAlert("");
setView("loginView");
} catch (err) {
showAlert(err.message);
}
}
async function submitLogin(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const data = await api("/auth/login", {
method: "POST",
body: {
username: form.get("username"),
password: form.get("password"),
},
});
state.token = data.token;
state.user = data.user;
sessionStorage.setItem("mcGatewayAdminToken", state.token);
showAlert("");
await showApp();
} catch (err) {
showAlert(err.message);
}
}
async function logout() {
try {
await api("/auth/logout", { method: "POST", body: {} });
} catch {
}
sessionStorage.removeItem("mcGatewayAdminToken");
state.token = "";
state.user = null;
setView("loginView");
}
async function showApp() {
setView("appView");
el("subtitle").textContent = "Admin";
el("sessionUser").textContent = `${state.user.username} (${state.user.role})`;
el("logoutBtn").classList.remove("hidden");
applyRoleVisibility();
await loadRoutes();
if (isMember()) {
await loadStatus();
await loadServices();
await loadMetrics();
}
if (isAdmin()) {
await loadUsers();
await loadAudit();
}
}
function applyRoleVisibility() {
const member = isMember();
const admin = isAdmin();
el("statusGrid").classList.toggle("hidden", !member);
el("newRouteBtn").classList.toggle("hidden", !member);
toggleTab("services", member);
toggleTab("metrics", member);
toggleTab("users", admin);
toggleTab("audit", admin);
selectTab("routes");
}
function toggleTab(name, visible) {
document.querySelector(`[data-tab="${name}"]`).classList.toggle("hidden", !visible);
}
function selectTab(name) {
for (const button of document.querySelectorAll(".tabs button")) {
button.classList.toggle("active", button.dataset.tab === name);
}
for (const panel of document.querySelectorAll(".tab-panel")) {
panel.classList.add("hidden");
}
el(`${name}Tab`).classList.remove("hidden");
}
async function loadStatus() {
try {
const status = await api("/status");
el("statusGrid").innerHTML = [
stat("PID", status.pid),
stat("Uptime", `${status.uptime_seconds}s`),
stat("SQLite", status.db_path),
stat("TCP/Admin", status.tcp_admin_port),
].join("");
} catch (err) {
showAlert(err.message);
}
}
async function loadRoutes() {
try {
const q = encodeURIComponent(el("routeSearch").value || "");
const data = await api(q ? `/routes?q=${q}` : "/routes");
state.routes = data.routes || [];
renderRoutes();
} catch (err) {
showAlert(err.message);
}
}
function renderRoutes() {
const canWrite = isMember();
el("routesBody").innerHTML = state.routes.map((route) => `
<tr>
<td>${escapeHTML(route.host)}</td>
<td>${escapeHTML(route.upstream)}</td>
<td>${badge(route.enabled ? "Enabled" : "Disabled", !route.enabled)}</td>
<td>${escapeHTML(route.note || "")}</td>
<td class="actions">${canWrite ? routeActions(route) : ""}</td>
</tr>
`).join("");
for (const button of document.querySelectorAll("[data-edit-route]")) {
button.addEventListener("click", () => {
const route = state.routes.find((item) => item.host === button.dataset.editRoute);
openRouteDialog(route);
});
}
for (const button of document.querySelectorAll("[data-delete-route]")) {
button.addEventListener("click", () => removeRoute(button.dataset.deleteRoute));
}
}
function routeActions(route) {
return `
<div class="row-actions">
<button class="secondary" type="button" data-edit-route="${escapeAttr(route.host)}">Edit</button>
<button class="danger" type="button" data-delete-route="${escapeAttr(route.host)}">Delete</button>
</div>
`;
}
function openRouteDialog(route = null) {
const form = el("routeForm");
form.reset();
form.dataset.originalHost = route ? route.host : "";
form.elements.host.disabled = Boolean(route);
if (route) {
form.elements.host.value = route.host;
form.elements.upstream.value = route.upstream;
form.elements.enabled.checked = route.enabled;
form.elements.note.value = route.note || "";
} else {
form.elements.enabled.checked = true;
}
el("routeDialog").showModal();
}
async function saveRoute(event) {
event.preventDefault();
const form = event.currentTarget;
const host = form.dataset.originalHost || form.elements.host.value;
try {
await api(`/routes/${encodeURIComponent(host)}`, {
method: "PUT",
body: {
upstream: form.elements.upstream.value,
enabled: form.elements.enabled.checked,
note: form.elements.note.value,
},
});
el("routeDialog").close();
await loadRoutes();
showAlert("");
} catch (err) {
showAlert(err.message);
}
}
async function removeRoute(host) {
if (host === "default" && !confirm("Delete default route?")) {
return;
}
try {
await api(`/routes/${encodeURIComponent(host)}`, { method: "DELETE" });
await loadRoutes();
} catch (err) {
showAlert(err.message);
}
}
async function loadServices() {
try {
const data = await api("/services");
state.services = data.services || [];
renderServices();
} catch (err) {
showAlert(err.message);
}
}
function renderServices() {
el("servicesGrid").innerHTML = state.services.map((service) => `
<article class="service">
<div>
<span>${escapeHTML(service.name)}</span>
<strong>${service.enabled ? "Enabled" : "Disabled"}${service.restart_required ? " / restart required" : ""}</strong>
</div>
${isAdmin() ? serviceForm(service) : serviceSummary(service)}
</article>
`).join("");
for (const form of document.querySelectorAll("[data-service-form]")) {
form.addEventListener("submit", saveService);
}
for (const button of document.querySelectorAll("[data-restart-service]")) {
button.addEventListener("click", () => restartService(button.dataset.restartService));
}
}
function serviceSummary(service) {
return `<div><span>Port</span><strong>${service.port}</strong></div>`;
}
function serviceForm(service) {
const disabled = service.name === "tcp_admin" ? "disabled" : "";
const optionFields = serviceOptionFields(service);
return `
<form data-service-form="${escapeAttr(service.name)}">
<label class="inline">
<input name="enabled" type="checkbox" ${service.enabled ? "checked" : ""} ${disabled}>
Enabled
</label>
<label>
Port
<input name="port" type="number" min="1" max="65535" value="${service.port}">
</label>
${optionFields}
<div class="row-actions">
<button type="submit">Save</button>
<button class="secondary" type="button" data-restart-service="${escapeAttr(service.name)}">Restart</button>
</div>
</form>
`;
}
function serviceOptionFields(service) {
const options = service.options || {};
if (service.name === "kcp") {
return `
<label>Data shards<input name="data_shards" type="number" min="1" value="${options.data_shards || 10}"></label>
<label>Parity shards<input name="parity_shards" type="number" min="1" value="${options.parity_shards || 3}"></label>
`;
}
if (service.name === "quic") {
const protocols = Array.isArray(options.application_protocols) ? options.application_protocols.join(",") : "";
return `<label>Protocols<input name="application_protocols" value="${escapeAttr(protocols)}"></label>`;
}
if (service.name === "websocket") {
return `<label>Path<input name="path" value="${escapeAttr(options.path || "/")}"></label>`;
}
return "";
}
async function saveService(event) {
event.preventDefault();
const form = event.currentTarget;
const name = form.dataset.serviceForm;
const options = {};
if (name === "kcp") {
options.data_shards = Number(form.elements.data_shards.value);
options.parity_shards = Number(form.elements.parity_shards.value);
} else if (name === "quic") {
options.application_protocols = form.elements.application_protocols.value.split(",").map((item) => item.trim()).filter(Boolean);
} else if (name === "websocket") {
options.path = form.elements.path.value;
}
try {
await api(`/services/${encodeURIComponent(name)}`, {
method: "PUT",
body: {
enabled: name === "tcp_admin" ? true : form.elements.enabled.checked,
port: Number(form.elements.port.value),
options,
},
});
await loadServices();
} catch (err) {
showAlert(err.message);
}
}
async function restartService(name) {
try {
await api(`/services/${encodeURIComponent(name)}/restart`, { method: "POST", body: {} });
await loadServices();
} catch (err) {
showAlert(err.message);
}
}
async function loadUsers() {
try {
const data = await api("/users");
state.users = data.users || [];
renderUsers();
} catch (err) {
showAlert(err.message);
}
}
function renderUsers() {
el("usersBody").innerHTML = state.users.map((user) => `
<tr>
<td>${escapeHTML(user.username)}</td>
<td>${escapeHTML(user.role)}</td>
<td>${badge(user.disabled ? "Disabled" : "Active", user.disabled)}</td>
<td class="actions">
<div class="row-actions">
<button class="secondary" type="button" data-edit-user="${escapeAttr(user.username)}">Edit</button>
<button class="danger" type="button" data-delete-user="${escapeAttr(user.username)}">Delete</button>
</div>
</td>
</tr>
`).join("");
for (const button of document.querySelectorAll("[data-edit-user]")) {
button.addEventListener("click", () => {
const user = state.users.find((item) => item.username === button.dataset.editUser);
openUserDialog(user);
});
}
for (const button of document.querySelectorAll("[data-delete-user]")) {
button.addEventListener("click", () => removeUser(button.dataset.deleteUser));
}
}
function openUserDialog(user = null) {
const form = el("userForm");
form.reset();
form.dataset.originalUsername = user ? user.username : "";
form.elements.username.disabled = Boolean(user);
form.elements.password.required = !user;
if (user) {
form.elements.username.value = user.username;
form.elements.role.value = user.role;
form.elements.disabled.checked = user.disabled;
} else {
form.elements.role.value = "member";
}
el("userDialog").showModal();
}
async function saveUser(event) {
event.preventDefault();
const form = event.currentTarget;
const username = form.dataset.originalUsername || form.elements.username.value;
const body = {
role: form.elements.role.value,
disabled: form.elements.disabled.checked,
};
if (form.elements.password.value) {
body.password = form.elements.password.value;
}
try {
if (form.dataset.originalUsername) {
await api(`/users/${encodeURIComponent(username)}`, { method: "PATCH", body });
} else {
body.username = username;
await api("/users", { method: "POST", body });
}
el("userDialog").close();
await loadUsers();
} catch (err) {
showAlert(err.message);
}
}
async function removeUser(username) {
if (!confirm(`Delete user ${username}?`)) {
return;
}
try {
await api(`/users/${encodeURIComponent(username)}`, { method: "DELETE" });
await loadUsers();
} catch (err) {
showAlert(err.message);
}
}
async function loadMetrics() {
try {
const data = await api("/metrics");
el("metricsGrid").innerHTML = [
stat("Total", data.total_connections),
stat("Active", data.active_connections),
stat("TCP", data.tcp_connections),
stat("WebSocket", data.websocket_connections),
stat("Misses", data.route_misses),
stat("Dial errors", data.upstream_dial_errors),
].join("");
const hits = data.route_hits || {};
el("routeHits").innerHTML = Object.keys(hits).length
? Object.entries(hits).map(([host, count]) => `<span class="chip">${escapeHTML(host)}: ${count}</span>`).join("")
: `<span class="chip">No hits</span>`;
} catch (err) {
showAlert(err.message);
}
}
async function loadAudit() {
try {
const data = await api("/audit-logs");
el("auditBody").innerHTML = (data.audit_logs || []).map((item) => `
<tr>
<td>${new Date(item.created_at * 1000).toLocaleString()}</td>
<td>${escapeHTML(item.actor)}</td>
<td>${escapeHTML(item.action)}</td>
<td>${escapeHTML(item.target_type)}:${escapeHTML(item.target_id)}</td>
<td>${badge(item.success ? "Success" : "Failed", !item.success)}</td>
<td>${escapeHTML(item.message || "")}</td>
</tr>
`).join("");
} catch (err) {
showAlert(err.message);
}
}
function stat(label, value) {
return `<div class="stat"><span>${escapeHTML(label)}</span><strong>${escapeHTML(String(value ?? ""))}</strong></div>`;
}
function badge(text, off = false) {
return `<span class="badge ${off ? "off" : ""}">${escapeHTML(text)}</span>`;
}
function isAdmin() {
return state.user && state.user.role === "admin";
}
function isMember() {
return state.user && (state.user.role === "admin" || state.user.role === "member");
}
function debounce(fn, wait) {
let id = 0;
return (...args) => {
clearTimeout(id);
id = setTimeout(() => fn(...args), wait);
};
}
function escapeHTML(value) {
return String(value).replace(/[&<>"']/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
}[ch]));
}
function escapeAttr(value) {
return escapeHTML(value).replace(/`/g, "&#96;");
}
boot();

View File

@@ -6,7 +6,7 @@
<title>mc-gateway admin</title> <title>mc-gateway admin</title>
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="app.css">
</head> </head>
<body data-api-prefix="__ADMIN_API_PREFIX__"> <body>
<main class="shell"> <main class="shell">
<header class="topbar"> <header class="topbar">
<div> <div>
@@ -14,8 +14,15 @@
<p id="subtitle">Admin</p> <p id="subtitle">Admin</p>
</div> </div>
<div class="session"> <div class="session">
<label class="language-field">
<span data-i18n="language">Language</span>
<select id="languageSelect" aria-label="Language">
<option value="en" data-i18n="languageEnglish">English</option>
<option value="zh" data-i18n="languageChinese">中文</option>
</select>
</label>
<span id="sessionUser"></span> <span id="sessionUser"></span>
<button id="logoutBtn" class="ghost hidden" type="button">Logout</button> <button id="logoutBtn" class="ghost hidden" type="button" data-i18n="logout">Logout</button>
</div> </div>
</header> </header>
@@ -23,31 +30,31 @@
<section id="setupView" class="auth-view hidden"> <section id="setupView" class="auth-view hidden">
<form id="setupForm" class="panel compact"> <form id="setupForm" class="panel compact">
<h2>Initial admin</h2> <h2 data-i18n="initialAdmin">Initial admin</h2>
<label> <label>
Username <span data-i18n="username">Username</span>
<input name="username" autocomplete="username" value="admin"> <input name="username" autocomplete="username" value="admin">
</label> </label>
<label> <label>
Password <span data-i18n="password">Password</span>
<input name="password" autocomplete="new-password" type="password" required> <input name="password" autocomplete="new-password" type="password" required>
</label> </label>
<button type="submit">Create admin</button> <button type="submit" data-i18n="createAdmin">Create admin</button>
</form> </form>
</section> </section>
<section id="loginView" class="auth-view hidden"> <section id="loginView" class="auth-view hidden">
<form id="loginForm" class="panel compact"> <form id="loginForm" class="panel compact">
<h2>Login</h2> <h2 data-i18n="login">Login</h2>
<label> <label>
Username <span data-i18n="username">Username</span>
<input name="username" autocomplete="username" required> <input name="username" autocomplete="username" required>
</label> </label>
<label> <label>
Password <span data-i18n="password">Password</span>
<input name="password" autocomplete="current-password" type="password" required> <input name="password" autocomplete="current-password" type="password" required>
</label> </label>
<button type="submit">Login</button> <button type="submit" data-i18n="login">Login</button>
</form> </form>
</section> </section>
@@ -55,27 +62,27 @@
<section id="statusGrid" class="status-grid"></section> <section id="statusGrid" class="status-grid"></section>
<nav class="tabs"> <nav class="tabs">
<button data-tab="routes" class="active" type="button">Routes</button> <button data-tab="routes" class="active" type="button" data-i18n="routes">Routes</button>
<button data-tab="services" type="button">Services</button> <button data-tab="services" type="button" data-i18n="services">Services</button>
<button data-tab="users" type="button">Users</button> <button data-tab="users" type="button" data-i18n="users">Users</button>
<button data-tab="metrics" type="button">Metrics</button> <button data-tab="metrics" type="button" data-i18n="metrics">Metrics</button>
<button data-tab="audit" type="button">Audit</button> <button data-tab="audit" type="button" data-i18n="audit">Audit</button>
</nav> </nav>
<section id="routesTab" class="tab-panel"> <section id="routesTab" class="tab-panel">
<div class="toolbar"> <div class="toolbar">
<input id="routeSearch" placeholder="Search host, upstream, note"> <input id="routeSearch" placeholder="Search host, upstream, note" data-i18n-placeholder="routeSearch">
<button id="newRouteBtn" type="button">New route</button> <button id="newRouteBtn" type="button" data-i18n="newRoute">New route</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Host</th> <th data-i18n="host">Host</th>
<th>Upstream</th> <th data-i18n="upstream">Upstream</th>
<th>Enabled</th> <th data-i18n="enabled">Enabled</th>
<th>Note</th> <th data-i18n="note">Note</th>
<th class="actions">Actions</th> <th class="actions" data-i18n="actions">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="routesBody"></tbody> <tbody id="routesBody"></tbody>
@@ -89,16 +96,16 @@
<section id="usersTab" class="tab-panel hidden"> <section id="usersTab" class="tab-panel hidden">
<div class="toolbar"> <div class="toolbar">
<button id="newUserBtn" type="button">New user</button> <button id="newUserBtn" type="button" data-i18n="newUser">New user</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Username</th> <th data-i18n="username">Username</th>
<th>Role</th> <th data-i18n="role">Role</th>
<th>Disabled</th> <th data-i18n="disabled">Disabled</th>
<th class="actions">Actions</th> <th class="actions" data-i18n="actions">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody id="usersBody"></tbody> <tbody id="usersBody"></tbody>
@@ -109,7 +116,7 @@
<section id="metricsTab" class="tab-panel hidden"> <section id="metricsTab" class="tab-panel hidden">
<div id="metricsGrid" class="status-grid"></div> <div id="metricsGrid" class="status-grid"></div>
<div class="panel"> <div class="panel">
<h2>Route hits</h2> <h2 data-i18n="routeHits">Route hits</h2>
<div id="routeHits" class="chips"></div> <div id="routeHits" class="chips"></div>
</div> </div>
</section> </section>
@@ -119,12 +126,12 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Time</th> <th data-i18n="time">Time</th>
<th>Actor</th> <th data-i18n="actor">Actor</th>
<th>Action</th> <th data-i18n="action">Action</th>
<th>Target</th> <th data-i18n="target">Target</th>
<th>Result</th> <th data-i18n="result">Result</th>
<th>Message</th> <th data-i18n="message">Message</th>
</tr> </tr>
</thead> </thead>
<tbody id="auditBody"></tbody> <tbody id="auditBody"></tbody>
@@ -136,60 +143,61 @@
<dialog id="routeDialog"> <dialog id="routeDialog">
<form id="routeForm" method="dialog"> <form id="routeForm" method="dialog">
<h2>Route</h2> <h2 data-i18n="route">Route</h2>
<label> <label>
Host <span data-i18n="host">Host</span>
<input name="host" required> <input name="host" required>
</label> </label>
<label> <label>
Upstream <span data-i18n="upstream">Upstream</span>
<input name="upstream" required placeholder="127.0.0.1:25565"> <input name="upstream" required placeholder="127.0.0.1:25565">
</label> </label>
<label class="inline"> <label class="inline">
<input name="enabled" type="checkbox" checked> <input name="enabled" type="checkbox" checked>
Enabled <span data-i18n="enabled">Enabled</span>
</label> </label>
<label> <label>
Note <span data-i18n="note">Note</span>
<input name="note"> <input name="note">
</label> </label>
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" data-close>Cancel</button> <button type="button" data-close data-i18n="cancel">Cancel</button>
<button type="submit">Save</button> <button type="submit" data-i18n="save">Save</button>
</div> </div>
</form> </form>
</dialog> </dialog>
<dialog id="userDialog"> <dialog id="userDialog">
<form id="userForm" method="dialog"> <form id="userForm" method="dialog">
<h2>User</h2> <h2 data-i18n="user">User</h2>
<label> <label>
Username <span data-i18n="username">Username</span>
<input name="username" required> <input name="username" required>
</label> </label>
<label> <label>
Role <span data-i18n="role">Role</span>
<select name="role"> <select name="role">
<option value="admin">Admin</option> <option value="admin" data-i18n="roleAdmin">Admin</option>
<option value="member">Member</option> <option value="member" data-i18n="roleMember">Member</option>
<option value="guest">Guest</option> <option value="guest" data-i18n="roleGuest">Guest</option>
</select> </select>
</label> </label>
<label> <label>
Password <span data-i18n="password">Password</span>
<input name="password" type="password"> <input name="password" type="password">
</label> </label>
<label class="inline"> <label class="inline">
<input name="disabled" type="checkbox"> <input name="disabled" type="checkbox">
Disabled <span data-i18n="disabled">Disabled</span>
</label> </label>
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" data-close>Cancel</button> <button type="button" data-close data-i18n="cancel">Cancel</button>
<button type="submit">Save</button> <button type="submit" data-i18n="save">Save</button>
</div> </div>
</form> </form>
</dialog> </dialog>
<script src="app.js"></script> <script src="config.js"></script>
<script type="module" src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -101,8 +101,12 @@ func TestParseStartupConfigReturnsErrors(t *testing.T) {
}, },
}, },
{ {
name: "api prefix under asset path", name: "api prefix under js asset path",
env: map[string]string{adminEnvAPIPrefix: "/admin/app.js/api"}, env: map[string]string{adminEnvAPIPrefix: "/admin/js/api"},
},
{
name: "api prefix under config asset path",
env: map[string]string{adminEnvAPIPrefix: "/admin/config.js/api"},
}, },
} }

View File

@@ -7,6 +7,7 @@ services:
MC_GATEWAY_TCP_ADMIN_PORT: "${MC_GATEWAY_TCP_ADMIN_PORT:-25565}" MC_GATEWAY_TCP_ADMIN_PORT: "${MC_GATEWAY_TCP_ADMIN_PORT:-25565}"
MC_GATEWAY_ADMIN_PATH: "${MC_GATEWAY_ADMIN_PATH:-/admin/}" MC_GATEWAY_ADMIN_PATH: "${MC_GATEWAY_ADMIN_PATH:-/admin/}"
MC_GATEWAY_ADMIN_API_PREFIX: "${MC_GATEWAY_ADMIN_API_PREFIX:-/admin/api}" MC_GATEWAY_ADMIN_API_PREFIX: "${MC_GATEWAY_ADMIN_API_PREFIX:-/admin/api}"
MC_GATEWAY_ADMIN_STATIC_DIR: "${MC_GATEWAY_ADMIN_STATIC_DIR:-/usr/share/mc-gateway/admin_static}"
MC_GATEWAY_DB: "${MC_GATEWAY_DB:-/data/mc-gateway.sqlite3}" MC_GATEWAY_DB: "${MC_GATEWAY_DB:-/data/mc-gateway.sqlite3}"
MC_GATEWAY_ADMIN_PASSWORD: "${MC_GATEWAY_ADMIN_PASSWORD:-}" MC_GATEWAY_ADMIN_PASSWORD: "${MC_GATEWAY_ADMIN_PASSWORD:-}"
volumes: volumes:

View File

@@ -56,6 +56,7 @@
| TCP/Admin 监听端口 | `25565` | `MC_GATEWAY_TCP_ADMIN_PORT` | | TCP/Admin 监听端口 | `25565` | `MC_GATEWAY_TCP_ADMIN_PORT` |
| Admin 页面路径 | `/admin/` | `MC_GATEWAY_ADMIN_PATH` | | Admin 页面路径 | `/admin/` | `MC_GATEWAY_ADMIN_PATH` |
| Admin API 前缀 | `/admin/api` | `MC_GATEWAY_ADMIN_API_PREFIX` | | Admin API 前缀 | `/admin/api` | `MC_GATEWAY_ADMIN_API_PREFIX` |
| Admin 静态目录 | `cmd/gateway/admin_static` | `MC_GATEWAY_ADMIN_STATIC_DIR` |
| KCP | 默认禁用 | 后台配置 | | KCP | 默认禁用 | 后台配置 |
| QUIC | 默认禁用 | 后台配置 | | QUIC | 默认禁用 | 后台配置 |
| WebSocket | 默认禁用 | 后台配置 | | WebSocket | 默认禁用 | 后台配置 |
@@ -67,7 +68,8 @@
- `MC_GATEWAY_TCP_ADMIN_PORT` 只在启动时读取,必须是 `1-65535` 的整数;为空时使用 `25565` - `MC_GATEWAY_TCP_ADMIN_PORT` 只在启动时读取,必须是 `1-65535` 的整数;为空时使用 `25565`
- `MC_GATEWAY_ADMIN_PATH` 只在启动时读取,必须以 `/` 开头,规范化为以 `/` 结尾;为空时使用 `/admin/` - `MC_GATEWAY_ADMIN_PATH` 只在启动时读取,必须以 `/` 开头,规范化为以 `/` 结尾;为空时使用 `/admin/`
- `MC_GATEWAY_ADMIN_API_PREFIX` 只在启动时读取,必须以 `/` 开头,规范化为不以 `/` 结尾;为空时使用 `/admin/api` - `MC_GATEWAY_ADMIN_API_PREFIX` 只在启动时读取,必须以 `/` 开头,规范化为不以 `/` 结尾;为空时使用 `/admin/api`
- `MC_GATEWAY_ADMIN_API_PREFIX` 不能等于 `MC_GATEWAY_ADMIN_PATH`,也不能落在静态资源路径下。 - `MC_GATEWAY_ADMIN_API_PREFIX` 不能等于 `MC_GATEWAY_ADMIN_PATH`,也不能落在静态资源路径下,例如 `config.js``js/`
- `MC_GATEWAY_ADMIN_STATIC_DIR` 指向 Admin 前端构建产物目录Docker 镜像内使用 `/usr/share/mc-gateway/admin_static`
- 环境变量覆盖的是本次进程的 Admin 入口;第一次创建 SQLite 默认服务配置时,应把解析后的 TCP/Admin 端口写入 `services.tcp_admin.port` - 环境变量覆盖的是本次进程的 Admin 入口;第一次创建 SQLite 默认服务配置时,应把解析后的 TCP/Admin 端口写入 `services.tcp_admin.port`
启动流程: 启动流程:
@@ -130,7 +132,7 @@ Accept
## HTTP 路由与页面 ## HTTP 路由与页面
页面使用 Go `embed` 打包到单个二进制,不引入 Node 构建链 Admin 前端源码使用 TypeScript 拆分,构建为原生 ES modules。Go 不再 `embed` 前端文件,而是从 `MC_GATEWAY_ADMIN_STATIC_DIR` 指向的目录读取并透传静态响应。运行时 API 前缀通过动态 `config.js` 响应注入
建议目录: 建议目录:
@@ -140,10 +142,11 @@ cmd/gateway/admin_api.go
cmd/gateway/admin_auth.go cmd/gateway/admin_auth.go
cmd/gateway/admin_db.go cmd/gateway/admin_db.go
cmd/gateway/admin_static.go cmd/gateway/admin_static.go
cmd/gateway/admin_frontend/src/
cmd/gateway/admin_static/ cmd/gateway/admin_static/
index.html index.html
app.css app.css
app.js js/
``` ```
API 路由: API 路由:
@@ -152,7 +155,8 @@ API 路由:
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GET | `/admin/` | 公开页面 | 管理页面入口或首次初始化页面 | | GET | `/admin/` | 公开页面 | 管理页面入口或首次初始化页面 |
| GET | `/admin/app.css` | 公开页面 | 页面样式 | | GET | `/admin/app.css` | 公开页面 | 页面样式 |
| GET | `/admin/app.js` | 公开页面 | 页面脚本 | | GET | `/admin/config.js` | 公开页面 | 运行时前端配置 |
| GET | `/admin/js/main.js` | 公开页面 | 页面脚本入口 |
| POST | `/admin/api/setup` | 仅用户表为空 | 创建第一个管理员 | | POST | `/admin/api/setup` | 仅用户表为空 | 创建第一个管理员 |
| POST | `/admin/api/auth/login` | 未登录 | 用户名密码登录 | | POST | `/admin/api/auth/login` | 未登录 | 用户名密码登录 |
| POST | `/admin/api/auth/logout` | 已登录 | 注销当前会话 | | POST | `/admin/api/auth/logout` | 已登录 | 注销当前会话 |

View File

@@ -132,7 +132,7 @@ func validateAdminPaths(adminPath, apiPrefix string) error {
return errors.New("admin API prefix cannot equal admin page path") return errors.New("admin API prefix cannot equal admin page path")
} }
for _, asset := range []string{"app.css", "app.js"} { for _, asset := range []string{"app.css", "config.js", "js"} {
assetPath := strings.TrimRight(adminPath, "/") + "/" + asset assetPath := strings.TrimRight(adminPath, "/") + "/" + asset
if apiPrefix == assetPath || strings.HasPrefix(apiPrefix+"/", assetPath+"/") { if apiPrefix == assetPath || strings.HasPrefix(apiPrefix+"/", assetPath+"/") {
return errors.New("admin API prefix cannot be under static asset path") return errors.New("admin API prefix cannot be under static asset path")

View File

@@ -60,8 +60,12 @@ func TestParseReturnsErrors(t *testing.T) {
}, },
}, },
{ {
name: "api prefix under asset path", name: "api prefix under js asset path",
env: map[string]string{EnvAPIPrefix: "/admin/app.js/api"}, env: map[string]string{EnvAPIPrefix: "/admin/js/api"},
},
{
name: "api prefix under config asset path",
env: map[string]string{EnvAPIPrefix: "/admin/config.js/api"},
}, },
} }

View File

@@ -1,21 +1,24 @@
package adminhttp package adminhttp
import ( import (
"io/fs"
"net/http" "net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings" "strings"
) )
const ( const (
staticIndexFile = "admin_static/index.html" defaultStaticDir = "cmd/gateway/admin_static"
staticCSSFile = "admin_static/app.css" staticConfigFile = "config.js"
staticJSFile = "admin_static/app.js" staticIndexFile = "index.html"
) )
type GatewayHandlerOptions struct { type GatewayHandlerOptions struct {
AdminPath string AdminPath string
AdminAPIPrefix string AdminAPIPrefix string
Assets fs.FS StaticDir string
APIHandler http.HandlerFunc APIHandler http.HandlerFunc
WebSocketEnabled bool WebSocketEnabled bool
WebSocketPath string WebSocketPath string
@@ -68,40 +71,67 @@ func serveAdminStatic(w http.ResponseWriter, r *http.Request, opts GatewayHandle
} }
if r.URL.Path == opts.AdminPath { if r.URL.Path == opts.AdminPath {
serveAdminIndex(w, opts) serveAdminFile(w, r, opts, staticIndexFile)
return return
} }
rel := strings.TrimPrefix(r.URL.Path, opts.AdminPath) rel := strings.TrimPrefix(r.URL.Path, opts.AdminPath)
switch rel { if rel == staticConfigFile {
case "app.css": serveAdminConfig(w, opts)
serveAdminFile(w, r, opts.Assets, staticCSSFile, "text/css; charset=utf-8")
case "app.js":
serveAdminFile(w, r, opts.Assets, staticJSFile, "application/javascript; charset=utf-8")
default:
http.NotFound(w, r)
}
}
func serveAdminIndex(w http.ResponseWriter, opts GatewayHandlerOptions) {
data, err := fs.ReadFile(opts.Assets, staticIndexFile)
if err != nil {
WriteAPIError(w, http.StatusInternalServerError, err.Error())
return return
} }
html := strings.ReplaceAll(string(data), "__ADMIN_API_PREFIX__", opts.AdminAPIPrefix) serveAdminFile(w, r, opts, rel)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(html))
} }
func serveAdminFile(w http.ResponseWriter, r *http.Request, assets fs.FS, name, contentType string) { func serveAdminConfig(w http.ResponseWriter, opts GatewayHandlerOptions) {
data, err := fs.ReadFile(assets, name) w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
if err != nil { w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(`window.MCGatewayAdmin={"apiPrefix":` + strconv.Quote(opts.AdminAPIPrefix) + `};`))
}
func serveAdminFile(w http.ResponseWriter, r *http.Request, opts GatewayHandlerOptions, rel string) {
name, ok := cleanStaticPath(rel)
if !ok {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
w.Header().Set("Content-Type", contentType) file := filepath.Join(staticDir(opts), name)
w.Header().Set("Cache-Control", "no-store") info, err := os.Stat(file)
_, _ = w.Write(data) if err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, file)
}
func cleanStaticPath(rel string) (string, bool) {
if rel == "" {
return "", false
}
cleaned := path.Clean("/" + rel)
if cleaned == "/" || strings.HasPrefix(cleaned, "/../") {
return "", false
}
name := strings.TrimPrefix(cleaned, "/")
if name == staticConfigFile {
return "", false
}
return name, true
}
func staticDir(opts GatewayHandlerOptions) string {
if strings.TrimSpace(opts.StaticDir) != "" {
return opts.StaticDir
}
if value := strings.TrimSpace(os.Getenv("MC_GATEWAY_ADMIN_STATIC_DIR")); value != "" {
return value
}
if _, err := os.Stat(defaultStaticDir); err == nil {
return defaultStaticDir
}
if _, err := os.Stat("admin_static"); err == nil {
return "admin_static"
}
return defaultStaticDir
} }

View File

@@ -4,9 +4,10 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"testing/fstest"
) )
func TestDecodeJSONRequest(t *testing.T) { func TestDecodeJSONRequest(t *testing.T) {
@@ -117,16 +118,16 @@ func TestRequestSourceIP(t *testing.T) {
} }
func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) { func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
assets := fstest.MapFS{ staticDir := writeTestAdminStatic(t, map[string]string{
staticIndexFile: {Data: []byte(`<html data-api-prefix="__ADMIN_API_PREFIX__"></html>`)}, "index.html": "<html><script src=\"config.js\"></script></html>",
staticCSSFile: {Data: []byte(`body{color:red}`)}, "app.css": "body{color:red}",
staticJSFile: {Data: []byte(`console.log("admin")`)}, "js/main.js": `console.log("admin")`,
} })
apiCalled := false apiCalled := false
handler := NewGatewayHandler(GatewayHandlerOptions{ handler := NewGatewayHandler(GatewayHandlerOptions{
AdminPath: "/ops/", AdminPath: "/ops/",
AdminAPIPrefix: "/ops/api", AdminAPIPrefix: "/ops/api",
Assets: assets, StaticDir: staticDir,
APIHandler: func(w http.ResponseWriter, r *http.Request) { APIHandler: func(w http.ResponseWriter, r *http.Request) {
apiCalled = true apiCalled = true
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
@@ -149,11 +150,8 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
if resp.Code != http.StatusOK { if resp.Code != http.StatusOK {
t.Fatalf("admin page status = %d, body=%s", resp.Code, resp.Body.String()) t.Fatalf("admin page status = %d, body=%s", resp.Code, resp.Body.String())
} }
if !strings.Contains(resp.Body.String(), `data-api-prefix="/ops/api"`) { if !strings.Contains(resp.Body.String(), `script src="config.js"`) {
t.Fatalf("admin page = %q, want API prefix", resp.Body.String()) t.Fatalf("admin page = %q, want static index", resp.Body.String())
}
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("admin page Cache-Control = %q, want no-store", got)
} }
resp = httptest.NewRecorder() resp = httptest.NewRecorder()
@@ -163,6 +161,33 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
t.Fatalf("css response status=%d body=%q", resp.Code, resp.Body.String()) t.Fatalf("css response status=%d body=%q", resp.Code, resp.Body.String())
} }
resp = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/ops/js/main.js", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK || !strings.Contains(resp.Body.String(), `console.log("admin")`) {
t.Fatalf("js response status=%d body=%q", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("js Cache-Control = %q, want no-store", got)
}
resp = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/ops/js/", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("js directory status=%d, want %d", resp.Code, http.StatusNotFound)
}
resp = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/ops/config.js", nil)
handler.ServeHTTP(resp, req)
if resp.Code != http.StatusOK || strings.TrimSpace(resp.Body.String()) != `window.MCGatewayAdmin={"apiPrefix":"/ops/api"};` {
t.Fatalf("config response status=%d body=%q", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("config Cache-Control = %q, want no-store", got)
}
resp = httptest.NewRecorder() resp = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/ops/", nil) req = httptest.NewRequest(http.MethodPost, "/ops/", nil)
handler.ServeHTTP(resp, req) handler.ServeHTTP(resp, req)
@@ -182,13 +207,11 @@ func TestNewGatewayHandlerServesAdminAndAPI(t *testing.T) {
} }
func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T) { func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T) {
assets := fstest.MapFS{ staticDir := writeTestAdminStatic(t, map[string]string{"index.html": ""})
staticIndexFile: {Data: []byte(``)},
}
handler := NewGatewayHandler(GatewayHandlerOptions{ handler := NewGatewayHandler(GatewayHandlerOptions{
AdminPath: "/admin/", AdminPath: "/admin/",
AdminAPIPrefix: "/admin/api", AdminAPIPrefix: "/admin/api",
Assets: assets, StaticDir: staticDir,
APIHandler: func(w http.ResponseWriter, r *http.Request) {}, APIHandler: func(w http.ResponseWriter, r *http.Request) {},
WebSocketEnabled: true, WebSocketEnabled: true,
WebSocketPath: "/ws", WebSocketPath: "/ws",
@@ -205,6 +228,21 @@ func TestNewGatewayHandlerRegistersWebSocketWhenPathDoesNotConflict(t *testing.T
} }
} }
func writeTestAdminStatic(t *testing.T, files map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, data := range files {
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%q) error = %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", path, err)
}
}
return dir
}
func TestWebSocketPathConflictsWithAdmin(t *testing.T) { func TestWebSocketPathConflictsWithAdmin(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

26
package-lock.json generated Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "mc-gateway",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"typescript": "6.0.3"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

9
package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"scripts": {
"build:admin": "tsc -p tsconfig.admin.json",
"check:admin": "tsc -p tsconfig.admin.json --noEmit"
},
"devDependencies": {
"typescript": "6.0.3"
}
}

19
tsconfig.admin.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"declaration": false,
"lib": ["DOM", "ES2022"],
"module": "ES2022",
"moduleResolution": "bundler",
"noEmitOnError": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"outDir": "cmd/gateway/admin_static/js",
"removeComments": true,
"rootDir": "cmd/gateway/admin_frontend/src",
"strict": true,
"target": "ES2022"
},
"include": ["cmd/gateway/admin_frontend/src/**/*.ts"]
}