This commit is contained in:
+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