feat(server+web): 客户端下载闭环——pangolin-server /downloads + 官网下载链接
Deploy Server / deploy-server (push) Successful in 2m59s
Deploy Server / deploy-server (push) Successful in 2m59s
- server: /downloads/* 公开静态服务(NewDownloadsHandler,DOWNLOADS_DIR 默认
/var/lib/pangolin/downloads),防目录穿越/不列目录/缺目录不崩,Cache-Control:
no-cache 保证 CI 覆盖后永远最新。挂在 /healthz 同级(免鉴权)。含 4 项测试。
- deploy.sh: install -d 下载目录 + server.env 注入 DOWNLOADS_DIR。
- web: site.ts 增 downloads.{android,windows}(→ https://api.yanmeiai.com/downloads/*);
Download.astro 接上 Android/Windows 按钮,iOS/macOS/Linux 改「即将推出」禁用态。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
This commit is contained in:
@@ -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" <<EOF
|
||||
@@ -201,6 +202,15 @@ curl -fsSL -o "$RULES_DIR/geosite-cn.srs" \
|
||||
https://github.com/SagerNet/sing-geosite/raw/rule-set/geosite-cn.srs \
|
||||
|| log "WARN: geosite-cn.srs 拉取失败,国内分流将不可用"
|
||||
|
||||
# ── 6c. 客户端安装包下载目录 ──────────────────────────────────────────────────
|
||||
# scripts/ci/deploy-client.sh 把最新 pangolin-android.apk /
|
||||
# pangolin-windows-x64-setup.exe scp 到这里(每平台只保留最新一份),控制面
|
||||
# GET /downloads/<file> 静态服务、官网下载按钮直链。这里先建目录占位(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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
<div class="ico"><Icon name={p.icon} /></div>
|
||||
<div class="pn">{p.name}</div>
|
||||
<div class="pv">{p.ver}</div>
|
||||
<a class="gb"><Icon name="download" /><span>{t('dl.get')}</span></a>
|
||||
{p.href ? (
|
||||
<a class="gb" href={p.href}><Icon name="download" /><span>{t('dl.get')}</span></a>
|
||||
) : (
|
||||
<span class="gb disabled" aria-disabled="true"><Icon name="download" /><span>{t('dl.soon')}</span></span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -19,4 +19,15 @@ export const SITE = {
|
||||
|
||||
/** 自助发卡商店 —— 占位待定(#24)。 */
|
||||
store: { label: 'shop.pangolin.vpn' },
|
||||
|
||||
/**
|
||||
* 客户端安装包直链。控制面 pangolin-server 通过 Cloudflare Tunnel 对外暴露
|
||||
* /downloads/<file>(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;
|
||||
|
||||
@@ -128,6 +128,7 @@ export const STRINGS: Record<string, [string, string]> = {
|
||||
'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'],
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user