fix: 收紧 WebGUI 默认安全配置
This commit is contained in:
@@ -11,13 +11,15 @@
|
|||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── webgui/ # webgui 源码(在父仓库,不在 rclone 子模块里)
|
├── webgui/ # webgui 源码(在父仓库,不在 rclone 子模块里)
|
||||||
|
│ ├── api/server.py # SQLite-backed recurring jobs API / scheduler
|
||||||
|
│ ├── data/ # jobs.sqlite 数据目录(不提交)
|
||||||
│ ├── webgui.go # Go 子命令源码(仅当自行构建 rclone 时需要)
|
│ ├── webgui.go # Go 子命令源码(仅当自行构建 rclone 时需要)
|
||||||
│ ├── rclone-cmd-all-add-webgui-import.patch # 注解:把 webgui 注册进 rclone 的 cmd/all
|
│ ├── rclone-cmd-all-add-webgui-import.patch # 注解:把 webgui 注册进 rclone 的 cmd/all
|
||||||
│ └── web/ # 静态前端(rclone rcd 直接服务)
|
│ └── web/ # 静态前端(rclone rcd 直接服务)
|
||||||
│ ├── index.html
|
│ ├── index.html
|
||||||
│ └── assets/
|
│ └── assets/
|
||||||
├── config/rclone/ # rclone.conf 挂载点(bind mount,不提交)
|
├── config/rclone/ # rclone.conf 挂载点(bind mount,不提交)
|
||||||
├── docker-compose.yml # 单容器 rclone rcd
|
├── docker-compose.yml # rclone rcd + jobs-api sidecar
|
||||||
├── DESIGN.md # UI 设计系统规范
|
├── DESIGN.md # UI 设计系统规范
|
||||||
├── CLAUDE.md # Claude Code 协作指引
|
├── CLAUDE.md # Claude Code 协作指引
|
||||||
└── rclone/ # submodule → github.com/rclone/rclone,纯净不改动
|
└── rclone/ # submodule → github.com/rclone/rclone,纯净不改动
|
||||||
@@ -29,20 +31,25 @@
|
|||||||
`/config/providers` 动态生成,覆盖全部 70+ 后端的全部选项。
|
`/config/providers` 动态生成,覆盖全部 70+ 后端的全部选项。
|
||||||
- **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete /
|
- **文件浏览** — 面包屑导航 + 文件表格,支持 mkdir / upload / delete /
|
||||||
rename / download。
|
rename / download。
|
||||||
- **同步任务** — copy / sync / move 异步任务,1.5 秒轮询进度(速度、
|
- **同步任务** — copy / sync / move 异步任务,5 秒轮询进度(速度、
|
||||||
ETA、已传输 / 总量、错误计数),任务元信息(src→dst)持久化到
|
ETA、已传输 / 总量、错误计数)。单次任务直接提交给 rclone RC;
|
||||||
localStorage,刷新页面不丢。
|
固定循环任务保存到 SQLite,并由 jobs-api sidecar 调度。
|
||||||
|
|
||||||
> OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅,
|
> OAuth 后端(drive、dropbox、onedrive 等)目前仅显示提示横幅,
|
||||||
> 引导用户在终端跑 `rclone config` 完成授权。
|
> 引导用户在终端跑 `rclone config` 完成授权。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
> 安全提示:默认 Docker 编排使用 `--rc-no-auth`,RC API 可以读写和删除
|
||||||
|
> 已配置 remote 上的数据。Compose 文件默认只绑定到 `127.0.0.1`。不要把
|
||||||
|
> `5580` / `5581` 直接暴露到 LAN 或公网;需要远程访问时,请先加带认证
|
||||||
|
> 和 TLS 的反向代理。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 拉取子模块(rcd 流程用不到,自行构建 rclone 二进制时才需要)
|
# 1. 拉取子模块(rcd 流程用不到,自行构建 rclone 二进制时才需要)
|
||||||
git clone --recurse-submodules <your-fork-url>
|
git clone --recurse-submodules <your-fork-url>
|
||||||
|
|
||||||
# 2. 启动堆栈(单容器,无需 build)
|
# 2. 启动堆栈(无需 build)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# 3. 打开 http://localhost:5580
|
# 3. 打开 http://localhost:5580
|
||||||
@@ -50,7 +57,14 @@ docker compose up -d
|
|||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
只用一个 rclone rcd 容器,一个端口(5580),同时承担:
|
Docker 编排包含两个服务:
|
||||||
|
|
||||||
|
| 服务 | 本机 URL | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| `rclone` | `http://localhost:5580` | 静态前端、RC API、远端文件下载 |
|
||||||
|
| `jobs-api` | `http://localhost:5581` | SQLite 循环任务 API 和调度器 |
|
||||||
|
|
||||||
|
`rclone` 容器通过 `rclone rcd` 同时承担:
|
||||||
|
|
||||||
| 职责 | URL | 配置项 |
|
| 职责 | URL | 配置项 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -58,8 +72,20 @@ docker compose up -d
|
|||||||
| RC API | `POST /config/*`、`/operations/*`、`/sync/*`、`/job/*` | 内置 |
|
| RC API | `POST /config/*`、`/operations/*`、`/sync/*`、`/job/*` | 内置 |
|
||||||
| 远端文件下载 | `GET /<remote>:<path>` | `--rc-serve` |
|
| 远端文件下载 | `GET /<remote>:<path>` | `--rc-serve` |
|
||||||
|
|
||||||
浏览器同源访问 → 不需要 CORS、不需要 nginx、不需要双端口。
|
浏览器从静态前端调用 `5580` 的 RC API,并调用 `5581` 的 jobs-api 管理
|
||||||
要加 TLS 或自定义 header 时,**在前面套你自己的反代**即可。
|
固定循环任务。jobs-api 默认只允许 `http://localhost:5580` 和
|
||||||
|
`http://127.0.0.1:5580` 这两个 Origin。反代或改端口时,同时调整
|
||||||
|
`JOBS_API_ALLOWED_ORIGINS`。
|
||||||
|
|
||||||
|
## 开发检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find webgui/web/assets/js -name '*.js' -exec node --check {} \;
|
||||||
|
python3 -m py_compile webgui/api/server.py
|
||||||
|
python3 -m unittest discover -s webgui/api -p '*_test.py'
|
||||||
|
curl -sS http://127.0.0.1:5580/
|
||||||
|
curl -sS http://127.0.0.1:5581/health
|
||||||
|
```
|
||||||
|
|
||||||
## 自行构建 rclone(可选)
|
## 自行构建 rclone(可选)
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -7,7 +7,8 @@
|
|||||||
#
|
#
|
||||||
# The jobs-api sidecar stores recurring task definitions in SQLite and starts
|
# The jobs-api sidecar stores recurring task definitions in SQLite and starts
|
||||||
# scheduled runs through rclone RC. The browser calls it on port 5581.
|
# scheduled runs through rclone RC. The browser calls it on port 5581.
|
||||||
# For TLS, auth, or custom headers, put a reverse proxy of your choice in front.
|
# Default port bindings are loopback-only because the RC API runs without auth.
|
||||||
|
# For LAN/public access, put TLS and authentication in a reverse proxy first.
|
||||||
#
|
#
|
||||||
# rclone config lives in ./config/rclone/rclone.conf (bind-mounted).
|
# rclone config lives in ./config/rclone/rclone.conf (bind-mounted).
|
||||||
# If it doesn't exist yet, create your remotes with:
|
# If it doesn't exist yet, create your remotes with:
|
||||||
@@ -50,7 +51,7 @@ services:
|
|||||||
- XDG_CONFIG_HOME=/config
|
- XDG_CONFIG_HOME=/config
|
||||||
- RCLONE_CACHE_DIR=/cache
|
- RCLONE_CACHE_DIR=/cache
|
||||||
ports:
|
ports:
|
||||||
- "5580:8080"
|
- "127.0.0.1:5580:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
jobs-api:
|
jobs-api:
|
||||||
@@ -67,9 +68,10 @@ services:
|
|||||||
- JOBS_API_HOST=0.0.0.0
|
- JOBS_API_HOST=0.0.0.0
|
||||||
- JOBS_API_PORT=8081
|
- JOBS_API_PORT=8081
|
||||||
- JOBS_SCHEDULER_INTERVAL=15
|
- JOBS_SCHEDULER_INTERVAL=15
|
||||||
|
- JOBS_API_ALLOWED_ORIGINS=http://localhost:5580,http://127.0.0.1:5580
|
||||||
- RCLONE_RC_URL=http://rclone:8080
|
- RCLONE_RC_URL=http://rclone:8080
|
||||||
ports:
|
ports:
|
||||||
- "5581:8081"
|
- "127.0.0.1:5581:8081"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+43
-7
@@ -27,6 +27,15 @@ RCLONE_RC_URL = os.environ.get("RCLONE_RC_URL", "http://rclone:8080").rstrip("/"
|
|||||||
HOST = os.environ.get("JOBS_API_HOST", "0.0.0.0")
|
HOST = os.environ.get("JOBS_API_HOST", "0.0.0.0")
|
||||||
PORT = int(os.environ.get("JOBS_API_PORT", "8081"))
|
PORT = int(os.environ.get("JOBS_API_PORT", "8081"))
|
||||||
SCHEDULER_INTERVAL_SECONDS = int(os.environ.get("JOBS_SCHEDULER_INTERVAL", "15"))
|
SCHEDULER_INTERVAL_SECONDS = int(os.environ.get("JOBS_SCHEDULER_INTERVAL", "15"))
|
||||||
|
MAX_BODY_BYTES = int(os.environ.get("JOBS_API_MAX_BODY_BYTES", str(64 * 1024)))
|
||||||
|
ALLOWED_ORIGINS = {
|
||||||
|
origin.strip().rstrip("/")
|
||||||
|
for origin in os.environ.get(
|
||||||
|
"JOBS_API_ALLOWED_ORIGINS",
|
||||||
|
"http://localhost:5580,http://127.0.0.1:5580",
|
||||||
|
).split(",")
|
||||||
|
if origin.strip()
|
||||||
|
}
|
||||||
VALID_ACTIONS = {"copy", "sync", "move"}
|
VALID_ACTIONS = {"copy", "sync", "move"}
|
||||||
VALID_SCHEDULE_KINDS = {"daily", "weekly", "monthly"}
|
VALID_SCHEDULE_KINDS = {"daily", "weekly", "monthly"}
|
||||||
|
|
||||||
@@ -646,11 +655,22 @@ def scheduler_loop() -> None:
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
def scheduler_initial_tick() -> None:
|
||||||
|
try:
|
||||||
|
scheduler_tick()
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
def do_OPTIONS(self) -> None:
|
def do_OPTIONS(self) -> None:
|
||||||
self.send_json(204, None)
|
try:
|
||||||
|
self.require_allowed_origin()
|
||||||
|
self.send_json(204, None)
|
||||||
|
except ApiError as exc:
|
||||||
|
self.send_json(exc.status, {"error": exc.message})
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
def do_GET(self) -> None:
|
||||||
self.handle_request("GET")
|
self.handle_request("GET")
|
||||||
@@ -663,6 +683,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def handle_request(self, method: str) -> None:
|
def handle_request(self, method: str) -> None:
|
||||||
try:
|
try:
|
||||||
|
self.require_allowed_origin()
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
path = parsed.path.rstrip("/") or "/"
|
path = parsed.path.rstrip("/") or "/"
|
||||||
parts = [p for p in path.split("/") if p]
|
parts = [p for p in path.split("/") if p]
|
||||||
@@ -704,8 +725,22 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
self.send_json(500, {"error": str(exc)})
|
self.send_json(500, {"error": str(exc)})
|
||||||
|
|
||||||
|
def require_allowed_origin(self) -> None:
|
||||||
|
origin = self.headers.get("Origin")
|
||||||
|
if not origin:
|
||||||
|
return
|
||||||
|
if origin.rstrip("/") not in ALLOWED_ORIGINS:
|
||||||
|
raise ApiError(403, "origin not allowed")
|
||||||
|
|
||||||
def read_json(self) -> dict[str, Any]:
|
def read_json(self) -> dict[str, Any]:
|
||||||
length = int(self.headers.get("Content-Length") or "0")
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length") or "0")
|
||||||
|
except ValueError:
|
||||||
|
raise ApiError(400, "invalid Content-Length") from None
|
||||||
|
if length < 0:
|
||||||
|
raise ApiError(400, "invalid Content-Length")
|
||||||
|
if length > MAX_BODY_BYTES:
|
||||||
|
raise ApiError(413, "request body too large")
|
||||||
if length == 0:
|
if length == 0:
|
||||||
return {}
|
return {}
|
||||||
raw = self.rfile.read(length)
|
raw = self.rfile.read(length)
|
||||||
@@ -719,8 +754,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def send_json(self, status: int, body: Any) -> None:
|
def send_json(self, status: int, body: Any) -> None:
|
||||||
raw = b"" if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
raw = b"" if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||||
|
origin = self.headers.get("Origin")
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
if origin and origin.rstrip("/") in ALLOWED_ORIGINS:
|
||||||
|
self.send_header("Access-Control-Allow-Origin", origin)
|
||||||
|
self.send_header("Vary", "Origin")
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
@@ -747,10 +785,8 @@ def main() -> None:
|
|||||||
init_db()
|
init_db()
|
||||||
thread = threading.Thread(target=scheduler_loop, name="job-scheduler", daemon=True)
|
thread = threading.Thread(target=scheduler_loop, name="job-scheduler", daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
try:
|
initial_thread = threading.Thread(target=scheduler_initial_tick, name="job-scheduler-initial", daemon=True)
|
||||||
scheduler_tick()
|
initial_thread.start()
|
||||||
except Exception:
|
|
||||||
traceback.print_exc()
|
|
||||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
print(f"jobs api listening on {HOST}:{PORT}, db={DB_PATH}, rclone={RCLONE_RC_URL}")
|
print(f"jobs api listening on {HOST}:{PORT}, db={DB_PATH}, rclone={RCLONE_RC_URL}")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import unittest
|
||||||
|
from io import BytesIO
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from server import ApiError, Handler, MAX_BODY_BYTES, compute_next_run_at
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeNextRunAtTest(unittest.TestCase):
|
||||||
|
def test_daily_uses_same_day_when_time_is_future(self):
|
||||||
|
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(
|
||||||
|
compute_next_run_at("daily", "23:30", 0, after),
|
||||||
|
"2026-06-20T23:30:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_daily_moves_to_next_day_when_time_has_passed(self):
|
||||||
|
after = datetime(2026, 6, 20, 23, 30, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(
|
||||||
|
compute_next_run_at("daily", "23:30", 0, after),
|
||||||
|
"2026-06-21T23:30:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_weekly_uses_next_target_weekday(self):
|
||||||
|
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc) # Saturday
|
||||||
|
self.assertEqual(
|
||||||
|
compute_next_run_at("weekly", "09:00", 0, after, weekday=1),
|
||||||
|
"2026-06-22T09:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_weekly_rolls_forward_a_week_after_same_day_time_passed(self):
|
||||||
|
after = datetime(2026, 6, 22, 10, 0, tzinfo=timezone.utc) # Monday
|
||||||
|
self.assertEqual(
|
||||||
|
compute_next_run_at("weekly", "09:00", 0, after, weekday=1),
|
||||||
|
"2026-06-29T09:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_monthly_uses_next_valid_monthday(self):
|
||||||
|
after = datetime(2026, 1, 30, 12, 0, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(
|
||||||
|
compute_next_run_at("monthly", "23:00", 0, after, monthday=31),
|
||||||
|
"2026-01-31T23:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_monthly_skips_short_months_for_day_31(self):
|
||||||
|
after = datetime(2026, 2, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(
|
||||||
|
compute_next_run_at("monthly", "23:00", 0, after, monthday=31),
|
||||||
|
"2026-03-31T23:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_timezone_offset_is_applied_from_local_schedule_time(self):
|
||||||
|
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
|
||||||
|
self.assertEqual(
|
||||||
|
compute_next_run_at("daily", "23:00", 8 * 60, after),
|
||||||
|
"2026-06-20T15:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_monthday_raises(self):
|
||||||
|
after = datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
compute_next_run_at("monthly", "23:00", 0, after, monthday=32)
|
||||||
|
|
||||||
|
|
||||||
|
class HandlerSecurityTest(unittest.TestCase):
|
||||||
|
def test_allowed_origin_is_accepted(self):
|
||||||
|
handler = type("DummyHandler", (), {"headers": {"Origin": "http://localhost:5580"}})()
|
||||||
|
Handler.require_allowed_origin(handler)
|
||||||
|
|
||||||
|
def test_disallowed_origin_is_rejected(self):
|
||||||
|
handler = type("DummyHandler", (), {"headers": {"Origin": "http://example.invalid"}})()
|
||||||
|
with self.assertRaises(ApiError) as ctx:
|
||||||
|
Handler.require_allowed_origin(handler)
|
||||||
|
self.assertEqual(ctx.exception.status, 403)
|
||||||
|
|
||||||
|
def test_request_body_size_is_limited(self):
|
||||||
|
handler = type(
|
||||||
|
"DummyHandler",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"headers": {"Content-Length": str(MAX_BODY_BYTES + 1)},
|
||||||
|
"rfile": BytesIO(),
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
with self.assertRaises(ApiError) as ctx:
|
||||||
|
Handler.read_json(handler)
|
||||||
|
self.assertEqual(ctx.exception.status, 413)
|
||||||
|
|
||||||
|
def test_read_json_accepts_small_object(self):
|
||||||
|
body = b'{"ok":true}'
|
||||||
|
handler = type(
|
||||||
|
"DummyHandler",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"headers": {"Content-Length": str(len(body))},
|
||||||
|
"rfile": BytesIO(body),
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
self.assertEqual(Handler.read_json(handler), {"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user