From bbab74afe8371083cb092c023c48689b59720a65 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Fri, 24 Jul 2026 14:14:58 +0200 Subject: [PATCH 1/2] update for startos 0.4.0 and latest sdk --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/tagAndRelease.yml | 2 +- Makefile | 2 +- README.md | 46 +- instructions.md | 12 +- package-lock.json | 1507 ++++++++++++++++++++- package.json | 4 +- s9pk.mk | 138 -- startos/actions/addPublicHostname.ts | 69 +- startos/actions/cloudflareLogin.ts | 4 +- startos/actions/deletePublicHostname.ts | 42 +- startos/actions/importPublicHostnames.ts | 218 +-- startos/actions/index.ts | 2 + startos/actions/repairRoutes.ts | 38 + startos/actions/selectTunnel.ts | 11 +- startos/cfApi.ts | 146 +- startos/cfRunner.ts | 2 +- startos/fileModels/store.yaml.ts | 30 +- startos/i18n/dictionaries/default.ts | 13 + startos/i18n/dictionaries/translations.ts | 54 +- startos/init/index.ts | 2 + startos/init/reconcileIngress.ts | 571 ++++++++ startos/init/setupTasks.ts | 19 +- startos/interfaces.ts | 10 +- startos/main.ts | 14 +- startos/manifest/index.ts | 2 +- startos/versions/current.ts | 40 +- startos/versions/index.ts | 3 +- startos/versions/v2026.6.1.0.ts | 16 + tsconfig.json | 11 +- 31 files changed, 2627 insertions(+), 405 deletions(-) delete mode 100644 s9pk.mk create mode 100644 startos/actions/repairRoutes.ts create mode 100644 startos/init/reconcileIngress.ts create mode 100644 startos/versions/v2026.6.1.0.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5164f9e..ca6a0dd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ concurrency: jobs: build: if: github.event.pull_request.draft == false - uses: start9labs/shared-workflows/.github/workflows/build.yml@master + uses: Start9Labs/start-technologies/.github/workflows/build.yml@master # with: # FREE_DISK_SPACE: true secrets: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b83acf..4680564 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: jobs: release: - uses: start9labs/shared-workflows/.github/workflows/release.yml@master + uses: Start9Labs/start-technologies/.github/workflows/release.yml@master with: # FREE_DISK_SPACE: true RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }} diff --git a/.github/workflows/tagAndRelease.yml b/.github/workflows/tagAndRelease.yml index 5bd58fa..52d059c 100644 --- a/.github/workflows/tagAndRelease.yml +++ b/.github/workflows/tagAndRelease.yml @@ -15,7 +15,7 @@ concurrency: jobs: tag: - uses: start9labs/shared-workflows/.github/workflows/tagAndRelease.yml@master + uses: Start9Labs/start-technologies/.github/workflows/tagAndRelease.yml@master with: REFERENCE_REGISTRY: ${{ vars.REFERENCE_REGISTRY }} # FREE_DISK_SPACE: true diff --git a/Makefile b/Makefile index c2cf7f8..890b6a9 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,3 @@ # overrides to s9pk.mk must precede the include statement ARCHES := x86 arm -include s9pk.mk \ No newline at end of file +include node_modules/@start9labs/start-sdk/s9pk.mk diff --git a/README.md b/README.md index bc75024..171ad2a 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Upstream repo: ## Image and Container Runtime -- Base image: `cloudflare/cloudflared:2026.3.0` copied into `debian:12-slim` +- Upstream image: `cloudflare/cloudflared`, copied into `debian:12-slim` - Architectures: `x86_64`, `aarch64` (aarch64 emulated if missing) - Entrypoint: `cloudflared tunnel --credentials-file /root/.cloudflared/.json run ` - Autoupdate disabled via `--no-autoupdate` @@ -43,12 +43,12 @@ Upstream repo: All persistent data is stored in the `main` volume: -| Path | Contents | -|---|---| -| `/root/data/start9/config.yaml` | Package store with selected tunnel, logged-in DNS zones, and managed routes | -| `/root/data/start9/login-url.txt` | Temporary Cloudflare authorization URL during login flow | -| `/root/data/.cloudflared/zone-.pem` | Zone-specific Cloudflare origin certificate | -| `/root/data/.cloudflared/.json` | Tunnel credentials file used to run cloudflared | +| Path | Contents | +| -------------------------------------------- | --------------------------------------------------------------------------- | +| `/root/data/start9/config.yaml` | Package store with selected tunnel, logged-in DNS zones, and managed routes | +| `/root/data/start9/login-url.txt` | Temporary Cloudflare authorization URL during login flow | +| `/root/data/.cloudflared/zone-.pem` | Zone-specific Cloudflare origin certificate | +| `/root/data/.cloudflared/.json` | Tunnel credentials file used to run cloudflared | ## Installation and First-Run Flow @@ -66,42 +66,48 @@ No manual token or credentials-file management is required. - Tunnel ingress is managed through the Cloudflare API. - After selecting a tunnel, the package retrieves the credentials file automatically with `cloudflared tunnel token --cred-file ...`. - DNS record management uses the zone-specific origin certificate for the selected zone. -- If hostnames already exist on the tunnel in Cloudflare, use **Import Public Hostnames** before making further edits in StartOS so those routes are brought into the package store. +- Existing dashboard-managed routes and advanced tunnel settings are preserved when StartOS changes one of its managed hostnames. +- Use **Import Public Hostnames** when you also want a compatible existing route to appear in StartOS and follow its service binding. ## Network Access and Interfaces -- **Metrics** - Prometheus metrics endpoint at `http://cloudflared.startos:20241/metrics` (internal only) +- **Metrics** - Prometheus metrics endpoint on internal loopback port 20241 - All public traffic is proxied through the Cloudflare edge. No inbound ports need to be opened on your router. ## Actions -| Action | When available | Purpose | -|---|---|---| -| Login to Cloudflare / Add DNS Zone | Always | Start the Cloudflare login flow and authorize one DNS zone at a time | -| Select Tunnel | When at least one zone is configured | Choose an existing tunnel or create a new one | -| Remove DNS Zone | When zones exist | Remove a DNS zone from this package without deleting existing Cloudflare records | -| Import Public Hostnames | Always | Import existing Cloudflare tunnel hostnames into the StartOS-managed route list | -| Managed Public Routes | Always | Show the selected tunnel, managed DNS zones, and application routes | +| Action | When available | Purpose | +| ---------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------- | +| Login to Cloudflare / Add DNS Zone | Always | Start the Cloudflare login flow and authorize one DNS zone at a time | +| Select Tunnel | When at least one zone is configured | Choose an existing tunnel or create a new one | +| Remove DNS Zone | When zones exist | Remove a DNS zone from this package without deleting existing Cloudflare records | +| Import Public Hostnames | Always | Import existing Cloudflare tunnel hostnames into the StartOS-managed route list | +| Managed Public Routes | Always | Show the selected tunnel, managed DNS zones, and application routes | ## URL Plugin Cloudflare Tunnel registers as a `url-v0` URL plugin. Any other installed service can add a public Cloudflare hostname directly from its StartOS URL list. **Adding a hostname:** + - Open any service → URLs → Add URL → select Cloudflare Tunnel - Enter a subdomain and choose one of the logged-in DNS zones - The package updates the Cloudflare tunnel ingress configuration automatically +- StartOS targets are stored by package, host, interface, and internal port; their live bridge address is re-resolved after installs or assigned-port changes - It also tries to create the DNS CNAME automatically - If the DNS step fails, the route is still added and the action returns the manual fallback: `hostname → .cfargotunnel.com` (proxied) **Removing a hostname:** + - Open the service → URLs → remove the Cloudflare URL - The package removes the ingress rule from the tunnel configuration - It also tries to delete the matching DNS record and returns a warning if manual cleanup is still needed **Importing existing dashboard routes:** + - If routes already exist on the tunnel in Cloudflare, run **Import Public Hostnames** - Matching routes are added to the package store so StartOS can manage and display them +- Routes that remain dashboard-managed are retained unchanged by StartOS route additions and removals ## Backups and Restore @@ -109,7 +115,7 @@ The entire `main` volume is backed up, including zone certificates, tunnel crede ## Health Checks -- **Cloudflare tunnel** - polls `http://cloudflared.startos:20241/metrics` +- **Cloudflare tunnel** - polls the local metrics endpoint on port 20241 - The service is considered healthy when the metrics endpoint responds successfully ## Dependencies @@ -125,10 +131,11 @@ None. 1. **One selected tunnel per package instance** - this package runs one cloudflared tunnel at a time. 2. **No tunnel management UI** - tunnels are selected or created through StartOS actions, not a web UI. For advanced tunnel settings, use the Cloudflare Zero Trust dashboard. -3. **Import before editing dashboard-managed routes** - if routes already exist in the Cloudflare dashboard, import them into StartOS first so later edits here do not overwrite unknown entries. +3. **Dashboard routes are preserved** - StartOS reads and merges the complete live tunnel configuration before each update. Import a compatible route only if you want StartOS to manage and display it. 4. **DNS automation can still need manual fallback** - if a DNS record already exists or Cloudflare rejects the change, the package returns the manual CNAME fallback instead of silently failing. 5. **Autoupdate disabled** - `--no-autoupdate` is set; updates are delivered via new package versions. 6. **Metrics endpoint is internal only** - the Prometheus metrics endpoint is not proxied through the tunnel. +7. **Upgrade repair** - beta.9-era `STARTOS`/nullable admin identities and `.startos` targets are migrated to `start-os` / `admin` / `admin-ui`. Resolvable legacy dashboard routes are adopted without removing unrelated routes. If a target is ambiguous or the Cloudflare API is unavailable, no remote configuration is written and the package creates a **Repair Cloudflare Routes** task. --- @@ -136,8 +143,7 @@ None. ```yaml package_id: cloudflared -upstream_version: 2026.3.0 -image: cloudflare/cloudflared:2026.3.0 +image: cloudflare/cloudflared architectures: [x86_64, aarch64] volumes: main: diff --git a/instructions.md b/instructions.md index 7171951..dfc6ae1 100644 --- a/instructions.md +++ b/instructions.md @@ -2,6 +2,8 @@ Cloudflare Tunnel (cloudflared) creates an outbound-only connection from your StartOS server to the Cloudflare edge network. This lets you expose services publicly via your own domain without opening inbound ports or changing your router. +Routes managed by this service are kept pointed at the correct service on your StartOS server. Routes and advanced settings that you manage directly in Cloudflare are left in place. If Cloudflare cannot be updated safely, no changes are sent; use **Repair Cloudflare Routes** after correcting the problem. + ## Requirements - A Cloudflare account @@ -12,7 +14,7 @@ Cloudflare Tunnel (cloudflared) creates an outbound-only connection from your St 1. Run the **Login to Cloudflare** action. A Cloudflare authorization URL will be returned. 2. Open that URL in a browser, log in, and approve access for one DNS zone (domain). 3. Repeat the login action if you want to manage additional DNS zones. -4. Run **Select Tunnel** to choose an existing tunnel or create a new one. +4. Run **Cloudflare Tunnel** to choose an existing tunnel or create a new one. 5. Once a tunnel is selected, the service will start automatically. ## Assigning a public address to a service @@ -27,10 +29,14 @@ Once a tunnel is selected and the service is running, you can assign a public Cl To remove an address, click the overflow menu on that row and select **Delete**. -If you have existing hostname routes already configured in Cloudflare, run **Import Public Hostnames** to load them into this service. +Routes created directly in Cloudflare continue to work. Run **Import Public Hostnames** if you want compatible routes to appear in StartOS and stay connected to their selected services automatically. ## Actions - **Login to Cloudflare** - Authenticate with a Cloudflare DNS zone. -- **Select Tunnel** - Choose or create a Cloudflare tunnel. +- **Cloudflare Tunnel** - Choose or create a Cloudflare tunnel. +- **Add DNS Zone** - Add another domain from Cloudflare. +- **Remove DNS Zone** - Stop managing a domain without deleting its existing Cloudflare records. - **Import Public Hostnames** - Import existing hostname routes from Cloudflare. +- **Managed Public Routes** - View the domains, tunnel, and public addresses managed here. +- **Repair Cloudflare Routes** - Retry route updates after a connection or configuration problem. diff --git a/package-lock.json b/package-lock.json index 175baa1..a0c5271 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "1.5.1" + "@start9labs/start-sdk": "2.0.7" }, "devDependencies": { "@types/node": "^22.19.17", "@vercel/ncc": "^0.38.4", "prettier": "^3.8.2", - "typescript": "^5.9.3" + "typescript": "^6.0.3" } }, "node_modules/@iarna/toml": { @@ -61,23 +61,1477 @@ "license": "MIT" }, "node_modules/@start9labs/start-sdk": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.5.1.tgz", - "integrity": "sha512-iztLiOCtHuTfUCd2JOWio4OvBk5qFGa0NI+G+ZB/dQ1sWtunYEnzqMcF6N/Ss4L6+7bBOMAMU4VuhyxeZoHyIw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-2.0.7.tgz", + "integrity": "sha512-lJuZto0vfD/2fPubzwavuStAdlnQQo8AmYFvBBhENY4WbXAXJ64b5zuXwrkmJ8wZObUngno5LYZWWr7yOlYLxQ==", + "bundleDependencies": [ + "@start9labs/start-core", + "eslint", + "typescript-eslint" + ], + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@start9labs/start-core": "file:./node_modules/@start9labs/start-core", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "eslint": "^9.39.4", + "fast-xml-parser": "~5.7.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "typescript-eslint": "^8.61.0", + "yaml": "^2.8.3", + "zod": "4.4.3", + "zod-deep-partial": "^1.2.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array": { + "version": "0.21.2", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.15", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/core": { + "version": "0.17.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "inBundle": true, + "license": "Python-2.0" + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.15", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "inBundle": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/js": { + "version": "9.39.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/object-schema": { + "version": "2.1.7", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanfs/core": { + "version": "0.19.2", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanfs/node": { + "version": "0.16.8", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanfs/types": { + "version": "0.15.0", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core": { + "inBundle": true, + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "deep-equality-data-structures": "^2.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "yaml": "^2.8.3", + "zod": "4.4.3", + "zod-deep-partial": "^1.4.4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/@iarna/toml": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/@noble/curves": { + "version": "1.9.7", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/@noble/hashes": { + "version": "1.8.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/isomorphic-fetch": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/mime": { + "version": "4.1.0", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "inBundle": true, + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/node-fetch": { + "version": "2.7.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/object-hash": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/tr46": { + "version": "0.0.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/webidl-conversions": { + "version": "3.0.1", + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/whatwg-fetch": { + "version": "3.6.20", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/whatwg-url": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/yaml": { + "version": "2.9.0", + "inBundle": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/zod": { + "version": "4.4.3", + "inBundle": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@start9labs/start-core/node_modules/zod-deep-partial": { + "version": "1.4.4", + "inBundle": true, + "license": "MIT", + "peerDependencies": { + "zod": "^4.1.13" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@types/estree": { + "version": "1.0.9", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/@types/json-schema": { + "version": "7.0.15", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/parser": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/project-service": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/type-utils": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/types": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.3", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/utils": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/acorn": { + "version": "8.16.0", + "inBundle": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/acorn-jsx": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ajv": { + "version": "6.15.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/callsites": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/chalk": { + "version": "4.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/concat-map": { + "version": "0.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/cross-spawn": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/debug": { + "version": "4.4.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@start9labs/start-sdk/node_modules/deep-is": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint": { + "version": "9.39.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint-scope": { + "version": "8.4.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.15", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "inBundle": true, "license": "MIT", "dependencies": { - "@iarna/toml": "^3.0.0", - "@noble/curves": "^1.9.7", - "@noble/hashes": "^1.8.0", - "@types/ini": "^4.1.1", - "deep-equality-data-structures": "^2.0.0", - "fast-xml-parser": "~5.7.0", - "ini": "^5.0.0", - "isomorphic-fetch": "^3.0.0", - "mime": "^4.1.0", - "yaml": "^2.8.3", - "zod": "4.3.6", - "zod-deep-partial": "^1.2.0" + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/espree": { + "version": "10.4.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/esquery": { + "version": "1.7.0", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/esrecurse": { + "version": "4.3.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/estraverse": { + "version": "5.3.0", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/esutils": { + "version": "2.0.3", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/fast-deep-equal": { + "version": "3.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/fast-levenshtein": { + "version": "2.0.6", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/fdir": { + "version": "6.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@start9labs/start-sdk/node_modules/file-entry-cache": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/flat-cache": { + "version": "4.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/flatted": { + "version": "3.4.2", + "inBundle": true, + "license": "ISC" + }, + "node_modules/@start9labs/start-sdk/node_modules/globals": { + "version": "14.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/has-flag": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/import-fresh": { + "version": "3.3.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/imurmurhash": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/is-extglob": { + "version": "2.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/is-glob": { + "version": "4.0.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/isexe": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/@start9labs/start-sdk/node_modules/json-buffer": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/json-schema-traverse": { + "version": "0.4.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/keyv": { + "version": "4.5.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/levn": { + "version": "0.4.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/lodash.merge": { + "version": "4.6.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/ms": { + "version": "2.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/natural-compare": { + "version": "1.4.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk/node_modules/optionator": { + "version": "0.9.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/p-limit": { + "version": "3.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/parent-module": { + "version": "1.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/path-exists": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/path-key": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/picomatch": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/prelude-ls": { + "version": "1.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/shebang-command": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/shebang-regex": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/strip-json-comments": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/supports-color": { + "version": "7.2.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/tinyglobby": { + "version": "0.2.17", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/ts-api-utils": { + "version": "2.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/type-check": { + "version": "0.4.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/typescript-eslint": { + "version": "8.61.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/uri-js": { + "version": "4.4.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/which": { + "version": "2.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/word-wrap": { + "version": "1.2.5", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@start9labs/start-sdk/node_modules/yocto-queue": { + "version": "0.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/@types/ini": { @@ -265,10 +1719,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -323,9 +1776,9 @@ } }, "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -338,9 +1791,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index fc1c702..b32d716 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,13 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "1.5.1" + "@start9labs/start-sdk": "2.0.7" }, "devDependencies": { "@types/node": "^22.19.17", "@vercel/ncc": "^0.38.4", "prettier": "^3.8.2", - "typescript": "^5.9.3" + "typescript": "^6.0.3" }, "prettier": { "trailingComma": "all", diff --git a/s9pk.mk b/s9pk.mk deleted file mode 100644 index 675ebc9..0000000 --- a/s9pk.mk +++ /dev/null @@ -1,138 +0,0 @@ -# ** Plumbing. DO NOT EDIT **. -# This file is imported by ./Makefile. Make edits there - -PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts) -INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null) -# Resolve the actual git dir so this works inside git worktrees, where .git -# is a file pointing at
/.git/worktrees/ rather than a directory. -GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null) -GIT_DEPS := $(if $(GIT_DIR),$(GIT_DIR)/HEAD $(GIT_DIR)/index) -ARCHES ?= x86 arm riscv -# TARGETS is the list of leaf make-targets the build matrix fans out over. -# Defaults to the arches; variant packages override (e.g. immich, ollama, vllm -# set this to a list of variant or variant-arch leaf targets). -TARGETS ?= $(ARCHES) -ifdef VARIANT -BASE_NAME := $(PACKAGE_ID)_$(VARIANT) -else -BASE_NAME := $(PACKAGE_ID) -endif - -.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients -.DELETE_ON_ERROR: -.SECONDARY: - -define SUMMARY - @manifest=$$(start-cli s9pk inspect $(1) manifest); \ - size=$$(du -h $(1) | awk '{print $$1}'); \ - title=$$(printf '%s' "$$manifest" | jq -r .title); \ - version=$$(printf '%s' "$$manifest" | jq -r .version); \ - arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \ - sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \ - gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \ - printf "\n"; \ - printf "\033[1;32m✅ Build Complete!\033[0m\n"; \ - printf "\n"; \ - printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \ - printf "───────────────────────────────\n"; \ - printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \ - printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \ - printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \ - printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \ - printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \ - echo "" -endef - -all: $(TARGETS) - -arches: $(ARCHES) - -# Generic make-variable introspection. Used by the release workflow to -# read $(TARGETS) and fan out one matrix runner per target. `make -s -# print-TARGETS` echoes the list with no other output. -print-%: - @echo '$($*)' - -universal: $(BASE_NAME).s9pk - $(call SUMMARY,$<) - -arch/%: $(BASE_NAME)_%.s9pk - $(call SUMMARY,$<) - -x86 x86_64: arch/x86_64 -arm arm64 aarch64: arch/aarch64 -riscv riscv64: arch/riscv64 - -$(BASE_NAME).s9pk: $(INGREDIENTS) $(GIT_DEPS) - @$(MAKE) --no-print-directory ingredients - @echo " Packing '$@'..." - start-cli s9pk pack -o $@ - -$(BASE_NAME)_%.s9pk: $(INGREDIENTS) $(GIT_DEPS) - @$(MAKE) --no-print-directory ingredients - @echo " Packing '$@'..." - start-cli s9pk pack --arch=$* -o $@ - -ingredients: $(INGREDIENTS) - @echo " Re-evaluating ingredients..." - -install: | check-deps check-init - @HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \ - if [ -z "$$HOST" ]; then \ - echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \ - exit 1; \ - fi; \ - if [ -z "$$(ls *.s9pk 2>/dev/null)" ]; then \ - echo "Error: No .s9pk file found. Run 'make' first."; \ - exit 1; \ - fi; \ - S9PK=$$(start-cli s9pk select) || exit 1; \ - printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \ - start-cli package install -s "$$S9PK" - -publish: | all - @REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \ - if [ -z "$$REGISTRY" ]; then \ - echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \ - exit 1; \ - fi; \ - S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \ - if [ -z "$$S3BASE" ]; then \ - echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \ - exit 1; \ - fi; \ - command -v s3cmd >/dev/null || \ - (echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \ - printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \ - for s9pk in *.s9pk; do \ - age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \ - if [ "$$age" -gt 3600 ]; then \ - printf "\033[1;33m⚠️ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \ - read -r ans; \ - case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \ - fi; \ - start-cli s9pk publish "$$s9pk"; \ - done - -check-deps: - @command -v start-cli >/dev/null || \ - (echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1) - @command -v npm >/dev/null || \ - (echo "Error: npm not found. Please install Node.js and npm." && exit 1) - -check-init: - @if [ ! -f ~/.startos/developer.key.pem ]; then \ - echo "Initializing StartOS developer environment..."; \ - start-cli init-key; \ - fi - -javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules - npm run check - npm run build - -node_modules: package-lock.json package.json - npm ci - -clean: - @echo "Cleaning up build artifacts..." - @rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules diff --git a/startos/actions/addPublicHostname.ts b/startos/actions/addPublicHostname.ts index cfc647f..313194d 100644 --- a/startos/actions/addPublicHostname.ts +++ b/startos/actions/addPublicHostname.ts @@ -1,8 +1,12 @@ import { sdk } from '../sdk' -import { store } from '../fileModels/store.yaml' -import { pushIngressToApi, summarizeCloudflareError } from '../cfApi' +import { IngressEntry, store } from '../fileModels/store.yaml' +import { summarizeCloudflareError } from '../cfApi' import { zoneCertSubpath } from '../fileModels/certPem' import { i18n } from '../i18n' +import { + associateMigratedIngressWithZones, + updateCloudflareIngress, +} from '../init/reconcileIngress' const { InputSpec, Value, Variants } = sdk @@ -94,15 +98,24 @@ export const addPublicHostname = sdk.Action.withInput( async ({ effects, prefill }) => { const p = prefill as typeof inputSpec._PARTIAL const suggestedHost = p?.urlPluginMetadata?.packageId - return suggestedHost && suggestedHost !== 'STARTOS' + return suggestedHost && + suggestedHost !== 'STARTOS' && + suggestedHost !== 'start-os' ? { subdomain: suggestedHost } : null }, async ({ effects, input }) => { - const { packageId: rawPkgId, internalPort, interfaceId, hostId } = - input.urlPluginMetadata - const packageId = rawPkgId ?? 'STARTOS' + const { + packageId: rawPkgId, + internalPort, + interfaceId, + hostId, + } = input.urlPluginMetadata + const packageId = rawPkgId === 'STARTOS' ? 'start-os' : rawPkgId + const targetHostId = packageId === 'start-os' ? 'admin' : hostId + const targetInterfaceId = + packageId === 'start-os' ? 'admin-ui' : interfaceId const subdomain = input.subdomain.trim().toLowerCase() const zoneId = (input.domain as { selection: string }).selection @@ -144,29 +157,34 @@ export const addPublicHostname = sdk.Action.withInput( } const hostname = `${subdomain}.${zone.zoneName}` - const host = packageId === 'STARTOS' ? 'startos' : `${packageId}.startos` - const service = `http://${host}:${internalPort}` const tunnelId = conf.tunnel.id - const nextEntry = { - packageId: packageId === 'STARTOS' ? null : packageId, - hostId, - interfaceId, + const stableEntry = { + packageId, + hostId: targetHostId, + interfaceId: targetInterfaceId, internalPort, - service, zoneId, } - const nextIngress = { - ...(conf.ingress ?? {}), - [hostname]: nextEntry, - } - - // Push to Cloudflare first so local state only changes after the remote config is updated. + let nextEntry: IngressEntry + let migratedIngress: Record = {} + // Merge into the complete live configuration first so local state only + // changes after Cloudflare accepts the preservation-aware update. try { - await pushIngressToApi( - zone.accountId, - tunnelId, - zone.apiToken, - nextIngress, + const result = await updateCloudflareIngress( + effects, + { + accountId: zone.accountId, + tunnelId, + apiToken: zone.apiToken, + }, + { [hostname]: stableEntry }, + [], + { [hostname]: conf.ingress?.[hostname]?.service ?? null }, + ) + nextEntry = result.ingress[hostname] + migratedIngress = associateMigratedIngressWithZones( + result.migratedIngress, + conf.zones, ) } catch (error) { const summary = summarizeCloudflareError(error) @@ -187,6 +205,7 @@ export const addPublicHostname = sdk.Action.withInput( accountId: tunnelAccountId, }, ingress: { + ...migratedIngress, [hostname]: nextEntry, }, }) @@ -242,7 +261,7 @@ export const addPublicHostname = sdk.Action.withInput( dnsCreated = true } else { dnsFailureDetail = summarizeDnsRouteFailure( - `${result.stderr || ''}\n${result.stdout || ''}`, + `${result.stderr?.toString() ?? ''}\n${result.stdout?.toString() ?? ''}`, ) console.error( `DNS route creation failed for ${hostname}: ${dnsFailureDetail}. Add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`, diff --git a/startos/actions/cloudflareLogin.ts b/startos/actions/cloudflareLogin.ts index ef53b43..8048613 100644 --- a/startos/actions/cloudflareLogin.ts +++ b/startos/actions/cloudflareLogin.ts @@ -75,7 +75,9 @@ export const cloudflareLogin = sdk.Action.withoutInput( while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 2000)) - const activeSession = (await sdk.volumes.main.readFile(LOGIN_SESSION_PATH)) + const activeSession = ( + await sdk.volumes.main.readFile(LOGIN_SESSION_PATH) + ) .toString() .trim() if (activeSession !== sessionId) { diff --git a/startos/actions/deletePublicHostname.ts b/startos/actions/deletePublicHostname.ts index dc78ec8..d006a73 100644 --- a/startos/actions/deletePublicHostname.ts +++ b/startos/actions/deletePublicHostname.ts @@ -1,18 +1,18 @@ import { sdk } from '../sdk' -import { store } from '../fileModels/store.yaml' -import { - pushIngressToApi, - deleteDnsRecord, - summarizeCloudflareError, -} from '../cfApi' +import { IngressEntry, store } from '../fileModels/store.yaml' +import { deleteDnsRecord, summarizeCloudflareError } from '../cfApi' import { i18n } from '../i18n' +import { + associateMigratedIngressWithZones, + updateCloudflareIngress, +} from '../init/reconcileIngress' const { InputSpec, Value } = sdk const inputSpec = InputSpec.of({ urlPluginMetadata: Value.hidden<{ interfaceId: string - packageId: string | null + packageId: string hostId: string internalPort: number ssl: boolean @@ -59,6 +59,7 @@ export const deletePublicHostname = sdk.Action.withInput( const zone = zoneId ? conf.zones?.[zoneId] : undefined const nextIngress = { ...(conf.ingress ?? {}) } delete nextIngress[hostname] + let migratedIngress: Record = {} if (conf.tunnel) { if (!zone) { @@ -67,13 +68,23 @@ export const deletePublicHostname = sdk.Action.withInput( ) } - // Push to Cloudflare first so local state only changes after the remote config is updated. + // Remove only this hostname from the complete live configuration so + // dashboard-managed routes and advanced settings remain untouched. try { - await pushIngressToApi( - zone.accountId, - conf.tunnel.id, - zone.apiToken, - nextIngress, + const result = await updateCloudflareIngress( + effects, + { + accountId: zone.accountId, + tunnelId: conf.tunnel.id, + apiToken: zone.apiToken, + }, + {}, + [hostname], + { [hostname]: entry?.service ?? null }, + ) + migratedIngress = associateMigratedIngressWithZones( + result.migratedIngress, + conf.zones, ) } catch (error) { const summary = summarizeCloudflareError(error) @@ -112,7 +123,10 @@ export const deletePublicHostname = sdk.Action.withInput( 'The Cloudflare tunnel was updated, but this package could not determine which zone to use for deleting the DNS record automatically.' } - await store.write(effects, { ...conf, ingress: nextIngress }) + await store.write(effects, { + ...conf, + ingress: { ...nextIngress, ...migratedIngress }, + }) console.info(`Public hostname ${hostname} removed`) diff --git a/startos/actions/importPublicHostnames.ts b/startos/actions/importPublicHostnames.ts index d6c9c34..1a199c6 100644 --- a/startos/actions/importPublicHostnames.ts +++ b/startos/actions/importPublicHostnames.ts @@ -1,36 +1,21 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { fetchIngressFromApi, summarizeCloudflareError } from '../cfApi' +import { + CloudflareIngressRule, + fetchIngressFromApi, + summarizeCloudflareError, +} from '../cfApi' import { i18n } from '../i18n' +import { + associateMigratedIngressWithZones, + isWholeHostnameRule, + parseLegacyServiceTarget, + StableIngress, + updateCloudflareIngress, +} from '../init/reconcileIngress' -/** - * Parse a cloudflared service URL of the form http://: - * and return the packageId + internalPort if it matches a StartOS service pattern. - * - * StartOS service hostnames look like: - * http://.startos: (regular services) - * http://startos: (STARTOS itself) - */ -function parseServiceUrl( - service: string, -): { packageId: string | null; internalPort: number } | null { - try { - const url = new URL(service) - const port = Number(url.port) - if (!port) return null - - const host = url.hostname - if (host === 'startos') { - return { packageId: null, internalPort: port } - } - if (host.endsWith('.startos')) { - const packageId = host.slice(0, -'.startos'.length) - return { packageId, internalPort: port } - } - return null - } catch { - return null - } +function normalizedHostname(hostname: string) { + return hostname.trim().toLowerCase() } export const importPublicHostnames = sdk.Action.withoutInput( @@ -63,30 +48,37 @@ export const importPublicHostnames = sdk.Action.withoutInput( } } - // Need at least one zone to make API calls - const firstZone = Object.values(conf.zones ?? {}).find(Boolean) - if (!firstZone) { + const tunnelZone = Object.values(conf.zones ?? {}).find( + (zone) => + zone && + (!conf.tunnel?.accountId || zone.accountId === conf.tunnel.accountId), + ) + if (!tunnelZone) { return { version: '1', title: i18n('No Zone Configured'), message: i18n( - 'Login to Cloudflare first (run "Login to Cloudflare" action) to configure a DNS zone.', + 'Login to a Cloudflare DNS zone in the same account as the selected tunnel, then try again.', ), result: null, } } const existingHostnames = new Set( - Object.keys(conf.ingress ?? {}).filter((h) => !!conf.ingress?.[h]), + Object.keys(conf.ingress ?? {}) + .filter((hostname) => !!conf.ingress?.[hostname]) + .map(normalizedHostname), ) // Fetch all ingress rules from Cloudflare - let cfRules: Array<{ hostname: string; service: string }> + let cfRules: Array< + CloudflareIngressRule & { hostname: string; service: string } + > try { cfRules = await fetchIngressFromApi( - firstZone.accountId, + tunnelZone.accountId, conf.tunnel.id, - firstZone.apiToken, + tunnelZone.apiToken, ) } catch (error) { const summary = summarizeCloudflareError(error) @@ -102,25 +94,16 @@ export const importPublicHostnames = sdk.Action.withoutInput( } // Only consider rules not already tracked locally - const newRules = cfRules.filter((r) => !existingHostnames.has(r.hostname)) - - if (newRules.length === 0) { - return { - version: '1', - title: i18n('Import Public Hostnames'), - message: i18n( - 'No new public hostnames found in Cloudflare that are not already tracked.', - ), - result: null, - } - } + const newRules = cfRules.filter( + (rule) => !existingHostnames.has(normalizedHostname(rule.hostname)), + ) // Get all installed packages and their interfaces const packageIds = await effects.getInstalledPackages() const interfaceMap: Map< string, { - packageId: string | null + packageId: string interfaceId: string hostId: string internalPort: number @@ -136,14 +119,12 @@ export const importPublicHostnames = sdk.Action.withoutInput( const { hostId, internalPort } = iface.addressInfo const key = `${pkgId}:${internalPort}` if (!interfaceMap.has(key)) interfaceMap.set(key, []) - interfaceMap - .get(key)! - .push({ - packageId: pkgId, - interfaceId: ifaceId, - hostId, - internalPort, - }) + interfaceMap.get(key)!.push({ + packageId: pkgId, + interfaceId: ifaceId, + hostId, + internalPort, + }) } } catch { // package may not be running / no interfaces yet — skip @@ -160,7 +141,7 @@ export const importPublicHostnames = sdk.Action.withoutInput( const ingressUpdates: Record< string, { - packageId: string | null + packageId: string hostId: string interfaceId: string internalPort: number @@ -170,54 +151,63 @@ export const importPublicHostnames = sdk.Action.withoutInput( > = {} for (const rule of newRules) { + if (!isWholeHostnameRule(rule)) { + skipped.push( + `${rule.hostname} (path-specific routes remain managed in Cloudflare)`, + ) + continue + } + // Only import hostnames that belong to a zone we know about - const matchedZone = knownZones.find( - ([, z]) => - rule.hostname.endsWith(`.${z.zoneName}`) || - rule.hostname === z.zoneName, - ) + const matchedZone = knownZones.find(([, zone]) => { + const hostname = normalizedHostname(rule.hostname) + const zoneName = normalizedHostname(zone.zoneName) + return hostname.endsWith(`.${zoneName}`) || hostname === zoneName + }) if (!matchedZone) { skipped.push(`${rule.hostname} (not in any configured zone)`) continue } const zoneId = matchedZone[0] - const parsed = parseServiceUrl(rule.service) + const parsed = parseLegacyServiceTarget(rule.service) if (!parsed) { skipped.push(`${rule.hostname} (unrecognised service: ${rule.service})`) continue } const { packageId, internalPort } = parsed - const key = packageId - ? `${packageId}:${internalPort}` - : `cloudflared:${internalPort}` // STARTOS itself unlikely but handled + const key = `${packageId}:${internalPort}` let match: | { - packageId: string | null + packageId: string interfaceId: string hostId: string internalPort: number } | undefined - if (packageId) { + if (packageId !== 'start-os') { const candidates = interfaceMap.get(key) - match = candidates?.[0] // take the first matching interface for this package+port + const hostIds = new Set( + candidates?.map((candidate) => candidate.hostId), + ) + if (hostIds.size === 1) match = candidates?.[0] } - if (!match && packageId) { + if (!match && packageId !== 'start-os') { + const candidates = interfaceMap.get(key) skipped.push( - `${rule.hostname} (no matching interface found for ${packageId}:${internalPort})`, + `${rule.hostname} (${candidates?.length ? 'multiple matching interfaces' : 'no matching interface found'} for ${packageId}:${internalPort})`, ) continue } ingressUpdates[rule.hostname] = { - packageId: packageId ?? null, - hostId: match?.hostId ?? 'main', - interfaceId: match?.interfaceId ?? 'main', + packageId, + hostId: match?.hostId ?? 'admin', + interfaceId: match?.interfaceId ?? 'admin-ui', internalPort, service: rule.service, zoneId, @@ -225,8 +215,79 @@ export const importPublicHostnames = sdk.Action.withoutInput( imported++ } - if (imported > 0) { - await store.merge(effects, { ingress: ingressUpdates }) + const currentEntries = Object.entries(conf.ingress ?? {}).filter( + (item): item is [string, NonNullable<(typeof item)[1]>] => !!item[1], + ) + const upserts: Record = Object.fromEntries([ + ...currentEntries.map(([hostname, entry]) => [ + hostname, + { + packageId: entry.packageId, + hostId: entry.hostId, + interfaceId: entry.interfaceId, + internalPort: entry.internalPort, + zoneId: entry.zoneId, + }, + ]), + ...Object.entries(ingressUpdates).map(([hostname, entry]) => [ + hostname, + { + packageId: entry.packageId, + hostId: entry.hostId, + interfaceId: entry.interfaceId, + internalPort: entry.internalPort, + zoneId: entry.zoneId, + }, + ]), + ]) + const expectedServices = Object.fromEntries([ + ...currentEntries.map(([hostname, entry]) => [hostname, entry.service]), + ...Object.entries(ingressUpdates).map(([hostname, entry]) => [ + hostname, + entry.service, + ]), + ]) + + let reconciled = false + try { + // Update Cloudflare first. Local ownership is recorded only after the + // complete preservation-aware update succeeds. + const result = await updateCloudflareIngress( + effects, + { + accountId: tunnelZone.accountId, + tunnelId: conf.tunnel.id, + apiToken: tunnelZone.apiToken, + }, + upserts, + [], + expectedServices, + ) + reconciled = result.updated + await store.merge(effects, { + tunnel: { ...conf.tunnel, accountId: tunnelZone.accountId }, + ingress: { + ...associateMigratedIngressWithZones( + result.migratedIngress, + conf.zones, + ), + ...result.ingress, + }, + repairRequired: false, + repairMessage: null, + }) + await sdk.action.clearTask(effects, 'repair-cloudflare-routes') + } catch (error) { + const summary = summarizeCloudflareError(error) + console.error( + `Failed to import and reconcile Cloudflare routes: ${summary}`, + ) + return { + version: '1', + title: 'Cloudflare Import Failed', + message: `Could not safely import and update the Cloudflare routes. No local routes were imported. ${summary}`, + result: null, + } } const lines: string[] = [] @@ -236,6 +297,7 @@ export const importPublicHostnames = sdk.Action.withoutInput( ) if (skipped.length > 0) lines.push(`Skipped ${skipped.length}: ${skipped.join('; ')}`) + if (reconciled) lines.push('Updated legacy routes in Cloudflare.') return { version: '1', diff --git a/startos/actions/index.ts b/startos/actions/index.ts index cfa5150..4e5ca0a 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -6,6 +6,7 @@ import { addPublicHostname } from './addPublicHostname' import { deletePublicHostname } from './deletePublicHostname' import { importPublicHostnames } from './importPublicHostnames' import { managedOverview } from './managedOverview' +import { repairRoutes } from './repairRoutes' export const actions = sdk.Actions.of() .addAction(cloudflareLogin) @@ -15,3 +16,4 @@ export const actions = sdk.Actions.of() .addAction(deletePublicHostname) .addAction(importPublicHostnames) .addAction(managedOverview) + .addAction(repairRoutes) diff --git a/startos/actions/repairRoutes.ts b/startos/actions/repairRoutes.ts new file mode 100644 index 0000000..8623de9 --- /dev/null +++ b/startos/actions/repairRoutes.ts @@ -0,0 +1,38 @@ +import { reconcileIngressOnce } from '../init/reconcileIngress' +import { sdk } from '../sdk' +import { i18n } from '../i18n' + +export const repairRoutes = sdk.Action.withoutInput( + 'repair-routes', + async () => ({ + name: i18n('Repair Cloudflare Routes'), + description: i18n( + 'Reconnect managed Cloudflare routes to their selected StartOS services.', + ), + warning: null, + allowedStatuses: 'any', + group: 'Configuration', + visibility: 'enabled', + }), + async ({ effects }) => { + try { + await reconcileIngressOnce(effects) + return { + version: '1', + title: i18n('Cloudflare Routes Repaired'), + message: i18n('Managed Cloudflare routes were updated successfully.'), + result: null, + } + } catch (error) { + console.error('Cloudflare route repair failed:', error) + return { + version: '1', + title: i18n('Cloudflare Route Repair Failed'), + message: i18n( + 'Cloudflare routes could not be updated safely. Check the service logs, then try again.', + ), + result: null, + } + } + }, +) diff --git a/startos/actions/selectTunnel.ts b/startos/actions/selectTunnel.ts index 8297273..f4af6ed 100644 --- a/startos/actions/selectTunnel.ts +++ b/startos/actions/selectTunnel.ts @@ -2,6 +2,7 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' import { runCf } from '../cfRunner' import { i18n } from '../i18n' +import { metricsHostId, metricsInterfaceId, metricsPort } from '../interfaces' const { InputSpec, Value, Variants } = sdk @@ -120,12 +121,14 @@ export const selectTunnel = sdk.Action.withInput( // Infer server name from mDNS for new tunnel default let serverName: string | null = null try { - const mdnsUrl = await sdk.serviceInterface + const mdnsUrl = await sdk.host .getOwn( effects!, - 'metrics', - (iface) => - iface?.addressInfo?.nonLocal + metricsHostId, + (host) => + host?.bindings[metricsPort]?.interfaces[ + metricsInterfaceId + ]?.addressInfo?.nonLocal .filter({ kind: 'mdns' }) ?.format()[0], ) diff --git a/startos/cfApi.ts b/startos/cfApi.ts index 9de28bc..6fbd31d 100644 --- a/startos/cfApi.ts +++ b/startos/cfApi.ts @@ -1,5 +1,3 @@ -import { IngressEntry } from './fileModels/store.yaml' - const CF_API = 'https://api.cloudflare.com/client/v4' type CloudflareBody = { @@ -9,6 +7,17 @@ type CloudflareBody = { result?: any } +export type CloudflareIngressRule = { + hostname?: string + service?: string + [key: string]: unknown +} + +export type CloudflareTunnelConfig = { + ingress: CloudflareIngressRule[] + [key: string]: unknown +} + export class CloudflareApiError extends Error { context: string status: number @@ -106,41 +115,94 @@ async function fetchCloudflare( } } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + /** - * Push ingress rules to the Cloudflare API. - * This is the single source of truth for tunnel ingress when source=cloudflare. - * Always appends the required catch-all rule. + * Fetch the complete remotely-managed tunnel configuration. + * + * Callers must preserve this object when changing ingress. Cloudflare's PUT + * endpoint replaces the complete configuration, not just the supplied rules. */ -export async function pushIngressToApi( +export async function fetchTunnelConfig( accountId: string, tunnelId: string, apiToken: string, - ingress: Record, -): Promise { - const rules: Array<{ hostname?: string; service: string }> = [] +): Promise { + const data = await fetchCloudflare( + `${CF_API}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, + { headers: authHeaders(apiToken) }, + `Failed to fetch Cloudflare tunnel configuration for tunnel ${tunnelId}`, + ) - for (const [hostname, entry] of Object.entries(ingress)) { - if (!entry) continue - rules.push({ hostname, service: entry.service }) + const config = isRecord(data.result) ? data.result.config : null + if (!isRecord(config)) { + throw new Error( + `Cloudflare returned no usable configuration for tunnel ${tunnelId}. Refusing to replace it.`, + ) } - // Required catch-all - rules.push({ service: 'http_status:404' }) + const ingress = config.ingress + if (!Array.isArray(ingress) || !ingress.every(isRecord)) { + throw new Error( + `Cloudflare returned malformed ingress rules for tunnel ${tunnelId}. Refusing to replace them.`, + ) + } - await fetchCloudflare( - `${CF_API}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, - { - method: 'PUT', - headers: authHeaders(apiToken), - body: JSON.stringify({ - config: { - ingress: rules, - 'warp-routing': { enabled: false }, - }, - }), - }, - `Failed to update Cloudflare tunnel configuration for tunnel ${tunnelId}`, - ) + return { + ...config, + ingress: ingress as CloudflareIngressRule[], + } +} + +/** + * Read, mutate, and replace a complete tunnel configuration. + * + * The updater receives the live Cloudflare object so it can retain every + * unowned route and field. Throwing from the updater prevents the PUT. + */ +export async function updateTunnelConfig( + accountId: string, + tunnelId: string, + apiToken: string, + updater: ( + config: CloudflareTunnelConfig, + ) => Promise | CloudflareTunnelConfig, +): Promise { + let current = await fetchTunnelConfig(accountId, tunnelId, apiToken) + + for (let attempt = 0; attempt < 3; attempt += 1) { + const next = await updater(current) + const currentJson = JSON.stringify(current) + if (JSON.stringify(next) === currentJson) return false + + // Resolving StartOS bindings can take time. Re-read immediately before the + // full-config PUT and rebase if a dashboard edit landed in the meantime. + const latest = await fetchTunnelConfig(accountId, tunnelId, apiToken) + if (JSON.stringify(latest) !== currentJson) { + if (attempt === 2) { + throw new Error( + `Cloudflare tunnel ${tunnelId} kept changing while preparing an update. Refusing to replace a newer configuration.`, + ) + } + current = latest + continue + } + + await fetchCloudflare( + `${CF_API}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, + { + method: 'PUT', + headers: authHeaders(apiToken), + body: JSON.stringify({ config: next }), + }, + `Failed to update Cloudflare tunnel configuration for tunnel ${tunnelId}`, + ) + return true + } + + return false } /** @@ -151,19 +213,23 @@ export async function fetchIngressFromApi( accountId: string, tunnelId: string, apiToken: string, -): Promise> { - const data = await fetchCloudflare( - `${CF_API}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, - { headers: authHeaders(apiToken) }, - `Failed to fetch Cloudflare tunnel configuration for tunnel ${tunnelId}`, - ) - - return ( - (data.result?.config?.ingress as Array<{ - hostname?: string +): Promise< + Array< + CloudflareIngressRule & { + hostname: string service: string - }>) ?? [] - ).filter((r): r is { hostname: string; service: string } => !!r.hostname) + } + > +> { + const config = await fetchTunnelConfig(accountId, tunnelId, apiToken) + return config.ingress.filter( + ( + rule, + ): rule is CloudflareIngressRule & { + hostname: string + service: string + } => typeof rule.hostname === 'string' && typeof rule.service === 'string', + ) } export type DeleteDnsRecordResult = { diff --git a/startos/cfRunner.ts b/startos/cfRunner.ts index d9a74cb..fb4d128 100644 --- a/startos/cfRunner.ts +++ b/startos/cfRunner.ts @@ -47,7 +47,7 @@ export async function runCf( if (result.stderr) console.info(result.stderr) if (result.exitCode !== 0) { throw new Error( - `cloudflared ${args[0]} failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`, + `cloudflared ${args[0]} failed (exit ${result.exitCode}): ${(result.stderr || result.stdout).toString()}`, ) } return result.stdout.toString() diff --git a/startos/fileModels/store.yaml.ts b/startos/fileModels/store.yaml.ts index cea9ae9..f5cde93 100644 --- a/startos/fileModels/store.yaml.ts +++ b/startos/fileModels/store.yaml.ts @@ -1,14 +1,24 @@ import { z, FileHelper, T } from '@start9labs/start-sdk' import { sdk } from '../sdk' -export const ingressEntryShape = z.object({ - packageId: z.string().nullable(), - hostId: z.string().catch('main'), - interfaceId: z.string(), - internalPort: z.number(), - service: z.string(), - zoneId: z.string().catch(''), // which zone's DNS this hostname was created in -}) +export const ingressEntryShape = z + .object({ + packageId: z + .union([z.string(), z.null()]) + .transform((value) => + value === null || value === 'STARTOS' ? 'start-os' : value, + ), + hostId: z.string().catch('main'), + interfaceId: z.string(), + internalPort: z.number(), + service: z.string(), + zoneId: z.string().catch(''), // which zone's DNS this hostname was created in + }) + .transform((entry) => + entry.packageId === 'start-os' + ? { ...entry, hostId: 'admin', interfaceId: 'admin-ui' } + : entry, + ) export type IngressEntry = z.infer @@ -33,6 +43,8 @@ const shape = z.object({ tunnel: tunnelInfoShape.nullable().catch(null), zones: z.record(z.string(), zoneInfoShape.nullish()).catch({}), ingress: z.record(z.string(), ingressEntryShape.nullable()).catch({}), + repairRequired: z.boolean().catch(false), + repairMessage: z.string().nullable().catch(null), }) export type StoreType = z.infer @@ -52,6 +64,8 @@ export const createDefaultStore = async (effects: T.Effects) => { tunnel: null, zones: {}, ingress: {}, + repairRequired: false, + repairMessage: null, }) } } diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index 7fe3077..f2a4781 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -48,6 +48,7 @@ const dict = { 'Scan existing public hostnames from your Cloudflare tunnel and add URLs to matching installed services.': 261, 'This will scan existing public hostnames from the Cloudflare tunnel and add URLs to matching installed services.': 262, 'No new public hostnames found in Cloudflare that are not already tracked.': 263, + 'Login to a Cloudflare DNS zone in the same account as the selected tunnel, then try again.': 264, // actions/deletePublicHostname.ts 'Delete Public Hostname': 250, @@ -78,6 +79,18 @@ const dict = { 'Internal Target': 290, 'Application Routes': 291, 'No application routes are currently managed in this zone': 292, + + // actions/repairRoutes.ts and init/setupTasks.ts + 'Repair Cloudflare Routes': 300, + 'Reconnect managed Cloudflare routes to their selected StartOS services.': 301, + 'Cloudflare Routes Repaired': 302, + 'Managed Cloudflare routes were updated successfully.': 303, + 'Cloudflare Route Repair Failed': 304, + 'Cloudflare routes could not be updated safely. Check the service logs, then try again.': 305, + 'Login to Cloudflare to configure a DNS zone': 306, + 'Select or create a Cloudflare tunnel for this server': 307, + 'Cloudflare routes need to be reconnected to their selected StartOS services.': 308, + 'No Cloudflare zone credentials are available to repair tunnel routes.': 309, } as const /** diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index 10a140f..4ef53fa 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -49,6 +49,7 @@ export default { 261: 'Escanea los nombres de host públicos existentes en tu túnel Cloudflare y agrega URLs a los servicios instalados coincidentes.', 262: 'Esto escaneará los nombres de host públicos existentes del túnel Cloudflare y agregará URLs a los servicios instalados coincidentes.', 263: 'No se encontraron nuevos nombres de host públicos en Cloudflare que no estén ya registrados.', + 264: 'Inicia sesión en una zona DNS de Cloudflare de la misma cuenta que el túnel seleccionado y vuelve a intentarlo.', // actions/deletePublicHostname.ts 250: 'Eliminar nombre de host público', @@ -79,6 +80,16 @@ export default { 290: 'Destino interno', 291: 'Rutas de la aplicación', 292: 'Actualmente no se gestionan rutas de aplicación en esta zona', + 300: 'Reparar rutas de Cloudflare', + 301: 'Vuelve a conectar las rutas de Cloudflare gestionadas con los servicios de StartOS seleccionados.', + 302: 'Rutas de Cloudflare reparadas', + 303: 'Las rutas de Cloudflare gestionadas se actualizaron correctamente.', + 304: 'Error al reparar las rutas de Cloudflare', + 305: 'Las rutas de Cloudflare no se pudieron actualizar de forma segura. Revisa los registros del servicio y vuelve a intentarlo.', + 306: 'Inicia sesión en Cloudflare para configurar una zona DNS', + 307: 'Selecciona o crea un túnel de Cloudflare para este servidor', + 308: 'Las rutas de Cloudflare deben volver a conectarse con los servicios de StartOS seleccionados.', + 309: 'No hay credenciales de zona de Cloudflare disponibles para reparar las rutas del túnel.', }, de_DE: { // main.ts @@ -128,6 +139,7 @@ export default { 261: 'Scannt vorhandene öffentliche Hostnamen aus deinem Cloudflare-Tunnel und fügt URLs zu passenden installierten Diensten hinzu.', 262: 'Dies scannt vorhandene öffentliche Hostnamen aus dem Cloudflare-Tunnel und fügt URLs zu passenden installierten Diensten hinzu.', 263: 'Keine neuen öffentlichen Hostnamen in Cloudflare gefunden, die noch nicht erfasst sind.', + 264: 'Melde dich bei einer Cloudflare-DNS-Zone im selben Konto wie der ausgewählte Tunnel an und versuche es erneut.', // actions/deletePublicHostname.ts 250: 'Öffentlichen Hostnamen löschen', @@ -158,6 +170,16 @@ export default { 290: 'Internes Ziel', 291: 'Anwendungsrouten', 292: 'In dieser Zone werden derzeit keine Anwendungsrouten verwaltet', + 300: 'Cloudflare-Routen reparieren', + 301: 'Verbindet verwaltete Cloudflare-Routen erneut mit den ausgewählten StartOS-Diensten.', + 302: 'Cloudflare-Routen repariert', + 303: 'Die verwalteten Cloudflare-Routen wurden erfolgreich aktualisiert.', + 304: 'Reparatur der Cloudflare-Routen fehlgeschlagen', + 305: 'Die Cloudflare-Routen konnten nicht sicher aktualisiert werden. Prüfe die Dienstprotokolle und versuche es erneut.', + 306: 'Bei Cloudflare anmelden, um eine DNS-Zone einzurichten', + 307: 'Einen Cloudflare-Tunnel für diesen Server auswählen oder erstellen', + 308: 'Die Cloudflare-Routen müssen erneut mit den ausgewählten StartOS-Diensten verbunden werden.', + 309: 'Für die Reparatur der Tunnelrouten sind keine Cloudflare-Zonenzugangsdaten verfügbar.', }, pl_PL: { // main.ts @@ -207,6 +229,7 @@ export default { 261: 'Skanuje istniejące publiczne nazwy hostów z tunelu Cloudflare i dodaje URL-e do pasujących zainstalowanych usług.', 262: 'Spowoduje to skanowanie istniejących publicznych nazw hostów z tunelu Cloudflare i dodanie URL-i do pasujących zainstalowanych usług.', 263: 'Nie znaleziono nowych publicznych nazw hostów w Cloudflare, które nie są jeszcze śledzone.', + 264: 'Zaloguj się do strefy DNS Cloudflare na tym samym koncie co wybrany tunel, a następnie spróbuj ponownie.', // actions/deletePublicHostname.ts 250: 'Usuń publiczną nazwę hosta', @@ -237,6 +260,16 @@ export default { 290: 'Cel wewnętrzny', 291: 'Trasy aplikacji', 292: 'W tej strefie nie są obecnie zarządzane żadne trasy aplikacji', + 300: 'Napraw trasy Cloudflare', + 301: 'Ponownie połącz zarządzane trasy Cloudflare z wybranymi usługami StartOS.', + 302: 'Trasy Cloudflare naprawione', + 303: 'Zarządzane trasy Cloudflare zostały pomyślnie zaktualizowane.', + 304: 'Naprawa tras Cloudflare nie powiodła się', + 305: 'Nie udało się bezpiecznie zaktualizować tras Cloudflare. Sprawdź dzienniki usługi i spróbuj ponownie.', + 306: 'Zaloguj się do Cloudflare, aby skonfigurować strefę DNS', + 307: 'Wybierz lub utwórz tunel Cloudflare dla tego serwera', + 308: 'Trasy Cloudflare muszą zostać ponownie połączone z wybranymi usługami StartOS.', + 309: 'Brak danych logowania strefy Cloudflare potrzebnych do naprawy tras tunelu.', }, fr_FR: { // main.ts @@ -286,6 +319,7 @@ export default { 261: "Analyse les noms d'h\u00f4tes publics existants dans votre tunnel Cloudflare et ajoute des URLs aux services install\u00e9s correspondants.", 262: "Cette action analysera les noms d'h\u00f4tes publics existants du tunnel Cloudflare et ajoutera des URLs aux services install\u00e9s correspondants.", 263: "Aucun nouveau nom d'h\u00f4te public trouv\u00e9 dans Cloudflare qui ne soit pas d\u00e9j\u00e0 suivi.", + 264: 'Connectez-vous à une zone DNS Cloudflare du même compte que le tunnel sélectionné, puis réessayez.', // actions/deletePublicHostname.ts 250: "Supprimer le nom d'h\u00f4te public", @@ -295,7 +329,7 @@ export default { // actions/managedOverview.ts 270: 'Routes publiques gérées', 271: 'Affiche les zones DNS, le tunnel et les routes applicatives Cloudflare actuellement gérés par ce paquet.', - 272: 'Affiche les zones DNS, le tunnel et les noms d\'hôte publics actuellement gérés par ce paquet.', + 272: "Affiche les zones DNS, le tunnel et les noms d'hôte publics actuellement gérés par ce paquet.", 273: 'Tunnel Cloudflare', 274: 'Aucun tunnel sélectionné', 275: 'Nom du tunnel', @@ -305,16 +339,26 @@ export default { 279: 'Aucune zone DNS configurée', 280: 'Nom de domaine', 281: 'ID de zone', - 282: 'Noms d\'hôte gérés', + 282: "Noms d'hôte gérés", 283: 'Routes applicatives gérées', - 284: 'Aucun nom d\'hôte public n\'est actuellement géré', + 284: "Aucun nom d'hôte public n'est actuellement géré", 285: 'URL publique', 286: 'Paquet', 287: 'Serveur StartOS', 288: 'Zone DNS', - 289: 'ID de l\'interface', + 289: "ID de l'interface", 290: 'Cible interne', 291: 'Routes applicatives', - 292: 'Aucune route applicative n\'est actuellement gérée dans cette zone', + 292: "Aucune route applicative n'est actuellement gérée dans cette zone", + 300: 'Réparer les routes Cloudflare', + 301: 'Reconnecte les routes Cloudflare gérées aux services StartOS sélectionnés.', + 302: 'Routes Cloudflare réparées', + 303: 'Les routes Cloudflare gérées ont été mises à jour.', + 304: 'Échec de la réparation des routes Cloudflare', + 305: "Les routes Cloudflare n'ont pas pu être mises à jour en toute sécurité. Consultez les journaux du service, puis réessayez.", + 306: 'Connectez-vous à Cloudflare pour configurer une zone DNS', + 307: 'Sélectionnez ou créez un tunnel Cloudflare pour ce serveur', + 308: 'Les routes Cloudflare doivent être reconnectées aux services StartOS sélectionnés.', + 309: "Aucun identifiant de zone Cloudflare n'est disponible pour réparer les routes du tunnel.", }, } satisfies Record diff --git a/startos/init/index.ts b/startos/init/index.ts index c31a7ba..2e02d53 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -7,6 +7,7 @@ import { seedStore } from './seedStore' import { exportUrls, registerUrlPlugin } from '../plugin/url' import { setupTasks } from './setupTasks' import { setupZones } from './setupZones' +import { reconcileIngress } from './reconcileIngress' export const init = sdk.setupInit( restoreInit, @@ -16,6 +17,7 @@ export const init = sdk.setupInit( setInterfaces, actions, registerUrlPlugin, + reconcileIngress, setupTasks, exportUrls, ) diff --git a/startos/init/reconcileIngress.ts b/startos/init/reconcileIngress.ts new file mode 100644 index 0000000..fee4494 --- /dev/null +++ b/startos/init/reconcileIngress.ts @@ -0,0 +1,571 @@ +import { T } from '@start9labs/start-sdk' +import { + CloudflareIngressRule, + CloudflareTunnelConfig, + summarizeCloudflareError, + updateTunnelConfig, +} from '../cfApi' +import { IngressEntry, StoreType, store } from '../fileModels/store.yaml' +import { sdk } from '../sdk' +import { i18n } from '../i18n' + +export type StableIngress = Omit + +type LegacyServiceTarget = { + packageId: string + internalPort: number + url: URL +} + +type CloudflareCredentials = { + accountId: string + tunnelId: string + apiToken: string +} + +function normalizedHostname(hostname: string) { + return hostname.trim().toLowerCase() +} + +export function isWholeHostnameRule(rule: CloudflareIngressRule) { + return typeof rule.path !== 'string' || rule.path.length === 0 +} + +/** Parse beta.9 StartOS targets without treating ordinary remote origins as owned. */ +export function parseLegacyServiceTarget( + service: string, +): LegacyServiceTarget | null { + try { + const url = new URL(service) + const hostname = url.hostname.toLowerCase() + const internalPort = + Number(url.port) || + (url.protocol === 'http:' ? 80 : url.protocol === 'https:' ? 443 : 0) + if (!internalPort) return null + + if (hostname === 'startos') { + return { packageId: 'start-os', internalPort, url } + } + if (!hostname.endsWith('.startos')) return null + + const packageId = hostname.slice(0, -'.startos'.length) + if (!packageId) return null + return { packageId, internalPort, url } + } catch { + return null + } +} + +function serviceWithAddress(url: URL, address: string) { + const next = new URL(url.toString()) + const separator = address.lastIndexOf(':') + if (separator < 1) + throw new Error(`Invalid StartOS bridge address: ${address}`) + next.hostname = address.slice(0, separator) + next.port = address.slice(separator + 1) + const result = next.toString() + return next.pathname === '/' && !next.search && !next.hash + ? result.replace(/\/$/, '') + : result +} + +async function resolveService( + effects: T.Effects, + entry: StableIngress, + reactive: boolean, +) { + const osIp = await sdk.getOsIp(effects) + const address = sdk.host.get( + effects, + { packageId: entry.packageId, hostId: entry.hostId }, + (host) => { + const port = host?.bindings[entry.internalPort]?.net.assignedPort + return port == null ? null : `${osIp}:${port}` + }, + ) + const value = reactive ? await address.const() : await address.once() + return value ? `http://${value}` : null +} + +export async function resolveIngressServiceOnce( + effects: T.Effects, + entry: StableIngress, +) { + return resolveService(effects, entry, false) +} + +async function resolveLegacyService( + effects: T.Effects, + target: LegacyServiceTarget, + reactive: boolean, +) { + let hostId = 'admin' + let interfaceId = 'admin-ui' + + if (target.packageId !== 'start-os') { + let interfaces: Awaited> + try { + interfaces = await effects.listServiceInterfaces({ + packageId: target.packageId, + }) + } catch { + throw new Error( + `Could not inspect ${target.packageId} while migrating ${target.url.toString()}.`, + ) + } + + const matchingInterfaces = Object.entries(interfaces).filter( + ([, iface]) => iface.addressInfo.internalPort === target.internalPort, + ) + const matchingHostIds = [ + ...new Set( + matchingInterfaces.map(([, iface]) => iface.addressInfo.hostId), + ), + ] + + if (matchingHostIds.length !== 1) { + const reason = + matchingHostIds.length === 0 + ? 'no matching interface exists' + : 'multiple matching interfaces exist' + throw new Error( + `Cannot safely migrate ${target.url.toString()}: ${reason} for ${target.packageId}:${target.internalPort}. The live Cloudflare configuration was not changed.`, + ) + } + hostId = matchingHostIds[0] + interfaceId = matchingInterfaces.find( + ([, iface]) => iface.addressInfo.hostId === hostId, + )![0] + } + + const address = await resolveService( + effects, + { + packageId: target.packageId, + hostId, + interfaceId: target.packageId === 'start-os' ? 'admin-ui' : '', + internalPort: target.internalPort, + zoneId: '', + }, + reactive, + ) + if (!address) { + throw new Error( + `Cannot resolve a StartOS bridge address for ${target.url.toString()}. The live Cloudflare configuration was not changed.`, + ) + } + + return { + service: serviceWithAddress(target.url, address.slice('http://'.length)), + entry: { + packageId: target.packageId, + hostId, + interfaceId, + internalPort: target.internalPort, + zoneId: '', + }, + } +} + +function mergeIngressRules( + config: CloudflareTunnelConfig, + upserts: Record, + removals: Set, + migratedRules: CloudflareIngressRule[], +): CloudflareTunnelConfig { + const normalizedUpserts = new Map( + Object.entries(upserts).map(([hostname, entry]) => [ + normalizedHostname(hostname), + { hostname, entry }, + ]), + ) + const matched = new Set() + const rules: CloudflareIngressRule[] = [] + + for (const hostname of new Set([...normalizedUpserts.keys(), ...removals])) { + const matches = migratedRules.filter( + (rule) => + typeof rule.hostname === 'string' && + normalizedHostname(rule.hostname) === hostname && + isWholeHostnameRule(rule), + ) + if (matches.length > 1) { + throw new Error( + `Cloudflare has multiple whole-hostname rules for ${hostname}. Refusing to guess which route is managed; the live configuration was not changed.`, + ) + } + } + + for (const rule of migratedRules) { + const hostname = + typeof rule.hostname === 'string' + ? normalizedHostname(rule.hostname) + : null + if (hostname && removals.has(hostname) && isWholeHostnameRule(rule)) { + continue + } + + const upsert = + hostname && isWholeHostnameRule(rule) + ? normalizedUpserts.get(hostname) + : undefined + if (upsert) { + matched.add(hostname!) + rules.push({ ...rule, service: upsert.entry.service }) + } else { + rules.push(rule) + } + } + + const additions = [...normalizedUpserts.entries()] + .filter(([hostname]) => !matched.has(hostname) && !removals.has(hostname)) + .map(([, { hostname, entry }]) => ({ + hostname, + service: entry.service, + })) + + const fallbackIndex = rules.findIndex( + (rule) => typeof rule.hostname !== 'string', + ) + if (fallbackIndex < 0) { + rules.push(...additions, { service: 'http_status:404' }) + } else { + rules.splice(fallbackIndex, 0, ...additions) + } + + return { ...config, ingress: rules } +} + +function assertMutationOwnership( + rules: CloudflareIngressRule[], + hostnames: Set, + expectedServices: Record, + desiredIngress: Record, +) { + const normalizedExpected = new Map( + Object.entries(expectedServices).map(([hostname, service]) => [ + normalizedHostname(hostname), + service, + ]), + ) + const normalizedDesired = new Map( + Object.entries(desiredIngress).map(([hostname, entry]) => [ + normalizedHostname(hostname), + entry.service, + ]), + ) + + for (const hostname of hostnames) { + const matches = rules.filter( + (rule) => + typeof rule.hostname === 'string' && + normalizedHostname(rule.hostname) === hostname && + isWholeHostnameRule(rule), + ) + if (matches.length > 1) { + throw new Error( + `Cloudflare has multiple whole-hostname rules for ${hostname}. Refusing to guess which route is managed; the live configuration was not changed.`, + ) + } + if (matches.length === 0) continue + + const expected = normalizedExpected.get(hostname) + const actual = matches[0].service + const desired = normalizedDesired.get(hostname) + if ( + actual !== desired && + (expected === undefined || expected === null || actual !== expected) + ) { + throw new Error( + `The live Cloudflare route for ${hostname} no longer matches the StartOS-managed route. Refusing to overwrite or remove it; import or reconcile the route manually first.`, + ) + } + } +} + +async function migrateLegacyRules( + effects: T.Effects, + rules: CloudflareIngressRule[], + skipHostnames: Set, + reactive: boolean, +) { + const resolved = new Map() + const resolvedEntries = new Map() + let migrated = 0 + const next: CloudflareIngressRule[] = [] + const ingress: Record = {} + + for (const rule of rules) { + const hostname = + typeof rule.hostname === 'string' + ? normalizedHostname(rule.hostname) + : null + if (hostname && skipHostnames.has(hostname) && isWholeHostnameRule(rule)) { + next.push(rule) + continue + } + + const target = + typeof rule.service === 'string' + ? parseLegacyServiceTarget(rule.service) + : null + if (!target) { + next.push(rule) + continue + } + + const cacheKey = target.url.toString() + let service = resolved.get(cacheKey) + let entry = resolvedEntries.get(cacheKey) + if (!service) { + const result = await resolveLegacyService(effects, target, reactive) + service = result.service + entry = result.entry + resolved.set(cacheKey, result.service) + resolvedEntries.set(cacheKey, result.entry) + } + next.push({ ...rule, service }) + if (hostname && entry && isWholeHostnameRule(rule)) { + ingress[rule.hostname!] = { ...entry, service } + } + migrated += 1 + } + + return { rules: next, ingress, migrated } +} + +export function associateMigratedIngressWithZones( + ingress: Record, + zones: StoreType['zones'], +) { + const knownZones = Object.entries(zones) + .filter( + (item): item is [string, NonNullable<(typeof item)[1]>] => + item[1] !== null && item[1] !== undefined, + ) + .sort(([, left], [, right]) => right.zoneName.length - left.zoneName.length) + const associated: Record = {} + + for (const [hostname, entry] of Object.entries(ingress)) { + const normalized = normalizedHostname(hostname) + const zone = knownZones.find( + ([, value]) => + normalized === normalizedHostname(value.zoneName) || + normalized.endsWith(`.${normalizedHostname(value.zoneName)}`), + ) + if (zone) associated[hostname] = { ...entry, zoneId: zone[0] } + } + + return associated +} + +/** + * Safely update owned routes while retaining the complete live Cloudflare + * configuration. Legacy .startos targets are migrated in the same atomic PUT. + */ +export async function updateCloudflareIngress( + effects: T.Effects, + credentials: CloudflareCredentials, + upserts: Record = {}, + removeHostnames: string[] = [], + expectedServices: Record = {}, + reactive = false, +) { + const resolvedUpserts: Record = {} + for (const [hostname, entry] of Object.entries(upserts)) { + const service = await resolveService(effects, entry, reactive) + if (!service) { + throw new Error( + `Could not resolve ${entry.packageId}/${entry.hostId}:${entry.internalPort} for ${hostname}.`, + ) + } + resolvedUpserts[hostname] = { ...entry, service } + } + + const removals = new Set(removeHostnames.map(normalizedHostname)) + const skipHostnames = new Set([ + ...removals, + ...Object.keys(resolvedUpserts).map(normalizedHostname), + ]) + let migrated = 0 + let migratedIngress: Record = {} + + const updated = await updateTunnelConfig( + credentials.accountId, + credentials.tunnelId, + credentials.apiToken, + async (config) => { + assertMutationOwnership( + config.ingress, + new Set([ + ...removals, + ...Object.keys(resolvedUpserts).map(normalizedHostname), + ]), + expectedServices, + resolvedUpserts, + ) + const migration = await migrateLegacyRules( + effects, + config.ingress, + skipHostnames, + reactive, + ) + migrated = migration.migrated + migratedIngress = migration.ingress + return mergeIngressRules( + config, + resolvedUpserts, + removals, + migration.rules, + ) + }, + ) + + return { ingress: resolvedUpserts, migratedIngress, migrated, updated } +} + +async function setRepairState( + effects: T.Effects, + required: boolean, + message: string | null, +) { + await store.merge( + effects, + { repairRequired: required, repairMessage: message }, + { allowWriteAfterConst: true }, + ) + if (!required) await sdk.action.clearTask(effects, 'repair-cloudflare-routes') +} + +async function reconcileConfig( + effects: T.Effects, + conf: Pick & { + ingress: Record + }, + reactive: boolean, +) { + const entries = Object.entries(conf.ingress).filter( + (item): item is [string, IngressEntry] => item[1] !== null, + ) + if (!conf.tunnel) { + await setRepairState(effects, false, null) + return { repaired: 0, migrated: 0, updated: false } + } + + const zone = + Object.values(conf.zones).find( + (candidate) => + candidate && + (!conf.tunnel?.accountId || + candidate.accountId === conf.tunnel.accountId), + ) ?? Object.values(conf.zones).find(Boolean) + if (!zone) { + const message = i18n( + 'No Cloudflare zone credentials are available to repair tunnel routes.', + ) + await setRepairState(effects, true, message) + throw new Error(message) + } + + let result: Awaited> + try { + result = await updateCloudflareIngress( + effects, + { + accountId: zone.accountId, + tunnelId: conf.tunnel.id, + apiToken: zone.apiToken, + }, + Object.fromEntries( + entries.map(([hostname, entry]) => [ + hostname, + { + packageId: entry.packageId, + hostId: entry.hostId, + interfaceId: entry.interfaceId, + internalPort: entry.internalPort, + zoneId: entry.zoneId, + }, + ]), + ), + [], + Object.fromEntries( + entries.map(([hostname, entry]) => [hostname, entry.service]), + ), + reactive, + ) + } catch (error) { + console.error( + 'Cloudflare route reconciliation failed:', + summarizeCloudflareError(error), + ) + const message = i18n( + 'Cloudflare routes could not be updated safely. Check the service logs, then try again.', + ) + await setRepairState(effects, true, message) + throw error + } + + await store.merge( + effects, + { + ingress: { + ...associateMigratedIngressWithZones( + result.migratedIngress, + conf.zones, + ), + ...result.ingress, + }, + repairRequired: false, + repairMessage: null, + }, + { allowWriteAfterConst: true }, + ) + await sdk.action.clearTask(effects, 'repair-cloudflare-routes') + return { + repaired: entries.length + result.migrated, + migrated: result.migrated, + updated: result.updated, + } +} + +export async function reconcileIngressOnce(effects: T.Effects) { + const conf = await store.read().once() + if (!conf) return { repaired: 0, migrated: 0, updated: false } + return reconcileConfig( + effects, + { + tunnel: conf.tunnel, + zones: conf.zones, + ingress: Object.fromEntries( + Object.entries(conf.ingress).map(([hostname, entry]) => [ + hostname, + entry, + ]), + ), + }, + false, + ) +} + +export const reconcileIngress = sdk.setupOnInit(async (effects) => { + const conf = await store + .read((value) => ({ + tunnel: value.tunnel, + zones: value.zones, + ingress: Object.fromEntries( + Object.entries(value.ingress).map(([hostname, entry]) => [ + hostname, + entry, + ]), + ), + })) + .once() + if (!conf) return + + try { + await reconcileConfig(effects, conf, false) + } catch (error) { + console.error('Cloudflare route reconciliation failed:', error) + } +}) diff --git a/startos/init/setupTasks.ts b/startos/init/setupTasks.ts index dca4407..ee116d7 100644 --- a/startos/init/setupTasks.ts +++ b/startos/init/setupTasks.ts @@ -2,6 +2,8 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' import { cloudflareLogin } from '../actions/cloudflareLogin' import { selectTunnel } from '../actions/selectTunnel' +import { repairRoutes } from '../actions/repairRoutes' +import { i18n } from '../i18n' /** * Reactively manage required tasks. @@ -14,20 +16,33 @@ export const setupTasks = sdk.setupOnInit(async (effects) => { .read((conf) => ({ hasZone: Object.keys(conf.zones ?? {}).length > 0, hasTunnel: !!conf.tunnel, + repairRequired: conf.repairRequired, + repairMessage: conf.repairMessage, })) .const(effects) const hasZone = state?.hasZone ?? false if (!hasZone) { await sdk.action.createOwnTask(effects, cloudflareLogin, 'critical', { - reason: 'Login to Cloudflare to configure a DNS zone', + reason: i18n('Login to Cloudflare to configure a DNS zone'), }) return } if (!state?.hasTunnel) { await sdk.action.createOwnTask(effects, selectTunnel, 'critical', { - reason: 'Select or create a Cloudflare tunnel for this server', + reason: i18n('Select or create a Cloudflare tunnel for this server'), + }) + } + + if (state?.repairRequired) { + await sdk.action.createOwnTask(effects, repairRoutes, 'important', { + replayId: 'repair-cloudflare-routes', + reason: + state.repairMessage ?? + i18n( + 'Cloudflare routes need to be reconnected to their selected StartOS services.', + ), }) } }) diff --git a/startos/interfaces.ts b/startos/interfaces.ts index 3dc7cfe..b2c297d 100644 --- a/startos/interfaces.ts +++ b/startos/interfaces.ts @@ -1,14 +1,18 @@ import { sdk } from './sdk' +export const metricsHostId = 'metrics' +export const metricsInterfaceId = 'metrics' +export const metricsPort = 20241 + export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { - const uiMulti = sdk.MultiHost.of(effects, 'metrics') - const uiMultiOrigin = await uiMulti.bindPort(20241, { + const uiMulti = sdk.MultiHost.of(effects, metricsHostId) + const uiMultiOrigin = await uiMulti.bindPort(metricsPort, { protocol: 'http', }) const ui = sdk.createInterface(effects, { name: 'Metrics', - id: 'metrics', + id: metricsInterfaceId, description: 'Prometheus metrics endpoint', type: 'api', schemeOverride: null, diff --git a/startos/main.ts b/startos/main.ts index a8adf18..b658fda 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -16,7 +16,7 @@ export const main = sdk.setupMain(async ({ effects }) => { const credFile = `/root/.cloudflared/${tunnelId}.json` return sdk.Daemons.of(effects).addDaemon('primary', { - subcontainer: await sdk.SubContainer.of( + subcontainer: sdk.SubContainer.of( effects, { imageId: 'main', @@ -54,14 +54,10 @@ export const main = sdk.setupMain(async ({ effects }) => { ready: { display: i18n('Cloudflare tunnel'), fn: () => - sdk.healthCheck.checkWebUrl( - effects, - 'http://cloudflared.startos:20241/metrics', - { - successMessage: i18n('Cloudflare tunnel is running'), - errorMessage: i18n('Cloudflare tunnel is not running'), - }, - ), + sdk.healthCheck.checkWebUrl(effects, 'http://127.0.0.1:20241/metrics', { + successMessage: i18n('Cloudflare tunnel is running'), + errorMessage: i18n('Cloudflare tunnel is not running'), + }), }, requires: [], }) diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index c5b1068..3153f83 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -4,7 +4,7 @@ import { CLOUDFLARED_VERSION } from '../versions' export const manifest = setupManifest({ id: 'cloudflared', title: 'Cloudflare Tunnel', - license: 'Apache 2.0', + license: 'Apache-2.0', packageRepo: 'https://github.com/remcoros/cloudflared-startos', upstreamRepo: 'https://github.com/cloudflare/cloudflared', marketingUrl: 'https://cloudflare.com/', diff --git a/startos/versions/current.ts b/startos/versions/current.ts index 67cf3c7..c48d904 100644 --- a/startos/versions/current.ts +++ b/startos/versions/current.ts @@ -1,18 +1,38 @@ -import { VersionInfo } from '@start9labs/start-sdk' +import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' +import { i18n } from '../i18n' export const current = VersionInfo.of({ - version: '2026.6.1:0', + version: '2026.6.1:1', releaseNotes: { - en_US: 'Updated to cloudflared 2026.6.1', - es_ES: 'Actualizado a cloudflared 2026.6.1', - de_DE: 'Auf cloudflared 2026.6.1 aktualisiert', - pl_PL: 'Zaktualizowano do cloudflared 2026.6.1', - fr_FR: 'Mis à jour vers cloudflared 2026.6.1', + en_US: + 'Adds StartOS 0.4.0-beta.10 and Start SDK 2 compatibility, migrates the admin identity, and repairs Cloudflare routes through live bridge bindings.', + es_ES: + 'Añade compatibilidad con StartOS 0.4.0-beta.10 y Start SDK 2, migra la identidad de administración y repara las rutas de Cloudflare mediante enlaces dinámicos.', + de_DE: + 'Fügt Kompatibilität mit StartOS 0.4.0-beta.10 und Start SDK 2 hinzu, migriert die Admin-Identität und repariert Cloudflare-Routen über Live-Bridge-Bindungen.', + pl_PL: + 'Dodaje zgodność ze StartOS 0.4.0-beta.10 i Start SDK 2, migruje tożsamość administratora i naprawia trasy Cloudflare przez bieżące powiązania mostu.', + fr_FR: + 'Ajoute la compatibilité avec StartOS 0.4.0-beta.10 et Start SDK 2, migre l’identité administrateur et répare les routes Cloudflare via les liaisons bridge actives.', }, migrations: { - up: async ({ effects }) => {}, - down: async ({ effects }) => {}, + up: async ({ effects }) => { + const { store } = await import('../fileModels/store.yaml') + const conf = await store.read().once() + if (!conf) return + const hasIngress = Object.values(conf.ingress).some(Boolean) + await store.merge(effects, { + ingress: conf.ingress, + repairRequired: hasIngress, + repairMessage: hasIngress + ? i18n( + 'Cloudflare routes need to be reconnected to their selected StartOS services.', + ) + : null, + }) + }, + down: IMPOSSIBLE, }, }) -export const CLOUDFLARED_VERSION = '2026.6.1' \ No newline at end of file +export const CLOUDFLARED_VERSION = '2026.6.1' diff --git a/startos/versions/index.ts b/startos/versions/index.ts index d0d99f9..dce7f25 100644 --- a/startos/versions/index.ts +++ b/startos/versions/index.ts @@ -2,10 +2,11 @@ import { VersionGraph } from '@start9labs/start-sdk' import { current, CLOUDFLARED_VERSION } from './current' import { v2026_3_0 } from './v2026.3.0' import { v2026_5_2 } from './v2026.5.2' +import { v2026_6_1_0 } from './v2026.6.1.0' export const versionGraph = VersionGraph.of({ current, - other: [v2026_3_0, v2026_5_2], + other: [v2026_6_1_0, v2026_5_2, v2026_3_0], }) export { CLOUDFLARED_VERSION } diff --git a/startos/versions/v2026.6.1.0.ts b/startos/versions/v2026.6.1.0.ts new file mode 100644 index 0000000..7bb4d7a --- /dev/null +++ b/startos/versions/v2026.6.1.0.ts @@ -0,0 +1,16 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v2026_6_1_0 = VersionInfo.of({ + version: '2026.6.1:0', + releaseNotes: { + en_US: 'Updated to cloudflared 2026.6.1', + es_ES: 'Actualizado a cloudflared 2026.6.1', + de_DE: 'Auf cloudflared 2026.6.1 aktualisiert', + pl_PL: 'Zaktualizowano do cloudflared 2026.6.1', + fr_FR: 'Mis à jour vers cloudflared 2026.6.1', + }, + migrations: { + up: async () => {}, + down: async () => {}, + }, +}) diff --git a/tsconfig.json b/tsconfig.json index a2945a5..f032dc1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,4 @@ { - "include": ["startos/**/*.ts", "node_modules/**/startos"], - "compilerOptions": { - "target": "ES2018", - "module": "CommonJS", - "moduleResolution": "node", - "esModuleInterop": true, - "strict": true, - "skipLibCheck": true - } + "extends": "@start9labs/start-sdk/tsconfig.base.json", + "include": ["startos/**/*.ts", "node_modules/**/startos"] } From c0079bfbac4c480041697c602812c46383a4c802 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Fri, 24 Jul 2026 14:49:01 +0200 Subject: [PATCH 2/2] update to cloudlared 2026.7.3 --- startos/versions/current.ts | 35 +++++++++----------------------- startos/versions/index.ts | 3 ++- startos/versions/v2026.6.1.1.ts | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 26 deletions(-) create mode 100644 startos/versions/v2026.6.1.1.ts diff --git a/startos/versions/current.ts b/startos/versions/current.ts index c48d904..76d5ed5 100644 --- a/startos/versions/current.ts +++ b/startos/versions/current.ts @@ -1,38 +1,23 @@ -import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' -import { i18n } from '../i18n' +import { VersionInfo } from '@start9labs/start-sdk' export const current = VersionInfo.of({ - version: '2026.6.1:1', + version: '2026.7.3:0', releaseNotes: { en_US: - 'Adds StartOS 0.4.0-beta.10 and Start SDK 2 compatibility, migrates the admin identity, and repairs Cloudflare routes through live bridge bindings.', + 'Updates cloudflared to 2026.7.3 with QUIC precheck improvements and dependency security fixes. Full changes: https://github.com/cloudflare/cloudflared/compare/2026.6.1...2026.7.3', es_ES: - 'Añade compatibilidad con StartOS 0.4.0-beta.10 y Start SDK 2, migra la identidad de administración y repara las rutas de Cloudflare mediante enlaces dinámicos.', + 'Actualiza cloudflared a 2026.7.3 con mejoras en las comprobaciones previas de QUIC y correcciones de seguridad en dependencias. Cambios completos: https://github.com/cloudflare/cloudflared/compare/2026.6.1...2026.7.3', de_DE: - 'Fügt Kompatibilität mit StartOS 0.4.0-beta.10 und Start SDK 2 hinzu, migriert die Admin-Identität und repariert Cloudflare-Routen über Live-Bridge-Bindungen.', + 'Aktualisiert cloudflared auf 2026.7.3 mit verbesserten QUIC-Vorabprüfungen und Sicherheitskorrekturen für Abhängigkeiten. Vollständige Änderungen: https://github.com/cloudflare/cloudflared/compare/2026.6.1...2026.7.3', pl_PL: - 'Dodaje zgodność ze StartOS 0.4.0-beta.10 i Start SDK 2, migruje tożsamość administratora i naprawia trasy Cloudflare przez bieżące powiązania mostu.', + 'Aktualizuje cloudflared do wersji 2026.7.3, dodając ulepszenia wstępnych kontroli QUIC i poprawki bezpieczeństwa zależności. Pełna lista zmian: https://github.com/cloudflare/cloudflared/compare/2026.6.1...2026.7.3', fr_FR: - 'Ajoute la compatibilité avec StartOS 0.4.0-beta.10 et Start SDK 2, migre l’identité administrateur et répare les routes Cloudflare via les liaisons bridge actives.', + 'Met à jour cloudflared vers 2026.7.3 avec des améliorations des vérifications préalables QUIC et des correctifs de sécurité pour les dépendances. Modifications complètes : https://github.com/cloudflare/cloudflared/compare/2026.6.1...2026.7.3', }, migrations: { - up: async ({ effects }) => { - const { store } = await import('../fileModels/store.yaml') - const conf = await store.read().once() - if (!conf) return - const hasIngress = Object.values(conf.ingress).some(Boolean) - await store.merge(effects, { - ingress: conf.ingress, - repairRequired: hasIngress, - repairMessage: hasIngress - ? i18n( - 'Cloudflare routes need to be reconnected to their selected StartOS services.', - ) - : null, - }) - }, - down: IMPOSSIBLE, + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, }, }) -export const CLOUDFLARED_VERSION = '2026.6.1' +export const CLOUDFLARED_VERSION = '2026.7.3' diff --git a/startos/versions/index.ts b/startos/versions/index.ts index dce7f25..c2c2229 100644 --- a/startos/versions/index.ts +++ b/startos/versions/index.ts @@ -3,10 +3,11 @@ import { current, CLOUDFLARED_VERSION } from './current' import { v2026_3_0 } from './v2026.3.0' import { v2026_5_2 } from './v2026.5.2' import { v2026_6_1_0 } from './v2026.6.1.0' +import { v2026_6_1_1 } from './v2026.6.1.1' export const versionGraph = VersionGraph.of({ current, - other: [v2026_6_1_0, v2026_5_2, v2026_3_0], + other: [v2026_6_1_1, v2026_6_1_0, v2026_5_2, v2026_3_0], }) export { CLOUDFLARED_VERSION } diff --git a/startos/versions/v2026.6.1.1.ts b/startos/versions/v2026.6.1.1.ts new file mode 100644 index 0000000..25294d0 --- /dev/null +++ b/startos/versions/v2026.6.1.1.ts @@ -0,0 +1,36 @@ +import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' +import { i18n } from '../i18n' + +export const v2026_6_1_1 = VersionInfo.of({ + version: '2026.6.1:1', + releaseNotes: { + en_US: + 'Adds StartOS 0.4.0-beta.10 and Start SDK 2 compatibility, migrates the admin identity, and repairs Cloudflare routes through live bridge bindings.', + es_ES: + 'Añade compatibilidad con StartOS 0.4.0-beta.10 y Start SDK 2, migra la identidad de administración y repara las rutas de Cloudflare mediante enlaces dinámicos.', + de_DE: + 'Fügt Kompatibilität mit StartOS 0.4.0-beta.10 und Start SDK 2 hinzu, migriert die Admin-Identität und repariert Cloudflare-Routen über Live-Bridge-Bindungen.', + pl_PL: + 'Dodaje zgodność ze StartOS 0.4.0-beta.10 i Start SDK 2, migruje tożsamość administratora i naprawia trasy Cloudflare przez bieżące powiązania mostu.', + fr_FR: + 'Ajoute la compatibilité avec StartOS 0.4.0-beta.10 et Start SDK 2, migre l’identité administrateur et répare les routes Cloudflare via les liaisons bridge actives.', + }, + migrations: { + up: async ({ effects }) => { + const { store } = await import('../fileModels/store.yaml') + const conf = await store.read().once() + if (!conf) return + const hasIngress = Object.values(conf.ingress).some(Boolean) + await store.merge(effects, { + ingress: conf.ingress, + repairRequired: hasIngress, + repairMessage: hasIngress + ? i18n( + 'Cloudflare routes need to be reconnected to their selected StartOS services.', + ) + : null, + }) + }, + down: IMPOSSIBLE, + }, +})