@@ -0,0 +1,383 @@
import { Buffer } from "node:buffer" ;
import { existsSync } from "node:fs" ;
import { resolve } from "node:path" ;
import type { UniDeskConfig } from "./config" ;
import { repoRoot } from "./config" ;
import { renderMachine } from "./cicd-render" ;
import type { RenderedCliResult } from "./output" ;
import { CliInputError } from "./output" ;
import { capture , compactCapture , parseJsonOutput , readYamlRecord , sha256Fingerprint , shQuote } from "./platform-infra-ops-library" ;
type EdgeAction = "plan" | "apply" | "status" ;
interface EdgeOptions {
action : EdgeAction ;
configPath : string ;
confirm : boolean ;
}
interface EdgeSite {
id : string ;
hostname : string ;
upstream : string ;
healthPath : string ;
}
interface EdgeSpec {
route : string ;
publicAddress : string ;
caddy : {
image : string ;
workDir : string ;
composePath : string ;
caddyfilePath : string ;
dataDir : string ;
configDir : string ;
containerName : string ;
httpPort : number ;
httpsPort : number ;
protocols : string [ ] ;
responseHeaderTimeoutSeconds : number ;
} ;
sites : EdgeSite [ ] ;
tcpPortRelease : {
settingsPath : string ;
workDir : string ;
service : string ;
fromPort : number ;
toPort : number ;
applyCommand : string ;
} ;
legacyExposure : {
pk01 : { route : string ; caddyConfigPath : string ; caddyServiceName : string ; managedBlock : string ; hostname : string } ;
frpc : { route : string ; namespace : string ; deploymentName : string ; retirementBlocking : boolean } ;
} ;
}
export function pikaoaPublicEdgeHelp ( ) : Record < string , unknown > {
return {
ok : true ,
command : "pikaoa public-edge" ,
usage : [
"bun scripts/cli.ts pikaoa public-edge plan [--config path]" ,
"bun scripts/cli.ts pikaoa public-edge apply [--config path] --confirm" ,
"bun scripts/cli.ts pikaoa public-edge status [--config path]" ,
] ,
source : "config/pikaoa.yaml#publicEdge" ,
guarantees : [
"TCP 80/443、站点、upstream、Caddy 和 TCP 端口释放全部来自 owning YAML。" ,
"apply 保留 UDP 443;只迁移 YAML 声明的 TCP 服务端口。" ,
"旧 PK01 Caddy 托管块按 marker 精确删除;FRPC 退役仅为 non-blocking warning。" ,
] ,
} ;
}
export async function runPikaoaPublicEdgeCommand ( config : UniDeskConfig , args : string [ ] ) : Promise < RenderedCliResult > {
if ( args . length === 0 || args . some ( ( arg ) = > arg === "--help" || arg === "-h" ) ) {
return renderMachine ( "pikaoa public-edge" , pikaoaPublicEdgeHelp ( ) , "json" ) ;
}
const options = parseOptions ( args ) ;
const spec = readSpec ( options . configPath ) ;
if ( options . action === "plan" ) return renderMachine ( "pikaoa public-edge plan" , plan ( spec , options . configPath ) , "json" ) ;
if ( options . action === "status" ) return renderMachine ( "pikaoa public-edge status" , await status ( config , spec ) , "json" ) ;
if ( ! options . confirm ) throw inputError ( "apply 必须显式提供 --confirm" , "confirm-required" , "--confirm" ) ;
return renderMachine ( "pikaoa public-edge apply" , await apply ( config , spec ) , "json" ) ;
}
function parseOptions ( args : string [ ] ) : EdgeOptions {
const action = args [ 0 ] ;
if ( action !== "plan" && action !== "apply" && action !== "status" ) {
throw inputError ( "public-edge action 必须是 plan、apply 或 status" , "invalid-action" , action ? ? "<missing>" ) ;
}
let configPath = resolve ( repoRoot , "config/pikaoa.yaml" ) ;
let confirm = false ;
for ( let index = 1 ; index < args . length ; index += 1 ) {
const arg = args [ index ] ! ;
if ( arg === "--config" ) {
const value = args [ index + 1 ] ;
if ( value === undefined || value . startsWith ( "--" ) ) throw inputError ( "--config 需要参数" , "missing-option-value" , "--config" ) ;
configPath = resolve ( repoRoot , value ) ;
index += 1 ;
} else if ( arg === "--confirm" ) {
confirm = true ;
} else {
throw inputError ( ` 不支持的 public-edge 参数: ${ arg } ` , "unsupported-option" , arg ) ;
}
}
if ( ( action === "plan" || action === "status" ) && confirm ) throw inputError ( ` ${ action } 不接受 --confirm ` , "confirm-not-allowed" , "--confirm" ) ;
return { action , configPath , confirm } ;
}
function readSpec ( path : string ) : EdgeSpec {
if ( ! existsSync ( path ) ) throw inputError ( ` 找不到 owning YAML: ${ path } ` , "config-not-found" , "--config" ) ;
const root = readYamlRecord ( path , "pikaoa-platform-delivery" ) ;
const edge = record ( root . publicEdge , "publicEdge" ) ;
const target = record ( edge . target , "publicEdge.target" ) ;
const caddy = record ( edge . caddy , "publicEdge.caddy" ) ;
const sites = record ( edge . sites , "publicEdge.sites" ) ;
const tcp = record ( edge . tcpPortRelease , "publicEdge.tcpPortRelease" ) ;
const legacy = record ( edge . legacyExposure , "publicEdge.legacyExposure" ) ;
const pk01 = record ( legacy . pk01 , "publicEdge.legacyExposure.pk01" ) ;
const frpc = record ( legacy . frpc , "publicEdge.legacyExposure.frpc" ) ;
return {
route : string ( target . route , "publicEdge.target.route" ) ,
publicAddress : string ( target . publicAddress , "publicEdge.target.publicAddress" ) ,
caddy : {
image : string ( caddy . image , "publicEdge.caddy.image" ) ,
workDir : string ( caddy . workDir , "publicEdge.caddy.workDir" ) ,
composePath : string ( caddy . composePath , "publicEdge.caddy.composePath" ) ,
caddyfilePath : string ( caddy . caddyfilePath , "publicEdge.caddy.caddyfilePath" ) ,
dataDir : string ( caddy . dataDir , "publicEdge.caddy.dataDir" ) ,
configDir : string ( caddy . configDir , "publicEdge.caddy.configDir" ) ,
containerName : string ( caddy . containerName , "publicEdge.caddy.containerName" ) ,
httpPort : integer ( caddy . httpPort , "publicEdge.caddy.httpPort" ) ,
httpsPort : integer ( caddy . httpsPort , "publicEdge.caddy.httpsPort" ) ,
protocols : stringList ( caddy . protocols , "publicEdge.caddy.protocols" ) ,
responseHeaderTimeoutSeconds : integer ( caddy . responseHeaderTimeoutSeconds , "publicEdge.caddy.responseHeaderTimeoutSeconds" ) ,
} ,
sites : Object.entries ( sites ) . map ( ( [ id , value ] ) = > {
const site = record ( value , ` publicEdge.sites. ${ id } ` ) ;
return { id , hostname : hostname ( site . hostname , ` publicEdge.sites. ${ id } .hostname ` ) , upstream : string ( site . upstream , ` publicEdge.sites. ${ id } .upstream ` ) , healthPath : httpPath ( site . healthPath , ` publicEdge.sites. ${ id } .healthPath ` ) } ;
} ) ,
tcpPortRelease : {
settingsPath : string ( tcp . settingsPath , "publicEdge.tcpPortRelease.settingsPath" ) ,
workDir : string ( tcp . workDir , "publicEdge.tcpPortRelease.workDir" ) ,
service : string ( tcp . service , "publicEdge.tcpPortRelease.service" ) ,
fromPort : integer ( tcp . fromPort , "publicEdge.tcpPortRelease.fromPort" ) ,
toPort : integer ( tcp . toPort , "publicEdge.tcpPortRelease.toPort" ) ,
applyCommand : string ( tcp . applyCommand , "publicEdge.tcpPortRelease.applyCommand" ) ,
} ,
legacyExposure : {
pk01 : {
route : string ( pk01 . route , "publicEdge.legacyExposure.pk01.route" ) ,
caddyConfigPath : string ( pk01 . caddyConfigPath , "publicEdge.legacyExposure.pk01.caddyConfigPath" ) ,
caddyServiceName : string ( pk01 . caddyServiceName , "publicEdge.legacyExposure.pk01.caddyServiceName" ) ,
managedBlock : string ( pk01 . managedBlock , "publicEdge.legacyExposure.pk01.managedBlock" ) ,
hostname : hostname ( pk01 . hostname , "publicEdge.legacyExposure.pk01.hostname" ) ,
} ,
frpc : {
route : string ( frpc . route , "publicEdge.legacyExposure.frpc.route" ) ,
namespace : string ( frpc . namespace , "publicEdge.legacyExposure.frpc.namespace" ) ,
deploymentName : string ( frpc . deploymentName , "publicEdge.legacyExposure.frpc.deploymentName" ) ,
retirementBlocking : boolean ( frpc . retirementBlocking , "publicEdge.legacyExposure.frpc.retirementBlocking" ) ,
} ,
} ,
} ;
}
function plan ( spec : EdgeSpec , configPath : string ) : Record < string , unknown > {
return {
ok : true ,
mutation : false ,
configRef : ` ${ configPath } #publicEdge ` ,
target : { route : spec.route , publicAddress : spec.publicAddress } ,
listeners : { tcp : [ spec . caddy . httpPort , spec . caddy . httpsPort ] , udpPreserved : [ spec . caddy . httpsPort ] } ,
sites : spec.sites ,
tcpPortRelease : { service : spec.tcpPortRelease.service , fromPort : spec.tcpPortRelease.fromPort , toPort : spec.tcpPortRelease.toPort } ,
legacyExposure : { hostname : spec.legacyExposure.pk01.hostname , managedBlock : spec.legacyExposure.pk01.managedBlock , frpcRetirementBlocking : false } ,
renderedFingerprint : sha256Fingerprint ( ` ${ renderCaddyfile ( spec ) } \ n ${ renderCompose ( spec ) } ` ) ,
valuesPrinted : false ,
} ;
}
async function apply ( config : UniDeskConfig , spec : EdgeSpec ) : Promise < Record < string , unknown > > {
const edgeResult = await capture ( config , spec . route , [ "sh" ] , edgeApplyScript ( spec ) , { runtimeTimeoutMs : 240_000 } ) ;
const edge = parseJsonOutput ( edgeResult . stdout ) ? ? { ok : false , capture : compactCapture ( edgeResult , { full : true } ) } ;
if ( edgeResult . exitCode !== 0 || edge . ok === false ) return { ok : false , mutation : true , edge , valuesPrinted : false } ;
const legacyResult = await capture ( config , spec . legacyExposure . pk01 . route , [ "sh" ] , legacyRemovalScript ( spec ) , { runtimeTimeoutMs : 60_000 } ) ;
const legacy = parseJsonOutput ( legacyResult . stdout ) ? ? { ok : false , capture : compactCapture ( legacyResult , { full : true } ) } ;
const frpcResult = await capture ( config , spec . legacyExposure . frpc . route , [ "kubectl" , "get" , "deployment" , "-n" , spec . legacyExposure . frpc . namespace , spec . legacyExposure . frpc . deploymentName , "-o" , "name" ] , "" ) ;
const warnings = frpcResult . exitCode === 0 ? [ { code : "legacy-frpc-still-present" , blocking : false , object : frpcResult . stdout . trim ( ) } ] : [ ] ;
return { ok : legacyResult.exitCode === 0 && legacy . ok !== false , mutation : true , edge , legacy , warnings , valuesPrinted : false } ;
}
async function status ( config : UniDeskConfig , spec : EdgeSpec ) : Promise < Record < string , unknown > > {
const edgeResult = await capture ( config , spec . route , [ "sh" ] , edgeStatusScript ( spec ) ) ;
const legacyResult = await capture ( config , spec . legacyExposure . pk01 . route , [ "sh" ] , legacyStatusScript ( spec ) ) ;
const edge = parseJsonOutput ( edgeResult . stdout ) ? ? { ok : false , capture : compactCapture ( edgeResult , { full : true } ) } ;
const legacy = parseJsonOutput ( legacyResult . stdout ) ? ? { ok : false , capture : compactCapture ( legacyResult , { full : true } ) } ;
return { ok : edgeResult.exitCode === 0 && legacyResult . exitCode === 0 && edge . ok !== false && legacy . ok !== false , mutation : false , edge , legacy , valuesPrinted : false } ;
}
function edgeApplyScript ( spec : EdgeSpec ) : string {
const caddyfile = Buffer . from ( renderCaddyfile ( spec ) , "utf8" ) . toString ( "base64" ) ;
const compose = Buffer . from ( renderCompose ( spec ) , "utf8" ) . toString ( "base64" ) ;
return ` set -u
tmp=" $ (mktemp -d)"
trap 'rm -rf " $ tmp"' EXIT
settings= ${ shQuote ( spec . tcpPortRelease . settingsPath ) }
python3 - " $ settings" ${ shQuote ( spec . tcpPortRelease . service ) } ${ spec . tcpPortRelease . fromPort } ${ spec . tcpPortRelease . toPort } >" $ tmp/port.json" <<'PY'
import json, sys
path, service, old, new = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4])
text = open(path, encoding="utf-8").read()
lines = text.splitlines(keepends=True)
inside = False
changed = False
seen = False
for i, line in enumerate(lines):
if line.rstrip() == f"{service}:":
inside = True
continue
if inside and line and not line[0].isspace():
inside = False
if inside and line.strip() == f"port: {new}":
seen = True
if inside and line.strip() == f"port: {old}":
prefix = line[:len(line) - len(line.lstrip())]
newline = " \\ n" if line.endswith(" \\ n") else ""
lines[i] = f"{prefix}port: {new}{newline}"
changed = True
seen = True
if not seen:
raise SystemExit(f"{service} port is neither {old} nor {new}")
if changed:
open(path, "w", encoding="utf-8").write("".join(lines))
print(json.dumps({"ok": True, "service": service, "fromPort": old, "toPort": new, "changed": changed}))
PY
port_rc= $ ?
if [ " $ port_rc" -eq 0 ] && grep -q '"changed": true' " $ tmp/port.json"; then
cd ${ shQuote ( spec . tcpPortRelease . workDir ) }
${ spec . tcpPortRelease . applyCommand } >" $ tmp/vpn.out" 2>" $ tmp/vpn.err"
vpn_rc= $ ?
elif [ " $ port_rc" -eq 0 ]; then
: >" $ tmp/vpn.out"; : >" $ tmp/vpn.err"; vpn_rc=0
else
: >" $ tmp/vpn.out"; : >" $ tmp/vpn.err"; vpn_rc=1
fi
mkdir -p ${ shQuote ( spec . caddy . workDir ) } ${ shQuote ( spec . caddy . dataDir ) } ${ shQuote ( spec . caddy . configDir ) }
printf '%s' ' ${ caddyfile } ' | base64 -d > ${ shQuote ( spec . caddy . caddyfilePath ) }
printf '%s' ' ${ compose } ' | base64 -d > ${ shQuote ( spec . caddy . composePath ) }
if [ " $ vpn_rc" -eq 0 ]; then
docker compose -f ${ shQuote ( spec . caddy . composePath ) } up -d >" $ tmp/caddy.out" 2>" $ tmp/caddy.err"
caddy_rc= $ ?
else
: >" $ tmp/caddy.out"; : >" $ tmp/caddy.err"; caddy_rc=1
fi
sleep 2
probe_rc=0
for item in ${ spec . sites . map ( ( site ) = > shQuote ( ` ${ site . hostname } | ${ site . healthPath } ` ) ) . join ( " " ) } ; do
host=" ${ '${item%%|*}' } "; path="/ ${ '${item#*|}' } "; path="/ ${ '${path#/}' } "
curl --noproxy '*' -fsS --connect-timeout 5 --max-time 30 --resolve " $ host: ${ spec . caddy . httpsPort } :127.0.0.1" "https:// $ host $ path" >/dev/null 2>" $ tmp/probe- $ host.err" || probe_rc=1
done
python3 - " $ port_rc" " $ vpn_rc" " $ caddy_rc" " $ probe_rc" " $ tmp/port.json" <<'PY'
import json, sys
rcs = [int(x) for x in sys.argv[1:5]]
try:
port = json.load(open(sys.argv[5], encoding="utf-8"))
except Exception:
port = {"ok": False}
print(json.dumps({"ok": all(x == 0 for x in rcs), "steps": {"tcpPortRelease": {"exitCode": rcs[0], **port}, "vpnApply": {"exitCode": rcs[1], "outputRedacted": True}, "caddyApply": {"exitCode": rcs[2]}, "siteProbes": {"exitCode": rcs[3]}}, "valuesPrinted": False}))
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
PY
` ;
}
function edgeStatusScript ( spec : EdgeSpec ) : string {
return ` set -u
container_rc=0; docker inspect -f '{{.State.Running}}' ${ shQuote ( spec . caddy . containerName ) } 2>/dev/null | grep -qx true || container_rc=1
ports_rc=0; ss -ltn | grep -Eq '[:.] ${ spec . caddy . httpPort } [[:space:]]' || ports_rc=1; ss -ltn | grep -Eq '[:.] ${ spec . caddy . httpsPort } [[:space:]]' || ports_rc=1
sites_rc=0
for item in ${ spec . sites . map ( ( site ) = > shQuote ( ` ${ site . hostname } | ${ site . healthPath } ` ) ) . join ( " " ) } ; do
host=" ${ '${item%%|*}' } "; path="/ ${ '${item#*|}' } "; path="/ ${ '${path#/}' } "
curl --noproxy '*' -fsS --connect-timeout 5 --max-time 20 --resolve " $ host: ${ spec . caddy . httpsPort } :127.0.0.1" "https:// $ host $ path" >/dev/null || sites_rc=1
done
xray_rc=0; ss -ltn | grep -Eq '[:.] ${ spec . tcpPortRelease . toPort } [[:space:]]' || xray_rc=1
python3 - " $ container_rc" " $ ports_rc" " $ sites_rc" " $ xray_rc" <<'PY'
import json, sys
rcs = [int(x) for x in sys.argv[1:]]
print(json.dumps({"ok": all(x == 0 for x in rcs), "container": {"exitCode": rcs[0]}, "tcpListeners": {"exitCode": rcs[1]}, "siteProbes": {"exitCode": rcs[2]}, "releasedService": {"exitCode": rcs[3]}, "valuesPrinted": False}))
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
PY
` ;
}
function legacyRemovalScript ( spec : EdgeSpec ) : string {
const legacy = spec . legacyExposure . pk01 ;
return ` set -u
tmp=" $ (mktemp -d)"; trap 'rm -rf " $ tmp"' EXIT
python3 - ${ shQuote ( legacy . caddyConfigPath ) } ${ shQuote ( legacy . managedBlock ) } " $ tmp/Caddyfile" <<'PY'
import re, sys
path, marker, out = sys.argv[1:]
text = open(path, encoding="utf-8").read()
pattern = re.compile(rf"(?ms)^# BEGIN unidesk managed {re.escape(marker)} \\ n.*?^# END unidesk managed {re.escape(marker)} \\ n?")
next_text, count = pattern.subn("", text)
open(out, "w", encoding="utf-8").write(next_text)
print(count)
PY
remove_rc= $ ?
if [ " $ remove_rc" -eq 0 ]; then caddy fmt --overwrite " $ tmp/Caddyfile" >/dev/null 2>" $ tmp/fmt.err"; fmt_rc= $ ?; else fmt_rc=1; fi
if [ " $ fmt_rc" -eq 0 ]; then caddy validate --config " $ tmp/Caddyfile" >/dev/null 2>" $ tmp/validate.err"; validate_rc= $ ?; else validate_rc=1; fi
if [ " $ validate_rc" -eq 0 ]; then install -m 0644 " $ tmp/Caddyfile" ${ shQuote ( legacy . caddyConfigPath ) } ; install_rc= $ ?; else install_rc=1; fi
if [ " $ install_rc" -eq 0 ]; then systemctl reload ${ shQuote ( legacy . caddyServiceName ) } ; reload_rc= $ ?; else reload_rc=1; fi
python3 - " $ remove_rc" " $ fmt_rc" " $ validate_rc" " $ install_rc" " $ reload_rc" <<'PY'
import json, sys
rcs = [int(x) for x in sys.argv[1:]]
print(json.dumps({"ok": all(x == 0 for x in rcs), "managedBlockRemoved": all(x == 0 for x in rcs), "steps": rcs, "valuesPrinted": False}))
raise SystemExit(0 if all(x == 0 for x in rcs) else 1)
PY
` ;
}
function legacyStatusScript ( spec : EdgeSpec ) : string {
const legacy = spec . legacyExposure . pk01 ;
return ` set -u
if grep -Fq ${ shQuote ( ` # BEGIN unidesk managed ${ legacy . managedBlock } ` ) } ${ shQuote ( legacy . caddyConfigPath ) } ; then present=true; rc=1; else present=false; rc=0; fi
python3 - " $ present" " $ rc" <<'PY'
import json, sys
present = sys.argv[1] == "true"; rc = int(sys.argv[2])
print(json.dumps({"ok": rc == 0, "legacyManagedBlockPresent": present, "valuesPrinted": False}))
raise SystemExit(rc)
PY
` ;
}
function renderCaddyfile ( spec : EdgeSpec ) : string {
const global = ` { \ n servers { \ n protocols ${ spec . caddy . protocols . join ( " " ) } \ n } \ n} \ n ` ;
return global + spec . sites . map ( ( site ) = > ` ${ site . hostname } { \ n reverse_proxy ${ site . upstream } { \ n transport http { \ n response_header_timeout ${ spec . caddy . responseHeaderTimeoutSeconds } s \ n } \ n } \ n} ` ) . join ( "\n\n" ) + "\n" ;
}
function renderCompose ( spec : EdgeSpec ) : string {
return ` services: \ n caddy: \ n image: ${ spec . caddy . image } \ n container_name: ${ spec . caddy . containerName } \ n network_mode: host \ n restart: unless-stopped \ n volumes: \ n - ${ spec . caddy . caddyfilePath } :/etc/caddy/Caddyfile:ro \ n - ${ spec . caddy . dataDir } :/data \ n - ${ spec . caddy . configDir } :/config \ n ` ;
}
function record ( value : unknown , path : string ) : Record < string , unknown > {
if ( typeof value !== "object" || value === null || Array . isArray ( value ) ) throw inputError ( ` ${ path } 必须是对象 ` , "invalid-config" , path ) ;
return value as Record < string , unknown > ;
}
function string ( value : unknown , path : string ) : string {
if ( typeof value !== "string" || value . trim ( ) . length === 0 ) throw inputError ( ` ${ path } 必须是非空字符串 ` , "invalid-config" , path ) ;
return value . trim ( ) ;
}
function integer ( value : unknown , path : string ) : number {
if ( ! Number . isInteger ( value ) || ( value as number ) < 1 || ( value as number ) > 65 _535 ) throw inputError ( ` ${ path } 必须是 1..65535 整数 ` , "invalid-config" , path ) ;
return value as number ;
}
function boolean ( value : unknown , path : string ) : boolean {
if ( typeof value !== "boolean" ) throw inputError ( ` ${ path } 必须是布尔值 ` , "invalid-config" , path ) ;
return value ;
}
function stringList ( value : unknown , path : string ) : string [ ] {
if ( ! Array . isArray ( value ) || value . length === 0 ) throw inputError ( ` ${ path } 必须是非空字符串数组 ` , "invalid-config" , path ) ;
return value . map ( ( item , index ) = > string ( item , ` ${ path } [ ${ index } ] ` ) ) ;
}
function hostname ( value : unknown , path : string ) : string {
const result = string ( value , path ) ;
if ( ! / ^ [ a - z 0 - 9 ] ( ? : [ a - z 0 - 9 . - ] * [ a - z 0 - 9 ] ) ? $ / u . t e s t ( r e s u l t ) ) t h r o w i n p u t E r r o r ( ` $ { p a t h } 不 是 合 法 h o s t n a m e ` , " i n v a l i d - c o n f i g " , p a t h ) ;
return result ;
}
function httpPath ( value : unknown , path : string ) : string {
const result = string ( value , path ) ;
if ( ! result . startsWith ( "/" ) ) throw inputError ( ` ${ path } 必须以 / 开头 ` , "invalid-config" , path ) ;
return result ;
}
function inputError ( message : string , code : string , argument? : string ) : CliInputError {
return new CliInputError ( message , { code , argument , usage : "bun scripts/cli.ts pikaoa public-edge plan|apply|status" } ) ;
}