Skip to content

Commit 8f1ff12

Browse files
committed
fix: 优化备注输入体验并完成安全与部署规范升级
1 parent 9e435b8 commit 8f1ff12

23 files changed

Lines changed: 1073 additions & 360 deletions

.env.example

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# -----------------------------
2+
# 镜像来源设置(必看)
3+
# ghcr = 使用 GHCR 预构建镜像(默认,启动最快)
4+
# local = 使用本地源码构建镜像(更易做环境定制)
5+
# 修改后直接执行:docker compose up -d
6+
# -----------------------------
7+
COMPOSE_PROFILES=ghcr
8+
GHCR_IMAGE=ghcr.io/cheluen/singbox-proxy-manager:latest
9+
LOCAL_IMAGE=singbox-proxy-manager:local
10+
SINGBOX_VERSION=1.12.12
11+
GO_MODULE_PROXY=https://proxy.golang.org,direct
12+
GO_SUM_DB=sum.golang.org
13+
14+
# -----------------------------
15+
# 基础运行配置
16+
# -----------------------------
17+
PORT=30000
18+
CONFIG_DIR=/app/config
19+
CONFIG_VOLUME_HOST=./config
20+
21+
# 可选:固定管理员密码(不为空时由环境变量托管)
22+
ADMIN_PASSWORD=
23+
24+
# 可选:允许跨域来源(逗号分隔),例如:
25+
# CORS_ALLOWED_ORIGINS=https://panel.example.com,https://ops.example.com
26+
CORS_ALLOWED_ORIGINS=
27+
28+
# 登录限速(防暴力破解)
29+
LOGIN_RATE_LIMIT_WINDOW_SECONDS=60
30+
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=10
31+
LOGIN_RATE_LIMIT_BLOCK_SECONDS=600
32+
33+
# 管理 API 会话有效期(小时)
34+
ADMIN_SESSION_TTL_HOURS=168
35+
36+
# 仅信任这些反向代理的客户端 IP 头(留空表示不信任任何代理)
37+
# 例:TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8
38+
TRUSTED_PROXIES=
39+
40+
# HTTP 服务器保护参数
41+
HTTP_READ_HEADER_TIMEOUT=5s
42+
HTTP_READ_TIMEOUT=15s
43+
HTTP_WRITE_TIMEOUT=30s
44+
HTTP_IDLE_TIMEOUT=60s
45+
HTTP_MAX_HEADER_BYTES=1048576
46+
API_MAX_BODY_BYTES=1048576
47+
48+
# 可选:Turso 远程数据库(两者需同时设置)
49+
TURSO_DATABASE_URL=
50+
TURSO_AUTH_TOKEN=

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ COPY frontend/ ./
66
RUN npm run build
77

88
FROM golang:1.24 AS backend-builder
9+
ARG GOPROXY=https://proxy.golang.org,direct
10+
ARG GOSUMDB=sum.golang.org
11+
ENV GOPROXY=${GOPROXY}
12+
ENV GOSUMDB=${GOSUMDB}
913
WORKDIR /app
1014
COPY go.mod go.sum ./
1115
RUN go mod download

README.md

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
<div align="center">
44

5-
![Version](https://img.shields.io/badge/version-1.2.3-blue.svg)
5+
<img src="./logo.svg" alt="SingBox Proxy Manager Logo" width="96" />
6+
7+
![Version](https://img.shields.io/badge/version-1.2.4-blue.svg)
68
![License](https://img.shields.io/badge/license-MIT-green.svg)
79
![SingBox](https://img.shields.io/badge/sing--box-1.12.12-orange.svg)
810

@@ -46,15 +48,19 @@
4648
git clone https://github.com/cheluen/singbox-proxy-manager.git
4749
cd singbox-proxy-manager
4850

49-
# 2. 可选:设置管理员密码(推荐)
50-
# 方式 A:通过环境变量 ADMIN_PASSWORD 指定固定管理员密码(面板内将无法修改)
51-
# 方式 B:不设置 ADMIN_PASSWORD,首次打开面板会要求设置管理员密码(可在面板内修改)
52-
nano docker-compose.yml
51+
# 2. 生成部署环境变量文件(所有部署方式建议参考这个变量清单)
52+
cp .env.example .env
53+
54+
# 3. 按需修改 .env(至少建议设置 ADMIN_PASSWORD)
55+
# 镜像来源切换:
56+
# COMPOSE_PROFILES=ghcr -> 使用 GHCR 预构建镜像(默认)
57+
# COMPOSE_PROFILES=local -> 使用本地源码构建镜像
58+
nano .env
5359

54-
# 3. 启动服务
60+
# 4. 启动服务
5561
docker compose up -d
5662

57-
# 4. 查看日志
63+
# 5. 查看日志
5864
docker compose logs -f
5965
```
6066

@@ -81,6 +87,7 @@ docker compose logs -f
8187
- `CONFIG_DIR=/app/config`
8288
- `ADMIN_PASSWORD=你的强密码`
8389
- 必填:`TURSO_DATABASE_URL``TURSO_AUTH_TOKEN`(云平台部署默认强制使用 Turso)
90+
- 变量命名建议与仓库根目录 `.env.example` 保持一致,便于迁移和回滚
8491
5.**Volumes** 挂载目录 `/app/config`(用于持久化 `config.json`、日志、SQLite)。
8592
6. 资源建议使用默认规格:`0.5 vCPU / 512MB`
8693
7. 部署完成后,访问 Zeabur 分配的 HTTP 域名进入面板。
@@ -118,6 +125,7 @@ docker compose logs -f
118125
- HTTP 端口:`30000`
119126
- TCP 端口:`30001`(默认),按需再加 `30002+`
120127
- 环境变量:`PORT=30000``CONFIG_DIR=/app/config``ADMIN_PASSWORD=...`、必填 `TURSO_DATABASE_URL``TURSO_AUTH_TOKEN`
128+
- 建议对照根目录 `.env.example` 填写同名变量,避免多环境配置漂移
121129
- 持久化路径:`/app/config`
122130
- 资源规格:`0.5 vCPU / 512MB`
123131

@@ -176,26 +184,44 @@ docker compose logs -f
176184

177185
### 环境变量
178186

179-
`docker-compose.yml` 中配置:
187+
统一在项目根目录 `.env` 中配置(可由 `.env.example` 复制而来)
180188

181-
```yaml
182-
environment:
183-
- PORT=30000 # 管理界面端口
184-
- CONFIG_DIR=/app/config # 配置文件目录
185-
- ADMIN_PASSWORD= # 可选:管理员密码(不为空则使用该值;面板内无法修改)
186-
- CORS_ALLOWED_ORIGINS= # 可选:管理 API 允许的跨域来源(逗号分隔;默认不启用 CORS)
187-
- LOGIN_RATE_LIMIT_WINDOW_SECONDS=60 # 可选:登录限速窗口(秒)
188-
- LOGIN_RATE_LIMIT_MAX_ATTEMPTS=10 # 可选:窗口内最大失败次数
189-
- LOGIN_RATE_LIMIT_BLOCK_SECONDS=600 # 可选:触发限速后的封禁时间(秒)
190-
- HTTP_READ_HEADER_TIMEOUT=5s # 可选:管理 API 读请求头超时
191-
- HTTP_READ_TIMEOUT=15s # 可选:读请求体超时
192-
- HTTP_WRITE_TIMEOUT=30s # 可选:写响应超时
193-
- HTTP_IDLE_TIMEOUT=60s # 可选:空闲连接超时
194-
- HTTP_MAX_HEADER_BYTES=1048576 # 可选:最大请求头大小(字节)
195-
- TURSO_DATABASE_URL=${TURSO_DATABASE_URL} # 可选:Turso 远程数据库 URL(需与 TURSO_AUTH_TOKEN 一起设置)
196-
- TURSO_AUTH_TOKEN=${TURSO_AUTH_TOKEN} # 可选:Turso 认证 Token(需与 TURSO_DATABASE_URL 一起设置)
189+
```bash
190+
# 镜像来源(关键开关)
191+
COMPOSE_PROFILES=ghcr # ghcr 或 local
192+
GHCR_IMAGE=ghcr.io/cheluen/singbox-proxy-manager:latest
193+
LOCAL_IMAGE=singbox-proxy-manager:local
194+
SINGBOX_VERSION=1.12.12
195+
GO_MODULE_PROXY=https://proxy.golang.org,direct
196+
GO_SUM_DB=sum.golang.org
197+
198+
# 运行配置
199+
PORT=30000
200+
CONFIG_DIR=/app/config
201+
CONFIG_VOLUME_HOST=./config
202+
ADMIN_PASSWORD=
203+
204+
# 安全与稳定性
205+
CORS_ALLOWED_ORIGINS=
206+
LOGIN_RATE_LIMIT_WINDOW_SECONDS=60
207+
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=10
208+
LOGIN_RATE_LIMIT_BLOCK_SECONDS=600
209+
ADMIN_SESSION_TTL_HOURS=168
210+
TRUSTED_PROXIES=
211+
HTTP_READ_HEADER_TIMEOUT=5s
212+
HTTP_READ_TIMEOUT=15s
213+
HTTP_WRITE_TIMEOUT=30s
214+
HTTP_IDLE_TIMEOUT=60s
215+
HTTP_MAX_HEADER_BYTES=1048576
216+
API_MAX_BODY_BYTES=1048576
217+
218+
# 可选:Turso
219+
TURSO_DATABASE_URL=
220+
TURSO_AUTH_TOKEN=
197221
```
198222

223+
> 如本地源码构建遇到 Go 模块下载受限,可改为:`GO_MODULE_PROXY=https://goproxy.cn,direct`,必要时再配合 `GO_SUM_DB=off`
224+
199225
> 不想用 Turso 可以不设置 `TURSO_DATABASE_URL / TURSO_AUTH_TOKEN`,留空会自动使用本地 SQLite。
200226
201227
### 端口说明
@@ -238,12 +264,18 @@ environment:
238264
### 使用环境变量管理密码
239265
240266
```bash
241-
# 创建 .env 文件
242-
echo "ADMIN_PASSWORD=您的超级安全密码" > .env
267+
# 编辑 .env
268+
ADMIN_PASSWORD=您的超级安全密码
269+
```
243270

244-
# 修改 docker-compose.yml
245-
environment:
246-
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
271+
### 切换镜像来源(仅改 .env)
272+
273+
```bash
274+
# 快速启动:使用 GHCR 预构建镜像
275+
COMPOSE_PROFILES=ghcr
276+
277+
# 环境适配:使用本地源码构建镜像
278+
COMPOSE_PROFILES=local
247279
```
248280

249281
### 使用 Turso 远程数据库(可选)

backend/api/auth_sessions_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package api
22

33
import (
4+
"net/http"
5+
"net/http/httptest"
46
"testing"
57
"time"
68

@@ -65,3 +67,36 @@ func TestCreateAdminSession_InvalidTTLFromEnvFallsBackToDefault(t *testing.T) {
6567
t.Fatalf("expected fallback ttl around 168h, got %v", dur)
6668
}
6769
}
70+
71+
func TestLogoutRevokesCurrentSession(t *testing.T) {
72+
gin.SetMode(gin.TestMode)
73+
74+
h := newTestHandler(t, func(proxyAddr, username, password string) (*services.IPInfo, error) {
75+
return nil, nil
76+
})
77+
78+
token, _, err := h.createAdminSession(nil)
79+
if err != nil {
80+
t.Fatalf("create session: %v", err)
81+
}
82+
83+
rec := httptest.NewRecorder()
84+
ctx, _ := gin.CreateTestContext(rec)
85+
req := httptest.NewRequest(http.MethodPost, "/api/logout", nil)
86+
req.Header.Set("Authorization", token)
87+
ctx.Request = req
88+
89+
h.Logout(ctx)
90+
91+
if rec.Code != http.StatusOK {
92+
t.Fatalf("unexpected status: %d", rec.Code)
93+
}
94+
95+
ok, err := h.isValidAdminSession(token)
96+
if err != nil {
97+
t.Fatalf("validate session: %v", err)
98+
}
99+
if ok {
100+
t.Fatalf("expected session to be revoked")
101+
}
102+
}

backend/api/handlers.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,7 @@ func (h *Handler) CheckNodeIP(c *gin.Context) {
862862
`, id); clearErr != nil {
863863
fmt.Printf("[API] Failed to clear node %d status after error: %v\n", id, clearErr)
864864
}
865-
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to check IP: %v", err)})
865+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check IP"})
866866
return
867867
}
868868

@@ -1094,6 +1094,22 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
10941094
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
10951095
}
10961096

1097+
// Logout revokes the current admin session token.
1098+
func (h *Handler) Logout(c *gin.Context) {
1099+
token := normalizeAuthToken(c.GetHeader("Authorization"))
1100+
if token == "" {
1101+
c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
1102+
return
1103+
}
1104+
1105+
if _, err := h.db.Exec("DELETE FROM admin_sessions WHERE token_hash = ?", hashSessionToken(token)); err != nil {
1106+
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
1107+
return
1108+
}
1109+
1110+
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
1111+
}
1112+
10971113
// ParseShareLink parses a share link and returns the config
10981114
func (h *Handler) ParseShareLink(c *gin.Context) {
10991115
var req struct {

backend/main.go

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ func main() {
144144
// Initialize Gin
145145
gin.SetMode(gin.ReleaseMode)
146146
r := gin.Default()
147+
trustedProxies := parseCommaListEnv("TRUSTED_PROXIES")
148+
if err := r.SetTrustedProxies(trustedProxies); err != nil {
149+
log.Fatalf("Invalid TRUSTED_PROXIES: %v", err)
150+
}
151+
r.Use(apiSecurityHeadersMiddleware())
152+
r.Use(apiRequestBodyLimitMiddleware(int64(readIntEnv("API_MAX_BODY_BYTES", 1<<20))))
147153
r.Use(apiCorsMiddlewareFromEnv())
148154

149155
// Initialize handler
@@ -185,6 +191,7 @@ func main() {
185191
// Settings
186192
authorized.GET("/settings", handler.GetSettings)
187193
authorized.PUT("/settings", handler.UpdateSettings)
194+
authorized.POST("/logout", handler.Logout)
188195
}
189196

190197
// Serve frontend static files
@@ -258,6 +265,30 @@ func registerFrontendRoutes(r *gin.Engine, frontendDistDir string, appVersion st
258265
r.GET("/assets/*filepath", withAssetsHeaders, assetsHandler)
259266
r.HEAD("/assets/*filepath", withAssetsHeaders, assetsHandler)
260267

268+
serveStaticFile := func(route string, fileName string) {
269+
handler := func(c *gin.Context) {
270+
filePath := filepath.Join(frontendDistDir, fileName)
271+
if _, err := os.Stat(filePath); err != nil {
272+
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
273+
return
274+
}
275+
c.Header("Cache-Control", assetsCacheControlHeader)
276+
c.Header("X-App-Version", appVersion)
277+
c.Header("X-Frontend-Fingerprint", indexFingerprint)
278+
c.File(filePath)
279+
}
280+
r.GET(route, handler)
281+
r.HEAD(route, handler)
282+
}
283+
284+
serveStaticFile("/logo.svg", "logo.svg")
285+
r.GET("/favicon.ico", func(c *gin.Context) {
286+
c.Redirect(http.StatusMovedPermanently, "/logo.svg")
287+
})
288+
r.HEAD("/favicon.ico", func(c *gin.Context) {
289+
c.Redirect(http.StatusMovedPermanently, "/logo.svg")
290+
})
291+
261292
r.GET("/", serveIndex)
262293
r.HEAD("/", serveIndex)
263294
r.NoRoute(func(c *gin.Context) {
@@ -280,7 +311,7 @@ func apiCorsMiddlewareFromEnv() gin.HandlerFunc {
280311
allowed := parseCommaListEnv("CORS_ALLOWED_ORIGINS")
281312
if len(allowed) == 0 {
282313
return func(c *gin.Context) {
283-
if strings.HasPrefix(c.Request.URL.Path, "/api") && c.Request.Method == http.MethodOptions {
314+
if isAPIRequest(c.Request.URL.Path) && c.Request.Method == http.MethodOptions {
284315
c.AbortWithStatus(http.StatusNoContent)
285316
return
286317
}
@@ -298,7 +329,7 @@ func apiCorsMiddlewareFromEnv() gin.HandlerFunc {
298329
}
299330

300331
return func(c *gin.Context) {
301-
if !strings.HasPrefix(c.Request.URL.Path, "/api") {
332+
if !isAPIRequest(c.Request.URL.Path) {
302333
c.Next()
303334
return
304335
}
@@ -322,6 +353,46 @@ func apiCorsMiddlewareFromEnv() gin.HandlerFunc {
322353
}
323354
}
324355

356+
func apiSecurityHeadersMiddleware() gin.HandlerFunc {
357+
return func(c *gin.Context) {
358+
c.Header("X-Frame-Options", "DENY")
359+
c.Header("X-Content-Type-Options", "nosniff")
360+
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
361+
c.Header("X-XSS-Protection", "0")
362+
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
363+
c.Header("Cross-Origin-Opener-Policy", "same-origin")
364+
c.Header("Cross-Origin-Resource-Policy", "same-origin")
365+
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'")
366+
367+
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
368+
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
369+
}
370+
371+
c.Next()
372+
}
373+
}
374+
375+
func apiRequestBodyLimitMiddleware(maxBytes int64) gin.HandlerFunc {
376+
if maxBytes <= 0 {
377+
return func(c *gin.Context) { c.Next() }
378+
}
379+
380+
return func(c *gin.Context) {
381+
if isAPIRequest(c.Request.URL.Path) {
382+
if c.Request.ContentLength > maxBytes {
383+
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"error": "request body too large"})
384+
return
385+
}
386+
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
387+
}
388+
c.Next()
389+
}
390+
}
391+
392+
func isAPIRequest(path string) bool {
393+
return path == "/api" || strings.HasPrefix(path, "/api/")
394+
}
395+
325396
func parseCommaListEnv(key string) []string {
326397
raw := strings.TrimSpace(os.Getenv(key))
327398
if raw == "" {

0 commit comments

Comments
 (0)