@synopackageland/cli 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.1 (2026-10-22)
4
+
5
+ - Build SDK/App 0.23.1 with its verified immutable executor/v0.20.0 digests; retain support for SDK/App 0.23.0. This enables the SDK HTTP transfer error-status fix without changing executor or lifecycle payloads.
6
+ - Include the native HTTPS shutdown/start port-release fix and recognize its exact diagnostic prefix when inspecting Portal and background-job lifecycle scripts. Keep templates 0.23.0 and the existing UI pin.
7
+
8
+ ## 0.23.0 (2026-10-21)
9
+
10
+ - Validate the explicit OIDC `browser_redirect` contract before packaging and pin templates/SDK 0.23.0.
11
+ - Reuse executor/v0.20.0 with unchanged executor and lifecycle payloads.
12
+
3
13
  ## 0.22.0 (2026-10-21)
4
14
 
5
15
  - Support SDK/App 0.22.x and pin templates 0.22.0 with published UI 0.12.5.
@@ -2,7 +2,7 @@ import { COMPOSE_ERRORS } from "./standalone/compose-errors.js";
2
2
  export const ERROR_REGISTRY = {
3
3
  ...COMPOSE_ERRORS,
4
4
  INSTALL_SETTINGS_INVALID: { "code": "INSTALL_SETTINGS_INVALID", "description": "The installation setting declaration is invalid.", "suggestion": "Declare 1–32 lowercase identifier keys in install.settings, each with type https-origin, a plain label and an explicit boolean required; omit defaults and values." },
5
- SDK_INSTALL_SETTINGS_UNSUPPORTED: { "code": "SDK_INSTALL_SETTINGS_UNSUPPORTED", "description": "The selected SDK does not provide native installation settings.", "suggestion": "Pin SDK 0.22.0 or a matching supported release with installsettings/cmd/control, then rebuild with the matching CLI." },
5
+ SDK_INSTALL_SETTINGS_UNSUPPORTED: { "code": "SDK_INSTALL_SETTINGS_UNSUPPORTED", "description": "The selected SDK does not provide native installation settings.", "suggestion": "Pin SDK 0.23.0 or a matching supported release with installsettings/cmd/control, then rebuild with the matching CLI." },
6
6
  SDK_INSTALL_SETTINGS_BUILD_FAILED: { "code": "SDK_INSTALL_SETTINGS_BUILD_FAILED", "description": "The SDK installation settings controller could not be built.", "suggestion": "Install the pinned Go toolchain, inspect the compiler diagnostics and rebuild with a complete matching SDK release." },
7
7
  INIT_HEALTH_INVALID: { code: "INIT_HEALTH_INVALID", description: "The Compose readiness selection is invalid.", suggestion: "Supply both --health-path and --health-status with the actual endpoint and expected 2xx response; redirects are not followed." },
8
8
  INIT_UPSTREAM_IMAGE_REQUIRED: { code: "INIT_UPSTREAM_IMAGE_REQUIRED", description: "The official upstream image repository was not selected.", suggestion: "Use init --upstream-fork --upstream-image <official-repository> for Compose." },
@@ -1,5 +1,16 @@
1
1
  {
2
- "0.22.0": {
2
+ "0.23.0": {
3
+ "x86_64": "e27cfeb85fed221b06f9c58166f02c0f701a724308cd17c96a43a843a0b5c4aa",
4
+ "aarch64": "908db00cbd37d072f6ffc339e7bca24da5efb8cddd4befcbad02c7398beb7191",
5
+ "armv7": "a0f35d62ec80a1bf3fb49f5e2d1a5f7576eda569a6fd0695084cec6cb8e100bc",
6
+ "lifecycle": {
7
+ "x86_64": "9bb53301670b43cdcd6e21cebac99ab7abd82962d874f32423a81e6cb49d4972",
8
+ "aarch64": "38fe873c86eb102c850baa1f304114a83a31d1df72defd22792bc37610fec9bb",
9
+ "armv7": "256fb348b275da7ac5728a3cb7cb7c65660de0d5c2c59430ab4f5ade1b0a3681"
10
+ },
11
+ "goToolchain": "go1.26.9"
12
+ },
13
+ "0.23.1": {
3
14
  "x86_64": "e27cfeb85fed221b06f9c58166f02c0f701a724308cd17c96a43a843a0b5c4aa",
4
15
  "aarch64": "908db00cbd37d072f6ffc339e7bca24da5efb8cddd4befcbad02c7398beb7191",
5
16
  "armv7": "a0f35d62ec80a1bf3fb49f5e2d1a5f7576eda569a6fd0695084cec6cb8e100bc",
@@ -76,7 +76,7 @@ function goMod(backendDir) {
76
76
  }
77
77
  }
78
78
  /** SDK releases that reuse an immutable executor release with identical payload bytes. */
79
- const EXECUTOR_RELEASES = { '0.21.0': '0.20.0', '0.21.1': '0.20.0', '0.22.0': '0.20.0' };
79
+ const EXECUTOR_RELEASES = { '0.21.0': '0.20.0', '0.21.1': '0.20.0', '0.22.0': '0.20.0', '0.23.0': '0.20.0', '0.23.1': '0.20.0' };
80
80
  /**
81
81
  * The module directories of one SDK version. SYNOPKGLAND_SDK_DIR names a
82
82
  * local sdk checkout (development and gates only); otherwise modules come from
@@ -59,6 +59,10 @@ export interface IdentityGateway {
59
59
  roles?: GatewayRoles;
60
60
  headers?: Partial<Record<"user" | "email" | "name" | "groups" | "uid", string>>;
61
61
  oidc?: {
62
+ browser_redirect?: {
63
+ html_paths?: string[];
64
+ same_origin_referrer?: boolean;
65
+ };
62
66
  native_login?: boolean;
63
67
  issuer_port: number;
64
68
  browser?: GatewayBrowser;
@@ -291,7 +291,7 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
291
291
  if (v.mode === "oidc") {
292
292
  const o = v.oidc;
293
293
  if (!record(o) ||
294
- !only(o, ["issuer_port", "clients", "rewrite_json", "browser", "native_login"]) ||
294
+ !only(o, ["issuer_port", "clients", "rewrite_json", "browser", "native_login", "browser_redirect"]) ||
295
295
  (o.native_login !== undefined && typeof o.native_login !== "boolean") ||
296
296
  !port(o.issuer_port) ||
297
297
  !Array.isArray(o.clients) ||
@@ -312,6 +312,14 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
312
312
  name(r.field))) {
313
313
  return invalid("oidc.rewrite_json entries are { method, path, field }.");
314
314
  }
315
+ const br = o.browser_redirect;
316
+ if (br !== undefined) {
317
+ const path = (p) => localPath(p) && p.length <= 512 && !/[%\s]/.test(p) && !p.startsWith("/_sso");
318
+ if (!record(br) || !only(br, ["html_paths", "same_origin_referrer"]) ||
319
+ (br.same_origin_referrer !== undefined && typeof br.same_origin_referrer !== "boolean") ||
320
+ (br.html_paths !== undefined && (!strings(br.html_paths, path, 16) || new Set(br.html_paths).size !== br.html_paths.length)))
321
+ return invalid("oidc.browser_redirect accepts only optional exact html_paths outside /_sso and same_origin_referrer.");
322
+ }
315
323
  const b = o.browser;
316
324
  if (b !== undefined) {
317
325
  const [major, minor] = v.sdk.split(".").map(Number);
@@ -334,6 +342,7 @@ export function readIdentityGateway(value, daemon, fail, packagedPayload = false
334
342
  return invalid("oidc.browser needs fixed local start, login_paths and callback_paths outside /_sso.");
335
343
  }
336
344
  gateway.oidc = {
345
+ ...(br !== undefined ? { browser_redirect: br } : {}),
337
346
  ...(o.native_login !== undefined ? { native_login: o.native_login } : {}),
338
347
  issuer_port: o.issuer_port,
339
348
  ...(b !== undefined ? { browser: b } : {}),
@@ -481,6 +490,11 @@ export async function buildIdentityGateway(m, arch, work, run) {
481
490
  throw new StandaloneBuildError("SDK_DOWNLOAD_FAILED", error.message.replace(/^\[[A-Z_]+\] /, ""));
482
491
  }
483
492
  runtimeBudgetSource(sdk.goDir, m);
493
+ if (m.identityGateway.oidc?.browser_redirect) {
494
+ const capability = join(sdk.goDir, "idgateway/browser_redirect.go");
495
+ if (!existsSync(capability) || !/const BrowserRedirectVersion = 2\b/.test(readFileSync(capability, "utf8")))
496
+ throw new StandaloneBuildError("STANDALONE_IDENTITY_GATEWAY_INVALID", "The selected SDK lacks per-request OIDC browser redirect support; use a matching SDK release or an explicit development source override.");
497
+ }
484
498
  if (m.identityGateway.upstream_access === "public") {
485
499
  const capability = join(sdk.goDir, "idgateway/provider_only.go");
486
500
  if (!existsSync(capability) || !/const ProviderOnlyOIDCVersion = 1\b/.test(readFileSync(capability, "utf8")))
@@ -1,6 +1,6 @@
1
1
  import { readInstallSettings, settingsInstallSteps, settingsConfig, renderSettingsHook } from './install-settings.js';
2
2
  import { nativePortal } from "./portal.js";
3
- import { nativeGatewaySupported, renderNativeGatewayConfig, renderNativeGatewayPrepare, NATIVE_GATEWAY_CONFIG, gatewayEntryPath } from './native-gateway.js';
3
+ import { nativeGatewaySupported, renderNativeGatewayConfig, renderNativeGatewayDiagnostic, renderNativeGatewayPrepare, NATIVE_GATEWAY_CONFIG, gatewayEntryPath } from './native-gateway.js';
4
4
  import { inspectPortalInfo } from "../portal/info.js";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { inspectApiDescription } from "./api-description-inspect.js";
@@ -36,7 +36,7 @@ import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
36
36
  import { renderUiConfig } from "./dsm-window.js";
37
37
  import { identityGatewayConfig, gatewayAdmissionReport, gatewayAdmissionPolicy, readIdentityGateway, } from "./identity-gateway.js";
38
38
  import { checkCgiBinary } from "./binary.js";
39
- import { renderStandaloneScript } from "./lifecycle.js";
39
+ import { matchesStandaloneScript, renderStandaloneScript } from "./lifecycle.js";
40
40
  import { listPortExposures, loadCompose } from "../compose/analyze.js";
41
41
  import { uninstallCleanup, } from "../runtime/uninstall-cleanup.js";
42
42
  import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep, } from "../portal/package.js";
@@ -209,7 +209,7 @@ export async function inspectSpk(spkPath, expectedKind) {
209
209
  ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined, nativeGateway, uninstallHook?.uninstall)
210
210
  : undefined;
211
211
  if (background)
212
- checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go", metadata.lifecycleTimeouts);
212
+ checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go", metadata.lifecycleTimeouts, nativeGateway);
213
213
  let executor;
214
214
  if ((metadata.capabilities ?? []).some((name) => name === "rootOps" || name === "delegate" || name === "webapi") ||
215
215
  paths.has("executor/executor")) {
@@ -701,8 +701,7 @@ async function inspectUnifiedCompose(dir, payload, metadata, findings) {
701
701
  "postupgrade",
702
702
  "start-stop-status",
703
703
  ])
704
- if (readOptional(join(dir, "scripts", hook)) !==
705
- renderStandaloneScript(hook, manifest))
704
+ if (!matchesStandaloneScript(hook, manifest, readOptional(join(dir, "scripts", hook))))
706
705
  throw new Error(`unexpected scripts/${hook}`);
707
706
  if (appScripts.length)
708
707
  base.appScripts = appScripts;
@@ -897,8 +896,7 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
897
896
  if (readFileSync(join(unpacked, "daemon/config.json"), "utf8") !== JSON.stringify(expectedConfig) + '\n')
898
897
  throw new Error("non-canonical daemon config");
899
898
  for (const hook of ["start-stop-status", "preupgrade", "preuninst"]) {
900
- if (readFileSync(join(dir, "scripts", hook), "utf8") !==
901
- renderStandaloneScript(hook, nativeGateway ? { ...manifest, os_min_ver: info.os_min_ver, identityGateway: nativeGateway } : manifest))
899
+ if (!matchesStandaloneScript(hook, nativeGateway ? { ...manifest, os_min_ver: info.os_min_ver, identityGateway: nativeGateway } : manifest, readFileSync(join(dir, "scripts", hook), "utf8")))
902
900
  findings.push(`daemon scripts/${hook} does not match the fixed lifecycle`);
903
901
  }
904
902
  return daemon;
@@ -1120,7 +1118,7 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, c
1120
1118
  }
1121
1119
  }
1122
1120
  /** The opt-in package user unit must be exactly what the generator writes. */
1123
- function checkBackground(dir, info, payload, findings, executor, daemon, bridge = false, lifecycleTimeouts) {
1121
+ function checkBackground(dir, info, payload, findings, executor, daemon, bridge = false, lifecycleTimeouts, nativeGateway) {
1124
1122
  const pkg = info.package;
1125
1123
  if (!pkg)
1126
1124
  return;
@@ -1141,16 +1139,17 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
1141
1139
  renderBackgroundUnit(pkg, bridge ? BRIDGE_WORKER_ARGS : [], daemon?.timeouts.stop ?? lifecycleTimeouts?.stop ?? 30)) {
1142
1140
  findings.push(`${BACKGROUND_UNIT_DIR} must contain only the fixed ${unit} (runs as the package account, no User=, no root)`);
1143
1141
  }
1144
- if (read("scripts/start-stop-status") !==
1145
- renderStandaloneScript("start-stop-status", {
1146
- package: pkg,
1147
- lifecycleTimeouts,
1148
- ...(daemon ? { daemon } : {}),
1149
- capabilities: {
1150
- jobs: { worker: "sha256-v1" },
1151
- ...(executor ? { rootOps: true } : {}),
1152
- },
1153
- }))
1142
+ if (!matchesStandaloneScript("start-stop-status", {
1143
+ package: pkg,
1144
+ os_min_ver: info.os_min_ver,
1145
+ ...(nativeGateway ? { identityGateway: nativeGateway } : {}),
1146
+ lifecycleTimeouts,
1147
+ ...(daemon ? { daemon } : {}),
1148
+ capabilities: {
1149
+ jobs: { worker: "sha256-v1" },
1150
+ ...(executor ? { rootOps: true } : {}),
1151
+ },
1152
+ }, read("scripts/start-stop-status")))
1154
1153
  findings.push("background scripts/start-stop-status does not match the fixed synosystemctl lifecycle");
1155
1154
  }
1156
1155
  /**
@@ -1204,16 +1203,14 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal,
1204
1203
  findings.push(`WIZARD_UIFILES/${name} is not a wizard file produced by ${source}`);
1205
1204
  if (selected) {
1206
1205
  for (const hook of ["preinst", "postinst"]) {
1207
- if (readOptional(join(dir, "scripts", hook)) !==
1208
- renderStandaloneScript(hook, lifecycleManifest))
1206
+ if (!matchesStandaloneScript(hook, lifecycleManifest, readOptional(join(dir, "scripts", hook))))
1209
1207
  findings.push(`scripts/${hook} does not match the fixed install-selected draft handling`);
1210
1208
  }
1211
1209
  }
1212
1210
  if (!folder && !portal)
1213
1211
  return;
1214
1212
  for (const hook of ["postinst", "postupgrade"]) {
1215
- if (readOptional(join(dir, "scripts", hook)) !==
1216
- renderStandaloneScript(hook, lifecycleManifest)) {
1213
+ if (!matchesStandaloneScript(hook, lifecycleManifest, readOptional(join(dir, "scripts", hook)))) {
1217
1214
  findings.push(`scripts/${hook} does not match the fixed script declared by ${folder ? "install.sharedFolder" : "portal"}`);
1218
1215
  }
1219
1216
  }
@@ -1313,7 +1310,15 @@ async function recoverPortal(dir, info, appId, paths, payload, findings, nativeG
1313
1310
  identityGateway: nativeGateway,
1314
1311
  })
1315
1312
  : "";
1316
- if (!readOptional(join(dir, "scripts", hook))?.startsWith(`#!/bin/sh\n${prepare}${renderPortalLifecycle(hook, portal)}`))
1313
+ const diagnostic = renderNativeGatewayDiagnostic({
1314
+ package: info.package,
1315
+ os_min_ver: info.os_min_ver,
1316
+ identityGateway: nativeGateway,
1317
+ }, hook);
1318
+ const script = readOptional(join(dir, "scripts", hook));
1319
+ // The fixed native diagnostics precede preparation in current scripts;
1320
+ // released scripts can still start directly with preparation.
1321
+ if (![diagnostic, ""].some(prefix => script?.startsWith(`#!/bin/sh\n${prefix}${prepare}${renderPortalLifecycle(hook, portal)}`)))
1317
1322
  throw new Error(hook);
1318
1323
  }
1319
1324
  return portal;
@@ -1,3 +1,5 @@
1
1
  import type { StandaloneManifest } from "./manifest.js";
2
2
  /** Package-account lifecycle hooks for CGI, daemon and background packages. */
3
3
  export declare function renderStandaloneScript(name: string, manifest: StandaloneManifest): string;
4
+ /** Accept the exact current, diagnostic-only, and released lifecycle variants. */
5
+ export declare function matchesStandaloneScript(name: string, manifest: StandaloneManifest, actual: string | undefined): boolean;
@@ -1,6 +1,6 @@
1
1
  import { renderSettingsHook } from './install-settings.js';
2
2
  import { renderComposeRetainedData } from "../build-spk/compose-retained-data.js";
3
- import { renderNativeGatewayPrepare } from './native-gateway.js';
3
+ import { renderNativeGatewayDiagnostic, renderNativeGatewayPortWait, renderNativeGatewayPrepare } from './native-gateway.js';
4
4
  import { renderComposeDiagnosis } from "../build-spk/compose-diagnosis.js";
5
5
  import { composeDaemon, renderComposeInstall } from "./compose.js";
6
6
  import { renderBackgroundControl, renderBackgroundWorkerStart, renderBackgroundWorkerStop, renderUserManagerRecovery, backgroundUnitName, } from "./background.js";
@@ -16,12 +16,23 @@ const UNINSTALL_ONLY = '[ "${SYNOPKG_PKG_STATUS:-}" = UPGRADE ] || synopkgland_u
16
16
  export function renderStandaloneScript(name, manifest) {
17
17
  const script = renderStandaloneScriptInternal(name, manifest);
18
18
  const prepared = ['postinst', 'postupgrade'].includes(name) ? renderNativeGatewayPrepare(manifest) : '';
19
- const prefix = renderSettingsHook(name, manifest) + prepared;
19
+ const prefix = renderNativeGatewayDiagnostic(manifest, name) + renderNativeGatewayPortWait(manifest, name) + renderSettingsHook(name, manifest) + prepared;
20
20
  const sdk = prefix ? script.replace('#!/bin/sh\n', () => '#!/bin/sh\n' + prefix) : script;
21
21
  return manifest.appScripts?.some((app) => app === name)
22
22
  ? composeAppScript(name, sdk)
23
23
  : sdk;
24
24
  }
25
+ /** Accept the exact current, diagnostic-only, and released lifecycle variants. */
26
+ export function matchesStandaloneScript(name, manifest, actual) {
27
+ const expected = renderStandaloneScript(name, manifest);
28
+ if (actual === expected)
29
+ return true;
30
+ const diagnostic = renderNativeGatewayDiagnostic(manifest, name);
31
+ const wait = renderNativeGatewayPortWait(manifest, name);
32
+ const diagnosticOnly = wait ? expected.replace(wait, '') : expected;
33
+ return (wait !== '' && actual === diagnosticOnly) ||
34
+ (diagnostic !== '' && actual === diagnosticOnly.replace(diagnostic, ''));
35
+ }
25
36
  function renderStandaloneScriptInternal(name, manifest) {
26
37
  if (manifest.compose) {
27
38
  const native = {
@@ -8,6 +8,10 @@ type Target = {
8
8
  export declare const NATIVE_GATEWAY_CONFIG = "sso/native-nginx.conf";
9
9
  export declare function nativeGatewaySdk(sdk: string): boolean;
10
10
  export declare function nativeGatewaySupported(m: Target): boolean;
11
+ /** Observe the boundary before DSM acquires web-config, without claiming port ownership. */
12
+ export declare function renderNativeGatewayDiagnostic(m: Target, hook: string): string;
13
+ /** Give a retiring nginx listener a bounded window before DSM acquires web-config. */
14
+ export declare function renderNativeGatewayPortWait(m: Target, hook: string): string;
11
15
  export declare function gatewayEntryPath(m: Target): string;
12
16
  export declare function legacyGatewayNotice(): string;
13
17
  export declare function nativeGatewayResource(m: Target): Record<string, unknown> | undefined;
@@ -10,6 +10,92 @@ export function nativeGatewaySupported(m) {
10
10
  return Boolean(m.identityGateway && nativeGatewaySdk(m.identityGateway.sdk)
11
11
  && m.os_min_ver && isDsmAtLeast(m.os_min_ver, PORTAL_MIN_DSM));
12
12
  }
13
+ /** Observe the boundary before DSM acquires web-config, without claiming port ownership. */
14
+ export function renderNativeGatewayDiagnostic(m, hook) {
15
+ if (!nativeGatewaySupported(m) || !['start-stop-status', 'postinst', 'postupgrade', 'preupgrade', 'preuninst'].includes(hook))
16
+ return '';
17
+ const ports = [...new Set([m.identityGateway.listen.https, m.identityGateway.listen.http].filter((port) => port !== undefined))];
18
+ const action = hook === 'start-stop-status'
19
+ ? ` case "\${1:-}" in prestart|prestop|start|stop|restart) action="$1" ;; *) exit 0 ;; esac\n`
20
+ : ' action=hook\n';
21
+ // Only fixed phase names and declared public ports reach syslog. In particular,
22
+ // never log argv, environment dumps, request/session data or a guessed owner.
23
+ return `synopkgland_native_diagnostic() (
24
+ command -v logger >/dev/null 2>&1 || exit 0
25
+ ${action} case "\${SYNOPKG_PKG_STATUS:-}" in
26
+ INSTALL|UPGRADE|UNINSTALL|START|STOP) reason="$SYNOPKG_PKG_STATUS" ;;
27
+ '') reason=NONE ;;
28
+ *) reason=UNKNOWN ;;
29
+ esac
30
+ stamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ') || stamp=unknown
31
+ socket_state() {
32
+ if [ ! -r "$1" ]; then echo unknown; return; fi
33
+ awk -v port="$2" '$4 == "0A" && toupper($2) ~ (":" port "$") { found=1 } END { print found ? "listening" : "clear" }' "$1" || echo unknown
34
+ }
35
+ ${ports.map(port => ` tcp4=$(socket_state /proc/net/tcp ${port.toString(16).toUpperCase().padStart(4, '0')})
36
+ tcp6=$(socket_state /proc/net/tcp6 ${port.toString(16).toUpperCase().padStart(4, '0')})
37
+ logger -p user.warning -t synopkgland "NATIVE_GATEWAY_LIFECYCLE at=$stamp package=${m.package} hook=${hook} action=$action reason=$reason port=${port} tcp4=$tcp4 tcp6=$tcp6" || :
38
+ `).join('')})
39
+ synopkgland_native_diagnostic "$@" >/dev/null 2>&1 || :
40
+ `;
41
+ }
42
+ /** Give a retiring nginx listener a bounded window before DSM acquires web-config. */
43
+ export function renderNativeGatewayPortWait(m, hook) {
44
+ if (hook !== 'start-stop-status' || !nativeGatewaySupported(m))
45
+ return '';
46
+ const ports = [...new Set([m.identityGateway.listen.https, m.identityGateway.listen.http].filter((port) => port !== undefined))];
47
+ return `synopkgland_native_port_wait() (
48
+ [ "\${1:-}" = prestart ] || exit 0
49
+ socket_state() {
50
+ if [ ! -r "$1" ]; then echo unknown; return; fi
51
+ awk -v port="$2" '$4 == "0A" && toupper($2) ~ (":" port "$") { found=1 } END { print found ? "listening" : "clear" }' "$1" || echo unknown
52
+ }
53
+ remaining=30
54
+ polls=0
55
+ monotonic_cs() {
56
+ read uptime unused < /proc/uptime || return 1
57
+ whole=\${uptime%.*}
58
+ fraction=\${uptime#*.}
59
+ case "$whole" in ''|*[!0-9]*) return 1 ;; esac
60
+ case "$fraction" in [0-9][0-9]) : ;; *) return 1 ;; esac
61
+ echo $((whole * 100 + 1$fraction - 100))
62
+ }
63
+ started=$(monotonic_cs) || started=
64
+ deadline=$((\${started:-0} + 1500))
65
+ while :; do
66
+ busy=0
67
+ unknown=0
68
+ for port in ${ports.map(port => port.toString(16).toUpperCase().padStart(4, '0')).join(' ')}; do
69
+ for table in /proc/net/tcp /proc/net/tcp6; do
70
+ case "$(socket_state "$table" "$port")" in
71
+ listening) busy=1 ;;
72
+ clear) : ;;
73
+ *) unknown=1 ;;
74
+ esac
75
+ done
76
+ done
77
+ if [ "$busy" -eq 0 ]; then
78
+ outcome=clear
79
+ [ "$unknown" -eq 0 ] || outcome=unknown
80
+ break
81
+ fi
82
+ if [ "$remaining" -eq 0 ]; then outcome=timeout; break; fi
83
+ now=$(monotonic_cs) || now=
84
+ if [ -z "$started" ] || [ -z "$now" ]; then outcome=unavailable; break; fi
85
+ left=$((deadline - now))
86
+ if [ "$left" -le 0 ]; then outcome=timeout; break; fi
87
+ delay=0.5
88
+ if [ "$left" -lt 50 ]; then delay=$(printf '0.%02d' "$left"); fi
89
+ sleep "$delay" || { outcome=unavailable; break; }
90
+ remaining=$((remaining - 1))
91
+ polls=$((polls + 1))
92
+ done
93
+ stamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ') || stamp=unknown
94
+ logger -p user.warning -t synopkgland "NATIVE_GATEWAY_PORT_WAIT at=$stamp package=${m.package} action=prestart ports=${ports.join(',')} outcome=$outcome polls=$polls" || :
95
+ )
96
+ synopkgland_native_port_wait "$@" >/dev/null 2>&1 || :
97
+ `;
98
+ }
13
99
  export function gatewayEntryPath(m) {
14
100
  return `/webman/3rdparty/${m.package}/sso/open.${m.identityGateway && !nativeGatewaySdk(m.identityGateway.sdk) ? 'html' : 'cgi'}`;
15
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "0.22.0",
30
+ "@synopackageland/templates": "0.23.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
@@ -5,3 +5,51 @@ Start with check; inspect the built SPK. For DSM broken / 150 inspect Package Ce
5
5
  The independent container path uses resource-worker lifecycle. Unsupported Compose settings fail before build; there is no alternate installer. COMPOSE_NUMERIC_COMMAND_ARG_FORBIDDEN means DSM may strip quoting from 純數字 command 元素. Use a single shell command when appropriate, for example `command: ["sh","-c","httpd -f -p 18333 -h /srv"]`.
6
6
 
7
7
  Never work around a failed permission check by running the package as root. Native CGI authentication and root-operation administrator approval are separate checks.
8
+
9
+ ## Native HTTPS fails before the App starts
10
+
11
+ For a package using DSM-managed native gateway HTTPS (including Compose Apps),
12
+ `start_failed` can occur after `start-stop-status prestart` succeeds but before
13
+ DSM invokes `start`. Check `/var/log/packages/<package>.log`,
14
+ `/var/log/synopkg.log`, `/var/log/synopkgmgr.log` and `/var/log/messages` at the
15
+ same timestamp. `Failed to acquire startup worker` (272) plus
16
+ `Runtime port <port> conflict for nginx` identifies the DSM `web-config` resource
17
+ boundary. Increasing the daemon or Compose startup timeout cannot repair a
18
+ failure that occurs before the daemon controller runs.
19
+
20
+ New builds emit `NATIVE_GATEWAY_LIFECYCLE` records to local syslog at SDK hook entry
21
+ for prestart, prestop, start, stop, restart and the install/upgrade/removal hooks.
22
+ The record contains a UTC timestamp, package, fixed hook/action/reason, declared
23
+ public port, and IPv4/IPv6 TCP listener presence from `/proc/net/tcp{,6}`:
24
+ `listening`, `clear`, or `unknown`. It records no usernames, environment dumps,
25
+ credentials or application arguments. Records use `user.warning` because DSM
26
+ may filter notice-level messages; these records are observations, not error verdicts. Status polls are silent. Missing logger or
27
+ unreadable proc data does not change the lifecycle result.
28
+
29
+ Before the existing SDK and App prestart steps, native gateway builds also
30
+ check all declared public ports for IPv4/IPv6 TCP listeners. While any is
31
+ listening, one shared monotonic 15-second deadline includes probe time, with at
32
+ most thirty sleeps of up to 0.5 seconds. The final sleep uses only the remaining
33
+ budget; scheduling and the final local probe can add slight overhead. No listener
34
+ means no sleep. Unknown
35
+ probe results do not themselves trigger a wait. `NATIVE_GATEWAY_PORT_WAIT`
36
+ records the outcome (`clear`, `timeout`, `unknown`, or `unavailable`) and the
37
+ number of sleeps. Timeout, logger failure, or an unavailable sleep command
38
+ continues to the original prestart steps and preserves their exit semantics;
39
+ DSM remains responsible for acquiring the resource and reporting a conflict.
40
+ This is an observation-only mitigation, without signalling processes or a root
41
+ helper. Inspect accepts the exact current scripts, the prior diagnostic-only
42
+ scripts, and the original released scripts; edited waits or SDK steps fail.
43
+
44
+ These observations do not identify the listener's owner or reserve the port.
45
+ They do not prove that a listener persisted until DSM's subsequent resource
46
+ check, and a missing record may mean logging was unavailable. Correlate them
47
+ with DSM's own hook begin/end records. During an authorized reproduction, also
48
+ capture a timestamped `netstat -lntp` (administrator access may be required),
49
+ nginx process/config state, package state and daemon logs before attempting
50
+ recovery. Keep system logs private and redact them before sharing. A later
51
+ native Start success proves recovery, not why the previous attempt failed.
52
+
53
+ Do not restart nginx, change DSM port assignments, kill unrelated listeners,
54
+ or add automatic package-start retries to hide an unproven conflict. Rebuild
55
+ an App to obtain the diagnostic hooks; existing installed packages are unchanged.
@@ -1,11 +1,42 @@
1
1
  # Wrap an OSS app with DSM accounts
2
2
 
3
+ ## Per-request OIDC browser origins (development)
4
+
5
+ `oidc.browser_redirect: {}` opts into per-request browser origins for SDK/CLI
6
+ 0.23. Optional `html_paths` and `same_origin_referrer` configure HTML behavior.
7
+ No host list is accepted or stored in the SPK: users can open the same install
8
+ by IP, hostname, DDNS or a new DNS alias. Each request must use a syntactically
9
+ valid IP/DNS authority and the declared gateway scheme/port; forwarding headers
10
+ cannot select the destination. DSM native ingress uses its HTTPS port.
11
+ Host selects only this browser response's navigation URL, never an upstream
12
+ dial target or an authorization decision. DSM admission, transaction origin
13
+ and browser binding, exact redirect registration and PKCE remain required.
14
+ Gateway cookies are host-only. This does not make public upstream routes a
15
+ network security boundary; upstream retains its authentication/CSRF duties.
16
+ Use trusted DNS and TLS, as a user signing in on an attacker-controlled origin
17
+ exposes that origin's session to its owner. Rewritten responses use no-store
18
+ and remove validators/digests; issuer/discovery never depend on Host.
19
+
20
+ Location and existing `rewrite_json` rules already map the loopback authorization
21
+ endpoint. For clients returning it in HTML, add `html_paths: ["/auth/redirect"]`:
22
+ only those exact GET/200 HTML responses are rewritten, up to 1 MiB using identity
23
+ encoding, with raw/escaped/percent-encoded URL support. Query bytes and upstream
24
+ CSP stay intact; inline CSP hashes over modified scripts are incompatible.
25
+ If an upstream native form uses `no-referrer` and browsers send `Origin: null`,
26
+ explicit `same_origin_referrer: true` restores same-origin metadata only on
27
+ the declared HTML paths. Include the form's page in that list. Referrers never
28
+ go to other origins; null/foreign Origin checks are not relaxed.
29
+ Issuer/ID token `iss`, discovery, token, userinfo and JWKS remain on loopback.
30
+ Redirect registration, PKCE and DSM browser binding are unchanged. This needs
31
+ matching SDK/CLI support; builds reject an SDK without the capability, including
32
+ old releases selected without a development source override.
33
+
3
34
  ## Provider-only OIDC
4
35
 
5
36
  For an upstream with native authentication for browsers and native clients,
6
37
  declare `mode: oidc`, `upstream_access: public`, and retain `access: dsm-users`,
7
38
  `dsm-admins` or `dsm-groups` for the issuer. This capability requires
8
- SDK/CLI 0.22.0 or later. A build with an old SDK
39
+ SDK/CLI 0.23.0 or later. A build with an old SDK
9
40
  without that capability fails rather than claiming support.
10
41
 
11
42
  The gateway does not require DSM cookies or browser Origin on upstream requests.