diff --git a/config/platform-db/postgres-pk01.yaml b/config/platform-db/postgres-pk01.yaml index 36346909..31ef245d 100644 --- a/config/platform-db/postgres-pk01.yaml +++ b/config/platform-db/postgres-pk01.yaml @@ -93,6 +93,9 @@ postgres: - id: D518-public cidr: 202.98.13.68/32 purpose: platform-infra-sub2api-active + - id: JD01-public + cidr: 117.72.122.178/32 + purpose: platform-infra-sub2api-active tuning: maxConnections: 160 sharedBuffers: 512MB @@ -153,6 +156,16 @@ postgres: user: sub2api address: 202.98.13.68/32 method: scram-sha-256 + - type: hostssl + database: sub2api + user: sub2api + address: 117.72.122.178/32 + method: scram-sha-256 + - type: hostssl + database: postgres + user: sub2api + address: 117.72.122.178/32 + method: scram-sha-256 - type: hostssl database: langbot user: langbot diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index 1b51fbaf..c62d0205 100644 --- a/config/platform-infra/sub2api.yaml +++ b/config/platform-infra/sub2api.yaml @@ -140,12 +140,12 @@ targets: - id: D518 route: D518:k3s namespace: platform-infra - role: active + role: standby enabled: true - databaseMode: external-active + databaseMode: external-pending redisMode: local-ephemeral - appReplicas: 1 - redisReplicas: 1 + appReplicas: 0 + redisReplicas: 0 image: repository: weishaw/sub2api tag: 0.1.138 @@ -174,7 +174,7 @@ targets: - kubernetes.default - kubernetes.default.svc publicExposure: - enabled: true + enabled: false publicBaseUrl: https://api2.pikapython.com dns: hostname: api2.pikapython.com @@ -208,7 +208,7 @@ targets: pikanodeHttpHostPort: 18888 responseHeaderTimeoutSeconds: 600 egressProxy: - enabled: true + enabled: false deploymentName: sub2api-egress-proxy serviceName: sub2api-egress-proxy secretName: sub2api-egress-proxy-config @@ -218,8 +218,8 @@ targets: listenPort: 10808 hostNetwork: true sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks - applyToSub2Api: true - applyToSentinel: true + applyToSub2Api: false + applyToSentinel: false noProxy: - localhost - 127.0.0.1 @@ -238,8 +238,8 @@ targets: namespace: platform-infra role: active enabled: true - databaseMode: bundled - redisMode: bundled-persistent + databaseMode: external-active + redisMode: local-ephemeral appReplicas: 1 redisReplicas: 1 image: @@ -271,8 +271,42 @@ targets: - kubernetes.default.svc - 127.0.0.1:5000 - localhost:5000 + publicExposure: + enabled: true + publicBaseUrl: https://api2.pikapython.com + dns: + hostname: api2.pikapython.com + expectedA: 82.156.23.220 + resolvers: [1.1.1.1, 8.8.8.8, 223.5.5.5, 114.114.114.114] + frpc: + deploymentName: sub2api-frpc + secretName: sub2api-frpc-secrets + secretKey: frpc.toml + image: ghcr.io/fatedier/frpc:v0.68.1 + serverAddr: 82.156.23.220 + serverPort: 22000 + proxyName: platform-infra-sub2api-jd01-api + remotePort: 22087 + localIP: sub2api.platform-infra.svc.cluster.local + localPort: 8080 + tokenSourceRef: platform-infra/pk01-frp.env + tokenSourceKey: FRP_TOKEN + pk01: + route: PK01 + caddyBinaryPath: /usr/local/bin/caddy + caddyDownloadUrl: https://caddyserver.com/api/download?os=linux&arch=amd64 + caddyDownloadProxyUrl: http://127.0.0.1:18789 + caddyConfigPath: /etc/caddy/Caddyfile + caddyServiceName: caddy + caddyStorageDir: /var/lib/caddy + caddyEmail: ops@pikapython.com + pikanodeRoot: /home/ubuntu/pikanode + pikanodeContainerName: pikanode + pikanodeImage: pikanode + pikanodeHttpHostPort: 18888 + responseHeaderTimeoutSeconds: 600 egressProxy: - enabled: false + enabled: true deploymentName: sub2api-egress-proxy serviceName: sub2api-egress-proxy secretName: sub2api-egress-proxy-config @@ -284,8 +318,8 @@ targets: hostProxyConfigRef: config/platform-infra/host-proxy.yaml#targets.JD01 proxyEnvPath: /etc/unidesk/proxy.env sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks - applyToSub2Api: false - applyToSentinel: false + applyToSub2Api: true + applyToSentinel: true noProxy: - localhost - 127.0.0.1 diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts index 463d2711..73db6f9a 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts @@ -972,14 +972,30 @@ def generate_api_key(): alphabet = string.ascii_letters + string.digits return "sk-unidesk-codex-" + "".join(secrets.choice(alphabet) for _ in range(48)) -def ensure_api_key_secret(group_id): +def existing_sub2api_pool_api_key(token): + try: + existing = next((item for item in list_user_keys(token) if item.get("name") == POOL_API_KEY_NAME), None) + except Exception: + return None + if not isinstance(existing, dict): + return None + key = existing.get("key") + return key if isinstance(key, str) and key else None + +def ensure_api_key_secret(group_id, token): existing = None try: existing = decode_secret_value(POOL_API_KEY_SECRET_NAME, POOL_API_KEY_SECRET_KEY) except Exception: existing = None - api_key = existing if existing else generate_api_key() - secret_action = "kept-existing" if existing else "created" + reused = None if existing else existing_sub2api_pool_api_key(token) + api_key = existing or reused or generate_api_key() + if existing: + secret_action = "kept-existing" + elif reused: + secret_action = "reused-existing-sub2api-key" + else: + secret_action = "created" manifest = { "apiVersion": "v1", "kind": "Secret", @@ -2419,7 +2435,7 @@ def run_sync(): load_factor_status = account_load_factor_status(token) ws_v2_status = account_ws_v2_status(token) temp_unschedulable_status = account_temp_unschedulable_status(token) - api_key, secret_action, secret_apply_stdout = ensure_api_key_secret(group_id) + api_key, secret_action, secret_apply_stdout = ensure_api_key_secret(group_id, token) api_key_result = ensure_sub2api_api_key(token, api_key, group_id) owner_balance = ensure_pool_owner_balance(token, api_key_result["userId"]) owner_concurrency = ensure_pool_owner_concurrency(token, api_key_result["userId"]) diff --git a/scripts/src/platform-infra/actions.ts b/scripts/src/platform-infra/actions.ts index 6e3d9030..0c0a6c94 100644 --- a/scripts/src/platform-infra/actions.ts +++ b/scripts/src/platform-infra/actions.ts @@ -290,7 +290,7 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi : applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial), ); const parsed = parseJsonOutput(result.stdout); - const pk01Exposure = target.publicExposure?.enabled === true ? await applyPk01PublicExposure(config, target) : null; + const pk01Exposure = target.publicExposure === null ? null : await applyPk01PublicExposure(config, target); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false) && (pk01Exposure === null || pk01Exposure.ok === true), action: "platform-infra-sub2api-apply", diff --git a/scripts/src/platform-infra/pk01-public-exposure.ts b/scripts/src/platform-infra/pk01-public-exposure.ts index 5a999d3e..dfe4bde6 100644 --- a/scripts/src/platform-infra/pk01-public-exposure.ts +++ b/scripts/src/platform-infra/pk01-public-exposure.ts @@ -14,6 +14,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; +import { sub2apiCaddyManagedMarker } from "./entry"; import type { EgressProxySubscriptionCandidateSummary, EgressProxySubscriptionDiagnostics, Sub2ApiEgressProxyConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry"; import { status } from "./actions"; import { publicExposureUpstream, renderPk01CaddyService, renderPk01Caddyfile } from "./apply-script"; @@ -217,7 +218,8 @@ export function redactSubscriptionUri(uri: string): string { export async function applyPk01PublicExposure(config: UniDeskConfig, target: Sub2ApiTargetConfig): Promise> { const exposure = target.publicExposure; - if (exposure === null || !exposure.enabled) return { ok: true, action: "not-enabled" }; + if (exposure === null) return { ok: true, action: "not-configured" }; + if (!exposure.enabled) return await removePk01PublicExposure(config, target, exposure); const start = await startPk01PublicExposureJob(config, target, exposure); if (!start.ok || typeof start.remoteJobId !== "string") { return { @@ -244,6 +246,139 @@ export async function applyPk01PublicExposure(config: UniDeskConfig, target: Sub }; } +export async function removePk01PublicExposure(config: UniDeskConfig, target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): Promise> { + const marker = sub2apiCaddyManagedMarker(target); + const script = ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +config_path=${shQuote(exposure.pk01.caddyConfigPath)} +service_name=${shQuote(exposure.pk01.caddyServiceName)} +marker=${shQuote(marker)} +hostname=${shQuote(exposure.dns.hostname)} +next="$tmp/Caddyfile.next" +update_out="$tmp/update.out" +update_err="$tmp/update.err" +fmt_out="$tmp/fmt.out" +fmt_err="$tmp/fmt.err" +validate_out="$tmp/validate.out" +validate_err="$tmp/validate.err" +install_out="$tmp/install.out" +install_err="$tmp/install.err" +reload_out="$tmp/reload.out" +reload_err="$tmp/reload.err" +if ! [ -f "$config_path" ]; then + python3 - "$config_path" "$marker" "$hostname" <<'PY' +import json +import sys +print(json.dumps({ + "ok": True, + "action": "caddy-config-missing-noop", + "configPath": sys.argv[1], + "marker": sys.argv[2], + "hostname": sys.argv[3], + "valuesPrinted": False, +}, ensure_ascii=False, indent=2)) +PY + exit 0 +fi +sudo python3 - "$config_path" "$next" "$marker" >"$update_out" 2>"$update_err" <<'PY' +import pathlib +import re +import sys + +config_path = pathlib.Path(sys.argv[1]) +next_path = pathlib.Path(sys.argv[2]) +marker = sys.argv[3] +text = config_path.read_text(encoding="utf-8") +pattern = re.compile(r"(?ms)^# BEGIN unidesk managed (?P[^\\n]+)\\n(?P.*?)\\n# END unidesk managed (?P=name)\\n*") +removed = False + +def replace(match): + global removed + if match.group("name") != marker: + return match.group(0) + removed = True + return "" + +next_text = pattern.sub(replace, text) +next_text = re.sub(r"\\n{3,}", "\\n\\n", next_text).rstrip() + "\\n" +next_path.write_text(next_text, encoding="utf-8") +print("removed" if removed else "absent") +PY +update_rc=$? +if [ "$update_rc" -eq 0 ]; then + sudo caddy fmt --overwrite "$next" >"$fmt_out" 2>"$fmt_err" + fmt_rc=$? +else + : >"$fmt_out"; : >"$fmt_err"; fmt_rc=1 +fi +if [ "$fmt_rc" -eq 0 ]; then + sudo caddy validate --config "$next" >"$validate_out" 2>"$validate_err" + validate_rc=$? +else + : >"$validate_out"; : >"$validate_err"; validate_rc=1 +fi +if [ "$validate_rc" -eq 0 ]; then + sudo install -m 0644 "$next" "$config_path" >"$install_out" 2>"$install_err" + install_rc=$? +else + : >"$install_out"; : >"$install_err"; install_rc=1 +fi +if [ "$install_rc" -eq 0 ]; then + sudo systemctl reload "$service_name" >"$reload_out" 2>"$reload_err" || sudo systemctl restart "$service_name" >>"$reload_out" 2>>"$reload_err" + reload_rc=$? +else + : >"$reload_out"; : >"$reload_err"; reload_rc=1 +fi +python3 - "$update_rc" "$fmt_rc" "$validate_rc" "$install_rc" "$reload_rc" "$config_path" "$marker" "$hostname" "$update_out" "$update_err" "$fmt_out" "$fmt_err" "$validate_out" "$validate_err" "$install_out" "$install_err" "$reload_out" "$reload_err" <<'PY' +import json +import sys + +update_rc, fmt_rc, validate_rc, install_rc, reload_rc = [int(value) for value in sys.argv[1:6]] +config_path, marker, hostname = sys.argv[6:9] +paths = sys.argv[9:] + +def text(path, limit=3000): + try: + return open(path, encoding="utf-8", errors="replace").read()[-limit:] + except FileNotFoundError: + return "" + +update_stdout = text(paths[0], 1000).strip() +payload = { + "ok": update_rc == 0 and fmt_rc == 0 and validate_rc == 0 and install_rc == 0 and reload_rc == 0, + "action": "removed-managed-block" if update_stdout == "removed" else "managed-block-absent", + "target": "${target.id}", + "publicBaseUrl": "${exposure.publicBaseUrl}", + "hostname": hostname, + "configPath": config_path, + "managedBlock": {"marker": marker}, + "steps": { + "update": {"exitCode": update_rc, "stdout": update_stdout, "stderr": text(paths[1])}, + "fmt": {"exitCode": fmt_rc, "stdout": text(paths[2]), "stderr": text(paths[3])}, + "validate": {"exitCode": validate_rc, "stdout": text(paths[4]), "stderr": text(paths[5])}, + "install": {"exitCode": install_rc, "stdout": text(paths[6]), "stderr": text(paths[7])}, + "reload": {"exitCode": reload_rc, "stdout": text(paths[8]), "stderr": text(paths[9])}, + }, + "valuesPrinted": False, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if payload["ok"] else 1) +PY +`; + const result = await capture(config, exposure.pk01.route, ["sh"], script); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && boolField(parsed, "ok", false), + action: "platform-infra-sub2api-pk01-public-exposure", + route: exposure.pk01.route, + mode: "remove-disabled-managed-block", + summary: parsed ?? null, + capture: compactCapture(result, { full: result.exitCode !== 0 }), + }; +} + export async function startPk01PublicExposureJob(config: UniDeskConfig, target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): Promise> { const jobId = `sub2api-pk01-exposure-${new Date().toISOString().replace(/[^0-9A-Za-z]/gu, "")}-${randomBytes(3).toString("hex")}`; const payload = pk01PublicExposureScript(target, exposure);