Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions backend/app/services/intake.py
Original file line number Diff line number Diff line change
Expand Up @@ -1079,11 +1079,27 @@ def claim_submission(
):
raise HTTPException(status_code=409, detail="该提交正在执行其他审核动作")
raw_claim = secrets.token_urlsafe(32)
submission.claim_token_hash = token_digest(raw_claim)
submission.claim_expires_at = changed_at + CLAIM_TTL
submission.revision_number += 1
submission.updated_at = changed_at
session.flush()
result = session.execute(
update(CustomerFormSubmission)
.where(
CustomerFormSubmission.id == submission.id,
CustomerFormSubmission.status == FormSubmissionStatus.PROCESSING,
CustomerFormSubmission.revision_number == submission.revision_number,
CustomerFormSubmission.idempotency_key == idempotency_key,
CustomerFormSubmission.decision_mode == decision_mode,
)
.values(
claim_token_hash=token_digest(raw_claim),
claim_expires_at=changed_at + CLAIM_TTL,
revision_number=submission.revision_number + 1,
updated_at=changed_at,
)
.execution_options(synchronize_session=False)
)
if result.rowcount != 1:
session.rollback()
raise HTTPException(status_code=409, detail="处理租约已被其他请求重领")
session.expire(submission)
record_intake_audit_event(
session,
event_type="processing_reclaimed",
Expand Down
19 changes: 17 additions & 2 deletions backend/app/services/intake_relay_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@
}


async def _read_public_body_bounded(request: Request) -> bool:
"""Cache an accepted body for downstream without reading past the hard limit."""

chunks: list[bytes] = []
total = 0
async for chunk in request.stream():
total += len(chunk)
if total > MAX_PUBLIC_BODY_BYTES:
return False
if chunk:
chunks.append(chunk)
# Starlette's cached request wrapper replays this bounded body to the endpoint.
setattr(request, "_body", b"".join(chunks))
return True


def require_relay_service(
authorization: str | None = Header(default=None),
) -> None:
Expand Down Expand Up @@ -115,8 +131,7 @@ async def protect_public_intake(request: Request, call_next):
headers=PRIVATE_RESPONSE_HEADERS,
)
if request.method in {"PUT", "POST", "PATCH"}:
body = await request.body()
if len(body) > MAX_PUBLIC_BODY_BYTES:
if not await _read_public_body_bounded(request):
return JSONResponse(
status_code=413,
content={"detail": "提交内容过大"},
Expand Down
29 changes: 26 additions & 3 deletions backend/app/services/intake_remote.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from urllib.parse import urljoin
from urllib.parse import urljoin, urlsplit

import httpx
from fastapi import HTTPException
Expand Down Expand Up @@ -34,6 +34,7 @@
PUBLIC_ORIGIN_ENV = "CATCARE_PUBLIC_FILL_ORIGIN"
RELAY_TIMEOUT_SECONDS = 12.0
RELAY_SERVER_ENV = "CATCARE_INTAKE_RELAY_SERVER"
LOOPBACK_RELAY_HOSTS = {"localhost", "127.0.0.1", "::1"}


def remote_intake_enabled() -> bool:
Expand All @@ -43,13 +44,35 @@ def remote_intake_enabled() -> bool:
)


def _validated_relay_url(raw_url: str) -> str:
value = raw_url.strip().rstrip("/")
parsed = urlsplit(value)
if (
not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise HTTPException(status_code=503, detail="云端填写中转地址无效")
if parsed.scheme == "https":
return value
if parsed.scheme == "http" and parsed.hostname.lower() in LOOPBACK_RELAY_HOSTS:
return value
raise HTTPException(
status_code=503,
detail="云端填写中转地址必须使用 HTTPS,明文 HTTP 仅允许本机回环",
)


class RemoteIntakeClient:
def __init__(self) -> None:
self.base_url = os.getenv(RELAY_URL_ENV, "").strip().rstrip("/")
raw_url = os.getenv(RELAY_URL_ENV, "")
self.key = os.getenv(RELAY_KEY_ENV, "")
self.public_origin = os.getenv(PUBLIC_ORIGIN_ENV, "").strip().rstrip("/")
if not self.base_url:
if not raw_url.strip():
raise HTTPException(status_code=503, detail="云端填写中转地址未配置")
self.base_url = _validated_relay_url(raw_url)
if not self.key:
raise HTTPException(status_code=503, detail="云端填写中转凭据未配置")
if not self.public_origin:
Expand Down
33 changes: 33 additions & 0 deletions backend/tests/test_deployment_contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from pathlib import Path


PROJECT_ROOT = Path(__file__).resolve().parents[2]


def _read(relative_path: str) -> str:
return (PROJECT_ROOT / relative_path).read_text(encoding="utf-8")


def test_synology_relay_is_loopback_only_and_gateway_denies_admin_routes() -> None:
compose = _read("deploy/synology/compose.yaml")
gateway = _read("deploy/synology/gateway/nginx.conf")
postgres_service = compose.split(" postgres:", 1)[1].split("\n relay:", 1)[0]

assert '"127.0.0.1:${CATCARE_RELAY_LAN_PORT:-18081}:8080"' in compose
assert '"${CATCARE_NAS_LAN_IP}:${CATCARE_RELAY_LAN_PORT:-18081}:8080"' not in compose
assert '"127.0.0.1:${CATCARE_FUNNEL_TARGET_PORT:-18080}:8080"' in compose
assert "\n ports:" not in postgres_service
assert "location ^~ /api/admin/" in gateway
assert "location ~ ^/(?:f|fill)/[A-Za-z0-9_-]+/?$" in gateway
assert "location ~ ^/api/fill/[A-Za-z0-9_-]+(?:/submit)?$" in gateway


def test_public_hosting_docs_cover_current_and_compatibility_fill_paths() -> None:
cloudbase = _read("deploy/cloudbase/README.md")
synology = _read("deploy/synology/README.md")

assert "/f/*" in cloudbase
assert "/fill/*" in cloudbase
assert "tailscale serve --https=8443 --bg http://127.0.0.1:18081" in synology
assert "http://<NAS-LAN-IP>:18081" in synology
assert "https://<NAS 的 tailnet DNS 名>:8443" in synology
60 changes: 59 additions & 1 deletion backend/tests/test_intake_relay.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import asyncio
from collections.abc import Generator
from datetime import datetime, timedelta, timezone

import httpx
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session, sessionmaker

from app.db.session import build_engine, get_db
from app.intake_relay import create_relay_app
from app.models.intake import CustomerFormSubmission
from app.services.intake import claim_submission


@pytest.fixture
Expand Down Expand Up @@ -105,6 +109,34 @@ def test_relay_cors_auth_body_limit_and_route_isolation(
assert relay_client.get(private_path).status_code == 404


def test_relay_stops_streaming_unknown_length_body_at_limit(
relay_client: TestClient,
) -> None:
yielded_chunks: list[int] = []

async def oversized_chunks():
for index in range(4):
yielded_chunks.append(index)
yield b"x" * (100 * 1024)

async def request() -> httpx.Response:
transport = httpx.ASGITransport(app=relay_client.app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.put(
"/api/fill/streamed-test-token",
content=oversized_chunks(),
)

response = asyncio.run(request())

assert response.status_code == 413
assert response.headers["cache-control"] == "no-store"
assert yielded_chunks == [0, 1, 2]


def test_relay_rate_limit_applies_to_token(
relay_client: TestClient,
) -> None:
Expand Down Expand Up @@ -159,11 +191,36 @@ def test_relay_claim_complete_and_redaction_keep_only_receipt(
},
)
assert claim.status_code == 200

session_factory = relay_client.app.state.testing_session
with session_factory() as first_session, session_factory() as stale_session:
first_submission = first_session.get(CustomerFormSubmission, summary["id"])
stale_submission = stale_session.get(CustomerFormSubmission, summary["id"])
assert first_submission is not None and stale_submission is not None
reclaimed = claim_submission(
first_session,
first_submission,
expected_revision=claim.json()["revision"],
idempotency_key="relay-decision-idempotency-0001",
decision_mode="customer",
)
assert reclaimed.claim_token is not None
first_session.commit()
with pytest.raises(HTTPException) as conflict:
claim_submission(
stale_session,
stale_submission,
expected_revision=claim.json()["revision"],
idempotency_key="relay-decision-idempotency-0001",
decision_mode="customer",
)
assert conflict.value.status_code == 409

complete = relay_client.post(
f"/api/admin/intake/submissions/{summary['id']}/complete",
headers=_auth(),
json={
"claim_token": claim.json()["claim_token"],
"claim_token": reclaimed.claim_token,
"idempotency_key": "relay-decision-idempotency-0001",
"decision_mode": "customer",
"customer_id": 987654,
Expand All @@ -184,6 +241,7 @@ def test_relay_claim_complete_and_redaction_keep_only_receipt(
assert [event["event_type"] for event in detail["audit_events"]] == [
"submitted",
"processing_claimed",
"processing_reclaimed",
"completed_customer",
]
assert "TEST-CONTACT" not in str(detail["audit_events"])
Expand Down
22 changes: 22 additions & 0 deletions backend/tests/test_intake_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,28 @@ def complete(
)


def test_remote_client_requires_https_except_for_loopback(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("CATCARE_INTAKE_RELAY_KEY", "relay-test-secret")
monkeypatch.setenv("CATCARE_PUBLIC_FILL_ORIGIN", "https://fill.example.test")
monkeypatch.setenv("CATCARE_INTAKE_RELAY_URL", "http://192.168.1.30:18081")

with pytest.raises(HTTPException, match="必须使用 HTTPS"):
intake_remote.RemoteIntakeClient()

monkeypatch.setenv(
"CATCARE_INTAKE_RELAY_URL",
"https://nas-name.example.ts.net:8443/",
)
secure = intake_remote.RemoteIntakeClient()
assert secure.base_url == "https://nas-name.example.ts.net:8443"

monkeypatch.setenv("CATCARE_INTAKE_RELAY_URL", "http://127.0.0.1:18081/")
loopback = intake_remote.RemoteIntakeClient()
assert loopback.base_url == "http://127.0.0.1:18081"


def test_remote_complete_failure_retry_returns_local_receipt_without_duplicates(
migrated_database_url: str,
monkeypatch: pytest.MonkeyPatch,
Expand Down
2 changes: 1 addition & 1 deletion deploy/cloudbase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ docker build -f deploy/cloudbase/intake-relay/Dockerfile -t catcare-intake-relay

## 静态托管要求

- 上传 `frontend/dist-public/`,将所有 `/fill/*` 回退到 `index.html`。
- 上传 `frontend/dist-public/`, `/f/*` 和兼容路径 `/fill/*` 都回退到 `index.html`;新生成的正式分享链接使用 `/f/{token}`。
- 只允许公开页 Origin 调用 CloudBase Run;不要使用 `*` CORS。
- 首次联调可使用平台默认 HTTPS 域名,第一版不创建公众号或小程序资源;CloudBase 官方说明默认 HTTP 访问域名仅适合开发测试,正式对客前应换成已备案自定义域名,避免安全提示中间页和默认域名限制。
- 公网检查必须确认 `/admin`、`/mobile`、`/api/admin/customers`、`/api/orders` 等路径均不可访问。
Expand Down
1 change: 1 addition & 0 deletions deploy/synology/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
CATCARE_PUBLIC_HOST=nas-name.example.ts.net
CATCARE_NAS_LAN_IP=192.168.1.30
CATCARE_FUNNEL_TARGET_PORT=18080
# Legacy variable name; Compose binds this Relay port to NAS loopback only.
CATCARE_RELAY_LAN_PORT=18081
38 changes: 32 additions & 6 deletions deploy/synology/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
## 已确认的目标结构

```text
互联网 HTTPS
-> Tailscale Funnel(只转发 NAS 127.0.0.1:18080)
互联网 HTTPS :443
-> Tailscale Funnel(只转发 NAS 127.0.0.1:18080 的公开 Gateway
-> gateway(只允许填写页和 public fill API)
-> relay(局域网管理口另绑定 192.168.1.30:18081 + Bearer Secret)
-> relay
-> PostgreSQL(仅内部 Docker 网络)

Windows 管理端 -> Tailnet HTTPS :8443
-> Tailscale Serve(只转发 NAS 127.0.0.1:18081)
-> relay(Bearer Secret 只在 TLS 内传输)
```

## 本地生成镜像和部署包
Expand Down Expand Up @@ -45,11 +49,32 @@
## 网络边界

- Funnel 目标:`http://127.0.0.1:18080`。
- 本地 CatCare 中转地址:`http://192.168.1.30:18081`。
- Relay 宿主机端口只绑定 NAS 回环:`http://127.0.0.1:18081`,不得改回 NAS 局域网 IP 或 `0.0.0.0`。
- Windows 侧 `CATCARE_INTAKE_RELAY_URL` 必须使用 Tailscale Serve 提供的 `https://<NAS 的 tailnet DNS 名>:8443`,不能使用 `http://<NAS-LAN-IP>:18081`。
- PostgreSQL 无 `ports`,只能由内部 `data` 网络访问。
- 公网网关明确拒绝 `/api/admin/*`,其余非白名单路径统一 404。
- DS218+ 的群晖内核不支持 Docker `NanoCPUs` 硬配额,因此使用兼容的 `cpu_shares` 相对权重。该内核也会忽略 `pids_limit`;实际硬限制只有内存,进程数限制不能作为已生效的安全边界。

### Tailnet 内管理 HTTPS

以下命令属于设备侧网络变更,本仓库不会自动执行。首次配置或变更前,必须确认 Windows 和 NAS 位于同一 tailnet、MagicDNS/HTTPS 已启用,并在 tailnet ACL 中只允许指定 Windows 设备或用户访问 NAS 的 TCP 8443。然后由用户在 NAS SSH 终端执行:

```sh
tailscale serve --https=8443 --bg http://127.0.0.1:18081
tailscale serve status --json
```

Serve 8443 只在 tailnet 内可达并自动终止 TLS;公网 Funnel 继续使用 443。两者不得配置到同一端口。设备侧验收至少包括:

命令语法与端口隔离规则以 [Tailscale Serve CLI](https://tailscale.com/docs/reference/tailscale-cli/serve) 和 [Tailscale Funnel 限制](https://tailscale.com/kb/1223/funnel) 为准。

```powershell
Test-NetConnection '<NAS-LAN-IP>' -Port 18081
Invoke-RestMethod 'https://<NAS-tailnet-DNS>:8443/api/ready'
```

第一条必须显示 LAN 直连失败,第二条必须通过系统信任链完成 HTTPS 校验并返回 `ready`。不得使用 `-SkipCertificateCheck`。Windows 防火墙三个 Profile 应继续启用且默认阻止入站,不要为 CatCare 的 8000、5180、18080 或 18081 新增入站放行规则。

## 公开 Gateway 的受限更新

公开页面改动不需要重建 PostgreSQL、Relay 或备份容器。先在 Windows 本机生成只包含 Gateway 的离线镜像包:
Expand Down Expand Up @@ -100,8 +125,9 @@ sudo sh /volume3/docker/CatCare/install-catcare-restricted-ssh.sh
## 公开前闸门

1. 四个容器均健康。
2. 局域网填写、保存、提交和本地审核链路通过
2. 公网填写、保存、提交和 tailnet HTTPS 审核链路通过;NAS LAN 的 18081 直连失败
3. 网关对 `/admin`、`/api/admin/intake/tokens`、订单、财务和数据库端口拒绝。
4. 用户再次确认“将 CatCare 指定入口公开到互联网”后,才执行 `tailscale funnel --bg http://127.0.0.1:18080`。
4. `tailscale serve status --json` 证明管理入口只在 8443,且 tailnet ACL 已限制管理来源。
5. 用户再次确认“将 CatCare 指定入口公开到互联网”后,才执行 `tailscale funnel --bg http://127.0.0.1:18080`。

Funnel 当前仍是 Beta,存在带宽限制;家庭断网、断电、NAS 故障和第三方服务异常都会造成公网填写不可用,不构成正式生产 SLA。
2 changes: 1 addition & 1 deletion deploy/synology/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ services:
- postgres_app_password
- relay_key
ports:
- "${CATCARE_NAS_LAN_IP}:${CATCARE_RELAY_LAN_PORT:-18081}:8080"
- "127.0.0.1:${CATCARE_RELAY_LAN_PORT:-18081}:8080"
networks:
- edge
- data
Expand Down
Loading