This commit is contained in:
@@ -40,6 +40,15 @@
|
||||
"36.49.30.105/32",
|
||||
"59.72.97.73/32",
|
||||
"175.30.53.221/32"
|
||||
],
|
||||
"allowedForwardRules": [
|
||||
{
|
||||
"id": "nc01-k3s-pods-to-pk01-postgres",
|
||||
"sourceCidr": "10.42.0.0/16",
|
||||
"destinationCidr": "82.156.23.220/32",
|
||||
"port": 5432,
|
||||
"purpose": "NC01 k3s Sub2API external PostgreSQL on PK01"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -96,6 +96,9 @@ postgres:
|
||||
- id: JD01-public
|
||||
cidr: 117.72.122.178/32
|
||||
purpose: platform-infra-sub2api-active
|
||||
- id: NC01-public
|
||||
cidr: 152.53.229.148/32
|
||||
purpose: platform-infra-sub2api-active
|
||||
tuning:
|
||||
maxConnections: 160
|
||||
sharedBuffers: 512MB
|
||||
@@ -166,6 +169,16 @@ postgres:
|
||||
user: sub2api
|
||||
address: 117.72.122.178/32
|
||||
method: scram-sha-256
|
||||
- type: hostssl
|
||||
database: sub2api
|
||||
user: sub2api
|
||||
address: 152.53.229.148/32
|
||||
method: scram-sha-256
|
||||
- type: hostssl
|
||||
database: postgres
|
||||
user: sub2api
|
||||
address: 152.53.229.148/32
|
||||
method: scram-sha-256
|
||||
- type: hostssl
|
||||
database: langbot
|
||||
user: langbot
|
||||
|
||||
@@ -8,6 +8,7 @@ metadata:
|
||||
- 220
|
||||
- 339
|
||||
- 340
|
||||
- 1615
|
||||
|
||||
defaults:
|
||||
targetId: PK01
|
||||
@@ -233,6 +234,84 @@ targets:
|
||||
- 74.48.78.17
|
||||
- hyueapi.com
|
||||
- .hyueapi.com
|
||||
- id: NC01
|
||||
route: NC01:k3s
|
||||
namespace: platform-infra
|
||||
role: active
|
||||
enabled: true
|
||||
databaseMode: external-active
|
||||
redisMode: local-ephemeral
|
||||
appReplicas: 1
|
||||
redisReplicas: 1
|
||||
runtime:
|
||||
autoSetup: false
|
||||
image:
|
||||
repository: weishaw/sub2api
|
||||
tag: 0.1.144
|
||||
pullPolicy: IfNotPresent
|
||||
dependencyImages:
|
||||
postgres: docker.m.daocloud.io/library/postgres:18-alpine
|
||||
redis: docker.m.daocloud.io/library/redis:8-alpine
|
||||
publicExposure:
|
||||
enabled: true
|
||||
mode: node-local-https
|
||||
publicBaseUrl: https://api.152-53-229-148.nip.io:18443
|
||||
dns:
|
||||
hostname: api.152-53-229-148.nip.io
|
||||
expectedA: 152.53.229.148
|
||||
resolvers: [1.1.1.1, 8.8.8.8, 223.5.5.5, 114.114.114.114]
|
||||
localHttps:
|
||||
route: NC01
|
||||
upstreamMode: kubernetes-service-cluster-ip
|
||||
workDir: /opt/unidesk/platform-infra/sub2api-nc01-edge
|
||||
composePath: /opt/unidesk/platform-infra/sub2api-nc01-edge/docker-compose.yml
|
||||
projectName: sub2api-nc01-edge
|
||||
serviceName: caddy
|
||||
containerName: sub2api-nc01-https-edge
|
||||
image: caddy:2.10.2-alpine
|
||||
hostBind: 0.0.0.0
|
||||
httpsPort: 18443
|
||||
caddyfilePath: /opt/unidesk/platform-infra/sub2api-nc01-edge/Caddyfile
|
||||
dataDir: /var/lib/unidesk/platform-infra/sub2api-nc01-edge/caddy-data
|
||||
configDir: /var/lib/unidesk/platform-infra/sub2api-nc01-edge/caddy-config
|
||||
tlsMode: internal
|
||||
responseHeaderTimeoutSeconds: 600
|
||||
egressProxy:
|
||||
enabled: true
|
||||
deploymentName: sub2api-egress-proxy
|
||||
serviceName: sub2api-egress-proxy
|
||||
secretName: sub2api-egress-proxy-config
|
||||
secretKey: config.json
|
||||
image: ghcr.io/sagernet/sing-box:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
listenPort: 10808
|
||||
hostNetwork: false
|
||||
sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.nc01-vpn-server-shadowsocks
|
||||
applyToSub2Api: true
|
||||
applyToSentinel: false
|
||||
noProxy:
|
||||
- localhost
|
||||
- 127.0.0.1
|
||||
- ::1
|
||||
- .svc
|
||||
- .cluster.local
|
||||
- 10.0.0.0/8
|
||||
- 172.16.0.0/12
|
||||
- 192.168.0.0/16
|
||||
- 82.156.23.220
|
||||
- 152.53.229.148
|
||||
- hyueapi.com
|
||||
- .hyueapi.com
|
||||
accountLocalProxy:
|
||||
enabled: false
|
||||
containerName: account-local-proxy
|
||||
secretName: sub2api-account-local-proxy-config
|
||||
secretKey: config.json
|
||||
image: ghcr.io/sagernet/sing-box:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
listenHost: 127.0.0.1
|
||||
listenPort: 18789
|
||||
sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.nc01-vpn-server-shadowsocks
|
||||
- id: JD01
|
||||
route: JD01:k3s
|
||||
namespace: platform-infra
|
||||
|
||||
@@ -48,10 +48,12 @@
|
||||
- Raw `kubectl` through `trans <target>:k3s` is only for bounded diagnosis and evidence, not a formal mutate path.
|
||||
- The image version is controlled by `config/platform-infra/sub2api.yaml`. Image update procedures are daily operations owned by `$unidesk-sub2api`; the development boundary is that image choices remain YAML-controlled.
|
||||
- k3s Sub2API targets should stay ClusterIP-only by default. Host-Docker targets should bind app ports to loopback or a YAML-declared host interface and use a managed edge such as PK01 Caddy for public HTTPS. Do not add Ingress, NodePort, LoadBalancer, hostPort, or broad FRP exposure unless a YAML-controlled public exposure decision exists.
|
||||
- NC01 Sub2API uses `publicExposure.mode=node-local-https`: the k8s Service stays ClusterIP-only, and a YAML-declared NC01 host-Docker Caddy edge binds the local HTTPS port and reverse-proxies to the Service ClusterIP. This target must not use PK01 Caddy/FRP, Kubernetes Ingress, NodePort, LoadBalancer, hostPort, or a Pod sidecar for public exposure.
|
||||
- Sub2API currently has no resource limits by design. Do not add CPU or memory limits unless a later explicit decision changes that policy and stores the new policy in YAML.
|
||||
- Master server is a consumer/control host, not the runtime location. Do not deploy Sub2API, PostgreSQL, Redis, or heavy validation loops on master server.
|
||||
- Sub2API active/standby placement is selected by YAML, not by ad hoc runtime patches. A standby target must render without a local PostgreSQL StatefulSet, keep the Sub2API app and local Redis cache scaled to zero, use only ephemeral Redis storage if Redis is later activated, and omit public exposure, HTTPS egress proxy, and account sentinel resources unless YAML explicitly promotes that target. An externally backed active target connects directly to the YAML-declared external PostgreSQL endpoint with `sslmode=require`, keeps durable app state outside the runtime node, and uses local Redis only as ephemeral cache. Host-Docker active targets such as PK01 are still Sub2API platform targets; `codex-pool plan|sync|validate` has a host-Docker adapter for PK01, while sentinel image/report/probe and parts of trace remain target-capability-specific and must not be treated as PK01 runtime health failures when they require k3s resources. Multiple externally backed active targets may coexist when YAML declares distinct target ids, host routes, public URLs, FRP remote ports or local edge bindings, and Secret sources; target-scoped operations must use `--target <id>` and must not treat one target's URL or Secret as a fallback for another. Promotion or failback must be applied by editing `config/platform-infra/sub2api.yaml` and running the same `platform-infra sub2api --target <id>` CLI path.
|
||||
- External platform PostgreSQL endpoints for Sub2API are produced by the platform DB YAML and its `platform-db postgres` CLI. Cross-node Sub2API consumers connect directly to that endpoint; the master server is not a PostgreSQL data-plane relay. DNS aliases are optional when the exported `DATABASE_URL` uses a reachable IP with `sslmode=require`; current PK01-specific rules live in `docs/reference/pk01.md`.
|
||||
- Remote k3s targets that share the already-initialized PK01 Sub2API database should use YAML-controlled `target.runtime.autoSetup=false` and a rendered runtime config initContainer instead of rerunning Sub2API auto setup. The app's migration check path is latency-sensitive because it checks many migration rows one by one; repeated remote auto setup against PK01 can time out without indicating DB corruption. Required PostgreSQL `pg_hba` entries and host firewall forward allowances must be declared in YAML and applied through the controlled platform DB/server paths rather than by changing DB passwords, connection strings, or data.
|
||||
- Sub2API account sentinel, public exposure, and HTTPS egress proxy are target-scoped YAML decisions. The active target may run them when YAML enables them; the standby G14 target must stay deployed but inactive until YAML promotion. `sentinel.enabledOnTargets` is the authority for where Codex-pool sentinel image, CronJob, Secret and state resources are expected; disabled targets should report sentinel validation as skipped instead of failing on missing runtime sentinel objects. Do not create a second sentinel, FRP client, public management surface, or edge proxy by hand; enable or move those resources only through the target YAML and the `platform-infra sub2api` / `codex-pool --target` CLI paths.
|
||||
|
||||
## Kafka Event Bus Boundary
|
||||
|
||||
+16
-1
@@ -14,7 +14,11 @@ export interface UniDeskConfig {
|
||||
database: { port: number; containerPort: number };
|
||||
providerIngress: { port: number; containerPort: number };
|
||||
providerData: { port: number; containerPort: number };
|
||||
restrictedHostAccess?: { bindHost: string; allowedSourceCidrs: string[] };
|
||||
restrictedHostAccess?: {
|
||||
bindHost: string;
|
||||
allowedSourceCidrs: string[];
|
||||
allowedForwardRules: Array<{ id: string; sourceCidr: string; destinationCidr: string; port: number; purpose: string }>;
|
||||
};
|
||||
};
|
||||
auth: { username: string; password: string; sessionSecret: string; sessionTtlSeconds: number };
|
||||
database: { user: string; password: string; name: string; volume: string; volumeSize: string };
|
||||
@@ -181,9 +185,20 @@ function optionalRestrictedHostAccess(network: Record<string, unknown>): UniDesk
|
||||
const record = asRecord(value, "network.restrictedHostAccess");
|
||||
const allowedSourceCidrs = stringArrayField(record, "allowedSourceCidrs", "network.restrictedHostAccess");
|
||||
if (allowedSourceCidrs.length === 0) throw new Error("network.restrictedHostAccess.allowedSourceCidrs must not be empty");
|
||||
const allowedForwardRules = optionalArray(record.allowedForwardRules, "network.restrictedHostAccess.allowedForwardRules").map((item, index) => {
|
||||
const path = `network.restrictedHostAccess.allowedForwardRules[${index}]`;
|
||||
return {
|
||||
id: stringField(item, "id", path),
|
||||
sourceCidr: stringField(item, "sourceCidr", path),
|
||||
destinationCidr: stringField(item, "destinationCidr", path),
|
||||
port: numberField(item, "port", path),
|
||||
purpose: stringField(item, "purpose", path),
|
||||
};
|
||||
});
|
||||
return {
|
||||
bindHost: stringField(record, "bindHost", "network.restrictedHostAccess"),
|
||||
allowedSourceCidrs,
|
||||
allowedForwardRules,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -548,6 +548,11 @@ function restrictedHostAccessScript(config: UniDeskConfig): string {
|
||||
];
|
||||
return [
|
||||
"iptables -N DOCKER-USER 2>/dev/null || true",
|
||||
...access.allowedForwardRules.map((rule) => [
|
||||
`iptables -C DOCKER-USER -s ${shellQuote(rule.sourceCidr)} -d ${shellQuote(rule.destinationCidr)} -p tcp --dport ${rule.port} -j ACCEPT 2>/dev/null`,
|
||||
` || iptables -I DOCKER-USER 1 -s ${shellQuote(rule.sourceCidr)} -d ${shellQuote(rule.destinationCidr)} -p tcp --dport ${rule.port} -j ACCEPT`,
|
||||
`echo ${shellJoin(["restricted_host_forward_access", rule.id, rule.sourceCidr, rule.destinationCidr, String(rule.port), rule.purpose])}`,
|
||||
].join(" \\\n")),
|
||||
...ports.flatMap((port) => [
|
||||
...access.allowedSourceCidrs.map((source) => [
|
||||
`iptables -C DOCKER-USER -s ${shellQuote(source)} -p tcp --dport ${port.containerPort} -j ACCEPT 2>/dev/null`,
|
||||
|
||||
@@ -49,15 +49,32 @@ export function publicExposureSummary(exposure: Sub2ApiPublicExposureConfig): Re
|
||||
tokenSourceRef: exposure.frpc.tokenSourceRef,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
pk01: {
|
||||
route: exposure.pk01.route,
|
||||
mode: "caddy-edge",
|
||||
caddyBinaryPath: exposure.pk01.caddyBinaryPath,
|
||||
caddyDownloadProxyUrl: exposure.pk01.caddyDownloadProxyUrl,
|
||||
caddyConfigPath: exposure.pk01.caddyConfigPath,
|
||||
caddyServiceName: exposure.pk01.caddyServiceName,
|
||||
pikanodeHttpHostPort: exposure.pk01.pikanodeHttpHostPort,
|
||||
},
|
||||
localHttps: exposure.localHttps === null
|
||||
? null
|
||||
: {
|
||||
route: exposure.localHttps.route,
|
||||
mode: "node-local-docker-caddy",
|
||||
upstreamMode: exposure.localHttps.upstreamMode,
|
||||
composePath: exposure.localHttps.composePath,
|
||||
projectName: exposure.localHttps.projectName,
|
||||
serviceName: exposure.localHttps.serviceName,
|
||||
containerName: exposure.localHttps.containerName,
|
||||
image: exposure.localHttps.image,
|
||||
bind: `${exposure.localHttps.hostBind}:${exposure.localHttps.httpsPort}`,
|
||||
tlsMode: exposure.localHttps.tlsMode,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
pk01: exposure.pk01 === null
|
||||
? null
|
||||
: {
|
||||
route: exposure.pk01.route,
|
||||
mode: "caddy-edge",
|
||||
caddyBinaryPath: exposure.pk01.caddyBinaryPath,
|
||||
caddyDownloadProxyUrl: exposure.pk01.caddyDownloadProxyUrl,
|
||||
caddyConfigPath: exposure.pk01.caddyConfigPath,
|
||||
caddyServiceName: exposure.pk01.caddyServiceName,
|
||||
pikanodeHttpHostPort: exposure.pk01.pikanodeHttpHostPort,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,6 +159,7 @@ export function plan(options: TargetOptions): Record<string, unknown> {
|
||||
security: sub2api.security,
|
||||
target: {
|
||||
runtimeMode: target.runtimeMode,
|
||||
runtime: target.runtime,
|
||||
databaseMode: target.databaseMode,
|
||||
redisMode: target.redisMode,
|
||||
appReplicas: target.appReplicas,
|
||||
@@ -178,7 +196,9 @@ export function plan(options: TargetOptions): Record<string, unknown> {
|
||||
exposure: target.publicExposure?.enabled
|
||||
? isHostDockerTarget(target)
|
||||
? `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy to PK01 local Docker; no D601 egress, no k3s Ingress/NodePort/LoadBalancer.`
|
||||
: `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and ${target.id} frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
|
||||
: target.publicExposure.mode === "node-local-https"
|
||||
? `Node-local HTTPS ${target.publicExposure.publicBaseUrl} through ${target.id} Docker Caddy to the ClusterIP Sub2API Service; no PK01 Caddy/FRP and no Kubernetes Ingress/NodePort/LoadBalancer.`
|
||||
: `Public HTTPS ${target.publicExposure.publicBaseUrl} through PK01 Caddy and ${target.id} frpc; no master server forwarding and no Kubernetes Ingress/NodePort/LoadBalancer.`
|
||||
: "ClusterIP only; no public ingress or node-level exposure.",
|
||||
resourcePolicy: isHostDockerTarget(target) ? "PK01 host-Docker compose is controlled by YAML; no Kubernetes resources are rendered." : "No Kubernetes CPU/memory requests or limits, matching issue #220.",
|
||||
imageVersionControl: "Sub2API image repository/tag/pullPolicy are controlled by config/platform-infra/sub2api.yaml in the UniDesk repository.",
|
||||
@@ -186,11 +206,19 @@ export function plan(options: TargetOptions): Record<string, unknown> {
|
||||
networkPolicy: isHostDockerTarget(target) ? "Not applicable for PK01 host-Docker deployment." : "NetworkPolicy/allow-all is rendered with the deployment so kube-router cannot silently default-deny Sub2API cross-pod traffic.",
|
||||
publicExposure: target.publicExposure?.enabled
|
||||
? {
|
||||
mode: target.publicExposure.mode === "pk01-local" ? "pk01-caddy-local-docker" : "pk01-caddy-frp-direct",
|
||||
mode: target.publicExposure.mode === "pk01-local"
|
||||
? "pk01-caddy-local-docker"
|
||||
: target.publicExposure.mode === "node-local-https"
|
||||
? "node-local-docker-caddy"
|
||||
: "pk01-caddy-frp-direct",
|
||||
dataPath: target.publicExposure.mode === "pk01-local"
|
||||
? `client -> PK01 Caddy -> ${target.publicExposure.local?.upstreamHost}:${target.publicExposure.local?.upstreamPort} -> PK01 Docker Sub2API`
|
||||
: `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`,
|
||||
pikanodeRole: `pikapython.com upstream only; ${target.publicExposure.dns.hostname} does not pass through pikanode Express`,
|
||||
: target.publicExposure.mode === "node-local-https"
|
||||
? `client -> ${target.id} local HTTPS port ${target.publicExposure.localHttps?.httpsPort} -> ${target.id} Docker Caddy -> ${serviceName}.${target.namespace}.svc.cluster.local:8080`
|
||||
: `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`,
|
||||
pikanodeRole: target.publicExposure.mode === "node-local-https"
|
||||
? "not involved; this target does not touch PK01 pikanode"
|
||||
: `pikapython.com upstream only; ${target.publicExposure.dns.hostname} does not pass through pikanode Express`,
|
||||
publicBaseUrl: target.publicExposure.publicBaseUrl,
|
||||
hostname: target.publicExposure.dns.hostname,
|
||||
expectedA: target.publicExposure.dns.expectedA,
|
||||
@@ -327,7 +355,9 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi
|
||||
: applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial, accountLocalProxySecretMaterial),
|
||||
);
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
const pk01Exposure = target.publicExposure === null ? null : await applyPk01PublicExposure(config, target);
|
||||
const pk01Exposure = target.publicExposure === null || !target.publicExposure.enabled || target.publicExposure.mode === "node-local-https"
|
||||
? null
|
||||
: await applyPk01PublicExposure(config, target);
|
||||
return {
|
||||
ok: result.exitCode === 0 && boolField(parsed, "ok", false) && (pk01Exposure === null || pk01Exposure.ok === true),
|
||||
action: "platform-infra-sub2api-apply",
|
||||
|
||||
@@ -15,10 +15,11 @@ import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-
|
||||
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
|
||||
|
||||
import type { AccountLocalProxySecretMaterial, EgressProxySecretMaterial, ExternalActiveSecretMaterial, PublicExposureSecretMaterial, Sub2ApiConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
|
||||
import { fieldManager, requiredSecretKeys, sub2apiCaddyManagedMarker } from "./entry";
|
||||
import { fieldManager, requiredSecretKeys, serviceName, sub2apiCaddyManagedMarker } from "./entry";
|
||||
import { managedResourceCleanupPlan } from "./manifest";
|
||||
|
||||
export function renderPk01Caddyfile(target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): string {
|
||||
if (exposure.pk01 === null) throw new Error(`publicExposure.pk01 is required for target ${target.id} when rendering PK01 Caddy`);
|
||||
const apexHost = baseDomain(exposure.dns.hostname);
|
||||
const apiBlock = renderCaddyManagedBlock(
|
||||
sub2apiCaddyManagedMarker(target),
|
||||
@@ -48,11 +49,16 @@ export function publicExposureUpstream(target: Sub2ApiTargetConfig, exposure: Su
|
||||
if (exposure.local === null) throw new Error(`publicExposure.local is required for target ${target.id} when mode=pk01-local`);
|
||||
return `${exposure.local.upstreamHost}:${exposure.local.upstreamPort}`;
|
||||
}
|
||||
if (exposure.mode === "node-local-https") {
|
||||
if (exposure.localHttps === null) throw new Error(`publicExposure.localHttps is required for target ${target.id} when mode=node-local-https`);
|
||||
return `${exposure.dns.hostname}:${exposure.localHttps.httpsPort}`;
|
||||
}
|
||||
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
|
||||
return `127.0.0.1:${exposure.frpc.remotePort}`;
|
||||
}
|
||||
|
||||
export function renderPk01CaddyService(exposure: Sub2ApiPublicExposureConfig): string {
|
||||
if (exposure.pk01 === null) throw new Error("publicExposure.pk01 is required when rendering PK01 Caddy service");
|
||||
return `[Unit]
|
||||
Description=UniDesk PK01 Caddy edge for PikaPython and Sub2API
|
||||
After=network-online.target docker.service
|
||||
@@ -227,6 +233,8 @@ export function applyScript(
|
||||
): string {
|
||||
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
||||
const cleanupPlan = managedResourceCleanupPlan(sub2api, target);
|
||||
const localHttpsExposure = target.publicExposure?.enabled === true && target.publicExposure.mode === "node-local-https" ? target.publicExposure : null;
|
||||
const localHttps = localHttpsExposure?.localHttps ?? null;
|
||||
const appSecretName = sub2api.runtime.database.secretName;
|
||||
const publicExposureSecretName = publicExposureSecretMaterial?.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName;
|
||||
const publicExposureSecretKey = publicExposureSecretMaterial?.secretKey ?? "frpc.toml";
|
||||
@@ -299,6 +307,24 @@ export function applyScript(
|
||||
proxyUrl: accountLocalProxySecretMaterial.proxyUrl,
|
||||
valuesPrinted: false,
|
||||
}))})`;
|
||||
const localHttpsSummary = localHttps === null
|
||||
? "None"
|
||||
: `json.loads(${JSON.stringify(JSON.stringify({
|
||||
publicBaseUrl: localHttpsExposure?.publicBaseUrl,
|
||||
hostname: localHttpsExposure?.dns.hostname,
|
||||
expectedA: localHttpsExposure?.dns.expectedA,
|
||||
mode: "node-local-docker-caddy",
|
||||
route: localHttps.route,
|
||||
upstreamMode: localHttps.upstreamMode,
|
||||
composePath: localHttps.composePath,
|
||||
projectName: localHttps.projectName,
|
||||
serviceName: localHttps.serviceName,
|
||||
containerName: localHttps.containerName,
|
||||
image: localHttps.image,
|
||||
bind: `${localHttps.hostBind}:${localHttps.httpsPort}`,
|
||||
tlsMode: localHttps.tlsMode,
|
||||
valuesPrinted: false,
|
||||
}))})`;
|
||||
return `
|
||||
set -u
|
||||
tmp="$(mktemp -d)"
|
||||
@@ -319,6 +345,8 @@ apply_out="$tmp/apply.out"
|
||||
apply_err="$tmp/apply.err"
|
||||
cleanup_out="$tmp/cleanup.out"
|
||||
cleanup_err="$tmp/cleanup.err"
|
||||
local_https_out="$tmp/local-https.out"
|
||||
local_https_err="$tmp/local-https.err"
|
||||
kubectl create namespace ${target.namespace} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$ns_out" 2>"$ns_err"
|
||||
ns_rc=$?
|
||||
secret_action="unknown"
|
||||
@@ -329,6 +357,8 @@ egress_secret_action="not-enabled"
|
||||
egress_secret_rc=0
|
||||
account_local_proxy_secret_action="not-enabled"
|
||||
account_local_proxy_secret_rc=0
|
||||
local_https_action="not-enabled"
|
||||
local_https_rc=0
|
||||
if [ "$ns_rc" -eq 0 ]; then
|
||||
if [ "${target.databaseMode}" = "external-pending" ]; then
|
||||
secret_action="external-pending-not-managed"
|
||||
@@ -506,7 +536,71 @@ else
|
||||
printf '%s\\n' 'skipped because apply failed' >"$cleanup_err"
|
||||
cleanup_rc=1
|
||||
fi
|
||||
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$account_local_proxy_secret_rc" "$apply_rc" "$cleanup_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$account_local_proxy_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$account_local_proxy_secret_out" "$account_local_proxy_secret_err" "$apply_out" "$apply_err" "$cleanup_out" "$cleanup_err" <<'PY'
|
||||
if [ "${localHttps === null ? "false" : "true"}" = "true" ]; then
|
||||
if [ "$apply_rc" -eq 0 ] && [ "$cleanup_rc" -eq 0 ]; then
|
||||
edge_dir=${localHttps === null ? "\"\"" : shQuote(localHttps.workDir)}
|
||||
compose_path=${localHttps === null ? "\"\"" : shQuote(localHttps.composePath)}
|
||||
caddyfile_path=${localHttps === null ? "\"\"" : shQuote(localHttps.caddyfilePath)}
|
||||
data_dir=${localHttps === null ? "\"\"" : shQuote(localHttps.dataDir)}
|
||||
config_dir=${localHttps === null ? "\"\"" : shQuote(localHttps.configDir)}
|
||||
mkdir -p "$edge_dir" "$(dirname "$compose_path")" "$(dirname "$caddyfile_path")" "$data_dir" "$config_dir" >"$local_https_out" 2>"$local_https_err"
|
||||
local_https_rc=$?
|
||||
if [ "$local_https_rc" -eq 0 ]; then
|
||||
service_cluster_ip="$(kubectl -n ${target.namespace} get service ${serviceName} -o jsonpath='{.spec.clusterIP}' 2>>"$local_https_err" || true)"
|
||||
if [ -z "$service_cluster_ip" ] || [ "$service_cluster_ip" = "None" ]; then
|
||||
printf '%s\\n' 'sub2api service clusterIP is empty; cannot render node-local HTTPS edge' >>"$local_https_err"
|
||||
local_https_rc=1
|
||||
fi
|
||||
fi
|
||||
if [ "$local_https_rc" -eq 0 ]; then
|
||||
cat >"$caddyfile_path" <<'CADDY'
|
||||
https://${localHttpsExposure?.dns.hostname}:${localHttps?.httpsPort} {
|
||||
tls internal
|
||||
reverse_proxy __SUB2API_SERVICE_CLUSTER_IP__:8080 {
|
||||
transport http {
|
||||
response_header_timeout ${localHttps?.responseHeaderTimeoutSeconds}s
|
||||
}
|
||||
}
|
||||
}
|
||||
CADDY
|
||||
sed -i "s/__SUB2API_SERVICE_CLUSTER_IP__/$service_cluster_ip/g" "$caddyfile_path"
|
||||
cat >"$compose_path" <<'COMPOSE'
|
||||
name: ${localHttps?.projectName}
|
||||
|
||||
services:
|
||||
${localHttps?.serviceName}:
|
||||
image: ${localHttps?.image}
|
||||
container_name: ${localHttps?.containerName}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${localHttps?.hostBind}:${localHttps?.httpsPort}:${localHttps?.httpsPort}"
|
||||
volumes:
|
||||
- ${localHttps?.caddyfilePath}:/etc/caddy/Caddyfile:ro
|
||||
- ${localHttps?.dataDir}:/data
|
||||
- ${localHttps?.configDir}:/config
|
||||
COMPOSE
|
||||
chmod 0644 "$caddyfile_path" "$compose_path" >>"$local_https_out" 2>>"$local_https_err" || local_https_rc=$?
|
||||
fi
|
||||
if [ "$local_https_rc" -eq 0 ]; then
|
||||
docker compose -f "$compose_path" -p ${localHttps === null ? "\"\"" : shQuote(localHttps.projectName)} up -d --force-recreate --remove-orphans >>"$local_https_out" 2>>"$local_https_err"
|
||||
local_https_rc=$?
|
||||
fi
|
||||
if [ "$local_https_rc" -eq 0 ]; then
|
||||
sleep 2
|
||||
curl --noproxy '*' -kfsS --connect-timeout 5 --max-time 20 --resolve ${localHttpsExposure === null || localHttps === null ? "\"\"" : shQuote(`${localHttpsExposure.dns.hostname}:${localHttps.httpsPort}:127.0.0.1`)} ${localHttpsExposure === null ? "\"\"" : shQuote(`${localHttpsExposure.publicBaseUrl}/health`)} >>"$local_https_out" 2>>"$local_https_err"
|
||||
local_https_rc=$?
|
||||
fi
|
||||
local_https_action="synced"
|
||||
else
|
||||
printf '%s\\n' 'skipped because Kubernetes apply or cleanup failed' >"$local_https_err"
|
||||
: >"$local_https_out"
|
||||
local_https_action="skipped-after-k8s-failure"
|
||||
fi
|
||||
else
|
||||
: >"$local_https_out"
|
||||
: >"$local_https_err"
|
||||
fi
|
||||
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$account_local_proxy_secret_rc" "$apply_rc" "$cleanup_rc" "$local_https_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$account_local_proxy_secret_action" "$local_https_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$account_local_proxy_secret_out" "$account_local_proxy_secret_err" "$apply_out" "$apply_err" "$cleanup_out" "$cleanup_err" "$local_https_out" "$local_https_err" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
ns_rc = int(sys.argv[1])
|
||||
@@ -516,11 +610,13 @@ egress_secret_rc = int(sys.argv[4])
|
||||
account_local_proxy_secret_rc = int(sys.argv[5])
|
||||
apply_rc = int(sys.argv[6])
|
||||
cleanup_rc = int(sys.argv[7])
|
||||
secret_action = sys.argv[8]
|
||||
exposure_secret_action = sys.argv[9]
|
||||
egress_secret_action = sys.argv[10]
|
||||
account_local_proxy_secret_action = sys.argv[11]
|
||||
paths = sys.argv[12:]
|
||||
local_https_rc = int(sys.argv[8])
|
||||
secret_action = sys.argv[9]
|
||||
exposure_secret_action = sys.argv[10]
|
||||
egress_secret_action = sys.argv[11]
|
||||
account_local_proxy_secret_action = sys.argv[12]
|
||||
local_https_action = sys.argv[13]
|
||||
paths = sys.argv[14:]
|
||||
def text(path):
|
||||
try:
|
||||
return open(path, encoding="utf-8").read()
|
||||
@@ -532,7 +628,7 @@ def parsed(path):
|
||||
except Exception:
|
||||
return None
|
||||
payload = {
|
||||
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and account_local_proxy_secret_rc == 0 and apply_rc == 0 and cleanup_rc == 0,
|
||||
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and account_local_proxy_secret_rc == 0 and apply_rc == 0 and cleanup_rc == 0 and local_https_rc == 0,
|
||||
"target": "${target.id}",
|
||||
"namespace": "${target.namespace}",
|
||||
"databaseMode": "${target.databaseMode}",
|
||||
@@ -547,6 +643,7 @@ payload = {
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"publicExposure": ${exposureSecretSummary},
|
||||
"localHttpsExposure": ${localHttpsSummary},
|
||||
"egressProxy": ${egressProxySecretSummary},
|
||||
"accountLocalProxy": ${accountLocalProxySecretSummary},
|
||||
"steps": {
|
||||
@@ -557,6 +654,7 @@ payload = {
|
||||
"accountLocalProxySecret": {"exitCode": account_local_proxy_secret_rc, "action": account_local_proxy_secret_action, "stdout": text(paths[8])[-4000:], "stderr": text(paths[9])[-4000:]},
|
||||
"apply": {"exitCode": apply_rc, "stdout": text(paths[10])[-8000:], "stderr": text(paths[11])[-4000:]},
|
||||
"cleanup": {"exitCode": cleanup_rc, "summary": parsed(paths[12]), "stdout": text(paths[12])[-8000:], "stderr": text(paths[13])[-4000:]},
|
||||
"localHttpsExposure": {"exitCode": local_https_rc, "action": local_https_action, "stdout": text(paths[14])[-4000:], "stderr": text(paths[15])[-4000:]},
|
||||
},
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
|
||||
@@ -169,6 +169,7 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
|
||||
const redisMode = enumField(record, "redisMode", path, ["bundled-persistent", "local-ephemeral"] as const);
|
||||
const appReplicas = integerField(record, "appReplicas", path);
|
||||
const redisReplicas = integerField(record, "redisReplicas", path);
|
||||
const runtime = parseTargetRuntime(record.runtime, path);
|
||||
const image = targetImageOverride(record, path);
|
||||
const dependencyImages = targetDependencyImageOverride(record, path);
|
||||
const hostDocker = parseHostDockerConfig(record.hostDocker, path, runtimeMode);
|
||||
@@ -184,7 +185,8 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
|
||||
if (runtimeMode === "host-docker" && egressProxy?.enabled === true) throw new Error(`${configPath}.${path}.egressProxy must be disabled or omitted for runtimeMode=host-docker`);
|
||||
if (runtimeMode === "host-docker" && accountLocalProxy?.enabled === true) throw new Error(`${configPath}.${path}.accountLocalProxy must be disabled or omitted for runtimeMode=host-docker`);
|
||||
if (runtimeMode === "host-docker" && publicExposure?.enabled === true && publicExposure.mode !== "pk01-local") throw new Error(`${configPath}.${path}.publicExposure.mode must be pk01-local for runtimeMode=host-docker`);
|
||||
return { id, route, namespace: targetNamespace, runtimeMode, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, hostDocker, publicExposure, egressProxy, accountLocalProxy };
|
||||
if (runtimeMode === "k3s" && publicExposure?.enabled === true && publicExposure.mode === "pk01-local") throw new Error(`${configPath}.${path}.publicExposure.mode=pk01-local is only supported for runtimeMode=host-docker`);
|
||||
return { id, route, namespace: targetNamespace, runtimeMode, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, runtime, image, dependencyImages, hostDocker, publicExposure, egressProxy, accountLocalProxy };
|
||||
});
|
||||
const ids = new Set<string>();
|
||||
for (const target of targets) {
|
||||
@@ -195,6 +197,15 @@ export function parseTargets(root: Record<string, unknown>, defaultTargetId: str
|
||||
return targets;
|
||||
}
|
||||
|
||||
export function parseTargetRuntime(value: unknown, path: string): Sub2ApiTargetConfig["runtime"] {
|
||||
if (value === undefined || value === null) return { autoSetup: true };
|
||||
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.runtime must be an object`);
|
||||
const record = value as Record<string, unknown>;
|
||||
return {
|
||||
autoSetup: record.autoSetup === undefined ? true : booleanField(record, "autoSetup", `${path}.runtime`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseHostDockerConfig(value: unknown, path: string, runtimeMode: Sub2ApiTargetConfig["runtimeMode"]): Sub2ApiHostDockerConfig | null {
|
||||
if (value === undefined || value === null) {
|
||||
if (runtimeMode === "host-docker") throw new Error(`${configPath}.${path}.hostDocker is required when runtimeMode=host-docker`);
|
||||
@@ -268,17 +279,20 @@ export function parsePublicExposureConfig(value: unknown, path: string): Sub2Api
|
||||
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.publicExposure must be an object`);
|
||||
const record = value as Record<string, unknown>;
|
||||
const enabled = booleanField(record, "enabled", `${path}.publicExposure`);
|
||||
const mode = record.mode === undefined ? "frp" : enumField(record, "mode", `${path}.publicExposure`, ["frp", "pk01-local"] as const);
|
||||
const mode = record.mode === undefined ? "frp" : enumField(record, "mode", `${path}.publicExposure`, ["frp", "pk01-local", "node-local-https"] as const);
|
||||
const publicBaseUrl = stringField(record, "publicBaseUrl", `${path}.publicExposure`);
|
||||
const dnsRaw = objectField(record, "dns", `${path}.publicExposure`);
|
||||
const localRaw = record.local === undefined || record.local === null ? null : objectField(record, "local", `${path}.publicExposure`);
|
||||
const localHttpsRaw = record.localHttps === undefined || record.localHttps === null ? null : objectField(record, "localHttps", `${path}.publicExposure`);
|
||||
const frpcRaw = record.frpc === undefined || record.frpc === null ? null : objectField(record, "frpc", `${path}.publicExposure`);
|
||||
const pk01Raw = objectField(record, "pk01", `${path}.publicExposure`);
|
||||
const pk01Raw = record.pk01 === undefined || record.pk01 === null ? null : objectField(record, "pk01", `${path}.publicExposure`);
|
||||
const hostname = stringField(dnsRaw, "hostname", `${path}.publicExposure.dns`);
|
||||
const expectedA = stringField(dnsRaw, "expectedA", `${path}.publicExposure.dns`);
|
||||
const resolvers = stringArrayField(dnsRaw, "resolvers", `${path}.publicExposure.dns`);
|
||||
if (mode === "frp" && frpcRaw === null) throw new Error(`${configPath}.${path}.publicExposure.frpc is required when mode=frp`);
|
||||
if (mode === "pk01-local" && localRaw === null) throw new Error(`${configPath}.${path}.publicExposure.local is required when mode=pk01-local`);
|
||||
if (mode === "node-local-https" && localHttpsRaw === null) throw new Error(`${configPath}.${path}.publicExposure.localHttps is required when mode=node-local-https`);
|
||||
if ((mode === "frp" || mode === "pk01-local") && pk01Raw === null) throw new Error(`${configPath}.${path}.publicExposure.pk01 is required when mode=${mode}`);
|
||||
const exposure: Sub2ApiPublicExposureConfig = {
|
||||
enabled,
|
||||
mode,
|
||||
@@ -292,6 +306,23 @@ export function parsePublicExposureConfig(value: unknown, path: string): Sub2Api
|
||||
upstreamHost: stringField(localRaw, "upstreamHost", `${path}.publicExposure.local`),
|
||||
upstreamPort: integerField(localRaw, "upstreamPort", `${path}.publicExposure.local`),
|
||||
},
|
||||
localHttps: localHttpsRaw === null ? null : {
|
||||
route: stringField(localHttpsRaw, "route", `${path}.publicExposure.localHttps`),
|
||||
upstreamMode: enumField(localHttpsRaw, "upstreamMode", `${path}.publicExposure.localHttps`, ["kubernetes-service-cluster-ip"] as const),
|
||||
workDir: stringField(localHttpsRaw, "workDir", `${path}.publicExposure.localHttps`),
|
||||
composePath: stringField(localHttpsRaw, "composePath", `${path}.publicExposure.localHttps`),
|
||||
projectName: stringField(localHttpsRaw, "projectName", `${path}.publicExposure.localHttps`),
|
||||
serviceName: stringField(localHttpsRaw, "serviceName", `${path}.publicExposure.localHttps`),
|
||||
containerName: stringField(localHttpsRaw, "containerName", `${path}.publicExposure.localHttps`),
|
||||
image: stringField(localHttpsRaw, "image", `${path}.publicExposure.localHttps`),
|
||||
hostBind: stringField(localHttpsRaw, "hostBind", `${path}.publicExposure.localHttps`),
|
||||
httpsPort: integerField(localHttpsRaw, "httpsPort", `${path}.publicExposure.localHttps`),
|
||||
caddyfilePath: stringField(localHttpsRaw, "caddyfilePath", `${path}.publicExposure.localHttps`),
|
||||
dataDir: stringField(localHttpsRaw, "dataDir", `${path}.publicExposure.localHttps`),
|
||||
configDir: stringField(localHttpsRaw, "configDir", `${path}.publicExposure.localHttps`),
|
||||
tlsMode: enumField(localHttpsRaw, "tlsMode", `${path}.publicExposure.localHttps`, ["internal"] as const),
|
||||
responseHeaderTimeoutSeconds: integerField(localHttpsRaw, "responseHeaderTimeoutSeconds", `${path}.publicExposure.localHttps`),
|
||||
},
|
||||
frpc: frpcRaw === null ? null : {
|
||||
deploymentName: stringField(frpcRaw, "deploymentName", `${path}.publicExposure.frpc`),
|
||||
secretName: stringField(frpcRaw, "secretName", `${path}.publicExposure.frpc`),
|
||||
@@ -306,7 +337,7 @@ export function parsePublicExposureConfig(value: unknown, path: string): Sub2Api
|
||||
tokenSourceRef: stringField(frpcRaw, "tokenSourceRef", `${path}.publicExposure.frpc`),
|
||||
tokenSourceKey: stringField(frpcRaw, "tokenSourceKey", `${path}.publicExposure.frpc`),
|
||||
},
|
||||
pk01: {
|
||||
pk01: pk01Raw === null ? null : {
|
||||
route: stringField(pk01Raw, "route", `${path}.publicExposure.pk01`),
|
||||
caddyBinaryPath: stringField(pk01Raw, "caddyBinaryPath", `${path}.publicExposure.pk01`),
|
||||
caddyDownloadUrl: stringField(pk01Raw, "caddyDownloadUrl", `${path}.publicExposure.pk01`),
|
||||
@@ -572,6 +603,22 @@ export function validatePublicExposureConfig(config: Sub2ApiPublicExposureConfig
|
||||
validateHostOrIp(config.local.upstreamHost, `${path}.publicExposure.local.upstreamHost`);
|
||||
validatePort(config.local.upstreamPort, `${path}.publicExposure.local.upstreamPort`);
|
||||
}
|
||||
if (config.mode === "node-local-https") {
|
||||
const localHttps = config.localHttps;
|
||||
if (localHttps === null) throw new Error(`${configPath}.${path}.publicExposure.localHttps is required when mode=node-local-https`);
|
||||
if (!/^[A-Za-z0-9:_./-]+$/u.test(localHttps.route)) throw new Error(`${configPath}.${path}.publicExposure.localHttps.route has an unsupported format`);
|
||||
for (const key of ["workDir", "composePath", "caddyfilePath", "dataDir", "configDir"] as const) {
|
||||
if (!localHttps[key].startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.localHttps.${key} must be absolute`);
|
||||
}
|
||||
validateProxyName(localHttps.projectName, `${path}.publicExposure.localHttps.projectName`);
|
||||
validateProxyName(localHttps.serviceName, `${path}.publicExposure.localHttps.serviceName`);
|
||||
validateProxyName(localHttps.containerName, `${path}.publicExposure.localHttps.containerName`);
|
||||
if (!isImageReference(localHttps.image)) throw new Error(`${configPath}.${path}.publicExposure.localHttps.image has an unsupported format`);
|
||||
validateHostOrIp(localHttps.hostBind, `${path}.publicExposure.localHttps.hostBind`);
|
||||
validatePort(localHttps.httpsPort, `${path}.publicExposure.localHttps.httpsPort`);
|
||||
if (localHttps.responseHeaderTimeoutSeconds < 1) throw new Error(`${configPath}.${path}.publicExposure.localHttps.responseHeaderTimeoutSeconds must be >= 1`);
|
||||
}
|
||||
if (config.pk01 === null) return;
|
||||
if (!/^[A-Za-z0-9:_./-]+$/u.test(config.pk01.route)) throw new Error(`${configPath}.${path}.publicExposure.pk01.route has an unsupported format`);
|
||||
if (!config.pk01.caddyBinaryPath.startsWith("/")) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyBinaryPath must be absolute`);
|
||||
if (!/^https:\/\//u.test(config.pk01.caddyDownloadUrl)) throw new Error(`${configPath}.${path}.publicExposure.pk01.caddyDownloadUrl must use https://`);
|
||||
|
||||
@@ -119,6 +119,9 @@ export interface Sub2ApiTargetConfig {
|
||||
redisMode: RedisMode;
|
||||
appReplicas: number;
|
||||
redisReplicas: number;
|
||||
runtime: {
|
||||
autoSetup: boolean;
|
||||
};
|
||||
image: Partial<Sub2ApiConfig["image"]>;
|
||||
dependencyImages: Partial<Sub2ApiConfig["dependencyImages"]>;
|
||||
hostDocker: Sub2ApiHostDockerConfig | null;
|
||||
@@ -142,7 +145,7 @@ export interface Sub2ApiHostDockerConfig {
|
||||
|
||||
export interface Sub2ApiPublicExposureConfig {
|
||||
enabled: boolean;
|
||||
mode: "frp" | "pk01-local";
|
||||
mode: "frp" | "pk01-local" | "node-local-https";
|
||||
publicBaseUrl: string;
|
||||
dns: {
|
||||
hostname: string;
|
||||
@@ -153,6 +156,23 @@ export interface Sub2ApiPublicExposureConfig {
|
||||
upstreamHost: string;
|
||||
upstreamPort: number;
|
||||
} | null;
|
||||
localHttps: {
|
||||
route: string;
|
||||
upstreamMode: "kubernetes-service-cluster-ip";
|
||||
workDir: string;
|
||||
composePath: string;
|
||||
projectName: string;
|
||||
serviceName: string;
|
||||
containerName: string;
|
||||
image: string;
|
||||
hostBind: string;
|
||||
httpsPort: number;
|
||||
caddyfilePath: string;
|
||||
dataDir: string;
|
||||
configDir: string;
|
||||
tlsMode: "internal";
|
||||
responseHeaderTimeoutSeconds: number;
|
||||
} | null;
|
||||
frpc: {
|
||||
deploymentName: string;
|
||||
secretName: string;
|
||||
@@ -181,7 +201,7 @@ export interface Sub2ApiPublicExposureConfig {
|
||||
pikanodeImage: string;
|
||||
pikanodeHttpHostPort: number;
|
||||
responseHeaderTimeoutSeconds: number;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Sub2ApiEgressProxyConfig {
|
||||
|
||||
@@ -68,7 +68,7 @@ export function hostDockerEnvFile(sub2api: Sub2ApiConfig, target: Sub2ApiTargetC
|
||||
const database = targetDatabase(sub2api, target);
|
||||
const urlAllowlist = sub2api.security.urlAllowlist;
|
||||
const values: Record<string, string> = {
|
||||
AUTO_SETUP: "true",
|
||||
AUTO_SETUP: String(target.runtime.autoSetup),
|
||||
SERVER_HOST: "127.0.0.1",
|
||||
SERVER_PORT: String(host.appPort),
|
||||
SERVER_MODE: "release",
|
||||
|
||||
@@ -217,6 +217,78 @@ export function codexPoolSentinelResourceNames(): ManagedResourceCleanupPlan["se
|
||||
};
|
||||
}
|
||||
|
||||
function renderRuntimeConfigInitContainer(
|
||||
target: Sub2ApiTargetConfig,
|
||||
database: ExternalDatabaseConfig,
|
||||
dependencyImages: Sub2ApiConfig["dependencyImages"],
|
||||
): string {
|
||||
if (target.runtime.autoSetup) return "";
|
||||
return ` - name: write-runtime-config
|
||||
image: ${dependencyImages.postgres}
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- sh
|
||||
- -ec
|
||||
- |
|
||||
set -eu
|
||||
yaml_quote() {
|
||||
value=$(printf '%s' "$1" | sed "s/'/''/g")
|
||||
printf "'%s'" "$value"
|
||||
}
|
||||
mkdir -p /app/data
|
||||
{
|
||||
printf '%s\\n' 'server:'
|
||||
printf ' host: %s\\n' "$(yaml_quote "$SERVER_HOST")"
|
||||
printf ' port: %s\\n' "$SERVER_PORT"
|
||||
printf ' mode: %s\\n' "$(yaml_quote "$SERVER_MODE")"
|
||||
printf '%s\\n' 'database:'
|
||||
printf ' host: %s\\n' "$(yaml_quote "$DATABASE_HOST")"
|
||||
printf ' port: %s\\n' "$DATABASE_PORT"
|
||||
printf ' user: %s\\n' "$(yaml_quote "$DATABASE_USER")"
|
||||
printf ' password: %s\\n' "$(yaml_quote "$DATABASE_PASSWORD")"
|
||||
printf ' dbname: %s\\n' "$(yaml_quote "$DATABASE_DBNAME")"
|
||||
printf ' sslmode: %s\\n' "$(yaml_quote "$DATABASE_SSLMODE")"
|
||||
printf '%s\\n' 'redis:'
|
||||
printf ' host: %s\\n' "$(yaml_quote "$REDIS_HOST")"
|
||||
printf ' port: %s\\n' "$REDIS_PORT"
|
||||
printf ' password: %s\\n' "$(yaml_quote "$REDIS_PASSWORD")"
|
||||
printf ' db: %s\\n' "$REDIS_DB"
|
||||
printf ' enable_tls: %s\\n' "$REDIS_ENABLE_TLS"
|
||||
printf '%s\\n' 'jwt:'
|
||||
printf ' secret: %s\\n' "$(yaml_quote "$JWT_SECRET")"
|
||||
printf ' expire_hour: %s\\n' "$JWT_EXPIRE_HOUR"
|
||||
printf '%s\\n' 'default:'
|
||||
printf '%s\\n' ' user_concurrency: 5'
|
||||
printf '%s\\n' ' user_balance: 0'
|
||||
printf '%s\\n' " api_key_prefix: 'sk-'"
|
||||
printf '%s\\n' ' rate_multiplier: 1'
|
||||
printf '%s\\n' 'rate_limit:'
|
||||
printf '%s\\n' ' requests_per_minute: 60'
|
||||
printf '%s\\n' ' burst_size: 10'
|
||||
printf 'timezone: %s\\n' "$(yaml_quote "$TZ")"
|
||||
} > /app/data/config.yaml
|
||||
chmod 600 /app/data/config.yaml
|
||||
chown -R 1000:1000 /app/data 2>/dev/null || true
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: sub2api-config
|
||||
env:
|
||||
- name: DATABASE_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${database.secretName}
|
||||
key: ${database.passwordKey}
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ${database.secretName}
|
||||
key: JWT_SECRET
|
||||
volumeMounts:
|
||||
- name: sub2api-data
|
||||
mountPath: /app/data
|
||||
`;
|
||||
}
|
||||
|
||||
export function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
||||
const urlAllowlist = sub2api.security.urlAllowlist;
|
||||
const database = targetDatabase(sub2api, target);
|
||||
@@ -229,6 +301,7 @@ export function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiT
|
||||
const publicExposure = renderPublicExposureManifest(target);
|
||||
const egressProxy = renderEgressProxyManifest(target);
|
||||
const proxyEnv = sub2ApiProxyEnv(target);
|
||||
const runtimeConfigInitContainer = renderRuntimeConfigInitContainer(target, database, dependencyImages);
|
||||
const accountLocalProxyContainer = renderAccountLocalProxyContainer(target.accountLocalProxy);
|
||||
const accountLocalProxyVolume = renderAccountLocalProxyVolume(target.accountLocalProxy);
|
||||
return `apiVersion: v1
|
||||
@@ -270,7 +343,7 @@ metadata:
|
||||
app.kubernetes.io/managed-by: unidesk
|
||||
unidesk.ai/runtime-node: ${target.id}
|
||||
data:
|
||||
AUTO_SETUP: "true"
|
||||
AUTO_SETUP: "${target.runtime.autoSetup}"
|
||||
SERVER_HOST: "0.0.0.0"
|
||||
SERVER_PORT: "8080"
|
||||
SERVER_MODE: "release"
|
||||
@@ -465,6 +538,7 @@ spec:
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
initContainers:
|
||||
${runtimeConfigInitContainer}
|
||||
- name: wait-postgres
|
||||
image: ${dependencyImages.postgres}
|
||||
imagePullPolicy: IfNotPresent
|
||||
@@ -587,6 +661,7 @@ export function renderPublicExposureManifest(target: Sub2ApiTargetConfig): strin
|
||||
const exposure = target.publicExposure;
|
||||
if (exposure === null || !exposure.enabled) return "";
|
||||
if (exposure.mode === "pk01-local") return "";
|
||||
if (exposure.mode === "node-local-https") return "";
|
||||
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
|
||||
return `---
|
||||
apiVersion: apps/v1
|
||||
|
||||
@@ -138,6 +138,7 @@ function renderSub2ApiPlan(result: Record<string, unknown>): RenderedCliResult {
|
||||
["TARGET", stringValue(target.id), "route", stringValue(target.route)],
|
||||
["NAMESPACE", stringValue(target.namespace), "role", stringValue(target.role)],
|
||||
["DATABASE", stringValue(config.databaseMode), "redis", stringValue(config.redisMode)],
|
||||
["AUTO_SETUP", boolText(record(config.runtime).autoSetup), "mode", stringValue(config.runtimeMode)],
|
||||
["REPLICAS", `${stringValue(config.appReplicas)}/${stringValue(config.redisReplicas)}`, "service", stringValue(target.serviceDns)],
|
||||
["PUBLIC", boolText(exposure.enabled), "url", stringValue(exposure.publicBaseUrl, "-")],
|
||||
["EGRESS", boolText(egress.enabled), "source", `${stringValue(egress.sourceType, "-")} ${stringValue(egress.sourceFingerprint, "-")}`],
|
||||
@@ -163,6 +164,7 @@ function renderSub2ApiStatus(result: Record<string, unknown>): RenderedCliResult
|
||||
const summary = record(result.summary);
|
||||
const secret = record(summary.secret);
|
||||
const egress = record(summary.egressProxy);
|
||||
const localHttps = record(summary.localHttpsExposure);
|
||||
const networkPolicy = record(summary.networkPolicy);
|
||||
const checks = record(summary.checks);
|
||||
const isHostDocker = stringValue(summary.runtimeMode, "") === "host-docker";
|
||||
@@ -214,6 +216,7 @@ function renderSub2ApiStatus(result: Record<string, unknown>): RenderedCliResult
|
||||
["namespace", boolText(summary.namespaceExists), stringValue(summary.namespace)],
|
||||
["secret", boolText(secret.ready), `name=${stringValue(secret.name, "-")} missing=${arrayValues(secret.missingKeys).length}`],
|
||||
["egressProxy", boolText(egress.aligned), `enabled=${boolText(egress.enabled)} valuesPrinted=false`],
|
||||
["localHttps", boolText(localHttps.ok), stringValue(localHttps.publicBaseUrl, "not-enabled")],
|
||||
["networkPolicy", boolText(networkPolicy.ok), `required=${stringValue(networkPolicy.requiredName, "-")}`],
|
||||
]),
|
||||
"",
|
||||
|
||||
@@ -219,6 +219,8 @@ export function redactSubscriptionUri(uri: string): string {
|
||||
export async function applyPk01PublicExposure(config: UniDeskConfig, target: Sub2ApiTargetConfig): Promise<Record<string, unknown>> {
|
||||
const exposure = target.publicExposure;
|
||||
if (exposure === null) return { ok: true, action: "not-configured" };
|
||||
if (exposure.mode === "node-local-https") return { ok: true, action: "not-applicable-node-local-https" };
|
||||
if (exposure.pk01 === null) throw new Error(`publicExposure.pk01 is required for target ${target.id} when applying PK01 public exposure`);
|
||||
if (!exposure.enabled) return await removePk01PublicExposure(config, target, exposure);
|
||||
const start = await startPk01PublicExposureJob(config, target, exposure);
|
||||
if (!start.ok || typeof start.remoteJobId !== "string") {
|
||||
@@ -247,6 +249,7 @@ export async function applyPk01PublicExposure(config: UniDeskConfig, target: Sub
|
||||
}
|
||||
|
||||
export async function removePk01PublicExposure(config: UniDeskConfig, target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): Promise<Record<string, unknown>> {
|
||||
if (exposure.pk01 === null) throw new Error(`publicExposure.pk01 is required for target ${target.id} when removing PK01 public exposure`);
|
||||
const marker = sub2apiCaddyManagedMarker(target);
|
||||
const script = `
|
||||
set -u
|
||||
@@ -380,6 +383,7 @@ PY
|
||||
}
|
||||
|
||||
export async function startPk01PublicExposureJob(config: UniDeskConfig, target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): Promise<Record<string, unknown>> {
|
||||
if (exposure.pk01 === null) throw new Error(`publicExposure.pk01 is required for target ${target.id} when starting PK01 public exposure`);
|
||||
const jobId = `sub2api-pk01-exposure-${new Date().toISOString().replace(/[^0-9A-Za-z]/gu, "")}-${randomBytes(3).toString("hex")}`;
|
||||
const payload = pk01PublicExposureScript(target, exposure);
|
||||
const payloadB64 = Buffer.from(payload, "utf8").toString("base64");
|
||||
@@ -483,6 +487,7 @@ PY
|
||||
}
|
||||
|
||||
export async function waitPk01PublicExposureJob(config: UniDeskConfig, exposure: Sub2ApiPublicExposureConfig, remoteJobId: string): Promise<Record<string, unknown>> {
|
||||
if (exposure.pk01 === null) throw new Error("publicExposure.pk01 is required when waiting for PK01 public exposure");
|
||||
const observations: Array<Record<string, unknown>> = [];
|
||||
const maxPolls = 120;
|
||||
let lastProgressKey: string | null = null;
|
||||
@@ -524,6 +529,7 @@ export async function waitPk01PublicExposureJob(config: UniDeskConfig, exposure:
|
||||
}
|
||||
|
||||
export async function readPk01PublicExposureJob(config: UniDeskConfig, exposure: Sub2ApiPublicExposureConfig, remoteJobId: string): Promise<{ status: string | null; summary: Record<string, unknown> }> {
|
||||
if (exposure.pk01 === null) throw new Error("publicExposure.pk01 is required when reading PK01 public exposure job");
|
||||
const safeId = remoteJobId.replace(/[^A-Za-z0-9_.-]/gu, "");
|
||||
const script = `
|
||||
set +e
|
||||
@@ -569,6 +575,7 @@ if [ -f "$download_cache" ]; then stat -c 'cache_bytes=%s cache_mtime=%y' "$down
|
||||
}
|
||||
|
||||
export function pk01PublicExposureScript(target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): string {
|
||||
if (exposure.pk01 === null) throw new Error(`publicExposure.pk01 is required for target ${target.id} when rendering PK01 public exposure script`);
|
||||
const caddyfile = renderPk01Caddyfile(target, exposure);
|
||||
const serviceUnit = renderPk01CaddyService(exposure);
|
||||
const exposureMode = exposure.mode === "pk01-local" ? "pk01-caddy-local-docker" : "pk01-caddy-frp-direct";
|
||||
|
||||
@@ -96,6 +96,7 @@ export function preparePublicExposureSecret(sub2api: Sub2ApiConfig, target: Sub2
|
||||
const exposure = target.publicExposure;
|
||||
if (exposure === null || !exposure.enabled) return null;
|
||||
if (exposure.mode === "pk01-local") return null;
|
||||
if (exposure.mode === "node-local-https") return null;
|
||||
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
|
||||
return prepareFrpcSecret({
|
||||
secretRoot: secretRoot(sub2api),
|
||||
|
||||
@@ -24,6 +24,8 @@ export function statusScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig
|
||||
const externalPending = target.databaseMode === "external-pending";
|
||||
const externalActive = target.databaseMode === "external-active";
|
||||
const proxy = target.egressProxy?.enabled ? target.egressProxy : null;
|
||||
const localHttps = target.publicExposure?.enabled === true && target.publicExposure.mode === "node-local-https" ? target.publicExposure.localHttps : null;
|
||||
const localHttpsExposure = localHttps === null ? null : target.publicExposure;
|
||||
const expectedProxyEnv = sub2ApiProxyEnv(target);
|
||||
const cleanupPlan = managedResourceCleanupPlan(sub2api, target);
|
||||
const appSecretName = sub2api.runtime.database.secretName;
|
||||
@@ -52,6 +54,32 @@ capture_json cronjobs kubectl -n ${target.namespace} get cronjob -l app.kubernet
|
||||
capture_json ingresses kubectl -n ${target.namespace} get ingress
|
||||
capture_json quotas kubectl -n ${target.namespace} get resourcequota
|
||||
capture_json limitranges kubectl -n ${target.namespace} get limitrange
|
||||
local_https_rc=0
|
||||
local_https_ps_rc=0
|
||||
local_https_probe_rc=0
|
||||
local_https_action="not-enabled"
|
||||
local_https_ps=""
|
||||
local_https_probe_url=""
|
||||
if [ "${localHttps === null ? "false" : "true"}" = "true" ]; then
|
||||
local_https_action="inspect"
|
||||
local_https_ps="$(docker ps --filter ${localHttps === null ? "\"\"" : shQuote(`name=^/${localHttps.containerName}$`)} --format '{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' 2>"$tmp/local-https-ps.err" || true)"
|
||||
if [ -n "$local_https_ps" ]; then local_https_ps_rc=0; else local_https_ps_rc=1; fi
|
||||
printf '%s' "$local_https_ps" >"$tmp/local-https-ps.out"
|
||||
local_https_probe_url=${localHttpsExposure === null ? "\"\"" : shQuote(`${localHttpsExposure.publicBaseUrl}/health`)}
|
||||
curl --noproxy '*' -kfsS --connect-timeout 5 --max-time 20 --resolve ${localHttpsExposure === null || localHttps === null ? "\"\"" : shQuote(`${localHttpsExposure.dns.hostname}:${localHttps.httpsPort}:127.0.0.1`)} "$local_https_probe_url" >"$tmp/local-https-probe.out" 2>"$tmp/local-https-probe.err"
|
||||
local_https_probe_rc=$?
|
||||
if [ "$local_https_ps_rc" -ne 0 ] || [ "$local_https_probe_rc" -ne 0 ]; then local_https_rc=1; fi
|
||||
else
|
||||
: >"$tmp/local-https-ps.out"
|
||||
: >"$tmp/local-https-ps.err"
|
||||
: >"$tmp/local-https-probe.out"
|
||||
: >"$tmp/local-https-probe.err"
|
||||
fi
|
||||
printf '%s' "$local_https_rc" >"$tmp/local-https.rc"
|
||||
printf '%s' "$local_https_ps_rc" >"$tmp/local-https-ps.rc"
|
||||
printf '%s' "$local_https_probe_rc" >"$tmp/local-https-probe.rc"
|
||||
printf '%s' "$local_https_action" >"$tmp/local-https-action.txt"
|
||||
printf '%s' "$local_https_probe_url" >"$tmp/local-https-probe-url.txt"
|
||||
pod_name="$(kubectl -n ${target.namespace} get pod -l app.kubernetes.io/name=${serviceName},app.kubernetes.io/component=app -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/pod-name.err" || true)"
|
||||
printf '%s' "$pod_name" >"$tmp/pod-name.txt"
|
||||
if [ -n "$pod_name" ]; then
|
||||
@@ -90,10 +118,11 @@ def items(name):
|
||||
return []
|
||||
return data.get("items") or []
|
||||
|
||||
def text(name):
|
||||
def text(name, limit=None):
|
||||
path = os.path.join(tmp, name)
|
||||
try:
|
||||
return open(path, encoding="utf-8", errors="replace").read()
|
||||
data = open(path, encoding="utf-8", errors="replace").read()
|
||||
return data if limit is None else data[-limit:]
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
|
||||
@@ -383,7 +412,7 @@ else:
|
||||
state_model_ok = local_postgres_present and sub2api_desired_aligned and redis_desired_aligned
|
||||
status_label = "pending-external-db" if external_pending else "external-db-active" if external_active else "active"
|
||||
payload = {
|
||||
"ok": rc("ns") == 0 and workload_ready and image_aligned and url_allowlist_aligned and proxy_env_aligned and network_policy["ok"] and boundary["internalOnly"] and len(resource_violations) == 0 and boundary["resourceQuotaCount"] == 0 and boundary["limitRangeCount"] == 0 and secret_ok and state_model_ok,
|
||||
"ok": rc("ns") == 0 and workload_ready and image_aligned and url_allowlist_aligned and proxy_env_aligned and network_policy["ok"] and boundary["internalOnly"] and len(resource_violations) == 0 and boundary["resourceQuotaCount"] == 0 and boundary["limitRangeCount"] == 0 and secret_ok and state_model_ok and rc("local-https") == 0,
|
||||
"target": "${target.id}",
|
||||
"route": "${target.route}",
|
||||
"namespace": "${target.namespace}",
|
||||
@@ -399,6 +428,19 @@ payload = {
|
||||
"services": [service_summary(item) for item in services],
|
||||
"pvcs": [pvc_summary(item) for item in pvcs],
|
||||
"networkPolicy": network_policy,
|
||||
"localHttpsExposure": {
|
||||
"enabled": ${localHttps === null ? "False" : "True"},
|
||||
"action": text("local-https-action.txt"),
|
||||
"ok": rc("local-https") == 0,
|
||||
"mode": ${localHttps === null ? "None" : JSON.stringify("node-local-docker-caddy")},
|
||||
"publicBaseUrl": ${localHttpsExposure === null ? "None" : JSON.stringify(localHttpsExposure.publicBaseUrl)},
|
||||
"hostname": ${localHttpsExposure === null ? "None" : JSON.stringify(localHttpsExposure.dns.hostname)},
|
||||
"bind": ${localHttps === null ? "None" : JSON.stringify(`${localHttps.hostBind}:${localHttps.httpsPort}`)},
|
||||
"containerName": ${localHttps === null ? "None" : JSON.stringify(localHttps.containerName)},
|
||||
"ps": {"exitCode": rc("local-https-ps"), "stdout": text("local-https-ps.out"), "stderr": text("local-https-ps.err")},
|
||||
"probe": {"exitCode": rc("local-https-probe"), "url": text("local-https-probe-url.txt"), "stdout": text("local-https-probe.out", 2000), "stderr": text("local-https-probe.err", 2000)},
|
||||
"valuesPrinted": False,
|
||||
},
|
||||
"secret": {
|
||||
"name": "${appSecretName}",
|
||||
"exists": rc("secrets") == 0,
|
||||
|
||||
@@ -196,7 +196,7 @@ public_dns_rc=1
|
||||
done
|
||||
} >"$tmp/public-dns.out" 2>"$tmp/public-dns.err"
|
||||
if grep -Fx ${shQuote(exposure.dns.expectedA)} "$tmp/public-dns.out" >/dev/null 2>&1; then public_dns_rc=0; fi
|
||||
curl -fsS --max-time 20 ${shQuote(`${exposure.publicBaseUrl}/health`)} >"$tmp/public-probe.out" 2>"$tmp/public-probe.err"
|
||||
curl ${exposure.mode === "node-local-https" ? "--noproxy '*' -k" : ""} -fsS --max-time 20 ${shQuote(`${exposure.publicBaseUrl}/health`)} >"$tmp/public-probe.out" 2>"$tmp/public-probe.err"
|
||||
public_probe_rc=$?
|
||||
`;
|
||||
const egressProbeBlock = proxy === null ? `
|
||||
@@ -257,8 +257,8 @@ fi
|
||||
"publicBaseUrl": "${exposure.publicBaseUrl}",
|
||||
"hostname": "${exposure.dns.hostname}",
|
||||
"expectedA": "${exposure.dns.expectedA}",
|
||||
"mode": "pk01-caddy-frp-direct",
|
||||
"dataPath": "client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API",
|
||||
"mode": "${exposure.mode === "node-local-https" ? "node-local-docker-caddy" : "pk01-caddy-frp-direct"}",
|
||||
"dataPath": "${exposure.mode === "node-local-https" ? `client -> ${target.id} local HTTPS port ${exposure.localHttps?.httpsPort} -> ${target.id} Docker Caddy -> Sub2API ClusterIP Service` : `client -> PK01 Caddy -> PK01 frps remotePort -> ${target.id} frpc -> Sub2API`}",
|
||||
"dns": {
|
||||
"exitCode": public_dns_rc,
|
||||
"resolvers": ${JSON.stringify(exposure.dns.resolvers)},
|
||||
|
||||
Reference in New Issue
Block a user