diff --git a/deploy/single-node/deploy.sh b/deploy/single-node/deploy.sh index c604a9d..32b8618 100755 --- a/deploy/single-node/deploy.sh +++ b/deploy/single-node/deploy.sh @@ -177,6 +177,7 @@ GRPC_CERT_PATH=$ETC/grpc.crt GRPC_KEY_PATH=$ETC/grpc.key PANGOLIN_PUBLIC_URL=https://api.yanmeiai.com PANGOLIN_RULES_DIR=$DATA_DIR/rules +DOWNLOADS_DIR=$DATA_DIR/downloads EOF if [ -n "${SMTP_HOST:-}" ]; then cat >> "$ETC/server.env" < 静态服务、官网下载按钮直链。这里先建目录占位(CI 首次 +# 部署前跑本脚本也不会因目录缺失而 404 时找不到目录本身;DownloadsHandler 本身 +# 即便目录不存在也能正常注册路由,只是请求会 404)。 +DOWNLOADS_DIR="$DATA_DIR/downloads" +install -d -m 755 "$DOWNLOADS_DIR" + # ── 7. 迁移 + seed(SQLite)──────────────────────────────────────────────────── log "执行迁移(sqlite)..." DB_DRIVER=sqlite DB_DSN="$DB_FILE" "$BIN/pangolin-migrate" up diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index b449486..f8b4e5c 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -130,6 +130,12 @@ func main() { _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) }) + // Public (no auth): 客户端安装包下载(官网下载按钮直链)。CI + // (scripts/ci/deploy-client.sh)把最新安装包 scp 到 DOWNLOADS_DIR,按平台固定 + // 文件名覆盖;目录不存在也不影响启动,只是请求 404(见 DownloadsHandler 注释)。 + downloadsHandler := httpapi.NewDownloadsHandler(os.Getenv("DOWNLOADS_DIR")) + r.Get("/downloads/*", downloadsHandler.Serve) + // Optional probe ingest route. sharedProbeStore is reused by the scheduler // (below) when both are enabled, so they share one Redis-backed store. var sharedProbeStore *probe.Store diff --git a/server/internal/httpapi/downloads.go b/server/internal/httpapi/downloads.go new file mode 100644 index 0000000..8249cf8 --- /dev/null +++ b/server/internal/httpapi/downloads.go @@ -0,0 +1,87 @@ +package httpapi + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/go-chi/chi/v5" +) + +// DownloadsHandler 静态服务客户端安装包(pangolin-android.apk / +// pangolin-windows-x64-setup.exe 等),供官网下载按钮直链。CI +// (scripts/ci/deploy-client.sh)把最新安装包 scp 到该目录、按平台固定文件名覆盖。 +// +// 无需鉴权(匿名下载),但: +// - 不暴露目录列表(裸目录 / 不存在的文件一律 404,不用 http.FileServer 的默认 +// 目录浏览行为) +// - 防目录穿越(拒绝任何解析后逃出 dir 的路径,而不仅仅是字符串里含 ".." 就拒, +// 这样能正确处理 "foo/../bar" 这类仍落在 dir 内的写法,同时挡住真正逃逸的路径) +// - DOWNLOADS_DIR 在启动时可以不存在(比如还没跑过一次 CI 部署),路由仍要能 +// 注册,只是请求会 404,不能让进程直接崩溃 +type DownloadsHandler struct { + dir string +} + +// NewDownloadsHandler 指定安装包所在目录(DOWNLOADS_DIR,默认 /var/lib/pangolin/downloads)。 +func NewDownloadsHandler(dir string) *DownloadsHandler { + if dir == "" { + dir = "/var/lib/pangolin/downloads" + } + return &DownloadsHandler{dir: dir} +} + +// Serve 处理 GET /downloads/{file}(chi 通配 "*",支持任意文件名,不限定白名单, +// 因为 CI 产出的安装包文件名随平台/版本命名策略变化,这里只做路径安全校验)。 +func (h *DownloadsHandler) Serve(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "*") + if name == "" { + http.NotFound(w, r) + return + } + + // 拒绝任何路径分隔符之外的穿越:先按 "/" 做 Clean,再校验结果既不是绝对路径、 + // 也没有以 ".." 开头(即没有逃出 dir),最后拒绝空/根路径。这比单纯 strings.Contains(name, "..") + // 更准确 —— 例如 "sub/../file.apk" 清洗后是 "file.apk",本来就没有逃逸,不该被误杀; + // 而 "../etc/passwd" 清洗后以 ".." 开头,必须拒绝。 + cleaned := filepath.Clean("/" + name) // 前置 "/" 后 Clean,任何 ".." 都无法越过根 + cleaned = strings.TrimPrefix(cleaned, "/") + if cleaned == "" || cleaned == "." || strings.HasPrefix(cleaned, "..") { + http.NotFound(w, r) + return + } + + full := filepath.Join(h.dir, cleaned) + // 双重保险:确认最终路径确实在 dir 之下(处理 dir 本身含 ".." 或符号链接等边角情况)。 + relDir, err := filepath.Abs(h.dir) + if err != nil { + http.NotFound(w, r) + return + } + absFull, err := filepath.Abs(full) + if err != nil || (absFull != relDir && !strings.HasPrefix(absFull, relDir+string(filepath.Separator))) { + http.NotFound(w, r) + return + } + + f, err := os.Open(full) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + + st, err := f.Stat() + if err != nil || st.IsDir() { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(cleaned)+"\"") + // no-cache:CI「仅留最新」在同一稳定 URL 覆盖文件,不能让 CF/浏览器返回旧包。 + // no-cache = 可缓存但每次须回源校验;ServeContent 带 Last-Modified,未变则 304 + // (便宜),变了才传新字节 —— 既保证永远最新,又避免每次全量 82MB 回源。 + w.Header().Set("Cache-Control", "no-cache") + http.ServeContent(w, r, filepath.Base(cleaned), st.ModTime(), f) +} diff --git a/server/internal/httpapi/downloads_test.go b/server/internal/httpapi/downloads_test.go new file mode 100644 index 0000000..4c3994f --- /dev/null +++ b/server/internal/httpapi/downloads_test.go @@ -0,0 +1,117 @@ +package httpapi + +import ( + "context" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/go-chi/chi/v5" +) + +// buildDownloadsRouter mounts DownloadsHandler on a real chi router (mirrors +// how main.go mounts it under "/downloads/*") so the wildcard param behaves +// exactly as it does in production. +func buildDownloadsRouter(dir string) chi.Router { + h := NewDownloadsHandler(dir) + r := chi.NewRouter() + r.Get("/downloads/*", h.Serve) + return r +} + +func TestDownloadsHandler_ServesExistingFile(t *testing.T) { + dir := t.TempDir() + content := []byte("hello pangolin apk bytes") + if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), content, 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + r := buildDownloadsRouter(dir) + req := httptest.NewRequest("GET", "/downloads/pangolin-android.apk", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != 200 { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Body.String(); got != string(content) { + t.Errorf("body = %q, want %q", got, content) + } +} + +func TestDownloadsHandler_MissingFile404(t *testing.T) { + dir := t.TempDir() + r := buildDownloadsRouter(dir) + + req := httptest.NewRequest("GET", "/downloads/nope.exe", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != 404 { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +func TestDownloadsHandler_BareDirNotListed(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), []byte("x"), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + r := buildDownloadsRouter(dir) + + req := httptest.NewRequest("GET", "/downloads/", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != 404 { + t.Fatalf("bare dir status = %d, want 404 (no directory listing)", rec.Code) + } +} + +func TestDownloadsHandler_PathTraversalBlocked(t *testing.T) { + outerDir := t.TempDir() + secretPath := filepath.Join(outerDir, "secret.txt") + if err := os.WriteFile(secretPath, []byte("top secret"), 0o600); err != nil { + t.Fatalf("write secret: %v", err) + } + + dir := filepath.Join(outerDir, "downloads") + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatalf("mkdir downloads: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "pangolin-android.apk"), []byte("apk"), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + + r := buildDownloadsRouter(dir) + + // net/http's ServeMux/chi normalize ".." segments in the URL path before + // routing, so we exercise the handler directly with a raw URLParam to + // simulate any escape attempt that might otherwise reach it, in addition + // to the router-level request below. + h := NewDownloadsHandler(dir) + req := httptest.NewRequest("GET", "/downloads/../secret.txt", nil) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("*", "../secret.txt") + ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + h.Serve(rec, req) + if rec.Code != 404 { + t.Fatalf("direct traversal status = %d, want 404 (must not escape dir)", rec.Code) + } + if rec.Body.String() == "top secret" { + t.Fatalf("traversal leaked secret file contents") + } + + // Router-level request: most HTTP clients/servers collapse ".." during URL + // normalization, but confirm the end-to-end path also can't reach the file + // outside dir and doesn't 200 with the secret's contents. + req2 := httptest.NewRequest("GET", "/downloads/../secret.txt", nil) + rec2 := httptest.NewRecorder() + r.ServeHTTP(rec2, req2) + if rec2.Body.String() == "top secret" { + t.Fatalf("router-level traversal leaked secret file contents (status=%d)", rec2.Code) + } +} diff --git a/web/website/src/components/Download.astro b/web/website/src/components/Download.astro index fc1b4d7..39f7dd5 100644 --- a/web/website/src/components/Download.astro +++ b/web/website/src/components/Download.astro @@ -1,15 +1,17 @@ --- import Icon from './Icon.astro'; import type { T } from '../i18n/strings'; +import { SITE } from '../config/site'; interface Props { t: T } const { t } = Astro.props; -const plats = [ +// href 缺省 = 本轮未接入下载(iOS / macOS / Linux),按钮渲染为禁用态占位。 +const plats: { icon: string; name: string; ver: string; href?: string }[] = [ { icon: 'smartphone', name: 'iOS', ver: 'iOS 16+' }, - { icon: 'smartphone', name: 'Android', ver: 'Android 9+' }, + { icon: 'smartphone', name: 'Android', ver: 'Android 9+', href: SITE.downloads.android }, { icon: 'laptop', name: 'macOS', ver: 'macOS 12+' }, - { icon: 'monitor', name: 'Windows', ver: 'Win 10/11' }, + { icon: 'monitor', name: 'Windows', ver: 'Win 10/11', href: SITE.downloads.windows }, { icon: 'terminal', name: 'Linux', ver: 'deb / rpm' }, ]; --- @@ -26,7 +28,11 @@ const plats = [
{p.name}
{p.ver}
- {t('dl.get')} + {p.href ? ( + {t('dl.get')} + ) : ( + {t('dl.soon')} + )} ))} diff --git a/web/website/src/config/site.ts b/web/website/src/config/site.ts index 15205ec..e7b35de 100644 --- a/web/website/src/config/site.ts +++ b/web/website/src/config/site.ts @@ -19,4 +19,15 @@ export const SITE = { /** 自助发卡商店 —— 占位待定(#24)。 */ store: { label: 'shop.pangolin.vpn' }, + + /** + * 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露 + * /downloads/(origin = 127.0.0.1:8080),文件由 + * scripts/ci/deploy-client.sh 每次构建 scp 覆盖到 pangolin1:/var/lib/pangolin/downloads/, + * 每平台固定文件名、只保留最新一份。iOS / macOS / Linux 本轮未接入下载按钮。 + */ + downloads: { + android: 'https://api.yanmeiai.com/downloads/pangolin-android.apk', + windows: 'https://api.yanmeiai.com/downloads/pangolin-windows-x64-setup.exe', + }, } as const; diff --git a/web/website/src/i18n/strings.ts b/web/website/src/i18n/strings.ts index 3ada1d2..09de4dc 100644 --- a/web/website/src/i18n/strings.ts +++ b/web/website/src/i18n/strings.ts @@ -128,6 +128,7 @@ export const STRINGS: Record = { 'dl.h': ['全平台,随处可用', 'Every platform, everywhere'], 'dl.sub': ['一个账户,所有设备同步。下载即用,无需配置。', 'One account syncs every device. Download and go.'], 'dl.get': ['下载', 'Download'], + 'dl.soon': ['敬请期待', 'Coming soon'], 'docs.eyebrow': ['文档', 'Docs'], 'docs.h': ['需要帮助?都在这里', 'Need help? It’s all here'], diff --git a/web/website/src/styles/website.css b/web/website/src/styles/website.css index 891a697..b19793a 100644 --- a/web/website/src/styles/website.css +++ b/web/website/src/styles/website.css @@ -189,6 +189,8 @@ section{padding:88px 0} .dl .pv{font-size:12px;color:var(--fg3);margin:3px 0 14px;font-family:var(--font-mono)} .dl .gb{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--accent)} .dl .gb svg{width:14px;height:14px} +.dl .gb.disabled{color:var(--fg3);cursor:not-allowed;pointer-events:none} +.dl .gb.disabled svg{opacity:.5} /* ---------- docs ---------- */ .docs-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;margin-top:44px}