From 8d4ca3c4119b2e4f9d61a0938f8098f64193c6ee Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sat, 24 May 2025 18:25:50 +0000 Subject: [PATCH 01/36] update for startos 0.4.0 --- .github/workflows/buildService.yml | 21 ++- .github/workflows/releaseService.yml | 29 +-- .gitignore | 4 +- Dockerfile | 6 +- Makefile | 102 ++++------ docker_entrypoint.sh | 9 - manifest.yaml | 81 -------- package-lock.json | 257 ++++++++++++++++++++++++++ package.json | 23 +++ scripts/bundle.ts | 3 - scripts/deps.ts | 1 - scripts/embassy.ts | 4 - scripts/procedures/getConfig.ts | 12 -- scripts/procedures/migrations.ts | 4 - scripts/procedures/properties.ts | 3 - scripts/procedures/setConfig.ts | 3 - startos/actions/index.ts | 4 + startos/actions/setToken.ts | 52 ++++++ startos/backups.ts | 5 + startos/fileModels/store.yaml.ts | 23 +++ startos/index.ts | 11 ++ startos/init/index.ts | 22 +++ startos/install/versionGraph.ts | 11 ++ startos/install/versions/index.ts | 2 + startos/install/versions/v2025.5.0.ts | 10 + startos/main.ts | 45 +++++ startos/manifest.ts | 42 +++++ startos/sdk.ts | 11 ++ tsconfig.json | 11 ++ 29 files changed, 602 insertions(+), 209 deletions(-) delete mode 100755 docker_entrypoint.sh delete mode 100644 manifest.yaml create mode 100644 package-lock.json create mode 100644 package.json delete mode 100644 scripts/bundle.ts delete mode 100644 scripts/deps.ts delete mode 100644 scripts/embassy.ts delete mode 100644 scripts/procedures/getConfig.ts delete mode 100644 scripts/procedures/migrations.ts delete mode 100644 scripts/procedures/properties.ts delete mode 100644 scripts/procedures/setConfig.ts create mode 100644 startos/actions/index.ts create mode 100644 startos/actions/setToken.ts create mode 100644 startos/backups.ts create mode 100644 startos/fileModels/store.yaml.ts create mode 100644 startos/index.ts create mode 100644 startos/init/index.ts create mode 100644 startos/install/versionGraph.ts create mode 100644 startos/install/versions/index.ts create mode 100644 startos/install/versions/v2025.5.0.ts create mode 100644 startos/main.ts create mode 100644 startos/manifest.ts create mode 100644 startos/sdk.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/buildService.yml b/.github/workflows/buildService.yml index 3fbc242..9a0e389 100644 --- a/.github/workflows/buildService.yml +++ b/.github/workflows/buildService.yml @@ -4,34 +4,35 @@ on: workflow_dispatch: pull_request: paths-ignore: ['*.md'] - branches: ['main', 'master'] + branches: ['main', 'master', 'update/040'] push: paths-ignore: ['*.md'] - branches: ['main', 'master'] + branches: ['main', 'master', 'update/040'] jobs: BuildPackage: runs-on: ubuntu-latest steps: - name: Prepare StartOS SDK - uses: Start9Labs/sdk@v1 + uses: start9Labs/sdk@v2 - name: Checkout services repository uses: actions/checkout@v4 + with: + submodules: recursive - name: Build the service package id: build run: | - git submodule update --init --recursive - start-sdk init - make - PACKAGE_ID=$(yq -oy ".id" manifest.*) - echo "package_id=$PACKAGE_ID" >> $GITHUB_ENV + start-cli init + RUST_LOG=debug RUST_BACKTRACE=1 make + PACKAGE_ID=$(start-cli s9pk inspect *.s9pk manifest | jq -r '.id') + echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" shell: bash - +# # - name: Upload .s9pk # uses: actions/upload-artifact@v4 # with: # name: ${{ env.package_id }}.s9pk -# path: ./${{ env.package_id }}.s9pk \ No newline at end of file +# path: ./${{ env.package_id }}.s9pk diff --git a/.github/workflows/releaseService.yml b/.github/workflows/releaseService.yml index 18a80aa..7d26e65 100644 --- a/.github/workflows/releaseService.yml +++ b/.github/workflows/releaseService.yml @@ -12,39 +12,44 @@ jobs: contents: write steps: - name: Prepare StartOS SDK - uses: Start9Labs/sdk@v1 + uses: start9Labs/sdk@v2 - name: Checkout services repository uses: actions/checkout@v4 + with: + submodules: recursive - name: Build the service package run: | - git submodule update --init --recursive - start-sdk init - make + start-cli init + RUST_LOG=debug RUST_BACKTRACE=1 make - name: Setting package ID and title from the manifest id: package run: | - echo "package_id=$(yq -oy ".id" manifest.*)" >> $GITHUB_ENV - echo "package_title=$(yq -oy ".title" manifest.*)" >> $GITHUB_ENV + PACKAGE_ID=$(start-cli s9pk inspect *.s9pk manifest | jq -r '.id') + PACKAGE_TITLE=$(start-cli s9pk inspect *.s9pk manifest | jq -r '.title') + echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV + echo "package_title=${PACKAGE_TITLE}" >> $GITHUB_ENV + printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" shell: bash - name: Generate sha256 checksum run: | - PACKAGE_ID=${{ env.package_id }} - printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" sha256sum ${PACKAGE_ID}.s9pk > ${PACKAGE_ID}.s9pk.sha256 shell: bash - name: Generate changelog run: | - PACKAGE_ID=${{ env.package_id }} echo "## What's Changed" > change-log.txt - yq -oy '.release-notes' manifest.* >> change-log.txt + echo "" >> change-log.txt + + RELEASE_NOTES=$(start-cli s9pk inspect *.s9pk manifest | jq -r '.releaseNotes') + echo "${RELEASE_NOTES}" >> change-log.txt + echo "## SHA256 Hash" >> change-log.txt echo '```' >> change-log.txt - sha256sum ${PACKAGE_ID}.s9pk >> change-log.txt + sha256sum ${{ env.package_id }}.s9pk >> change-log.txt echo '```' >> change-log.txt shell: bash @@ -69,4 +74,4 @@ jobs: echo "Publish skipped: missing registry credentials." else start-sdk publish https://$S9USER:$S9PASS@$S9REGISTRY ${{ env.package_id }}.s9pk - fi \ No newline at end of file + fi diff --git a/.gitignore b/.gitignore index 05f5a97..8ef7862 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ .DS_Store scripts/*.js docker-images -tmp +node_modules +javascript/* +ncc-cache \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 45f4fa9..f367227 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# cloudflared container version is defined in Makefile +# cloudflared container version is defined in manifest.ts ARG CLOUDFLARED_IMAGE # used to copy cloudflared binary @@ -19,7 +19,5 @@ RUN \ /var/tmp/* \ /tmp/* -# add local files -COPY --chmod=0755 ./docker_entrypoint.sh /usr/local/bin/docker_entrypoint.sh -COPY --chmod=0755 ./tmp/yq_linux_${PLATFORM} /usr/local/bin/yq +# add cloudflared binary COPY --chmod=0755 --from=cloudflared /usr/local/bin/cloudflared /usr/local/bin/cloudflared diff --git a/Makefile b/Makefile index 54f8088..4f99ba5 100644 --- a/Makefile +++ b/Makefile @@ -4,77 +4,55 @@ YQ_VERSION := 4.40.7 YQ_SHA_AMD64 := 4f13ee9303a49f7e8f61e7d9c87402e07cc920ae8dfaaa8c10d7ea1b8f9f48ed YQ_SHA_ARM64 := a84f2c8f105b70cd348c3bf14048aeb1665c2e7314cbe9aaff15479f268b8412 -PKG_ID := $(shell yq e ".id" manifest.yaml) -PKG_VERSION := $(shell yq e ".version" manifest.yaml) -TS_FILES := $(shell find ./ -name \*.ts) +.PHONY: all clean install check-deps check-init ingredients .DELETE_ON_ERROR: -all: verify - -arm: - @rm -f docker-images/aarch64.tar - @ARCH=aarch64 $(MAKE) - -x86: - @rm -f docker-images/x86_64.tar - @ARCH=x86_64 $(MAKE) - -verify: $(PKG_ID).s9pk - @start-sdk verify s9pk $(PKG_ID).s9pk +all: ${PACKAGE_ID}.s9pk @echo " Done!" - @echo " Filesize: $(shell du -h $(PKG_ID).s9pk) is ready" + @echo " Filesize:$(shell du -h $(PACKAGE_ID).s9pk) is ready" -install: - @if [ ! -f ~/.embassy/config.yaml ]; then echo "You must define \"host: http://server-name.local\" in ~/.embassy/config.yaml config file first."; exit 1; fi - @echo "\nInstalling to $$(grep -v '^#' ~/.embassy/config.yaml | cut -d'/' -f3) ...\n" - @[ -f $(PKG_ID).s9pk ] || ( $(MAKE) && echo "\nInstalling to $$(grep -v '^#' ~/.embassy/config.yaml | cut -d'/' -f3) ...\n" ) - @start-cli package install $(PKG_ID).s9pk +check-deps: + @if ! command -v start-cli > /dev/null; then \ + echo "Error: start-cli not found. Please install it first."; \ + exit 1; \ + fi + @if ! command -v npm > /dev/null; then \ + echo "Error: npm (Node Package Manager) not found. Please install Node.js and npm."; \ + exit 1; \ + fi -clean: - rm -rf docker-images - rm -f $(PKG_ID).s9pk - rm -f scripts/*.js +check-init: + @if [ ! -f ~/.startos/developer.key.pem ]; then \ + start-cli init; \ + fi + +ingredients: $(INGREDIENTS) + @echo "Re-evaluating ingredients..." -scripts/embassy.js: $(TS_FILES) - deno run --allow-read --allow-write --allow-env --allow-net scripts/bundle.ts +${PACKAGE_ID}.s9pk: $(INGREDIENTS) | check-deps check-init + @$(MAKE) --no-print-directory ingredients + start-cli s9pk pack -docker-images/aarch64.tar: manifest.yaml Dockerfile docker_entrypoint.sh tmp/yq_linux_arm64 -ifeq ($(ARCH),x86_64) -else - mkdir -p docker-images - docker buildx build --tag start9/$(PKG_ID)/main:$(PKG_VERSION) \ - --build-arg PLATFORM=arm64 \ - --build-arg CLOUDFLARED_IMAGE=$(CLOUDFLARED_IMAGE) \ - --platform=linux/arm64 -o type=docker,dest=docker-images/aarch64.tar . -endif +javascript/index.js: $(shell git ls-files startos) tsconfig.json node_modules package.json + npm run build -docker-images/x86_64.tar: manifest.yaml Dockerfile docker_entrypoint.sh tmp/yq_linux_amd64 -ifeq ($(ARCH),aarch64) -else - mkdir -p docker-images - docker buildx build --tag start9/$(PKG_ID)/main:$(PKG_VERSION) \ - --build-arg PLATFORM=amd64 \ - --build-arg CLOUDFLARED_IMAGE=$(CLOUDFLARED_IMAGE) \ - --platform=linux/amd64 -o type=docker,dest=docker-images/x86_64.tar . -endif +assets: + mkdir -p assets -tmp/yq_linux_amd64: - mkdir -p tmp - wget -qO ./tmp/yq_linux_amd64 https://github.com/mikefarah/yq/releases/download/v$(YQ_VERSION)/yq_linux_amd64 - echo "$(YQ_SHA_AMD64) ./tmp/yq_linux_amd64" | sha256sum --check || exit 1 +node_modules: package-lock.json + npm ci -tmp/yq_linux_arm64: - mkdir -p tmp - wget -qO ./tmp/yq_linux_arm64 https://github.com/mikefarah/yq/releases/download/v$(YQ_VERSION)/yq_linux_arm64 - echo "$(YQ_SHA_ARM64) ./tmp/yq_linux_arm64" | sha256sum --check || exit 1 +package-lock.json: package.json + npm i -$(PKG_ID).s9pk: manifest.yaml instructions.md icon.png LICENSE scripts/embassy.js docker-images/aarch64.tar docker-images/x86_64.tar -ifeq ($(ARCH),aarch64) - @echo "start-sdk: Preparing aarch64 package ..." -else ifeq ($(ARCH),x86_64) - @echo "start-sdk: Preparing x86_64 package ..." -else - @echo "start-sdk: Preparing Universal Package ..." -endif - @start-sdk pack +clean: + rm -rf ${PACKAGE_ID}.s9pk + rm -rf javascript + rm -rf node_modules + +install: ${PACKAGE_ID}.s9pk + @if [ ! -f ~/.startos/config.yaml ]; then echo "You must define \"host: http://server-name.local\" in ~/.startos/config.yaml config file first."; exit 1; fi + @echo "\nInstalling to $$(grep -v '^#' ~/.startos/config.yaml | cut -d'/' -f3) ...\n" + @[ -f $(PACKAGE_ID).s9pk ] || ( $(MAKE) && echo "\nInstalling to $$(grep -v '^#' ~/.startos/config.yaml | cut -d'/' -f3) ...\n" ) + @start-cli package install -s $(PACKAGE_ID).s9pk diff --git a/docker_entrypoint.sh b/docker_entrypoint.sh deleted file mode 100755 index d541a92..0000000 --- a/docker_entrypoint.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -echo -echo "Initialising Cloudflare Tunnel for StartOS..." -echo - -export TUNNEL_TOKEN="$(yq e '.token' /root/data/start9/config.yaml)" - -/usr/local/bin/cloudflared --no-autoupdate --management-diagnostics=false tunnel run diff --git a/manifest.yaml b/manifest.yaml deleted file mode 100644 index fdba22e..0000000 --- a/manifest.yaml +++ /dev/null @@ -1,81 +0,0 @@ -id: cloudflared -title: "Cloudflare Tunnel" -version: 2025.10.0 -release-notes: | - * Update to cloudflared 2025.10.0 - See [full changelog](https://github.com/cloudflare/cloudflared/blob/2025.10.0/RELEASE_NOTES) -license: Apache 2.0 -wrapper-repo: "https://github.com/remcoros/cloudflared-startos" -upstream-repo: "https://github.com/cloudflare/cloudflared" -support-site: "https://github.com/cloudflare/cloudflared/issues" -marketing-site: "https://cloudflare.com/" -donation-url: "https://cloudflare.com/" -build: ["make"] -description: - short: Cloudflare Tunnel client - long: | - With the Cloudflare Tunnel client you can proxy traffic from the Cloudflare network to your StartOS server. -assets: - license: LICENSE - icon: icon.png - instructions: instructions.md -main: - type: docker - image: main - entrypoint: "docker_entrypoint.sh" - args: [] - mounts: - main: /root/data - gpu-acceleration: false -hardware-requirements: - arch: - - x86_64 - - aarch64 -health-checks: {} -config: - get: - type: script - set: - type: script -properties: - type: script -volumes: - main: - type: data -interfaces: {} -dependencies: {} -backup: - create: - type: docker - image: compat - system: true - entrypoint: compat - args: - - duplicity - - create - - /mnt/backup - - /root/data - mounts: - BACKUP: "/mnt/backup" - main: "/root/data" - restore: - type: docker - image: compat - system: true - entrypoint: compat - args: - - duplicity - - restore - - /mnt/backup - - /root/data - mounts: - BACKUP: "/mnt/backup" - main: "/root/data" -migrations: - from: - "*": - type: script - args: ["from"] - to: - "*": - type: script - args: ["to"] diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..10672fa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,257 @@ +{ + "name": "cloudflared", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cloudflared", + "dependencies": { + "@start9labs/start-sdk": "^0.4.0-beta.24" + }, + "devDependencies": { + "@types/node": "^22.1.0", + "@vercel/ncc": "^0.38.1", + "prettier": "^3.2.5", + "typescript": "^5.4.3" + } + }, + "node_modules/@iarna/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", + "license": "ISC" + }, + "node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@start9labs/start-sdk": { + "version": "0.4.0-beta.25", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.25.tgz", + "integrity": "sha512-nW0aQbbx+17vFWw+6bTD2Wg/cbwNZb56bXshVsA9WzM5MUZjsWAr9/kEu/GlUbAwWkBRgcN0lAHsu6GmsC1E9w==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.8.2", + "@noble/hashes": "^1.7.2", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime-types": "^3.0.1", + "ts-matches": "^6.3.2", + "yaml": "^2.7.1" + } + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.15.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", + "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.3.tgz", + "integrity": "sha512-rnK6hJBS6mwc+Bkab+PGPs9OiS0i/3kdTO+CkI8V0/VrW3vmz7O2Pxjw/owOlmo6PKEIxRSeZKv/kuL9itnpYA==", + "dev": true, + "license": "MIT", + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", + "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "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/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-matches": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.3.2.tgz", + "integrity": "sha512-UhSgJymF8cLd4y0vV29qlKVCkQpUtekAaujXbQVc729FezS8HwqzepqvtjzQ3HboatIqN/Idor85O2RMwT7lIQ==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a3f582 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "cloudflared", + "scripts": { + "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", + "prettier": "prettier --write startos", + "check": "tsc --noEmit" + }, + "dependencies": { + "@start9labs/start-sdk": "^0.4.0-beta.24" + }, + "devDependencies": { + "@types/node": "^22.1.0", + "@vercel/ncc": "^0.38.1", + "prettier": "^3.2.5", + "typescript": "^5.4.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} diff --git a/scripts/bundle.ts b/scripts/bundle.ts deleted file mode 100644 index dbf577b..0000000 --- a/scripts/bundle.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { bundle } from "https://deno.land/x/emit@0.40.0/mod.ts"; -const result = await bundle("scripts/embassy.ts"); -await Deno.writeTextFile("scripts/embassy.js", result.code); diff --git a/scripts/deps.ts b/scripts/deps.ts deleted file mode 100644 index 3105b54..0000000 --- a/scripts/deps.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "https://deno.land/x/embassyd_sdk@v0.3.3.0.11/mod.ts"; diff --git a/scripts/embassy.ts b/scripts/embassy.ts deleted file mode 100644 index 4452fcd..0000000 --- a/scripts/embassy.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { getConfig } from "./procedures/getConfig.ts"; -export { setConfig } from "./procedures/setConfig.ts"; -export { properties } from "./procedures/properties.ts"; -export { migration } from "./procedures/migrations.ts"; diff --git a/scripts/procedures/getConfig.ts b/scripts/procedures/getConfig.ts deleted file mode 100644 index 1861c38..0000000 --- a/scripts/procedures/getConfig.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { compat, types as T } from "../deps.ts"; - -export const getConfig: T.ExpectedExports.getConfig = compat.getConfig({ - token: { - type: "string", - name: "Token", - description: "The Cloudflare Tunnel Token.", - nullable: false, - masked: true, - copyable: true, - }, -}); diff --git a/scripts/procedures/migrations.ts b/scripts/procedures/migrations.ts deleted file mode 100644 index be248fa..0000000 --- a/scripts/procedures/migrations.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { compat, types as T } from "../deps.ts"; - -export const migration: T.ExpectedExports.migration = compat.migrations - .fromMapping({}, "2025.10.0" ); diff --git a/scripts/procedures/properties.ts b/scripts/procedures/properties.ts deleted file mode 100644 index dff99aa..0000000 --- a/scripts/procedures/properties.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { compat, types as T } from "../deps.ts"; - -export const properties: T.ExpectedExports.properties = compat.properties; diff --git a/scripts/procedures/setConfig.ts b/scripts/procedures/setConfig.ts deleted file mode 100644 index ffd9d44..0000000 --- a/scripts/procedures/setConfig.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { compat } from "../deps.ts"; - -export const setConfig = compat.setConfig; diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..c80a925 --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,4 @@ +import { sdk } from '../sdk' +import { setToken } from './setToken' + +export const actions = sdk.Actions.of().addAction(setToken) diff --git a/startos/actions/setToken.ts b/startos/actions/setToken.ts new file mode 100644 index 0000000..f881a38 --- /dev/null +++ b/startos/actions/setToken.ts @@ -0,0 +1,52 @@ +import { sdk } from '../sdk' +import { createDefaultStore, store } from '../fileModels/store.yaml' + +const { InputSpec, Value } = sdk + +const inputSpec = InputSpec.of({ + token: Value.text({ + name: 'Authentication Token', + description: 'The authentication token for your Cloudflare tunnel.', + required: true, + default: '', + placeholder: '', + }), +}) + +export const setToken = sdk.Action.withInput( + // id + 'setToken', + + // metadata + async ({ effects }) => ({ + name: 'Set Authentication Token', + description: 'Set the authentication token for your Cloudflare tunnel.', + warning: null, + allowedStatuses: 'any', + group: 'Configuration', + visibility: 'enabled', + }), + + // form input specification + inputSpec, + + // optionally pre-fill the input form + async ({ effects }) => { + let settings = await store.read().once() + if (!settings) { + await createDefaultStore(effects) + settings = (await store.read().once())! + } + + return { + token: settings.token, + } + }, + + // the execution function + async ({ effects, input }) => { + await store.merge(effects, { + token: input.token, + }) + }, +) \ No newline at end of file diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 0000000..f25f5b6 --- /dev/null +++ b/startos/backups.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const { createBackup, restoreInit } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.volumes('main'), +) diff --git a/startos/fileModels/store.yaml.ts b/startos/fileModels/store.yaml.ts new file mode 100644 index 0000000..f5c2c87 --- /dev/null +++ b/startos/fileModels/store.yaml.ts @@ -0,0 +1,23 @@ +import { matches, FileHelper, T } from '@start9labs/start-sdk' +const { object, string } = matches + +const shape = object({ + token: string, +}) + +export type StoreType = typeof shape._TYPE + +export const store = FileHelper.yaml( + '/media/startos/volumes/main/start9/config.yaml', + shape, +) + +export const createDefaultStore = async (effects: T.Effects) => { + // check if the file exists (from previous installs or upgrades) + const conf = await store.read().once() + if (!conf) { + await store.write(effects, { + token: '', + }) + } +} diff --git a/startos/index.ts b/startos/index.ts new file mode 100644 index 0000000..1ff9579 --- /dev/null +++ b/startos/index.ts @@ -0,0 +1,11 @@ +/** + * Plumbing. DO NOT EDIT. + */ +export { createBackup } from './backups' +export { main } from './main' +export { init, uninit } from './init' +export { actions } from './actions' +import { buildManifest } from '@start9labs/start-sdk' +import { manifest as sdkManifest } from './manifest' +import { versionGraph } from './install/versionGraph' +export const manifest = buildManifest(versionGraph, sdkManifest) \ No newline at end of file diff --git a/startos/init/index.ts b/startos/init/index.ts new file mode 100644 index 0000000..26d0218 --- /dev/null +++ b/startos/init/index.ts @@ -0,0 +1,22 @@ +import { sdk } from '../sdk' +import { versionGraph } from '../install/versionGraph' +import { actions } from '../actions' +import { restoreInit } from '../backups' +import { setToken } from '../actions/setToken' + +const setupPostInstall = sdk.setupOnInit(async (effects, kind) => { + if (kind == 'install') { + // require the config action to run once, to have a password for the ui set + await sdk.action.createOwnTask(effects, setToken, 'critical', { + reason: 'Configure default settings', + }) + } +}) + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + actions, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/install/versionGraph.ts b/startos/install/versionGraph.ts new file mode 100644 index 0000000..2ee6996 --- /dev/null +++ b/startos/install/versionGraph.ts @@ -0,0 +1,11 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { current, other } from './versions' +import { createDefaultStore } from '../fileModels/store.yaml' + +export const versionGraph = VersionGraph.of({ + current, + other, + preInstall: async (effects) => { + await createDefaultStore(effects) + }, +}) diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts new file mode 100644 index 0000000..071c776 --- /dev/null +++ b/startos/install/versions/index.ts @@ -0,0 +1,2 @@ +export { v2025_5_0 as current } from './v2025.5.0' +export const other = [] diff --git a/startos/install/versions/v2025.5.0.ts b/startos/install/versions/v2025.5.0.ts new file mode 100644 index 0000000..0f33d5f --- /dev/null +++ b/startos/install/versions/v2025.5.0.ts @@ -0,0 +1,10 @@ +import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' + +export const v2025_5_0 = VersionInfo.of({ + version: '2025.5.0:1.0', + releaseNotes: 'Revamped for StartOS 0.4.0', + migrations: { + up: async ({ effects }) => {}, + down: IMPOSSIBLE, + }, +}) diff --git a/startos/main.ts b/startos/main.ts new file mode 100644 index 0000000..0d9417e --- /dev/null +++ b/startos/main.ts @@ -0,0 +1,45 @@ +import { store } from './fileModels/store.yaml' +import { sdk } from './sdk' +import { T } from '@start9labs/start-sdk' + +export const main = sdk.setupMain(async ({ effects, started }) => { + console.info('Starting cloudflared...') + + const conf = (await store.read().const(effects))! + + const healthReceipts: T.HealthCheck[] = [] + + return sdk.Daemons.of(effects, started, healthReceipts).addDaemon('primary', { + subcontainer: await sdk.SubContainer.of( + effects, + { + imageId: 'main', + }, + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/root/data', + readonly: false, + }), + 'main', + ), + command: [ + '/usr/local/bin/cloudflared', + '--no-autoupdate', + '--management-diagnostics=false', + 'tunnel', + 'run', + ], + env: { + TUNNEL_TOKEN: conf.token, + }, + ready: { + display: null, + fn: () => ({ + result: 'success', + message: null, + }), + }, + requires: [], + }) +}) diff --git a/startos/manifest.ts b/startos/manifest.ts new file mode 100644 index 0000000..92c52d0 --- /dev/null +++ b/startos/manifest.ts @@ -0,0 +1,42 @@ +import { setupManifest } from '@start9labs/start-sdk' + +const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.5.0' + +export const manifest = setupManifest({ + id: 'cloudflared', + title: 'Cloudflare Tunnel', + license: 'Apache 2.0', + wrapperRepo: 'https://github.com/remcoros/cloudflared-startos', + upstreamRepo: 'https://github.com/cloudflare/cloudflared', + supportSite: 'https://github.com/cloudflare/cloudflared/issues', + marketingSite: 'https://cloudflare.com/', + donationUrl: 'https://cloudflare.com/', + description: { + short: 'Cloudflare Tunnel client', + long: 'With the Cloudflare Tunnel client you can proxy traffic from the Cloudflare network to your StartOS server.', + }, + volumes: ['main'], + images: { + main: { + arch: ['x86_64', 'aarch64'], + source: { + dockerBuild: { + dockerfile: 'Dockerfile', + buildArgs: { + CLOUDFLARED_IMAGE: CLOUDFLARED_IMAGE, + }, + }, + }, + }, + }, + hardwareRequirements: {}, + alerts: { + install: null, + update: null, + uninstall: null, + restore: null, + start: null, + stop: null, + }, + dependencies: {}, +}) diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 0000000..a1c2496 --- /dev/null +++ b/startos/sdk.ts @@ -0,0 +1,11 @@ +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' + +/** + * Plumbing. DO NOT EDIT. + * + * The exported "sdk" const is used throughout this package codebase. + */ +export const sdk = StartSdk.of() + .withManifest(manifest) + .build(true) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a2945a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["startos/**/*.ts", "node_modules/**/startos"], + "compilerOptions": { + "target": "ES2018", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +} From 853e58d4e6fda29e7d1538c54600bc6e64febec3 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 25 May 2025 11:23:31 +0000 Subject: [PATCH 02/36] make it work, add healthcheck and metrics endpoint --- startos/actions/setToken.ts | 6 ++++-- startos/init/index.ts | 14 +++----------- startos/install/versionGraph.ts | 11 ++++++++++- startos/interfaces.ts | 24 ++++++++++++++++++++++++ startos/main.ts | 17 ++++++++++++----- 5 files changed, 53 insertions(+), 19 deletions(-) create mode 100644 startos/interfaces.ts diff --git a/startos/actions/setToken.ts b/startos/actions/setToken.ts index f881a38..390bde2 100644 --- a/startos/actions/setToken.ts +++ b/startos/actions/setToken.ts @@ -10,12 +10,14 @@ const inputSpec = InputSpec.of({ required: true, default: '', placeholder: '', + masked: true, + inputmode: 'text', }), }) export const setToken = sdk.Action.withInput( // id - 'setToken', + 'set-token', // metadata async ({ effects }) => ({ @@ -49,4 +51,4 @@ export const setToken = sdk.Action.withInput( token: input.token, }) }, -) \ No newline at end of file +) diff --git a/startos/init/index.ts b/startos/init/index.ts index 26d0218..ea71e15 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -1,22 +1,14 @@ import { sdk } from '../sdk' +import { setInterfaces } from '../interfaces' import { versionGraph } from '../install/versionGraph' import { actions } from '../actions' import { restoreInit } from '../backups' -import { setToken } from '../actions/setToken' - -const setupPostInstall = sdk.setupOnInit(async (effects, kind) => { - if (kind == 'install') { - // require the config action to run once, to have a password for the ui set - await sdk.action.createOwnTask(effects, setToken, 'critical', { - reason: 'Configure default settings', - }) - } -}) export const init = sdk.setupInit( restoreInit, versionGraph, - actions, + setInterfaces, + actions ) export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/install/versionGraph.ts b/startos/install/versionGraph.ts index 2ee6996..f1a28cc 100644 --- a/startos/install/versionGraph.ts +++ b/startos/install/versionGraph.ts @@ -1,11 +1,20 @@ import { VersionGraph } from '@start9labs/start-sdk' import { current, other } from './versions' -import { createDefaultStore } from '../fileModels/store.yaml' +import { createDefaultStore, store } from '../fileModels/store.yaml' +import { sdk } from '../sdk' +import { setToken } from '../actions/setToken' export const versionGraph = VersionGraph.of({ current, other, preInstall: async (effects) => { await createDefaultStore(effects) + + const authToken = (await store.read().once())?.token + if (!authToken) { + await sdk.action.createOwnTask(effects, setToken, 'critical', { + reason: 'Set Cloudflare tunnel authentication token', + }) + } }, }) diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..95fee9d --- /dev/null +++ b/startos/interfaces.ts @@ -0,0 +1,24 @@ +import { sdk } from './sdk' + +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + const uiMulti = sdk.MultiHost.of(effects, 'ui') + const uiMultiOrigin = await uiMulti.bindPort(20241, { + protocol: 'http', + }) + + const ui = sdk.createInterface(effects, { + name: 'Metrics', + id: 'metrics', + description: 'Prometheus metrics endpoint', + type: 'api', + schemeOverride: null, + masked: false, + username: null, + path: '', + query: {}, + }) + + const uiReceipt = await uiMultiOrigin.export([ui]) + + return [uiReceipt] +}) diff --git a/startos/main.ts b/startos/main.ts index 0d9417e..1aa7169 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -27,6 +27,8 @@ export const main = sdk.setupMain(async ({ effects, started }) => { '/usr/local/bin/cloudflared', '--no-autoupdate', '--management-diagnostics=false', + '--metrics', + '0.0.0.0:20241', 'tunnel', 'run', ], @@ -34,11 +36,16 @@ export const main = sdk.setupMain(async ({ effects, started }) => { TUNNEL_TOKEN: conf.token, }, ready: { - display: null, - fn: () => ({ - result: 'success', - message: null, - }), + display: 'Cloudflare tunnel client', + fn: () => + sdk.healthCheck.checkWebUrl( + effects, + 'http://cloudflared.startos:20241/metrics', + { + successMessage: 'Cloudflare tunnel client is running', + errorMessage: 'Cloudflare tunnel client is not running', + }, + ), }, requires: [], }) From 6a6003927c32b318d898c0b65d7baecb439e146a Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Thu, 19 Jun 2025 15:36:35 +0000 Subject: [PATCH 03/36] startos sdk beta.30 --- package-lock.json | 54 +++++++++---------- package.json | 2 +- startos/backups.ts | 2 +- startos/install/versions/index.ts | 2 +- .../versions/{v2025.5.0.ts => v2025.6.0.ts} | 4 +- startos/main.ts | 28 +++++----- startos/manifest.ts | 2 +- 7 files changed, 44 insertions(+), 50 deletions(-) rename startos/install/versions/{v2025.5.0.ts => v2025.6.0.ts} (73%) diff --git a/package-lock.json b/package-lock.json index 10672fa..7453fae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.24" + "@start9labs/start-sdk": "^0.4.0-beta.30" }, "devDependencies": { "@types/node": "^22.1.0", @@ -22,9 +22,9 @@ "license": "ISC" }, "node_modules/@noble/curves": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", - "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.2.tgz", + "integrity": "sha512-HxngEd2XUcg9xi20JkwlLCtYwfoFw4JGkuZpT+WlsPD4gB/cxkvTD8fSsoAnphGZhFdZYKeQIPCuFlWPm1uE0g==", "license": "MIT", "dependencies": { "@noble/hashes": "1.8.0" @@ -49,9 +49,9 @@ } }, "node_modules/@start9labs/start-sdk": { - "version": "0.4.0-beta.25", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.25.tgz", - "integrity": "sha512-nW0aQbbx+17vFWw+6bTD2Wg/cbwNZb56bXshVsA9WzM5MUZjsWAr9/kEu/GlUbAwWkBRgcN0lAHsu6GmsC1E9w==", + "version": "0.4.0-beta.30", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.30.tgz", + "integrity": "sha512-IiVrLKP3qhY6eEkmIF9ftOCEoMo6bJs1gnWMWJdBNmNcEYpd9Yra1trIhCJqc1rD9KVYvdBTP3P4MRZ9Xlo27g==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -61,7 +61,7 @@ "deep-equality-data-structures": "^2.0.0", "ini": "^5.0.0", "isomorphic-fetch": "^3.0.0", - "mime-types": "^3.0.1", + "mime": "^4.0.7", "ts-matches": "^6.3.2", "yaml": "^2.7.1" } @@ -73,9 +73,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "version": "22.15.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz", + "integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==", "dev": true, "license": "MIT", "dependencies": { @@ -120,25 +120,19 @@ "whatwg-fetch": "^3.4.1" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "node_modules/mime": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz", + "integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==", + "funding": [ + "https://github.com/sponsors/broofa" + ], "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" + "bin": { + "mime": "bin/cli.js" }, "engines": { - "node": ">= 0.6" + "node": ">=16" } }, "node_modules/node-fetch": { @@ -193,9 +187,9 @@ "license": "MIT" }, "node_modules/ts-matches": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.3.2.tgz", - "integrity": "sha512-UhSgJymF8cLd4y0vV29qlKVCkQpUtekAaujXbQVc729FezS8HwqzepqvtjzQ3HboatIqN/Idor85O2RMwT7lIQ==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.5.0.tgz", + "integrity": "sha512-MhuobYhHYn6MlOTPAF/qk3tsRRioPac5ofYn68tc3rAJaGjsw1MsX1MOSep52DkvNJPgNV0F73zfgcQfYTVeyQ==", "license": "MIT" }, "node_modules/typescript": { diff --git a/package.json b/package.json index 6a3f582..fad0790 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.24" + "@start9labs/start-sdk": "^0.4.0-beta.30" }, "devDependencies": { "@types/node": "^22.1.0", diff --git a/startos/backups.ts b/startos/backups.ts index f25f5b6..0a90b1e 100644 --- a/startos/backups.ts +++ b/startos/backups.ts @@ -1,5 +1,5 @@ import { sdk } from './sdk' export const { createBackup, restoreInit } = sdk.setupBackups( - async ({ effects }) => sdk.Backups.volumes('main'), + async ({ effects }) => sdk.Backups.ofVolumes('main'), ) diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts index 071c776..608acfd 100644 --- a/startos/install/versions/index.ts +++ b/startos/install/versions/index.ts @@ -1,2 +1,2 @@ -export { v2025_5_0 as current } from './v2025.5.0' +export { v2025_6_0 as current } from './v2025.6.0' export const other = [] diff --git a/startos/install/versions/v2025.5.0.ts b/startos/install/versions/v2025.6.0.ts similarity index 73% rename from startos/install/versions/v2025.5.0.ts rename to startos/install/versions/v2025.6.0.ts index 0f33d5f..39488fd 100644 --- a/startos/install/versions/v2025.5.0.ts +++ b/startos/install/versions/v2025.6.0.ts @@ -1,7 +1,7 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' -export const v2025_5_0 = VersionInfo.of({ - version: '2025.5.0:1.0', +export const v2025_6_0 = VersionInfo.of({ + version: '2025.6.0:1.0', releaseNotes: 'Revamped for StartOS 0.4.0', migrations: { up: async ({ effects }) => {}, diff --git a/startos/main.ts b/startos/main.ts index 1aa7169..a7cdf28 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -7,9 +7,7 @@ export const main = sdk.setupMain(async ({ effects, started }) => { const conf = (await store.read().const(effects))! - const healthReceipts: T.HealthCheck[] = [] - - return sdk.Daemons.of(effects, started, healthReceipts).addDaemon('primary', { + return sdk.Daemons.of(effects, started).addDaemon('primary', { subcontainer: await sdk.SubContainer.of( effects, { @@ -23,17 +21,19 @@ export const main = sdk.setupMain(async ({ effects, started }) => { }), 'main', ), - command: [ - '/usr/local/bin/cloudflared', - '--no-autoupdate', - '--management-diagnostics=false', - '--metrics', - '0.0.0.0:20241', - 'tunnel', - 'run', - ], - env: { - TUNNEL_TOKEN: conf.token, + exec: { + command: [ + '/usr/local/bin/cloudflared', + '--no-autoupdate', + '--management-diagnostics=false', + '--metrics', + '0.0.0.0:20241', + 'tunnel', + 'run', + ], + env: { + TUNNEL_TOKEN: conf.token, + }, }, ready: { display: 'Cloudflare tunnel client', diff --git a/startos/manifest.ts b/startos/manifest.ts index 92c52d0..ff3ae57 100644 --- a/startos/manifest.ts +++ b/startos/manifest.ts @@ -1,6 +1,6 @@ import { setupManifest } from '@start9labs/start-sdk' -const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.5.0' +const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.6.0' export const manifest = setupManifest({ id: 'cloudflared', From 22b8f198de1882804dcf98e910701d22db10c512 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Thu, 19 Jun 2025 16:27:38 +0000 Subject: [PATCH 04/36] cloudflared 2025.6.1 --- startos/install/versions/index.ts | 2 +- startos/install/versions/{v2025.6.0.ts => v2025.6.1.ts} | 4 ++-- startos/manifest.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename startos/install/versions/{v2025.6.0.ts => v2025.6.1.ts} (73%) diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts index 608acfd..f563dd1 100644 --- a/startos/install/versions/index.ts +++ b/startos/install/versions/index.ts @@ -1,2 +1,2 @@ -export { v2025_6_0 as current } from './v2025.6.0' +export { v2025_6_1 as current } from './v2025.6.1' export const other = [] diff --git a/startos/install/versions/v2025.6.0.ts b/startos/install/versions/v2025.6.1.ts similarity index 73% rename from startos/install/versions/v2025.6.0.ts rename to startos/install/versions/v2025.6.1.ts index 39488fd..45ef8ad 100644 --- a/startos/install/versions/v2025.6.0.ts +++ b/startos/install/versions/v2025.6.1.ts @@ -1,7 +1,7 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' -export const v2025_6_0 = VersionInfo.of({ - version: '2025.6.0:1.0', +export const v2025_6_1 = VersionInfo.of({ + version: '2025.6.1:1.0', releaseNotes: 'Revamped for StartOS 0.4.0', migrations: { up: async ({ effects }) => {}, diff --git a/startos/manifest.ts b/startos/manifest.ts index ff3ae57..9eb354f 100644 --- a/startos/manifest.ts +++ b/startos/manifest.ts @@ -1,6 +1,6 @@ import { setupManifest } from '@start9labs/start-sdk' -const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.6.0' +const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.6.1' export const manifest = setupManifest({ id: 'cloudflared', From 2d807f4daa91bcc8b26f0fefa2362ddc945c8ac9 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Wed, 2 Jul 2025 17:34:19 +0000 Subject: [PATCH 05/36] update startos sdk to beta.32 and enable .s9pk upload to github --- .github/workflows/buildService.yml | 11 +++++------ package-lock.json | 20 ++++++++++---------- package.json | 2 +- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/.github/workflows/buildService.yml b/.github/workflows/buildService.yml index 9a0e389..a988008 100644 --- a/.github/workflows/buildService.yml +++ b/.github/workflows/buildService.yml @@ -30,9 +30,8 @@ jobs: echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" shell: bash -# -# - name: Upload .s9pk -# uses: actions/upload-artifact@v4 -# with: -# name: ${{ env.package_id }}.s9pk -# path: ./${{ env.package_id }}.s9pk + - name: Upload .s9pk + uses: actions/upload-artifact@v4 + with: + name: ${{ env.package_id }}.s9pk + path: ./${{ env.package_id }}.s9pk diff --git a/package-lock.json b/package-lock.json index 7453fae..2ba257d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.30" + "@start9labs/start-sdk": "^0.4.0-beta.32" }, "devDependencies": { "@types/node": "^22.1.0", @@ -49,9 +49,9 @@ } }, "node_modules/@start9labs/start-sdk": { - "version": "0.4.0-beta.30", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.30.tgz", - "integrity": "sha512-IiVrLKP3qhY6eEkmIF9ftOCEoMo6bJs1gnWMWJdBNmNcEYpd9Yra1trIhCJqc1rD9KVYvdBTP3P4MRZ9Xlo27g==", + "version": "0.4.0-beta.32", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.32.tgz", + "integrity": "sha512-dd4xrVjnw2/zH1MrhvIMGDXCpMt3FhUTKChbevfdVxtiiXtI17DPsGPbWLC64aMXX3FZQ9VeezHkY6olqBJPpA==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -73,9 +73,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.32", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz", - "integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==", + "version": "22.16.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.0.tgz", + "integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -165,9 +165,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index fad0790..078a816 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.30" + "@start9labs/start-sdk": "^0.4.0-beta.32" }, "devDependencies": { "@types/node": "^22.1.0", From 1170ca4fc7a871d2a6d94181fc887317bd3d71e3 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Thu, 10 Jul 2025 14:49:55 +0000 Subject: [PATCH 06/36] update cloudflared to 2025.7.0 / update startos sdk to beta.33 --- package-lock.json | 14 +++++++------- package.json | 2 +- startos/install/versions/index.ts | 2 +- .../versions/{v2025.6.1.ts => v2025.7.0.ts} | 4 ++-- startos/main.ts | 1 - startos/manifest.ts | 2 +- 6 files changed, 12 insertions(+), 13 deletions(-) rename startos/install/versions/{v2025.6.1.ts => v2025.7.0.ts} (73%) diff --git a/package-lock.json b/package-lock.json index 2ba257d..95c5d54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.32" + "@start9labs/start-sdk": "^0.4.0-beta.33" }, "devDependencies": { "@types/node": "^22.1.0", @@ -49,9 +49,9 @@ } }, "node_modules/@start9labs/start-sdk": { - "version": "0.4.0-beta.32", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.32.tgz", - "integrity": "sha512-dd4xrVjnw2/zH1MrhvIMGDXCpMt3FhUTKChbevfdVxtiiXtI17DPsGPbWLC64aMXX3FZQ9VeezHkY6olqBJPpA==", + "version": "0.4.0-beta.33", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.33.tgz", + "integrity": "sha512-tvifk73nmpRzmBVrvoftQ1f9vV/+ykFT93SlZnFrY/tUnGbhXgNyDg4xcudVNk1D31kN9Jp+0ZDtBw9Sa48jmA==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -73,9 +73,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.16.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.0.tgz", - "integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==", + "version": "22.16.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.2.tgz", + "integrity": "sha512-Cdqa/eJTvt4fC4wmq1Mcc0CPUjp/Qy2FGqLza3z3pKymsI969TcZ54diNJv8UYUgeWxyb8FSbCkhdR6WqmUFhA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 078a816..ddd6745 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.32" + "@start9labs/start-sdk": "^0.4.0-beta.33" }, "devDependencies": { "@types/node": "^22.1.0", diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts index f563dd1..dfdbcb6 100644 --- a/startos/install/versions/index.ts +++ b/startos/install/versions/index.ts @@ -1,2 +1,2 @@ -export { v2025_6_1 as current } from './v2025.6.1' +export { v2025_7_0 as current } from './v2025.7.0' export const other = [] diff --git a/startos/install/versions/v2025.6.1.ts b/startos/install/versions/v2025.7.0.ts similarity index 73% rename from startos/install/versions/v2025.6.1.ts rename to startos/install/versions/v2025.7.0.ts index 45ef8ad..d1d534c 100644 --- a/startos/install/versions/v2025.6.1.ts +++ b/startos/install/versions/v2025.7.0.ts @@ -1,7 +1,7 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' -export const v2025_6_1 = VersionInfo.of({ - version: '2025.6.1:1.0', +export const v2025_7_0 = VersionInfo.of({ + version: '2025.7.0:1.0', releaseNotes: 'Revamped for StartOS 0.4.0', migrations: { up: async ({ effects }) => {}, diff --git a/startos/main.ts b/startos/main.ts index a7cdf28..a2bc8bd 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -1,6 +1,5 @@ import { store } from './fileModels/store.yaml' import { sdk } from './sdk' -import { T } from '@start9labs/start-sdk' export const main = sdk.setupMain(async ({ effects, started }) => { console.info('Starting cloudflared...') diff --git a/startos/manifest.ts b/startos/manifest.ts index 9eb354f..6e5a021 100644 --- a/startos/manifest.ts +++ b/startos/manifest.ts @@ -1,6 +1,6 @@ import { setupManifest } from '@start9labs/start-sdk' -const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.6.1' +const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.7.0' export const manifest = setupManifest({ id: 'cloudflared', From a4dc7206e5d9b38725c74f77d3780dc82c3df4ae Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Thu, 17 Jul 2025 16:44:15 +0000 Subject: [PATCH 07/36] update to startos sdk beta.36 / update github workflows --- .github/workflows/buildService.yml | 3 ++- .github/workflows/releaseService.yml | 38 ++++++++++++++++------------ package-lock.json | 22 ++++++++-------- package.json | 10 ++++---- startos/manifest.ts | 1 + 5 files changed, 41 insertions(+), 33 deletions(-) diff --git a/.github/workflows/buildService.yml b/.github/workflows/buildService.yml index a988008..06432ce 100644 --- a/.github/workflows/buildService.yml +++ b/.github/workflows/buildService.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Prepare StartOS SDK - uses: start9Labs/sdk@v2 + uses: start9labs/sdk@v2 - name: Checkout services repository uses: actions/checkout@v4 @@ -30,6 +30,7 @@ jobs: echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" shell: bash + - name: Upload .s9pk uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/releaseService.yml b/.github/workflows/releaseService.yml index 7d26e65..99fa697 100644 --- a/.github/workflows/releaseService.yml +++ b/.github/workflows/releaseService.yml @@ -12,7 +12,7 @@ jobs: contents: write steps: - name: Prepare StartOS SDK - uses: start9Labs/sdk@v2 + uses: start9labs/sdk@v2 - name: Checkout services repository uses: actions/checkout@v4 @@ -20,15 +20,22 @@ jobs: submodules: recursive - name: Build the service package + id: build + env: + S9DEVKEY: ${{ secrets.S9DEVKEY }} run: | start-cli init + if [[ -n "$S9DEVKEY" ]]; then + echo "Using developer key from secrets to sign the package." + printf '%s' "$S9DEVKEY" > ~/.startos/developer.key.pem + else + echo "Using newly generated developer key to sign the package." + fi RUST_LOG=debug RUST_BACKTRACE=1 make - - - name: Setting package ID and title from the manifest - id: package - run: | - PACKAGE_ID=$(start-cli s9pk inspect *.s9pk manifest | jq -r '.id') - PACKAGE_TITLE=$(start-cli s9pk inspect *.s9pk manifest | jq -r '.title') + sleep 2 + MANIFEST_JSON=$(start-cli s9pk inspect *.s9pk manifest) + PACKAGE_ID=$(echo "$MANIFEST_JSON" | jq -r '.id') + PACKAGE_TITLE=$(echo "$MANIFEST_JSON" | jq -r '.title') echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV echo "package_title=${PACKAGE_TITLE}" >> $GITHUB_ENV printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" @@ -36,17 +43,15 @@ jobs: - name: Generate sha256 checksum run: | - sha256sum ${PACKAGE_ID}.s9pk > ${PACKAGE_ID}.s9pk.sha256 + sha256sum ${{ env.package_id }}.s9pk > ${{ env.package_id }}.s9pk.sha256 shell: bash - name: Generate changelog run: | echo "## What's Changed" > change-log.txt echo "" >> change-log.txt - - RELEASE_NOTES=$(start-cli s9pk inspect *.s9pk manifest | jq -r '.releaseNotes') + RELEASE_NOTES=$(start-cli s9pk inspect *.s9pk manifest | jq -r '.releaseNotes') echo "${RELEASE_NOTES}" >> change-log.txt - echo "## SHA256 Hash" >> change-log.txt echo '```' >> change-log.txt sha256sum ${{ env.package_id }}.s9pk >> change-log.txt @@ -66,12 +71,13 @@ jobs: - name: Publish to Registry env: - S9USER: ${{ secrets.S9USER }} - S9PASS: ${{ secrets.S9PASS }} + S9DEVKEY: ${{ secrets.S9DEVKEY }} S9REGISTRY: ${{ secrets.S9REGISTRY }} run: | - if [[ -z "$S9USER" || -z "$S9PASS" || -z "$S9REGISTRY" ]]; then - echo "Publish skipped: missing registry credentials." + if [[ -z "$S9DEVKEY" || -z "$S9REGISTRY" ]]; then + echo "Publish skipped: One or both of S9DEVKEY and S9REGISTRY secrets are not set." else - start-sdk publish https://$S9USER:$S9PASS@$S9REGISTRY ${{ env.package_id }}.s9pk + echo "Publishing package to registry..." + start-cli --registry https://$S9REGISTRY registry package add ${{ env.package_id }}.s9pk ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.package_id }}.s9pk fi + shell: bash diff --git a/package-lock.json b/package-lock.json index 95c5d54..77f95e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.33" + "@start9labs/start-sdk": "^0.4.0-beta.36" }, "devDependencies": { - "@types/node": "^22.1.0", - "@vercel/ncc": "^0.38.1", - "prettier": "^3.2.5", - "typescript": "^5.4.3" + "@types/node": "^22.16.4", + "@vercel/ncc": "^0.38.3", + "prettier": "^3.6.2", + "typescript": "^5.8.3" } }, "node_modules/@iarna/toml": { @@ -49,9 +49,9 @@ } }, "node_modules/@start9labs/start-sdk": { - "version": "0.4.0-beta.33", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.33.tgz", - "integrity": "sha512-tvifk73nmpRzmBVrvoftQ1f9vV/+ykFT93SlZnFrY/tUnGbhXgNyDg4xcudVNk1D31kN9Jp+0ZDtBw9Sa48jmA==", + "version": "0.4.0-beta.36", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.36.tgz", + "integrity": "sha512-26C1NGJBy/yubp88SriuX2wfVDAM6/1rDx47wdypR2KEqGzhOrMRQC5hJDAMU8BeRAiL6ED8/Ca9CP+5+8BBBw==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -73,9 +73,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.16.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.2.tgz", - "integrity": "sha512-Cdqa/eJTvt4fC4wmq1Mcc0CPUjp/Qy2FGqLza3z3pKymsI969TcZ54diNJv8UYUgeWxyb8FSbCkhdR6WqmUFhA==", + "version": "22.16.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.4.tgz", + "integrity": "sha512-PYRhNtZdm2wH/NT2k/oAJ6/f2VD2N2Dag0lGlx2vWgMSJXGNmlce5MiTQzoWAiIJtso30mjnfQCOKVH+kAQC/g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ddd6745..19779ec 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,13 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.33" + "@start9labs/start-sdk": "^0.4.0-beta.36" }, "devDependencies": { - "@types/node": "^22.1.0", - "@vercel/ncc": "^0.38.1", - "prettier": "^3.2.5", - "typescript": "^5.4.3" + "@types/node": "^22.16.4", + "@vercel/ncc": "^0.38.3", + "prettier": "^3.6.2", + "typescript": "^5.8.3" }, "prettier": { "trailingComma": "all", diff --git a/startos/manifest.ts b/startos/manifest.ts index 6e5a021..94dd174 100644 --- a/startos/manifest.ts +++ b/startos/manifest.ts @@ -9,6 +9,7 @@ export const manifest = setupManifest({ wrapperRepo: 'https://github.com/remcoros/cloudflared-startos', upstreamRepo: 'https://github.com/cloudflare/cloudflared', supportSite: 'https://github.com/cloudflare/cloudflared/issues', + docsUrl: 'https://github.com/remcoros/cloudflared-startos/blob/main/instructions.md', marketingSite: 'https://cloudflare.com/', donationUrl: 'https://cloudflare.com/', description: { From 167d7c4239b79f5d6b06f540bcbc829e517ac826 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Thu, 14 Aug 2025 12:22:36 +0000 Subject: [PATCH 08/36] update cloudflared to 2025.8.0 --- package-lock.json | 22 +++++++++---------- package.json | 4 ++-- startos/install/versions/index.ts | 2 +- .../versions/{v2025.7.0.ts => v2025.8.0.ts} | 4 ++-- startos/manifest.ts | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) rename startos/install/versions/{v2025.7.0.ts => v2025.8.0.ts} (73%) diff --git a/package-lock.json b/package-lock.json index 77f95e7..10dbb35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,10 @@ "@start9labs/start-sdk": "^0.4.0-beta.36" }, "devDependencies": { - "@types/node": "^22.16.4", + "@types/node": "^22.17.1", "@vercel/ncc": "^0.38.3", "prettier": "^3.6.2", - "typescript": "^5.8.3" + "typescript": "^5.9.2" } }, "node_modules/@iarna/toml": { @@ -22,9 +22,9 @@ "license": "ISC" }, "node_modules/@noble/curves": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.2.tgz", - "integrity": "sha512-HxngEd2XUcg9xi20JkwlLCtYwfoFw4JGkuZpT+WlsPD4gB/cxkvTD8fSsoAnphGZhFdZYKeQIPCuFlWPm1uE0g==", + "version": "1.9.6", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.6.tgz", + "integrity": "sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==", "license": "MIT", "dependencies": { "@noble/hashes": "1.8.0" @@ -73,9 +73,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.16.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.4.tgz", - "integrity": "sha512-PYRhNtZdm2wH/NT2k/oAJ6/f2VD2N2Dag0lGlx2vWgMSJXGNmlce5MiTQzoWAiIJtso30mjnfQCOKVH+kAQC/g==", + "version": "22.17.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", + "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", "dev": true, "license": "MIT", "dependencies": { @@ -193,9 +193,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 19779ec..37ae557 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "@start9labs/start-sdk": "^0.4.0-beta.36" }, "devDependencies": { - "@types/node": "^22.16.4", + "@types/node": "^22.17.1", "@vercel/ncc": "^0.38.3", "prettier": "^3.6.2", - "typescript": "^5.8.3" + "typescript": "^5.9.2" }, "prettier": { "trailingComma": "all", diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts index dfdbcb6..619d49f 100644 --- a/startos/install/versions/index.ts +++ b/startos/install/versions/index.ts @@ -1,2 +1,2 @@ -export { v2025_7_0 as current } from './v2025.7.0' +export { v2025_8_0 as current } from './v2025.8.0' export const other = [] diff --git a/startos/install/versions/v2025.7.0.ts b/startos/install/versions/v2025.8.0.ts similarity index 73% rename from startos/install/versions/v2025.7.0.ts rename to startos/install/versions/v2025.8.0.ts index d1d534c..ca23a26 100644 --- a/startos/install/versions/v2025.7.0.ts +++ b/startos/install/versions/v2025.8.0.ts @@ -1,7 +1,7 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' -export const v2025_7_0 = VersionInfo.of({ - version: '2025.7.0:1.0', +export const v2025_8_0 = VersionInfo.of({ + version: '2025.8.0:1.0', releaseNotes: 'Revamped for StartOS 0.4.0', migrations: { up: async ({ effects }) => {}, diff --git a/startos/manifest.ts b/startos/manifest.ts index 94dd174..140705f 100644 --- a/startos/manifest.ts +++ b/startos/manifest.ts @@ -1,6 +1,6 @@ import { setupManifest } from '@start9labs/start-sdk' -const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.7.0' +const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.8.0' export const manifest = setupManifest({ id: 'cloudflared', From bb1f6d22646fe0391db1aac86ed214c4bd8a1a15 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 14 Sep 2025 10:29:33 +0000 Subject: [PATCH 09/36] update startos sdk to beta.40 / cloudflared 2025.8.1 --- .github/workflows/buildService.yml | 568 ++++++++++++++++- .github/workflows/releaseService.yml | 569 +++++++++++++++++- Makefile | 97 ++- package-lock.json | 34 +- package.json | 4 +- startos/install/versions/index.ts | 2 +- .../versions/{v2025.8.0.ts => v2025.8.1.ts} | 4 +- startos/manifest.ts | 22 +- 8 files changed, 1212 insertions(+), 88 deletions(-) rename startos/install/versions/{v2025.8.0.ts => v2025.8.1.ts} (73%) diff --git a/.github/workflows/buildService.yml b/.github/workflows/buildService.yml index 06432ce..7208287 100644 --- a/.github/workflows/buildService.yml +++ b/.github/workflows/buildService.yml @@ -10,29 +10,573 @@ on: branches: ['main', 'master', 'update/040'] jobs: - BuildPackage: - runs-on: ubuntu-latest + BuildARM: + name: Build S9PK (aarch64) + runs-on: ubuntu-24.04-arm + outputs: + package_id: ${{ steps.build.outputs.package_id }} + arch_package: ${{ steps.build.outputs.arch_package }} + has_dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }} + build_type: ${{ steps.check-dockerfile.outputs.build_type }} steps: - - name: Prepare StartOS SDK - uses: start9labs/sdk@v2 + - name: Prepare StartOS SDK (Super Cached) + uses: k0gen/sdk@v3-optimization + + - name: Checkout services repository + uses: actions/checkout@v4 + with: + submodules: recursive + + # UNIVERSAL: Detect build strategy automatically + - name: Detect Docker build strategy + id: check-dockerfile + run: | + # Check for custom Dockerfile + if [ -f "Dockerfile" ] || find . -name "Dockerfile*" -type f | grep -q .; then + echo "has_dockerfile=true" >> $GITHUB_OUTPUT + echo "build_type=custom" >> $GITHUB_OUTPUT + echo "🏗️ DETECTED: Custom Docker build (Dockerfile found)" + echo " Strategy: BuildKit cache + Image export" + else + echo "has_dockerfile=false" >> $GITHUB_OUTPUT + echo "build_type=pull" >> $GITHUB_OUTPUT + echo "📥 DETECTED: Docker pull only (no Dockerfile)" + echo " Strategy: Image export only" + fi + + # CONDITIONAL: Setup BuildKit only for custom builds + - name: Set up Docker Buildx (custom builds only) + if: steps.check-dockerfile.outputs.has_dockerfile == 'true' + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:buildx-stable-1 + network=host + + # Cache Node.js dependencies if they exist + - name: Cache Node.js dependencies + uses: actions/cache@v4 + id: cache-node-modules + with: + path: | + node_modules + ~/.npm + key: ${{ runner.os }}-${{ runner.arch }}-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-node-modules- + + # Install Node.js dependencies only if cache miss and package.json exists + - name: Install Node.js dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' && hashFiles('**/package.json') != '' + run: | + if [ -f "package-lock.json" ]; then + echo "Installing with npm..." + npm ci + elif [ -f "yarn.lock" ]; then + echo "Installing with yarn..." + yarn install --frozen-lockfile + else + echo "Installing with npm (no lockfile)..." + npm install + fi + + # Cache Rust build artifacts (only if Rust project exists) + - name: Cache Rust build artifacts + if: hashFiles('**/Cargo.lock') != '' + uses: actions/cache@v4 + id: cache-rust + with: + path: | + target/ + ~/.cargo/registry/ + ~/.cargo/git/ + key: ${{ runner.os }}-${{ runner.arch }}-rust-${{ hashFiles('**/Cargo.lock') }}-v1 + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-rust- + + - name: Build ARM package + id: build + env: + # CONDITIONAL: Enable BuildKit cache only for custom builds + DOCKER_BUILDKIT: 1 + BUILDKIT_PROGRESS: plain + run: | + start-cli init + chmod 600 ~/.startos/developer.key.pem + + echo "🏗️ Building ARM package:" + echo " Build type: ${{ steps.check-dockerfile.outputs.build_type }}" + echo " Custom Dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }}" + echo " Node modules cache hit: ${{ steps.cache-node-modules.outputs.cache-hit }}" + echo " Rust cache hit: ${{ steps.cache-rust.outputs.cache-hit }}" + + # CONDITIONAL: Set BuildKit cache options only for custom builds + if [ "${{ steps.check-dockerfile.outputs.has_dockerfile }}" = "true" ]; then + export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build" + export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=arm-build" + echo " Docker BuildKit cache: Enabled (custom build)" + else + echo " Docker strategy: Pull-only (no BuildKit cache needed)" + fi + + time RUST_LOG=debug RUST_BACKTRACE=1 make aarch64 + + # Find the generated package (dynamic naming) + ARCH_PACKAGE=$(ls *_aarch64.s9pk | head -n1) + PACKAGE_ID=$(start-cli s9pk inspect "$ARCH_PACKAGE" manifest | jq -r '.id') + + echo "package_id=${PACKAGE_ID}" >> $GITHUB_OUTPUT + echo "arch_package=${ARCH_PACKAGE}" >> $GITHUB_OUTPUT + echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV + echo "arch_package=${ARCH_PACKAGE}" >> $GITHUB_ENV + + printf "\n ARM SHA256: $(sha256sum ${ARCH_PACKAGE}) \n" + + # SMART: Export Docker images based on S9PK manifest + - name: Export Docker images (manifest-driven + intelligent fallback) + run: | + echo "📦 Exporting Docker images with intelligent detection..." + mkdir -p docker-cache-arm + + # Step 1: Try to get images from S9PK manifest + ARCH_PACKAGE=$(ls *_aarch64.s9pk 2>/dev/null | head -n1) + MANIFEST_IMAGES="" + + if [ -n "$ARCH_PACKAGE" ]; then + echo "📋 Reading manifest from: $ARCH_PACKAGE" + MANIFEST_IMAGES=$(start-cli s9pk inspect "$ARCH_PACKAGE" manifest 2>/dev/null | jq -r '.images | keys[]' 2>/dev/null | tr '\n' ' ' || echo "") + echo "🎯 Manifest images: $MANIFEST_IMAGES" + fi + + # Step 2: Get current Docker images (excluding system images) + CURRENT_IMAGES=$(docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -vE "(|buildkit|qemu-user-static|alpine:latest|ubuntu:latest)" || true) + + echo "🐳 Available Docker images:" + echo "$CURRENT_IMAGES" | head -10 + + # Step 3: Smart export strategy + exported_count=0 + + if [ -n "$MANIFEST_IMAGES" ]; then + # Priority 1: Export images mentioned in manifest + echo "🎯 Exporting manifest-based images..." + for image_key in $MANIFEST_IMAGES; + do + echo "Looking for: $image_key" + matching_images=$(echo "$CURRENT_IMAGES" | grep -i "$image_key" | cut -d' ' -f1 || true) + + if [ -n "$matching_images" ]; then + echo "$matching_images" | while read docker_image; + do + if [ -n "$docker_image" ]; then + safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') + size_info=$(echo "$CURRENT_IMAGES" | grep "^$docker_image " | awk '{print $2}' || echo "unknown") + + echo " 📦 Exporting: $docker_image ($size_info)" + if docker save "$docker_image" -o "docker-cache-arm/${safe_name}.tar" 2>/dev/null; then + tar_size=$(ls -lh "docker-cache-arm/${safe_name}.tar" | awk '{print $5}') + echo " ✅ Saved: $tar_size" + exported_count=$((exported_count + 1)) + else + echo " ❌ Failed to export" + rm -f "docker-cache-arm/${safe_name}.tar" + fi + fi + done + else + echo " ⚠️ Not found in current images: $image_key" + fi + done + fi + + # Priority 2: If no manifest images found, export all relevant images + if [ "$exported_count" -eq 0 ]; then + echo "🔄 No manifest images exported, using fallback strategy..." + echo "$CURRENT_IMAGES" | while read image_line; + do + if [ -n "$image_line" ]; then + docker_image=$(echo "$image_line" | cut -d' ' -f1) + size_info=$(echo "$image_line" | cut -d' ' -f2) + safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') + + echo " 📦 Exporting: $docker_image ($size_info)" + if docker save "$docker_image" -o "docker-cache-arm/${safe_name}.tar" 2>/dev/null; then + tar_size=$(ls -lh "docker-cache-arm/${safe_name}.tar" | awk '{print $5}') + echo " ✅ Saved: $tar_size" + exported_count=$((exported_count + 1)) + else + echo " ❌ Failed" + rm -f "docker-cache-arm/${safe_name}.tar" + fi + fi + done + fi + + # Final summary + if [ "$exported_count" -eq 0 ]; then + echo "No images cached" > docker-cache-arm/.empty + echo "❌ No images were cached!" + else + echo "=== ARM CACHE SUMMARY ===" + echo "✅ Exported $exported_count image(s)" + ls -lah docker-cache-arm/*.tar 2>/dev/null | head -5 + total_size=$(du -sh docker-cache-arm/ | cut -f1) + echo "📊 Total cache size: $total_size" + fi + + - name: Upload ARM package + uses: actions/upload-artifact@v4 + with: + name: ${{ env.arch_package }} + path: ./${{ env.arch_package }} + + - name: Upload Docker cache + uses: actions/upload-artifact@v4 + with: + name: docker-cache-arm + path: docker-cache-arm/ + + BuildIntel: + name: Build S9PK (x86_64) + runs-on: ubuntu-24.04 + outputs: + package_id: ${{ steps.build.outputs.package_id }} + intel_package: ${{ steps.build.outputs.intel_package }} + has_dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }} + build_type: ${{ steps.check-dockerfile.outputs.build_type }} + steps: + - name: Prepare StartOS SDK (Super Cached) + uses: k0gen/sdk@v3-optimization - name: Checkout services repository uses: actions/checkout@v4 with: submodules: recursive - - name: Build the service package + # UNIVERSAL: Detect build strategy (same as ARM) + - name: Detect Docker build strategy + id: check-dockerfile + run: | + if [ -f "Dockerfile" ] || find . -name "Dockerfile*" -type f | grep -q .; then + echo "has_dockerfile=true" >> $GITHUB_OUTPUT + echo "build_type=custom" >> $GITHUB_OUTPUT + echo "🏗️ DETECTED: Custom Docker build (Dockerfile found)" + else + echo "has_dockerfile=false" >> $GITHUB_OUTPUT + echo "build_type=pull" >> $GITHUB_OUTPUT + echo "📥 DETECTED: Docker pull only (no Dockerfile)" + fi + + # CONDITIONAL: Setup BuildKit only for custom builds + - name: Set up Docker Buildx (custom builds only) + if: steps.check-dockerfile.outputs.has_dockerfile == 'true' + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:buildx-stable-1 + network=host + + # Cache Node.js dependencies + - name: Cache Node.js dependencies + uses: actions/cache@v4 + id: cache-node-modules + with: + path: | + node_modules + ~/.npm + key: ${{ runner.os }}-${{ runner.arch }}-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-node-modules- + + # Install Node.js dependencies + - name: Install Node.js dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' && hashFiles('**/package.json') != '' + run: | + if [ -f "package-lock.json" ]; then + echo "Installing with npm..." + npm ci + elif [ -f "yarn.lock" ]; then + echo "Installing with yarn..." + yarn install --frozen-lockfile + else + echo "Installing with npm (no lockfile)..." + npm install + fi + + # Cache Rust build artifacts + - name: Cache Rust build artifacts + if: hashFiles('**/Cargo.lock') != '' + uses: actions/cache@v4 + id: cache-rust + with: + path: | + target/ + ~/.cargo/registry/ + ~/.cargo/git/ + key: ${{ runner.os }}-${{ runner.arch }}-rust-${{ hashFiles('**/Cargo.lock') }}-v1 + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-rust- + + - name: Build Intel package id: build + env: + DOCKER_BUILDKIT: 1 + BUILDKIT_PROGRESS: plain run: | start-cli init - RUST_LOG=debug RUST_BACKTRACE=1 make - PACKAGE_ID=$(start-cli s9pk inspect *.s9pk manifest | jq -r '.id') + chmod 600 ~/.startos/developer.key.pem + + echo "🏗️ Building Intel package:" + echo " Build type: ${{ steps.check-dockerfile.outputs.build_type }}" + echo " Custom Dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }}" + echo " Node modules cache hit: ${{ steps.cache-node-modules.outputs.cache-hit }}" + echo " Rust cache hit: ${{ steps.cache-rust.outputs.cache-hit }}" + + # CONDITIONAL: Set BuildKit cache with ARM import for custom builds + if [ "${{ steps.check-dockerfile.outputs.has_dockerfile }}" = "true" ]; then + export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build type=gha,scope=intel-build" + export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=intel-build" + echo " Docker BuildKit cache: Enabled with ARM import" + else + echo " Docker strategy: Pull-only (no BuildKit cache needed)" + fi + + time RUST_LOG=debug RUST_BACKTRACE=1 make x86 + + # Find the generated package + INTEL_PACKAGE=$(ls *_x86_64.s9pk | head -n1) + + if [ -z "$INTEL_PACKAGE" ]; then + echo "❌ No x86_64 package found!" + ls -la *.s9pk || echo "No .s9pk files found" + exit 1 + fi + + PACKAGE_ID=$(start-cli s9pk inspect "$INTEL_PACKAGE" manifest | jq -r '.id') + + echo "package_id=${PACKAGE_ID}" >> $GITHUB_OUTPUT + echo "intel_package=${INTEL_PACKAGE}" >> $GITHUB_OUTPUT echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV - printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" - shell: bash + echo "intel_package=${INTEL_PACKAGE}" >> $GITHUB_ENV + + printf "\n Intel SHA256: $(sha256sum ${INTEL_PACKAGE}) \n" + + # SMART: Same intelligent Docker export as ARM + - name: Export Docker images (manifest-driven + intelligent fallback) + run: | + echo "📦 Exporting Docker images with intelligent detection..." + mkdir -p docker-cache-intel + + # Step 1: Try to get images from S9PK manifest + INTEL_PACKAGE=$(ls *_x86_64.s9pk 2>/dev/null | head -n1) + MANIFEST_IMAGES="" + + if [ -n "$INTEL_PACKAGE" ]; then + echo "📋 Reading manifest from: $INTEL_PACKAGE" + MANIFEST_IMAGES=$(start-cli s9pk inspect "$INTEL_PACKAGE" manifest 2>/dev/null | jq -r '.images | keys[]' 2>/dev/null | tr '\n' ' ' || echo "") + echo "🎯 Manifest images: $MANIFEST_IMAGES" + fi + + # Step 2: Get current Docker images (excluding system images) + CURRENT_IMAGES=$(docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -vE "(|buildkit|qemu-user-static|alpine:latest|ubuntu:latest)" || true) + + echo "🐳 Available Docker images:" + echo "$CURRENT_IMAGES" | head -10 + + # Step 3: Smart export strategy + exported_count=0 + + if [ -n "$MANIFEST_IMAGES" ]; then + # Priority 1: Export images mentioned in manifest + echo "🎯 Exporting manifest-based images..." + for image_key in $MANIFEST_IMAGES; + do + echo "Looking for: $image_key" + matching_images=$(echo "$CURRENT_IMAGES" | grep -i "$image_key" | cut -d' ' -f1 || true) + + if [ -n "$matching_images" ]; then + echo "$matching_images" | while read docker_image; + do + if [ -n "$docker_image" ]; then + safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') + size_info=$(echo "$CURRENT_IMAGES" | grep "^$docker_image " | awk '{print $2}' || echo "unknown") + + echo " 📦 Exporting: $docker_image ($size_info)" + if docker save "$docker_image" -o "docker-cache-intel/${safe_name}.tar" 2>/dev/null; then + tar_size=$(ls -lh "docker-cache-intel/${safe_name}.tar" | awk '{print $5}') + echo " ✅ Saved: $tar_size" + exported_count=$((exported_count + 1)) + else + echo " ❌ Failed to export" + rm -f "docker-cache-intel/${safe_name}.tar" + fi + fi + done + else + echo " ⚠️ Not found in current images: $image_key" + fi + done + fi + + # Priority 2: If no manifest images found, export all relevant images + if [ "$exported_count" -eq 0 ]; then + echo "🔄 No manifest images exported, using fallback strategy..." + echo "$CURRENT_IMAGES" | while read image_line; + do + if [ -n "$image_line" ]; then + docker_image=$(echo "$image_line" | cut -d' ' -f1) + size_info=$(echo "$image_line" | cut -d' ' -f2) + safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') + + echo " 📦 Exporting: $docker_image ($size_info)" + if docker save "$docker_image" -o "docker-cache-intel/${safe_name}.tar" 2>/dev/null; then + tar_size=$(ls -lh "docker-cache-intel/${safe_name}.tar" | awk '{print $5}') + echo " ✅ Saved: $tar_size" + exported_count=$((exported_count + 1)) + else + echo " ❌ Failed" + rm -f "docker-cache-intel/${safe_name}.tar" + fi + fi + done + fi + + # Final summary + if [ "$exported_count" -eq 0 ]; then + echo "No images cached" > docker-cache-intel/.empty + echo "❌ No images were cached!" + else + echo "=== INTEL CACHE SUMMARY ===" + echo "✅ Exported $exported_count image(s)" + ls -lah docker-cache-intel/*.tar 2>/dev/null | head -5 + total_size=$(du -sh docker-cache-intel/ | cut -f1) + echo "📊 Total cache size: $total_size" + fi + + - name: Upload Intel package + uses: actions/upload-artifact@v4 + with: + name: ${{ env.intel_package }} + path: ./${{ env.intel_package }} + + - name: Upload Docker cache + uses: actions/upload-artifact@v4 + with: + name: docker-cache-intel + path: docker-cache-intel/ + + BuildUniversal: + name: Build S9PK (Universal) + runs-on: ubuntu-24.04 + needs: [BuildARM, BuildIntel] + steps: + - name: Prepare StartOS SDK + uses: k0gen/sdk@v3-optimization + + - name: Checkout services repository + uses: actions/checkout@v4 + with: + submodules: recursive + + # CONDITIONAL: Setup BuildKit only if needed + - name: Set up Docker Buildx (custom builds only) + if: needs.BuildARM.outputs.has_dockerfile == 'true' || needs.BuildIntel.outputs.has_dockerfile == 'true' + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:buildx-stable-1 + network=host + + # Restore build dependencies from Intel build + - name: Restore Node.js dependencies (from Intel build) + uses: actions/cache/restore@v4 + with: + path: | + node_modules + ~/.npm + key: ${{ runner.os }}-X64-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 + restore-keys: | + ${{ runner.os }}-X64-node-modules- + + - name: Download Docker cache from ARM + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: docker-cache-arm + path: docker-cache-arm/ + + - name: Import Docker images from both architectures + run: | + echo "📦 Importing Docker images from ARM build..." + echo " Build type: ${{ needs.BuildARM.outputs.build_type }}" + + imported_count=0 + + # Import ARM cache + if [ -d "docker-cache-arm" ] && [ -n "$(ls -A docker-cache-arm/*.tar 2>/dev/null)" ]; then + echo "Loading ARM Docker cache..." + for tar_file in docker-cache-arm/*.tar; + do + if [ -f "$tar_file" ]; then + image_name=$(basename "$tar_file" .tar) + echo " Loading: $image_name" + if docker load -i "$tar_file" 2>/dev/null; then + echo " ✅ Loaded: $image_name" + imported_count=$((imported_count + 1)) + else + echo " ❌ Failed: $image_name" + fi + fi + done + else + echo "No ARM Docker cache found" + fi + + echo "=== IMPORT SUMMARY ===" + echo "📊 Imported $imported_count image archive(s)" + echo "🐳 Available Docker images after import:" + docker image ls --format "table {{.Repository}} {{.Tag}} {{.Size}}" | head -15 + + - name: Build Universal package (Maximum Cache Optimization) + env: + DOCKER_BUILDKIT: 1 + BUILDKIT_PROGRESS: plain + run: | + start-cli init + chmod 600 ~/.startos/developer.key.pem + + echo "⚡ Building universal package with MAXIMUM cache optimization:" + echo " Build type: ${{ needs.BuildARM.outputs.build_type }}" + echo " Custom Dockerfile: ${{ needs.BuildARM.outputs.has_dockerfile }}" + echo " Docker cache: ARM + Intel images available" + echo " Build artifacts: Restored from Intel build" + + # CONDITIONAL: Set BuildKit cache for custom builds + if [ "${{ needs.BuildARM.outputs.has_dockerfile }}" = "true" ]; then + export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build type=gha,scope=intel-build" + export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=universal-build" + echo " Docker BuildKit cache: Importing from ARM + Intel build caches" + else + echo " Docker strategy: Using imported pulled images (no BuildKit cache needed)" + fi + + echo "🚀 Starting universal build..." + time RUST_LOG=debug RUST_BACKTRACE=1 make + + # Find the universal package + UNIVERSAL_PACKAGE=$(ls *.s9pk | grep -v "_aarch64|_x86_64" | head -n1) + + if [ -z "$UNIVERSAL_PACKAGE" ]; then + echo "❌ No universal package found!" + ls -la *.s9pk || echo "No .s9pk files found" + exit 1 + fi + + echo "universal_package=${UNIVERSAL_PACKAGE}" >> $GITHUB_ENV + printf "\n ⚡ Universal SHA256: $(sha256sum ${UNIVERSAL_PACKAGE}) \n" - - name: Upload .s9pk + - name: Upload Universal package uses: actions/upload-artifact@v4 with: - name: ${{ env.package_id }}.s9pk - path: ./${{ env.package_id }}.s9pk + name: ${{ env.universal_package }} + path: ./${{ env.universal_package }} \ No newline at end of file diff --git a/.github/workflows/releaseService.yml b/.github/workflows/releaseService.yml index 99fa697..24000d6 100644 --- a/.github/workflows/releaseService.yml +++ b/.github/workflows/releaseService.yml @@ -6,40 +6,577 @@ on: - 'v*.*' jobs: - ReleasePackage: - runs-on: ubuntu-latest + BuildARM: + name: Build S9PK (aarch64) + runs-on: ubuntu-24.04-arm + outputs: + package_id: ${{ steps.build.outputs.package_id }} + arch_package: ${{ steps.build.outputs.arch_package }} + has_dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }} + build_type: ${{ steps.check-dockerfile.outputs.build_type }} + steps: + - name: Prepare StartOS SDK (Super Cached) + uses: k0gen/sdk@v3-optimization + + - name: Checkout services repository + uses: actions/checkout@v4 + with: + submodules: recursive + + # UNIVERSAL: Detect build strategy automatically + - name: Detect Docker build strategy + id: check-dockerfile + run: | + # Check for custom Dockerfile + if [ -f "Dockerfile" ] || find . -name "Dockerfile*" -type f | grep -q .; then + echo "has_dockerfile=true" >> $GITHUB_OUTPUT + echo "build_type=custom" >> $GITHUB_OUTPUT + echo "🏗️ DETECTED: Custom Docker build (Dockerfile found)" + echo " Strategy: BuildKit cache + Image export" + else + echo "has_dockerfile=false" >> $GITHUB_OUTPUT + echo "build_type=pull" >> $GITHUB_OUTPUT + echo "📥 DETECTED: Docker pull only (no Dockerfile)" + echo " Strategy: Image export only" + fi + + # CONDITIONAL: Setup BuildKit only for custom builds + - name: Set up Docker Buildx (custom builds only) + if: steps.check-dockerfile.outputs.has_dockerfile == 'true' + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:buildx-stable-1 + network=host + + # Cache Node.js dependencies if they exist + - name: Cache Node.js dependencies + uses: actions/cache@v4 + id: cache-node-modules + with: + path: | + node_modules + ~/.npm + key: ${{ runner.os }}-${{ runner.arch }}-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-node-modules- + + # Install Node.js dependencies only if cache miss and package.json exists + - name: Install Node.js dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' && hashFiles('**/package.json') != '' + run: | + if [ -f "package-lock.json" ]; then + echo "Installing with npm..." + npm ci + elif [ -f "yarn.lock" ]; then + echo "Installing with yarn..." + yarn install --frozen-lockfile + else + echo "Installing with npm (no lockfile)..." + npm install + fi + + # Cache Rust build artifacts (only if Rust project exists) + - name: Cache Rust build artifacts + if: hashFiles('**/Cargo.lock') != '' + uses: actions/cache@v4 + id: cache-rust + with: + path: | + target/ + ~/.cargo/registry/ + ~/.cargo/git/ + key: ${{ runner.os }}-${{ runner.arch }}-rust-${{ hashFiles('**/Cargo.lock') }}-v1 + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-rust- + + - name: Build ARM package + id: build + env: + # CONDITIONAL: Enable BuildKit cache only for custom builds + DOCKER_BUILDKIT: 1 + BUILDKIT_PROGRESS: plain + run: | + start-cli init + chmod 600 ~/.startos/developer.key.pem + + echo "🏗️ Building ARM package:" + echo " Build type: ${{ steps.check-dockerfile.outputs.build_type }}" + echo " Custom Dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }}" + echo " Node modules cache hit: ${{ steps.cache-node-modules.outputs.cache-hit }}" + echo " Rust cache hit: ${{ steps.cache-rust.outputs.cache-hit }}" + + # CONDITIONAL: Set BuildKit cache options only for custom builds + if [ "${{ steps.check-dockerfile.outputs.has_dockerfile }}" = "true" ]; then + export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build" + export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=arm-build" + echo " Docker BuildKit cache: Enabled (custom build)" + else + echo " Docker strategy: Pull-only (no BuildKit cache needed)" + fi + + time RUST_LOG=debug RUST_BACKTRACE=1 make aarch64 + + # Find the generated package (dynamic naming) + ARCH_PACKAGE=$(ls *_aarch64.s9pk | head -n1) + PACKAGE_ID=$(start-cli s9pk inspect "$ARCH_PACKAGE" manifest | jq -r '.id') + + echo "package_id=${PACKAGE_ID}" >> $GITHUB_OUTPUT + echo "arch_package=${ARCH_PACKAGE}" >> $GITHUB_OUTPUT + echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV + echo "arch_package=${ARCH_PACKAGE}" >> $GITHUB_ENV + + printf "\n ARM SHA256: $(sha256sum ${ARCH_PACKAGE}) \n" + + # SMART: Export Docker images based on S9PK manifest + - name: Export Docker images (manifest-driven + intelligent fallback) + run: | + echo "📦 Exporting Docker images with intelligent detection..." + mkdir -p docker-cache-arm + + # Step 1: Try to get images from S9PK manifest + ARCH_PACKAGE=$(ls *_aarch64.s9pk 2>/dev/null | head -n1) + MANIFEST_IMAGES="" + + if [ -n "$ARCH_PACKAGE" ]; then + echo "📋 Reading manifest from: $ARCH_PACKAGE" + MANIFEST_IMAGES=$(start-cli s9pk inspect "$ARCH_PACKAGE" manifest 2>/dev/null | jq -r '.images | keys[]' 2>/dev/null | tr '\n' ' ' || echo "") + echo "🎯 Manifest images: $MANIFEST_IMAGES" + fi + + # Step 2: Get current Docker images (excluding system images) + CURRENT_IMAGES=$(docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -vE "(|buildkit|qemu-user-static|alpine:latest|ubuntu:latest)" || true) + + echo "🐳 Available Docker images:" + echo "$CURRENT_IMAGES" | head -10 + + # Step 3: Smart export strategy + exported_count=0 + + if [ -n "$MANIFEST_IMAGES" ]; then + # Priority 1: Export images mentioned in manifest + echo "🎯 Exporting manifest-based images..." + for image_key in $MANIFEST_IMAGES; + do + echo "Looking for: $image_key" + matching_images=$(echo "$CURRENT_IMAGES" | grep -i "$image_key" | cut -d' ' -f1 || true) + + if [ -n "$matching_images" ]; then + echo "$matching_images" | while read docker_image; + do + if [ -n "$docker_image" ]; then + safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') + size_info=$(echo "$CURRENT_IMAGES" | grep "^$docker_image " | awk '{print $2}' || echo "unknown") + + echo " 📦 Exporting: $docker_image ($size_info)" + if docker save "$docker_image" -o "docker-cache-arm/${safe_name}.tar" 2>/dev/null; then + tar_size=$(ls -lh "docker-cache-arm/${safe_name}.tar" | awk '{print $5}') + echo " ✅ Saved: $tar_size" + exported_count=$((exported_count + 1)) + else + echo " ❌ Failed to export" + rm -f "docker-cache-arm/${safe_name}.tar" + fi + fi + done + else + echo " ⚠️ Not found in current images: $image_key" + fi + done + fi + + # Priority 2: If no manifest images found, export all relevant images + if [ "$exported_count" -eq 0 ]; then + echo "🔄 No manifest images exported, using fallback strategy..." + echo "$CURRENT_IMAGES" | while read image_line; + do + if [ -n "$image_line" ]; then + docker_image=$(echo "$image_line" | cut -d' ' -f1) + size_info=$(echo "$image_line" | cut -d' ' -f2) + safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') + + echo " 📦 Exporting: $docker_image ($size_info)" + if docker save "$docker_image" -o "docker-cache-arm/${safe_name}.tar" 2>/dev/null; then + tar_size=$(ls -lh "docker-cache-arm/${safe_name}.tar" | awk '{print $5}') + echo " ✅ Saved: $tar_size" + exported_count=$((exported_count + 1)) + else + echo " ❌ Failed" + rm -f "docker-cache-arm/${safe_name}.tar" + fi + fi + done + fi + + # Final summary + if [ "$exported_count" -eq 0 ]; then + echo "No images cached" > docker-cache-arm/.empty + echo "❌ No images were cached!" + else + echo "=== ARM CACHE SUMMARY ===" + echo "✅ Exported $exported_count image(s)" + ls -lah docker-cache-arm/*.tar 2>/dev/null | head -5 + total_size=$(du -sh docker-cache-arm/ | cut -f1) + echo "📊 Total cache size: $total_size" + fi + + - name: Upload ARM package + uses: actions/upload-artifact@v4 + with: + name: ${{ env.arch_package }} + path: ./${{ env.arch_package }} + + - name: Upload Docker cache + uses: actions/upload-artifact@v4 + with: + name: docker-cache-arm + path: docker-cache-arm/ + + BuildIntel: + name: Build S9PK (x86_64) + runs-on: ubuntu-24.04 + outputs: + package_id: ${{ steps.build.outputs.package_id }} + intel_package: ${{ steps.build.outputs.intel_package }} + has_dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }} + build_type: ${{ steps.check-dockerfile.outputs.build_type }} + steps: + - name: Prepare StartOS SDK (Super Cached) + uses: k0gen/sdk@v3-optimization + + - name: Checkout services repository + uses: actions/checkout@v4 + with: + submodules: recursive + + # UNIVERSAL: Detect build strategy (same as ARM) + - name: Detect Docker build strategy + id: check-dockerfile + run: | + if [ -f "Dockerfile" ] || find . -name "Dockerfile*" -type f | grep -q .; then + echo "has_dockerfile=true" >> $GITHUB_OUTPUT + echo "build_type=custom" >> $GITHUB_OUTPUT + echo "🏗️ DETECTED: Custom Docker build (Dockerfile found)" + else + echo "has_dockerfile=false" >> $GITHUB_OUTPUT + echo "build_type=pull" >> $GITHUB_OUTPUT + echo "📥 DETECTED: Docker pull only (no Dockerfile)" + fi + + # CONDITIONAL: Setup BuildKit only for custom builds + - name: Set up Docker Buildx (custom builds only) + if: steps.check-dockerfile.outputs.has_dockerfile == 'true' + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:buildx-stable-1 + network=host + + # Cache Node.js dependencies + - name: Cache Node.js dependencies + uses: actions/cache@v4 + id: cache-node-modules + with: + path: | + node_modules + ~/.npm + key: ${{ runner.os }}-${{ runner.arch }}-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-node-modules- + + # Install Node.js dependencies + - name: Install Node.js dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' && hashFiles('**/package.json') != '' + run: | + if [ -f "package-lock.json" ]; then + echo "Installing with npm..." + npm ci + elif [ -f "yarn.lock" ]; then + echo "Installing with yarn..." + yarn install --frozen-lockfile + else + echo "Installing with npm (no lockfile)..." + npm install + fi + + # Cache Rust build artifacts + - name: Cache Rust build artifacts + if: hashFiles('**/Cargo.lock') != '' + uses: actions/cache@v4 + id: cache-rust + with: + path: | + target/ + ~/.cargo/registry/ + ~/.cargo/git/ + key: ${{ runner.os }}-${{ runner.arch }}-rust-${{ hashFiles('**/Cargo.lock') }}-v1 + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-rust- + + - name: Build Intel package + id: build + env: + DOCKER_BUILDKIT: 1 + BUILDKIT_PROGRESS: plain + run: | + start-cli init + chmod 600 ~/.startos/developer.key.pem + + echo "🏗️ Building Intel package:" + echo " Build type: ${{ steps.check-dockerfile.outputs.build_type }}" + echo " Custom Dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }}" + echo " Node modules cache hit: ${{ steps.cache-node-modules.outputs.cache-hit }}" + echo " Rust cache hit: ${{ steps.cache-rust.outputs.cache-hit }}" + + # CONDITIONAL: Set BuildKit cache with ARM import for custom builds + if [ "${{ steps.check-dockerfile.outputs.has_dockerfile }}" = "true" ]; then + export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build type=gha,scope=intel-build" + export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=intel-build" + echo " Docker BuildKit cache: Enabled with ARM import" + else + echo " Docker strategy: Pull-only (no BuildKit cache needed)" + fi + + time RUST_LOG=debug RUST_BACKTRACE=1 make x86 + + # Find the generated package + INTEL_PACKAGE=$(ls *_x86_64.s9pk | head -n1) + + if [ -z "$INTEL_PACKAGE" ]; then + echo "❌ No x86_64 package found!" + ls -la *.s9pk || echo "No .s9pk files found" + exit 1 + fi + + PACKAGE_ID=$(start-cli s9pk inspect "$INTEL_PACKAGE" manifest | jq -r '.id') + + echo "package_id=${PACKAGE_ID}" >> $GITHUB_OUTPUT + echo "intel_package=${INTEL_PACKAGE}" >> $GITHUB_OUTPUT + echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV + echo "intel_package=${INTEL_PACKAGE}" >> $GITHUB_ENV + + printf "\n Intel SHA256: $(sha256sum ${INTEL_PACKAGE}) \n" + + # SMART: Same intelligent Docker export as ARM + - name: Export Docker images (manifest-driven + intelligent fallback) + run: | + echo "📦 Exporting Docker images with intelligent detection..." + mkdir -p docker-cache-intel + + # Step 1: Try to get images from S9PK manifest + INTEL_PACKAGE=$(ls *_x86_64.s9pk 2>/dev/null | head -n1) + MANIFEST_IMAGES="" + + if [ -n "$INTEL_PACKAGE" ]; then + echo "📋 Reading manifest from: $INTEL_PACKAGE" + MANIFEST_IMAGES=$(start-cli s9pk inspect "$INTEL_PACKAGE" manifest 2>/dev/null | jq -r '.images | keys[]' 2>/dev/null | tr '\n' ' ' || echo "") + echo "🎯 Manifest images: $MANIFEST_IMAGES" + fi + + # Step 2: Get current Docker images (excluding system images) + CURRENT_IMAGES=$(docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -vE "(|buildkit|qemu-user-static|alpine:latest|ubuntu:latest)" || true) + + echo "🐳 Available Docker images:" + echo "$CURRENT_IMAGES" | head -10 + + # Step 3: Smart export strategy + exported_count=0 + + if [ -n "$MANIFEST_IMAGES" ]; then + # Priority 1: Export images mentioned in manifest + echo "🎯 Exporting manifest-based images..." + for image_key in $MANIFEST_IMAGES; + do + echo "Looking for: $image_key" + matching_images=$(echo "$CURRENT_IMAGES" | grep -i "$image_key" | cut -d' ' -f1 || true) + + if [ -n "$matching_images" ]; then + echo "$matching_images" | while read docker_image; + do + if [ -n "$docker_image" ]; then + safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') + size_info=$(echo "$CURRENT_IMAGES" | grep "^$docker_image " | awk '{print $2}' || echo "unknown") + + echo " 📦 Exporting: $docker_image ($size_info)" + if docker save "$docker_image" -o "docker-cache-intel/${safe_name}.tar" 2>/dev/null; then + tar_size=$(ls -lh "docker-cache-intel/${safe_name}.tar" | awk '{print $5}') + echo " ✅ Saved: $tar_size" + exported_count=$((exported_count + 1)) + else + echo " ❌ Failed to export" + rm -f "docker-cache-intel/${safe_name}.tar" + fi + fi + done + else + echo " ⚠️ Not found in current images: $image_key" + fi + done + fi + + # Priority 2: If no manifest images found, export all relevant images + if [ "$exported_count" -eq 0 ]; then + echo "🔄 No manifest images exported, using fallback strategy..." + echo "$CURRENT_IMAGES" | while read image_line; + do + if [ -n "$image_line" ]; then + docker_image=$(echo "$image_line" | cut -d' ' -f1) + size_info=$(echo "$image_line" | cut -d' ' -f2) + safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') + + echo " 📦 Exporting: $docker_image ($size_info)" + if docker save "$docker_image" -o "docker-cache-intel/${safe_name}.tar" 2>/dev/null; then + tar_size=$(ls -lh "docker-cache-intel/${safe_name}.tar" | awk '{print $5}') + echo " ✅ Saved: $tar_size" + exported_count=$((exported_count + 1)) + else + echo " ❌ Failed" + rm -f "docker-cache-intel/${safe_name}.tar" + fi + fi + done + fi + + # Final summary + if [ "$exported_count" -eq 0 ]; then + echo "No images cached" > docker-cache-intel/.empty + echo "❌ No images were cached!" + else + echo "=== INTEL CACHE SUMMARY ===" + echo "✅ Exported $exported_count image(s)" + ls -lah docker-cache-intel/*.tar 2>/dev/null | head -5 + total_size=$(du -sh docker-cache-intel/ | cut -f1) + echo "📊 Total cache size: $total_size" + fi + + - name: Upload Intel package + uses: actions/upload-artifact@v4 + with: + name: ${{ env.intel_package }} + path: ./${{ env.intel_package }} + + - name: Upload Docker cache + uses: actions/upload-artifact@v4 + with: + name: docker-cache-intel + path: docker-cache-intel/ + + BuildUniversal: + name: Build S9PK (Universal) + runs-on: ubuntu-24.04 + needs: [BuildARM, BuildIntel] permissions: contents: write steps: - name: Prepare StartOS SDK - uses: start9labs/sdk@v2 + uses: k0gen/sdk@v3-optimization - name: Checkout services repository uses: actions/checkout@v4 with: submodules: recursive - - name: Build the service package - id: build + # CONDITIONAL: Setup BuildKit only if needed + - name: Set up Docker Buildx (custom builds only) + if: needs.BuildARM.outputs.has_dockerfile == 'true' || needs.BuildIntel.outputs.has_dockerfile == 'true' + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:buildx-stable-1 + network=host + + # Restore build dependencies from Intel build + - name: Restore Node.js dependencies (from Intel build) + uses: actions/cache/restore@v4 + with: + path: | + node_modules + ~/.npm + key: ${{ runner.os }}-X64-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 + restore-keys: | + ${{ runner.os }}-X64-node-modules- + + - name: Download Docker cache from ARM + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: docker-cache-arm + path: docker-cache-arm/ + + - name: Import Docker images from both architectures + run: | + echo "📦 Importing Docker images from ARM build..." + echo " Build type: ${{ needs.BuildARM.outputs.build_type }}" + + imported_count=0 + + # Import ARM cache + if [ -d "docker-cache-arm" ] && [ -n "$(ls -A docker-cache-arm/*.tar 2>/dev/null)" ]; then + echo "Loading ARM Docker cache..." + for tar_file in docker-cache-arm/*.tar; + do + if [ -f "$tar_file" ]; then + image_name=$(basename "$tar_file" .tar) + echo " Loading: $image_name" + if docker load -i "$tar_file" 2>/dev/null; then + echo " ✅ Loaded: $image_name" + imported_count=$((imported_count + 1)) + else + echo " ❌ Failed: $image_name" + fi + fi + done + else + echo "No ARM Docker cache found" + fi + + echo "=== IMPORT SUMMARY ===" + echo "📊 Imported $imported_count image archive(s)" + echo "🐳 Available Docker images after import:" + docker image ls --format "table {{.Repository}} {{.Tag}} {{.Size}}" | head -15 + + - name: Build Universal package (Maximum Cache Optimization) env: - S9DEVKEY: ${{ secrets.S9DEVKEY }} + DOCKER_BUILDKIT: 1 + BUILDKIT_PROGRESS: plain run: | start-cli init - if [[ -n "$S9DEVKEY" ]]; then - echo "Using developer key from secrets to sign the package." - printf '%s' "$S9DEVKEY" > ~/.startos/developer.key.pem + chmod 600 ~/.startos/developer.key.pem + + echo "⚡ Building universal package with MAXIMUM cache optimization:" + echo " Build type: ${{ needs.BuildARM.outputs.build_type }}" + echo " Custom Dockerfile: ${{ needs.BuildARM.outputs.has_dockerfile }}" + echo " Docker cache: ARM + Intel images available" + echo " Build artifacts: Restored from Intel build" + + # CONDITIONAL: Set BuildKit cache for custom builds + if [ "${{ needs.BuildARM.outputs.has_dockerfile }}" = "true" ]; then + export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build type=gha,scope=intel-build" + export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=universal-build" + echo " Docker BuildKit cache: Importing from ARM + Intel build caches" else - echo "Using newly generated developer key to sign the package." + echo " Docker strategy: Using imported pulled images (no BuildKit cache needed)" fi - RUST_LOG=debug RUST_BACKTRACE=1 make - sleep 2 - MANIFEST_JSON=$(start-cli s9pk inspect *.s9pk manifest) + + echo "🚀 Starting universal build..." + time RUST_LOG=debug RUST_BACKTRACE=1 make + + # Find the universal package + UNIVERSAL_PACKAGE=$(ls *.s9pk | grep -v "_aarch64|_x86_64" | head -n1) + + if [ -z "$UNIVERSAL_PACKAGE" ]; then + echo "❌ No universal package found!" + ls -la *.s9pk || echo "No .s9pk files found" + exit 1 + fi + + MANIFEST_JSON=$(start-cli s9pk inspect $UNIVERSAL_PACKAGE manifest) PACKAGE_ID=$(echo "$MANIFEST_JSON" | jq -r '.id') PACKAGE_TITLE=$(echo "$MANIFEST_JSON" | jq -r '.title') echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV echo "package_title=${PACKAGE_TITLE}" >> $GITHUB_ENV - printf "\n SHA256SUM: $(sha256sum ${PACKAGE_ID}.s9pk) \n" - shell: bash + echo "universal_package=${UNIVERSAL_PACKAGE}" >> $GITHUB_ENV + printf "\n ⚡ Universal SHA256: $(sha256sum ${UNIVERSAL_PACKAGE}) \n" - name: Generate sha256 checksum run: | @@ -50,7 +587,7 @@ jobs: run: | echo "## What's Changed" > change-log.txt echo "" >> change-log.txt - RELEASE_NOTES=$(start-cli s9pk inspect *.s9pk manifest | jq -r '.releaseNotes') + RELEASE_NOTES=$(start-cli s9pk inspect ${{ env.package_id }}.s9pk manifest | jq -r '.releaseNotes') echo "${RELEASE_NOTES}" >> change-log.txt echo "## SHA256 Hash" >> change-log.txt echo '```' >> change-log.txt diff --git a/Makefile b/Makefile index 4f99ba5..261e320 100644 --- a/Makefile +++ b/Makefile @@ -4,42 +4,82 @@ YQ_VERSION := 4.40.7 YQ_SHA_AMD64 := 4f13ee9303a49f7e8f61e7d9c87402e07cc920ae8dfaaa8c10d7ea1b8f9f48ed YQ_SHA_ARM64 := a84f2c8f105b70cd348c3bf14048aeb1665c2e7314cbe9aaff15479f268b8412 -.PHONY: all clean install check-deps check-init ingredients +CMD_ARCH_GOAL := $(filter aarch64 x86_64 arm x86, $(MAKECMDGOALS)) +ifeq ($(CMD_ARCH_GOAL),) + BUILD := universal + S9PK := $(PACKAGE_ID).s9pk +else + RAW_ARCH := $(firstword $(CMD_ARCH_GOAL)) + ACTUAL_ARCH := $(subst x86,x86_64,$(subst arm,aarch64,$(RAW_ARCH))) + BUILD := $(ACTUAL_ARCH) + S9PK := $(PACKAGE_ID)_$(BUILD).s9pk +endif +.PHONY: all aarch64 x86_64 arm x86 clean install check-deps check-init package ingredients .DELETE_ON_ERROR: -all: ${PACKAGE_ID}.s9pk - @echo " Done!" - @echo " Filesize:$(shell du -h $(PACKAGE_ID).s9pk) is ready" +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 '.hardwareRequirements?.arch // ["x86_64", "aarch64"] | 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 -check-deps: - @if ! command -v start-cli > /dev/null; then \ - echo "Error: start-cli not found. Please install it first."; \ - exit 1; \ - fi - @if ! command -v npm > /dev/null; then \ - echo "Error: npm (Node Package Manager) not found. Please install Node.js and npm."; \ +all: $(PACKAGE_ID).s9pk + $(call SUMMARY,$(S9PK)) + +$(BUILD): $(PACKAGE_ID)_$(BUILD).s9pk + $(call SUMMARY,$(S9PK)) + +x86: x86_64 +arm: aarch64 + +$(S9PK): $(INGREDIENTS) .git/HEAD .git/index + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$(S9PK)'..." + BUILD=$(BUILD) start-cli s9pk pack -o $(S9PK) + +ingredients: $(INGREDIENTS) + @echo " Re-evaluating ingredients..." + +install: package | 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 + fi; \ + echo "\n🚀 Installing to $$HOST ..."; \ + start-cli package install -s $(S9PK) + +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; \ fi -ingredients: $(INGREDIENTS) - @echo "Re-evaluating ingredients..." - -${PACKAGE_ID}.s9pk: $(INGREDIENTS) | check-deps check-init - @$(MAKE) --no-print-directory ingredients - start-cli s9pk pack - -javascript/index.js: $(shell git ls-files startos) tsconfig.json node_modules package.json +javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules npm run build -assets: - mkdir -p assets - node_modules: package-lock.json npm ci @@ -47,12 +87,5 @@ package-lock.json: package.json npm i clean: - rm -rf ${PACKAGE_ID}.s9pk - rm -rf javascript - rm -rf node_modules - -install: ${PACKAGE_ID}.s9pk - @if [ ! -f ~/.startos/config.yaml ]; then echo "You must define \"host: http://server-name.local\" in ~/.startos/config.yaml config file first."; exit 1; fi - @echo "\nInstalling to $$(grep -v '^#' ~/.startos/config.yaml | cut -d'/' -f3) ...\n" - @[ -f $(PACKAGE_ID).s9pk ] || ( $(MAKE) && echo "\nInstalling to $$(grep -v '^#' ~/.startos/config.yaml | cut -d'/' -f3) ...\n" ) - @start-cli package install -s $(PACKAGE_ID).s9pk + @echo "Cleaning up build artifacts..." + @rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk javascript node_modules \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 10dbb35..0014b5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,10 +6,10 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.36" + "@start9labs/start-sdk": "^0.4.0-beta.40" }, "devDependencies": { - "@types/node": "^22.17.1", + "@types/node": "^22.18.3", "@vercel/ncc": "^0.38.3", "prettier": "^3.6.2", "typescript": "^5.9.2" @@ -22,9 +22,9 @@ "license": "ISC" }, "node_modules/@noble/curves": { - "version": "1.9.6", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.6.tgz", - "integrity": "sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA==", + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", "license": "MIT", "dependencies": { "@noble/hashes": "1.8.0" @@ -49,9 +49,9 @@ } }, "node_modules/@start9labs/start-sdk": { - "version": "0.4.0-beta.36", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.36.tgz", - "integrity": "sha512-26C1NGJBy/yubp88SriuX2wfVDAM6/1rDx47wdypR2KEqGzhOrMRQC5hJDAMU8BeRAiL6ED8/Ca9CP+5+8BBBw==", + "version": "0.4.0-beta.40", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.40.tgz", + "integrity": "sha512-ldzhpOThnygKYMa3ItOJA25fQMLC96xuCgoy1LCoAiBNdDJzLmEG/HAju/RWoLH7+XgHqvOK+EtM90LocFOrAw==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -73,9 +73,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.17.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", - "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", + "version": "22.18.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.3.tgz", + "integrity": "sha512-gTVM8js2twdtqM+AE2PdGEe9zGQY4UvmFjan9rZcVb6FGdStfjWoWejdmy4CfWVO9rh5MiYQGZloKAGkJt8lMw==", "dev": true, "license": "MIT", "dependencies": { @@ -121,9 +121,9 @@ } }, "node_modules/mime": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz", - "integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", "funding": [ "https://github.com/sponsors/broofa" ], @@ -236,9 +236,9 @@ } }, "node_modules/yaml": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", - "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 37ae557..d439b6a 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,10 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.36" + "@start9labs/start-sdk": "^0.4.0-beta.40" }, "devDependencies": { - "@types/node": "^22.17.1", + "@types/node": "^22.18.3", "@vercel/ncc": "^0.38.3", "prettier": "^3.6.2", "typescript": "^5.9.2" diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts index 619d49f..523818b 100644 --- a/startos/install/versions/index.ts +++ b/startos/install/versions/index.ts @@ -1,2 +1,2 @@ -export { v2025_8_0 as current } from './v2025.8.0' +export { v2025_8_1 as current } from './v2025.8.1' export const other = [] diff --git a/startos/install/versions/v2025.8.0.ts b/startos/install/versions/v2025.8.1.ts similarity index 73% rename from startos/install/versions/v2025.8.0.ts rename to startos/install/versions/v2025.8.1.ts index ca23a26..858c8e1 100644 --- a/startos/install/versions/v2025.8.0.ts +++ b/startos/install/versions/v2025.8.1.ts @@ -1,7 +1,7 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' -export const v2025_8_0 = VersionInfo.of({ - version: '2025.8.0:1.0', +export const v2025_8_1 = VersionInfo.of({ + version: '2025.8.1:1.0', releaseNotes: 'Revamped for StartOS 0.4.0', migrations: { up: async ({ effects }) => {}, diff --git a/startos/manifest.ts b/startos/manifest.ts index 140705f..ebda22a 100644 --- a/startos/manifest.ts +++ b/startos/manifest.ts @@ -1,6 +1,13 @@ import { setupManifest } from '@start9labs/start-sdk' +import { ImageSource } from '@start9labs/start-sdk/base/lib/osBindings' +import { SDKImageInputSpec } from '@start9labs/start-sdk/base/lib/types/ManifestTypes' -const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.8.0' +const BUILD = process.env.BUILD || '' + +const architectures = + BUILD === 'x86_64' || BUILD === 'aarch64' ? [BUILD] : ['x86_64', 'aarch64'] + +const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.8.1' export const manifest = setupManifest({ id: 'cloudflared', @@ -9,7 +16,8 @@ export const manifest = setupManifest({ wrapperRepo: 'https://github.com/remcoros/cloudflared-startos', upstreamRepo: 'https://github.com/cloudflare/cloudflared', supportSite: 'https://github.com/cloudflare/cloudflared/issues', - docsUrl: 'https://github.com/remcoros/cloudflared-startos/blob/main/instructions.md', + docsUrl: + 'https://github.com/remcoros/cloudflared-startos/blob/main/instructions.md', marketingSite: 'https://cloudflare.com/', donationUrl: 'https://cloudflare.com/', description: { @@ -19,7 +27,7 @@ export const manifest = setupManifest({ volumes: ['main'], images: { main: { - arch: ['x86_64', 'aarch64'], + arch: architectures, source: { dockerBuild: { dockerfile: 'Dockerfile', @@ -27,10 +35,12 @@ export const manifest = setupManifest({ CLOUDFLARED_IMAGE: CLOUDFLARED_IMAGE, }, }, - }, - }, + } as ImageSource, + } as SDKImageInputSpec, + }, + hardwareRequirements: { + arch: architectures, }, - hardwareRequirements: {}, alerts: { install: null, update: null, From 042c3a6fc66434b6dc8644309c2b0ddcdb624575 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 14 Sep 2025 10:36:08 +0000 Subject: [PATCH 10/36] add missing assets folder --- assets/ABOUT.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 assets/ABOUT.md diff --git a/assets/ABOUT.md b/assets/ABOUT.md new file mode 100644 index 0000000..4fbfc10 --- /dev/null +++ b/assets/ABOUT.md @@ -0,0 +1 @@ +Use the `/assets` directory to include additional files or scripts needed by your service. From cba8f3859b87bbe071169f94c436f34e4e61a14f Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 28 Sep 2025 13:52:28 +0000 Subject: [PATCH 11/36] update sdk to beta.41 / move version to versions/index.ts --- package-lock.json | 24 ++++++++++++------------ package.json | 6 +++--- startos/install/versions/index.ts | 6 ++++-- startos/install/versions/v2025.9.1.ts | 10 ++++++++++ startos/manifest.ts | 5 ++--- 5 files changed, 31 insertions(+), 20 deletions(-) create mode 100644 startos/install/versions/v2025.9.1.ts diff --git a/package-lock.json b/package-lock.json index 0014b5f..19c250d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,11 +6,11 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.40" + "@start9labs/start-sdk": "^0.4.0-beta.41" }, "devDependencies": { - "@types/node": "^22.18.3", - "@vercel/ncc": "^0.38.3", + "@types/node": "^22.18.6", + "@vercel/ncc": "^0.38.4", "prettier": "^3.6.2", "typescript": "^5.9.2" } @@ -49,9 +49,9 @@ } }, "node_modules/@start9labs/start-sdk": { - "version": "0.4.0-beta.40", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.40.tgz", - "integrity": "sha512-ldzhpOThnygKYMa3ItOJA25fQMLC96xuCgoy1LCoAiBNdDJzLmEG/HAju/RWoLH7+XgHqvOK+EtM90LocFOrAw==", + "version": "0.4.0-beta.41", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.41.tgz", + "integrity": "sha512-nbr6fT8qtAr04lkFBMnUARAAVrTYdjoXA61j/1IzIvA9vkrkras985SE3YSAkgQPYfaAI27wLHQcAn8U0Kk01g==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -73,9 +73,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.3.tgz", - "integrity": "sha512-gTVM8js2twdtqM+AE2PdGEe9zGQY4UvmFjan9rZcVb6FGdStfjWoWejdmy4CfWVO9rh5MiYQGZloKAGkJt8lMw==", + "version": "22.18.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", + "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -83,9 +83,9 @@ } }, "node_modules/@vercel/ncc": { - "version": "0.38.3", - "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.3.tgz", - "integrity": "sha512-rnK6hJBS6mwc+Bkab+PGPs9OiS0i/3kdTO+CkI8V0/VrW3vmz7O2Pxjw/owOlmo6PKEIxRSeZKv/kuL9itnpYA==", + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", + "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index d439b6a..1252c35 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,11 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.40" + "@start9labs/start-sdk": "^0.4.0-beta.41" }, "devDependencies": { - "@types/node": "^22.18.3", - "@vercel/ncc": "^0.38.3", + "@types/node": "^22.18.6", + "@vercel/ncc": "^0.38.4", "prettier": "^3.6.2", "typescript": "^5.9.2" }, diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts index 523818b..28b7426 100644 --- a/startos/install/versions/index.ts +++ b/startos/install/versions/index.ts @@ -1,2 +1,4 @@ -export { v2025_8_1 as current } from './v2025.8.1' -export const other = [] +import { v2025_8_1 } from './v2025.8.1' +export { v2025_9_1 as current } from './v2025.9.1' +export const other = [v2025_8_1] +export const CLOUDFLARED_VERSION = '2025.9.1' \ No newline at end of file diff --git a/startos/install/versions/v2025.9.1.ts b/startos/install/versions/v2025.9.1.ts new file mode 100644 index 0000000..7f4445d --- /dev/null +++ b/startos/install/versions/v2025.9.1.ts @@ -0,0 +1,10 @@ +import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' + +export const v2025_9_1 = VersionInfo.of({ + version: '2025.9.1:1.0', + releaseNotes: 'Revamped for StartOS 0.4.0', + migrations: { + up: async ({ effects }) => {}, + down: IMPOSSIBLE, + }, +}) diff --git a/startos/manifest.ts b/startos/manifest.ts index ebda22a..f809a14 100644 --- a/startos/manifest.ts +++ b/startos/manifest.ts @@ -1,14 +1,13 @@ import { setupManifest } from '@start9labs/start-sdk' import { ImageSource } from '@start9labs/start-sdk/base/lib/osBindings' import { SDKImageInputSpec } from '@start9labs/start-sdk/base/lib/types/ManifestTypes' +import { CLOUDFLARED_VERSION } from './install/versions' const BUILD = process.env.BUILD || '' const architectures = BUILD === 'x86_64' || BUILD === 'aarch64' ? [BUILD] : ['x86_64', 'aarch64'] -const CLOUDFLARED_IMAGE = 'cloudflare/cloudflared:2025.8.1' - export const manifest = setupManifest({ id: 'cloudflared', title: 'Cloudflare Tunnel', @@ -32,7 +31,7 @@ export const manifest = setupManifest({ dockerBuild: { dockerfile: 'Dockerfile', buildArgs: { - CLOUDFLARED_IMAGE: CLOUDFLARED_IMAGE, + CLOUDFLARED_IMAGE: 'cloudflare/cloudflared:' + CLOUDFLARED_VERSION, }, }, } as ImageSource, From 853bc9aa9879e67bb6e1f65270aa2e5a6debc8f4 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Wed, 12 Nov 2025 17:05:16 +0000 Subject: [PATCH 12/36] update StartOS SDK, cloudflared to 2025.10.1 --- .github/workflows/buildService.yml | 6 +++--- .github/workflows/releaseService.yml | 6 +++--- Makefile | 2 +- package-lock.json | 24 ++++++++++++------------ package.json | 6 +++--- startos/install/versions/index.ts | 7 ++++--- startos/install/versions/v2025.10.1.ts | 10 ++++++++++ startos/install/versions/v2025.9.1.ts | 2 +- startos/interfaces.ts | 2 +- 9 files changed, 38 insertions(+), 27 deletions(-) create mode 100644 startos/install/versions/v2025.10.1.ts diff --git a/.github/workflows/buildService.yml b/.github/workflows/buildService.yml index 7208287..340ab54 100644 --- a/.github/workflows/buildService.yml +++ b/.github/workflows/buildService.yml @@ -101,7 +101,7 @@ jobs: DOCKER_BUILDKIT: 1 BUILDKIT_PROGRESS: plain run: | - start-cli init + start-cli init-key chmod 600 ~/.startos/developer.key.pem echo "🏗️ Building ARM package:" @@ -323,7 +323,7 @@ jobs: DOCKER_BUILDKIT: 1 BUILDKIT_PROGRESS: plain run: | - start-cli init + start-cli init-key chmod 600 ~/.startos/developer.key.pem echo "🏗️ Building Intel package:" @@ -542,7 +542,7 @@ jobs: DOCKER_BUILDKIT: 1 BUILDKIT_PROGRESS: plain run: | - start-cli init + start-cli init-key chmod 600 ~/.startos/developer.key.pem echo "⚡ Building universal package with MAXIMUM cache optimization:" diff --git a/.github/workflows/releaseService.yml b/.github/workflows/releaseService.yml index 24000d6..b8bcc2d 100644 --- a/.github/workflows/releaseService.yml +++ b/.github/workflows/releaseService.yml @@ -97,7 +97,7 @@ jobs: DOCKER_BUILDKIT: 1 BUILDKIT_PROGRESS: plain run: | - start-cli init + start-cli init-key chmod 600 ~/.startos/developer.key.pem echo "🏗️ Building ARM package:" @@ -319,7 +319,7 @@ jobs: DOCKER_BUILDKIT: 1 BUILDKIT_PROGRESS: plain run: | - start-cli init + start-cli init-key chmod 600 ~/.startos/developer.key.pem echo "🏗️ Building Intel package:" @@ -540,7 +540,7 @@ jobs: DOCKER_BUILDKIT: 1 BUILDKIT_PROGRESS: plain run: | - start-cli init + start-cli init-key chmod 600 ~/.startos/developer.key.pem echo "⚡ Building universal package with MAXIMUM cache optimization:" diff --git a/Makefile b/Makefile index 261e320..268785b 100644 --- a/Makefile +++ b/Makefile @@ -74,7 +74,7 @@ check-deps: check-init: @if [ ! -f ~/.startos/developer.key.pem ]; then \ echo "Initializing StartOS developer environment..."; \ - start-cli init; \ + start-cli init-key; \ fi javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules diff --git a/package-lock.json b/package-lock.json index 19c250d..0463b8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,13 +6,13 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.41" + "@start9labs/start-sdk": "^0.4.0-beta.43" }, "devDependencies": { - "@types/node": "^22.18.6", + "@types/node": "^22.19.1", "@vercel/ncc": "^0.38.4", "prettier": "^3.6.2", - "typescript": "^5.9.2" + "typescript": "^5.9.3" } }, "node_modules/@iarna/toml": { @@ -49,9 +49,9 @@ } }, "node_modules/@start9labs/start-sdk": { - "version": "0.4.0-beta.41", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.41.tgz", - "integrity": "sha512-nbr6fT8qtAr04lkFBMnUARAAVrTYdjoXA61j/1IzIvA9vkrkras985SE3YSAkgQPYfaAI27wLHQcAn8U0Kk01g==", + "version": "0.4.0-beta.43", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.43.tgz", + "integrity": "sha512-eSQy4rWuUvGdjlMfGBtM3Ir37YutqtYem4SG50d1WQ3P5+OUSqARbhBMcMkxy2oL0ttGuOBa/iqXY8J2MFwWaQ==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -73,9 +73,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.18.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", - "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -193,9 +193,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/package.json b/package.json index 1252c35..ccca03a 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,13 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.41" + "@start9labs/start-sdk": "^0.4.0-beta.43" }, "devDependencies": { - "@types/node": "^22.18.6", + "@types/node": "^22.19.1", "@vercel/ncc": "^0.38.4", "prettier": "^3.6.2", - "typescript": "^5.9.2" + "typescript": "^5.9.3" }, "prettier": { "trailingComma": "all", diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts index 28b7426..a493122 100644 --- a/startos/install/versions/index.ts +++ b/startos/install/versions/index.ts @@ -1,4 +1,5 @@ import { v2025_8_1 } from './v2025.8.1' -export { v2025_9_1 as current } from './v2025.9.1' -export const other = [v2025_8_1] -export const CLOUDFLARED_VERSION = '2025.9.1' \ No newline at end of file +import { v2025_9_1 } from './v2025.9.1' +export { v2025_10_1 as current } from './v2025.10.1' +export const other = [v2025_8_1, v2025_9_1] +export const CLOUDFLARED_VERSION = '2025.10.1' \ No newline at end of file diff --git a/startos/install/versions/v2025.10.1.ts b/startos/install/versions/v2025.10.1.ts new file mode 100644 index 0000000..c6aa5e4 --- /dev/null +++ b/startos/install/versions/v2025.10.1.ts @@ -0,0 +1,10 @@ +import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' + +export const v2025_10_1 = VersionInfo.of({ + version: '2025.10.1:1.0', + releaseNotes: 'Updated cloudflared to 2025.10.1', + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/startos/install/versions/v2025.9.1.ts b/startos/install/versions/v2025.9.1.ts index 7f4445d..e16fa16 100644 --- a/startos/install/versions/v2025.9.1.ts +++ b/startos/install/versions/v2025.9.1.ts @@ -5,6 +5,6 @@ export const v2025_9_1 = VersionInfo.of({ releaseNotes: 'Revamped for StartOS 0.4.0', migrations: { up: async ({ effects }) => {}, - down: IMPOSSIBLE, + down: async ({ effects }) => {}, }, }) diff --git a/startos/interfaces.ts b/startos/interfaces.ts index 95fee9d..3dc7cfe 100644 --- a/startos/interfaces.ts +++ b/startos/interfaces.ts @@ -1,7 +1,7 @@ import { sdk } from './sdk' export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { - const uiMulti = sdk.MultiHost.of(effects, 'ui') + const uiMulti = sdk.MultiHost.of(effects, 'metrics') const uiMultiOrigin = await uiMulti.bindPort(20241, { protocol: 'http', }) From 0c1f2cbf6be27a6e42912da408098f2bfcb11e52 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Wed, 12 Nov 2025 17:14:53 +0000 Subject: [PATCH 13/36] fix Makefile --- Makefile | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 268785b..d4fd5fe 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,5 @@ -CLOUDFLARED_IMAGE := cloudflare/cloudflared:2025.10.0 -# sha256 hashes can be found in https://github.com/mikefarah/yq/releases/download/v4.40.7/checksums-bsd -YQ_VERSION := 4.40.7 -YQ_SHA_AMD64 := 4f13ee9303a49f7e8f61e7d9c87402e07cc920ae8dfaaa8c10d7ea1b8f9f48ed -YQ_SHA_ARM64 := a84f2c8f105b70cd348c3bf14048aeb1665c2e7314cbe9aaff15479f268b8412 - +PACKAGE_ID := $(shell grep -o "id: '[^']*'" startos/manifest.ts | sed "s/id: '\([^']*\)'/\1/") +INGREDIENTS := $(shell start-cli s9pk list-ingredients 2> /dev/null) CMD_ARCH_GOAL := $(filter aarch64 x86_64 arm x86, $(MAKECMDGOALS)) ifeq ($(CMD_ARCH_GOAL),) BUILD := universal From e3c4c2b0df0033c6d5e28e4960579b54a3932899 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Thu, 20 Nov 2025 12:10:04 +0000 Subject: [PATCH 14/36] update to cloudflared 2025.11.1 --- package-lock.json | 8 ++++---- package.json | 2 +- startos/install/versions/index.ts | 9 ++++++--- startos/install/versions/v2025.10.1.ts | 2 +- startos/install/versions/v2025.11.1.ts | 10 ++++++++++ startos/install/versions/v2025.9.1.ts | 2 +- 6 files changed, 23 insertions(+), 10 deletions(-) create mode 100644 startos/install/versions/v2025.11.1.ts diff --git a/package-lock.json b/package-lock.json index 0463b8f..0bcc9c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.43" + "@start9labs/start-sdk": "^0.4.0-beta.44" }, "devDependencies": { "@types/node": "^22.19.1", @@ -49,9 +49,9 @@ } }, "node_modules/@start9labs/start-sdk": { - "version": "0.4.0-beta.43", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.43.tgz", - "integrity": "sha512-eSQy4rWuUvGdjlMfGBtM3Ir37YutqtYem4SG50d1WQ3P5+OUSqARbhBMcMkxy2oL0ttGuOBa/iqXY8J2MFwWaQ==", + "version": "0.4.0-beta.44", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.44.tgz", + "integrity": "sha512-7/4YaEqMnaHB6A04HraCtrZNcMWaRwBDE2Lt8y+V7WvkxKt2aQ7wJvgdq4gxwoFFcNDwq1CZSfXPzd7rIc3wQg==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/package.json b/package.json index ccca03a..27b2308 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.43" + "@start9labs/start-sdk": "^0.4.0-beta.44" }, "devDependencies": { "@types/node": "^22.19.1", diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts index a493122..1821944 100644 --- a/startos/install/versions/index.ts +++ b/startos/install/versions/index.ts @@ -1,5 +1,8 @@ import { v2025_8_1 } from './v2025.8.1' import { v2025_9_1 } from './v2025.9.1' -export { v2025_10_1 as current } from './v2025.10.1' -export const other = [v2025_8_1, v2025_9_1] -export const CLOUDFLARED_VERSION = '2025.10.1' \ No newline at end of file +import { v2025_10_1 } from './v2025.10.1' +import { v2025_11_1 } from './v2025.11.1' + +export { v2025_11_1 as current } +export const other = [v2025_8_1, v2025_9_1, v2025_10_1] +export const CLOUDFLARED_VERSION = '2025.11.1' \ No newline at end of file diff --git a/startos/install/versions/v2025.10.1.ts b/startos/install/versions/v2025.10.1.ts index c6aa5e4..3d7099f 100644 --- a/startos/install/versions/v2025.10.1.ts +++ b/startos/install/versions/v2025.10.1.ts @@ -1,4 +1,4 @@ -import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' +import { VersionInfo } from '@start9labs/start-sdk' export const v2025_10_1 = VersionInfo.of({ version: '2025.10.1:1.0', diff --git a/startos/install/versions/v2025.11.1.ts b/startos/install/versions/v2025.11.1.ts new file mode 100644 index 0000000..1679eec --- /dev/null +++ b/startos/install/versions/v2025.11.1.ts @@ -0,0 +1,10 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v2025_11_1 = VersionInfo.of({ + version: '2025.11.1:1.0', + releaseNotes: 'Updated cloudflared to 2025.11.1 - [Changelog](https://github.com/cloudflare/cloudflared/blob/2025.11.1/RELEASE_NOTES)', + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/startos/install/versions/v2025.9.1.ts b/startos/install/versions/v2025.9.1.ts index e16fa16..bcb05e7 100644 --- a/startos/install/versions/v2025.9.1.ts +++ b/startos/install/versions/v2025.9.1.ts @@ -1,4 +1,4 @@ -import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' +import { VersionInfo } from '@start9labs/start-sdk' export const v2025_9_1 = VersionInfo.of({ version: '2025.9.1:1.0', From 386897dda317b313386121bb7756307de8862d74 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 8 Feb 2026 13:23:36 +0100 Subject: [PATCH 15/36] start-os sdk beta.48 --- .github/workflows/buildService.yml | 581 +------------------- .github/workflows/releaseService.yml | 619 +--------------------- Makefile | 90 +--- package-lock.json | 24 +- package.json | 6 +- s9pk.mk | 103 ++++ startos/fileModels/store.yaml.ts | 6 +- startos/i18n/dictionaries/default.ts | 25 + startos/i18n/dictionaries/translations.ts | 68 +++ startos/i18n/index.ts | 8 + startos/index.ts | 2 +- startos/install/versions/index.ts | 7 +- startos/install/versions/v2026.2.0.ts | 10 + startos/main.ts | 11 +- startos/manifest.ts | 52 -- startos/manifest/index.ts | 52 ++ 16 files changed, 319 insertions(+), 1345 deletions(-) create mode 100644 s9pk.mk create mode 100644 startos/i18n/dictionaries/default.ts create mode 100644 startos/i18n/dictionaries/translations.ts create mode 100644 startos/i18n/index.ts create mode 100644 startos/install/versions/v2026.2.0.ts delete mode 100644 startos/manifest.ts create mode 100644 startos/manifest/index.ts diff --git a/.github/workflows/buildService.yml b/.github/workflows/buildService.yml index 340ab54..44e296e 100644 --- a/.github/workflows/buildService.yml +++ b/.github/workflows/buildService.yml @@ -9,574 +9,15 @@ on: paths-ignore: ['*.md'] branches: ['main', 'master', 'update/040'] -jobs: - BuildARM: - name: Build S9PK (aarch64) - runs-on: ubuntu-24.04-arm - outputs: - package_id: ${{ steps.build.outputs.package_id }} - arch_package: ${{ steps.build.outputs.arch_package }} - has_dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }} - build_type: ${{ steps.check-dockerfile.outputs.build_type }} - steps: - - name: Prepare StartOS SDK (Super Cached) - uses: k0gen/sdk@v3-optimization - - - name: Checkout services repository - uses: actions/checkout@v4 - with: - submodules: recursive - - # UNIVERSAL: Detect build strategy automatically - - name: Detect Docker build strategy - id: check-dockerfile - run: | - # Check for custom Dockerfile - if [ -f "Dockerfile" ] || find . -name "Dockerfile*" -type f | grep -q .; then - echo "has_dockerfile=true" >> $GITHUB_OUTPUT - echo "build_type=custom" >> $GITHUB_OUTPUT - echo "🏗️ DETECTED: Custom Docker build (Dockerfile found)" - echo " Strategy: BuildKit cache + Image export" - else - echo "has_dockerfile=false" >> $GITHUB_OUTPUT - echo "build_type=pull" >> $GITHUB_OUTPUT - echo "📥 DETECTED: Docker pull only (no Dockerfile)" - echo " Strategy: Image export only" - fi - - # CONDITIONAL: Setup BuildKit only for custom builds - - name: Set up Docker Buildx (custom builds only) - if: steps.check-dockerfile.outputs.has_dockerfile == 'true' - uses: docker/setup-buildx-action@v3 - with: - driver-opts: | - image=moby/buildkit:buildx-stable-1 - network=host - - # Cache Node.js dependencies if they exist - - name: Cache Node.js dependencies - uses: actions/cache@v4 - id: cache-node-modules - with: - path: | - node_modules - ~/.npm - key: ${{ runner.os }}-${{ runner.arch }}-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-node-modules- - - # Install Node.js dependencies only if cache miss and package.json exists - - name: Install Node.js dependencies - if: steps.cache-node-modules.outputs.cache-hit != 'true' && hashFiles('**/package.json') != '' - run: | - if [ -f "package-lock.json" ]; then - echo "Installing with npm..." - npm ci - elif [ -f "yarn.lock" ]; then - echo "Installing with yarn..." - yarn install --frozen-lockfile - else - echo "Installing with npm (no lockfile)..." - npm install - fi - - # Cache Rust build artifacts (only if Rust project exists) - - name: Cache Rust build artifacts - if: hashFiles('**/Cargo.lock') != '' - uses: actions/cache@v4 - id: cache-rust - with: - path: | - target/ - ~/.cargo/registry/ - ~/.cargo/git/ - key: ${{ runner.os }}-${{ runner.arch }}-rust-${{ hashFiles('**/Cargo.lock') }}-v1 - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-rust- - - - name: Build ARM package - id: build - env: - # CONDITIONAL: Enable BuildKit cache only for custom builds - DOCKER_BUILDKIT: 1 - BUILDKIT_PROGRESS: plain - run: | - start-cli init-key - chmod 600 ~/.startos/developer.key.pem - - echo "🏗️ Building ARM package:" - echo " Build type: ${{ steps.check-dockerfile.outputs.build_type }}" - echo " Custom Dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }}" - echo " Node modules cache hit: ${{ steps.cache-node-modules.outputs.cache-hit }}" - echo " Rust cache hit: ${{ steps.cache-rust.outputs.cache-hit }}" - - # CONDITIONAL: Set BuildKit cache options only for custom builds - if [ "${{ steps.check-dockerfile.outputs.has_dockerfile }}" = "true" ]; then - export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build" - export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=arm-build" - echo " Docker BuildKit cache: Enabled (custom build)" - else - echo " Docker strategy: Pull-only (no BuildKit cache needed)" - fi - - time RUST_LOG=debug RUST_BACKTRACE=1 make aarch64 - - # Find the generated package (dynamic naming) - ARCH_PACKAGE=$(ls *_aarch64.s9pk | head -n1) - PACKAGE_ID=$(start-cli s9pk inspect "$ARCH_PACKAGE" manifest | jq -r '.id') - - echo "package_id=${PACKAGE_ID}" >> $GITHUB_OUTPUT - echo "arch_package=${ARCH_PACKAGE}" >> $GITHUB_OUTPUT - echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV - echo "arch_package=${ARCH_PACKAGE}" >> $GITHUB_ENV - - printf "\n ARM SHA256: $(sha256sum ${ARCH_PACKAGE}) \n" - - # SMART: Export Docker images based on S9PK manifest - - name: Export Docker images (manifest-driven + intelligent fallback) - run: | - echo "📦 Exporting Docker images with intelligent detection..." - mkdir -p docker-cache-arm - - # Step 1: Try to get images from S9PK manifest - ARCH_PACKAGE=$(ls *_aarch64.s9pk 2>/dev/null | head -n1) - MANIFEST_IMAGES="" - - if [ -n "$ARCH_PACKAGE" ]; then - echo "📋 Reading manifest from: $ARCH_PACKAGE" - MANIFEST_IMAGES=$(start-cli s9pk inspect "$ARCH_PACKAGE" manifest 2>/dev/null | jq -r '.images | keys[]' 2>/dev/null | tr '\n' ' ' || echo "") - echo "🎯 Manifest images: $MANIFEST_IMAGES" - fi - - # Step 2: Get current Docker images (excluding system images) - CURRENT_IMAGES=$(docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -vE "(|buildkit|qemu-user-static|alpine:latest|ubuntu:latest)" || true) - - echo "🐳 Available Docker images:" - echo "$CURRENT_IMAGES" | head -10 - - # Step 3: Smart export strategy - exported_count=0 - - if [ -n "$MANIFEST_IMAGES" ]; then - # Priority 1: Export images mentioned in manifest - echo "🎯 Exporting manifest-based images..." - for image_key in $MANIFEST_IMAGES; - do - echo "Looking for: $image_key" - matching_images=$(echo "$CURRENT_IMAGES" | grep -i "$image_key" | cut -d' ' -f1 || true) - - if [ -n "$matching_images" ]; then - echo "$matching_images" | while read docker_image; - do - if [ -n "$docker_image" ]; then - safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') - size_info=$(echo "$CURRENT_IMAGES" | grep "^$docker_image " | awk '{print $2}' || echo "unknown") - - echo " 📦 Exporting: $docker_image ($size_info)" - if docker save "$docker_image" -o "docker-cache-arm/${safe_name}.tar" 2>/dev/null; then - tar_size=$(ls -lh "docker-cache-arm/${safe_name}.tar" | awk '{print $5}') - echo " ✅ Saved: $tar_size" - exported_count=$((exported_count + 1)) - else - echo " ❌ Failed to export" - rm -f "docker-cache-arm/${safe_name}.tar" - fi - fi - done - else - echo " ⚠️ Not found in current images: $image_key" - fi - done - fi - - # Priority 2: If no manifest images found, export all relevant images - if [ "$exported_count" -eq 0 ]; then - echo "🔄 No manifest images exported, using fallback strategy..." - echo "$CURRENT_IMAGES" | while read image_line; - do - if [ -n "$image_line" ]; then - docker_image=$(echo "$image_line" | cut -d' ' -f1) - size_info=$(echo "$image_line" | cut -d' ' -f2) - safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') - - echo " 📦 Exporting: $docker_image ($size_info)" - if docker save "$docker_image" -o "docker-cache-arm/${safe_name}.tar" 2>/dev/null; then - tar_size=$(ls -lh "docker-cache-arm/${safe_name}.tar" | awk '{print $5}') - echo " ✅ Saved: $tar_size" - exported_count=$((exported_count + 1)) - else - echo " ❌ Failed" - rm -f "docker-cache-arm/${safe_name}.tar" - fi - fi - done - fi - - # Final summary - if [ "$exported_count" -eq 0 ]; then - echo "No images cached" > docker-cache-arm/.empty - echo "❌ No images were cached!" - else - echo "=== ARM CACHE SUMMARY ===" - echo "✅ Exported $exported_count image(s)" - ls -lah docker-cache-arm/*.tar 2>/dev/null | head -5 - total_size=$(du -sh docker-cache-arm/ | cut -f1) - echo "📊 Total cache size: $total_size" - fi - - - name: Upload ARM package - uses: actions/upload-artifact@v4 - with: - name: ${{ env.arch_package }} - path: ./${{ env.arch_package }} - - - name: Upload Docker cache - uses: actions/upload-artifact@v4 - with: - name: docker-cache-arm - path: docker-cache-arm/ - - BuildIntel: - name: Build S9PK (x86_64) - runs-on: ubuntu-24.04 - outputs: - package_id: ${{ steps.build.outputs.package_id }} - intel_package: ${{ steps.build.outputs.intel_package }} - has_dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }} - build_type: ${{ steps.check-dockerfile.outputs.build_type }} - steps: - - name: Prepare StartOS SDK (Super Cached) - uses: k0gen/sdk@v3-optimization - - - name: Checkout services repository - uses: actions/checkout@v4 - with: - submodules: recursive - - # UNIVERSAL: Detect build strategy (same as ARM) - - name: Detect Docker build strategy - id: check-dockerfile - run: | - if [ -f "Dockerfile" ] || find . -name "Dockerfile*" -type f | grep -q .; then - echo "has_dockerfile=true" >> $GITHUB_OUTPUT - echo "build_type=custom" >> $GITHUB_OUTPUT - echo "🏗️ DETECTED: Custom Docker build (Dockerfile found)" - else - echo "has_dockerfile=false" >> $GITHUB_OUTPUT - echo "build_type=pull" >> $GITHUB_OUTPUT - echo "📥 DETECTED: Docker pull only (no Dockerfile)" - fi - - # CONDITIONAL: Setup BuildKit only for custom builds - - name: Set up Docker Buildx (custom builds only) - if: steps.check-dockerfile.outputs.has_dockerfile == 'true' - uses: docker/setup-buildx-action@v3 - with: - driver-opts: | - image=moby/buildkit:buildx-stable-1 - network=host - - # Cache Node.js dependencies - - name: Cache Node.js dependencies - uses: actions/cache@v4 - id: cache-node-modules - with: - path: | - node_modules - ~/.npm - key: ${{ runner.os }}-${{ runner.arch }}-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-node-modules- - - # Install Node.js dependencies - - name: Install Node.js dependencies - if: steps.cache-node-modules.outputs.cache-hit != 'true' && hashFiles('**/package.json') != '' - run: | - if [ -f "package-lock.json" ]; then - echo "Installing with npm..." - npm ci - elif [ -f "yarn.lock" ]; then - echo "Installing with yarn..." - yarn install --frozen-lockfile - else - echo "Installing with npm (no lockfile)..." - npm install - fi - - # Cache Rust build artifacts - - name: Cache Rust build artifacts - if: hashFiles('**/Cargo.lock') != '' - uses: actions/cache@v4 - id: cache-rust - with: - path: | - target/ - ~/.cargo/registry/ - ~/.cargo/git/ - key: ${{ runner.os }}-${{ runner.arch }}-rust-${{ hashFiles('**/Cargo.lock') }}-v1 - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-rust- - - - name: Build Intel package - id: build - env: - DOCKER_BUILDKIT: 1 - BUILDKIT_PROGRESS: plain - run: | - start-cli init-key - chmod 600 ~/.startos/developer.key.pem +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true - echo "🏗️ Building Intel package:" - echo " Build type: ${{ steps.check-dockerfile.outputs.build_type }}" - echo " Custom Dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }}" - echo " Node modules cache hit: ${{ steps.cache-node-modules.outputs.cache-hit }}" - echo " Rust cache hit: ${{ steps.cache-rust.outputs.cache-hit }}" - - # CONDITIONAL: Set BuildKit cache with ARM import for custom builds - if [ "${{ steps.check-dockerfile.outputs.has_dockerfile }}" = "true" ]; then - export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build type=gha,scope=intel-build" - export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=intel-build" - echo " Docker BuildKit cache: Enabled with ARM import" - else - echo " Docker strategy: Pull-only (no BuildKit cache needed)" - fi - - time RUST_LOG=debug RUST_BACKTRACE=1 make x86 - - # Find the generated package - INTEL_PACKAGE=$(ls *_x86_64.s9pk | head -n1) - - if [ -z "$INTEL_PACKAGE" ]; then - echo "❌ No x86_64 package found!" - ls -la *.s9pk || echo "No .s9pk files found" - exit 1 - fi - - PACKAGE_ID=$(start-cli s9pk inspect "$INTEL_PACKAGE" manifest | jq -r '.id') - - echo "package_id=${PACKAGE_ID}" >> $GITHUB_OUTPUT - echo "intel_package=${INTEL_PACKAGE}" >> $GITHUB_OUTPUT - echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV - echo "intel_package=${INTEL_PACKAGE}" >> $GITHUB_ENV - - printf "\n Intel SHA256: $(sha256sum ${INTEL_PACKAGE}) \n" - - # SMART: Same intelligent Docker export as ARM - - name: Export Docker images (manifest-driven + intelligent fallback) - run: | - echo "📦 Exporting Docker images with intelligent detection..." - mkdir -p docker-cache-intel - - # Step 1: Try to get images from S9PK manifest - INTEL_PACKAGE=$(ls *_x86_64.s9pk 2>/dev/null | head -n1) - MANIFEST_IMAGES="" - - if [ -n "$INTEL_PACKAGE" ]; then - echo "📋 Reading manifest from: $INTEL_PACKAGE" - MANIFEST_IMAGES=$(start-cli s9pk inspect "$INTEL_PACKAGE" manifest 2>/dev/null | jq -r '.images | keys[]' 2>/dev/null | tr '\n' ' ' || echo "") - echo "🎯 Manifest images: $MANIFEST_IMAGES" - fi - - # Step 2: Get current Docker images (excluding system images) - CURRENT_IMAGES=$(docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -vE "(|buildkit|qemu-user-static|alpine:latest|ubuntu:latest)" || true) - - echo "🐳 Available Docker images:" - echo "$CURRENT_IMAGES" | head -10 - - # Step 3: Smart export strategy - exported_count=0 - - if [ -n "$MANIFEST_IMAGES" ]; then - # Priority 1: Export images mentioned in manifest - echo "🎯 Exporting manifest-based images..." - for image_key in $MANIFEST_IMAGES; - do - echo "Looking for: $image_key" - matching_images=$(echo "$CURRENT_IMAGES" | grep -i "$image_key" | cut -d' ' -f1 || true) - - if [ -n "$matching_images" ]; then - echo "$matching_images" | while read docker_image; - do - if [ -n "$docker_image" ]; then - safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') - size_info=$(echo "$CURRENT_IMAGES" | grep "^$docker_image " | awk '{print $2}' || echo "unknown") - - echo " 📦 Exporting: $docker_image ($size_info)" - if docker save "$docker_image" -o "docker-cache-intel/${safe_name}.tar" 2>/dev/null; then - tar_size=$(ls -lh "docker-cache-intel/${safe_name}.tar" | awk '{print $5}') - echo " ✅ Saved: $tar_size" - exported_count=$((exported_count + 1)) - else - echo " ❌ Failed to export" - rm -f "docker-cache-intel/${safe_name}.tar" - fi - fi - done - else - echo " ⚠️ Not found in current images: $image_key" - fi - done - fi - - # Priority 2: If no manifest images found, export all relevant images - if [ "$exported_count" -eq 0 ]; then - echo "🔄 No manifest images exported, using fallback strategy..." - echo "$CURRENT_IMAGES" | while read image_line; - do - if [ -n "$image_line" ]; then - docker_image=$(echo "$image_line" | cut -d' ' -f1) - size_info=$(echo "$image_line" | cut -d' ' -f2) - safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') - - echo " 📦 Exporting: $docker_image ($size_info)" - if docker save "$docker_image" -o "docker-cache-intel/${safe_name}.tar" 2>/dev/null; then - tar_size=$(ls -lh "docker-cache-intel/${safe_name}.tar" | awk '{print $5}') - echo " ✅ Saved: $tar_size" - exported_count=$((exported_count + 1)) - else - echo " ❌ Failed" - rm -f "docker-cache-intel/${safe_name}.tar" - fi - fi - done - fi - - # Final summary - if [ "$exported_count" -eq 0 ]; then - echo "No images cached" > docker-cache-intel/.empty - echo "❌ No images were cached!" - else - echo "=== INTEL CACHE SUMMARY ===" - echo "✅ Exported $exported_count image(s)" - ls -lah docker-cache-intel/*.tar 2>/dev/null | head -5 - total_size=$(du -sh docker-cache-intel/ | cut -f1) - echo "📊 Total cache size: $total_size" - fi - - - name: Upload Intel package - uses: actions/upload-artifact@v4 - with: - name: ${{ env.intel_package }} - path: ./${{ env.intel_package }} - - - name: Upload Docker cache - uses: actions/upload-artifact@v4 - with: - name: docker-cache-intel - path: docker-cache-intel/ - - BuildUniversal: - name: Build S9PK (Universal) - runs-on: ubuntu-24.04 - needs: [BuildARM, BuildIntel] - steps: - - name: Prepare StartOS SDK - uses: k0gen/sdk@v3-optimization - - - name: Checkout services repository - uses: actions/checkout@v4 - with: - submodules: recursive - - # CONDITIONAL: Setup BuildKit only if needed - - name: Set up Docker Buildx (custom builds only) - if: needs.BuildARM.outputs.has_dockerfile == 'true' || needs.BuildIntel.outputs.has_dockerfile == 'true' - uses: docker/setup-buildx-action@v3 - with: - driver-opts: | - image=moby/buildkit:buildx-stable-1 - network=host - - # Restore build dependencies from Intel build - - name: Restore Node.js dependencies (from Intel build) - uses: actions/cache/restore@v4 - with: - path: | - node_modules - ~/.npm - key: ${{ runner.os }}-X64-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 - restore-keys: | - ${{ runner.os }}-X64-node-modules- - - - name: Download Docker cache from ARM - uses: actions/download-artifact@v4 - continue-on-error: true - with: - name: docker-cache-arm - path: docker-cache-arm/ - - - name: Import Docker images from both architectures - run: | - echo "📦 Importing Docker images from ARM build..." - echo " Build type: ${{ needs.BuildARM.outputs.build_type }}" - - imported_count=0 - - # Import ARM cache - if [ -d "docker-cache-arm" ] && [ -n "$(ls -A docker-cache-arm/*.tar 2>/dev/null)" ]; then - echo "Loading ARM Docker cache..." - for tar_file in docker-cache-arm/*.tar; - do - if [ -f "$tar_file" ]; then - image_name=$(basename "$tar_file" .tar) - echo " Loading: $image_name" - if docker load -i "$tar_file" 2>/dev/null; then - echo " ✅ Loaded: $image_name" - imported_count=$((imported_count + 1)) - else - echo " ❌ Failed: $image_name" - fi - fi - done - else - echo "No ARM Docker cache found" - fi - - echo "=== IMPORT SUMMARY ===" - echo "📊 Imported $imported_count image archive(s)" - echo "🐳 Available Docker images after import:" - docker image ls --format "table {{.Repository}} {{.Tag}} {{.Size}}" | head -15 - - - name: Build Universal package (Maximum Cache Optimization) - env: - DOCKER_BUILDKIT: 1 - BUILDKIT_PROGRESS: plain - run: | - start-cli init-key - chmod 600 ~/.startos/developer.key.pem - - echo "⚡ Building universal package with MAXIMUM cache optimization:" - echo " Build type: ${{ needs.BuildARM.outputs.build_type }}" - echo " Custom Dockerfile: ${{ needs.BuildARM.outputs.has_dockerfile }}" - echo " Docker cache: ARM + Intel images available" - echo " Build artifacts: Restored from Intel build" - - # CONDITIONAL: Set BuildKit cache for custom builds - if [ "${{ needs.BuildARM.outputs.has_dockerfile }}" = "true" ]; then - export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build type=gha,scope=intel-build" - export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=universal-build" - echo " Docker BuildKit cache: Importing from ARM + Intel build caches" - else - echo " Docker strategy: Using imported pulled images (no BuildKit cache needed)" - fi - - echo "🚀 Starting universal build..." - time RUST_LOG=debug RUST_BACKTRACE=1 make - - # Find the universal package - UNIVERSAL_PACKAGE=$(ls *.s9pk | grep -v "_aarch64|_x86_64" | head -n1) - - if [ -z "$UNIVERSAL_PACKAGE" ]; then - echo "❌ No universal package found!" - ls -la *.s9pk || echo "No .s9pk files found" - exit 1 - fi - - echo "universal_package=${UNIVERSAL_PACKAGE}" >> $GITHUB_ENV - printf "\n ⚡ Universal SHA256: $(sha256sum ${UNIVERSAL_PACKAGE}) \n" - - - name: Upload Universal package - uses: actions/upload-artifact@v4 - with: - name: ${{ env.universal_package }} - path: ./${{ env.universal_package }} \ No newline at end of file +jobs: + build: + if: github.event.pull_request.draft == false + uses: start9labs/shared-workflows/.github/workflows/buildService.yml@master + # with: + # FREE_DISK_SPACE: true + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} \ No newline at end of file diff --git a/.github/workflows/releaseService.yml b/.github/workflows/releaseService.yml index b8bcc2d..a447cae 100644 --- a/.github/workflows/releaseService.yml +++ b/.github/workflows/releaseService.yml @@ -6,615 +6,12 @@ on: - 'v*.*' jobs: - BuildARM: - name: Build S9PK (aarch64) - runs-on: ubuntu-24.04-arm - outputs: - package_id: ${{ steps.build.outputs.package_id }} - arch_package: ${{ steps.build.outputs.arch_package }} - has_dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }} - build_type: ${{ steps.check-dockerfile.outputs.build_type }} - steps: - - name: Prepare StartOS SDK (Super Cached) - uses: k0gen/sdk@v3-optimization - - - name: Checkout services repository - uses: actions/checkout@v4 - with: - submodules: recursive - - # UNIVERSAL: Detect build strategy automatically - - name: Detect Docker build strategy - id: check-dockerfile - run: | - # Check for custom Dockerfile - if [ -f "Dockerfile" ] || find . -name "Dockerfile*" -type f | grep -q .; then - echo "has_dockerfile=true" >> $GITHUB_OUTPUT - echo "build_type=custom" >> $GITHUB_OUTPUT - echo "🏗️ DETECTED: Custom Docker build (Dockerfile found)" - echo " Strategy: BuildKit cache + Image export" - else - echo "has_dockerfile=false" >> $GITHUB_OUTPUT - echo "build_type=pull" >> $GITHUB_OUTPUT - echo "📥 DETECTED: Docker pull only (no Dockerfile)" - echo " Strategy: Image export only" - fi - - # CONDITIONAL: Setup BuildKit only for custom builds - - name: Set up Docker Buildx (custom builds only) - if: steps.check-dockerfile.outputs.has_dockerfile == 'true' - uses: docker/setup-buildx-action@v3 - with: - driver-opts: | - image=moby/buildkit:buildx-stable-1 - network=host - - # Cache Node.js dependencies if they exist - - name: Cache Node.js dependencies - uses: actions/cache@v4 - id: cache-node-modules - with: - path: | - node_modules - ~/.npm - key: ${{ runner.os }}-${{ runner.arch }}-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-node-modules- - - # Install Node.js dependencies only if cache miss and package.json exists - - name: Install Node.js dependencies - if: steps.cache-node-modules.outputs.cache-hit != 'true' && hashFiles('**/package.json') != '' - run: | - if [ -f "package-lock.json" ]; then - echo "Installing with npm..." - npm ci - elif [ -f "yarn.lock" ]; then - echo "Installing with yarn..." - yarn install --frozen-lockfile - else - echo "Installing with npm (no lockfile)..." - npm install - fi - - # Cache Rust build artifacts (only if Rust project exists) - - name: Cache Rust build artifacts - if: hashFiles('**/Cargo.lock') != '' - uses: actions/cache@v4 - id: cache-rust - with: - path: | - target/ - ~/.cargo/registry/ - ~/.cargo/git/ - key: ${{ runner.os }}-${{ runner.arch }}-rust-${{ hashFiles('**/Cargo.lock') }}-v1 - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-rust- - - - name: Build ARM package - id: build - env: - # CONDITIONAL: Enable BuildKit cache only for custom builds - DOCKER_BUILDKIT: 1 - BUILDKIT_PROGRESS: plain - run: | - start-cli init-key - chmod 600 ~/.startos/developer.key.pem - - echo "🏗️ Building ARM package:" - echo " Build type: ${{ steps.check-dockerfile.outputs.build_type }}" - echo " Custom Dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }}" - echo " Node modules cache hit: ${{ steps.cache-node-modules.outputs.cache-hit }}" - echo " Rust cache hit: ${{ steps.cache-rust.outputs.cache-hit }}" - - # CONDITIONAL: Set BuildKit cache options only for custom builds - if [ "${{ steps.check-dockerfile.outputs.has_dockerfile }}" = "true" ]; then - export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build" - export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=arm-build" - echo " Docker BuildKit cache: Enabled (custom build)" - else - echo " Docker strategy: Pull-only (no BuildKit cache needed)" - fi - - time RUST_LOG=debug RUST_BACKTRACE=1 make aarch64 - - # Find the generated package (dynamic naming) - ARCH_PACKAGE=$(ls *_aarch64.s9pk | head -n1) - PACKAGE_ID=$(start-cli s9pk inspect "$ARCH_PACKAGE" manifest | jq -r '.id') - - echo "package_id=${PACKAGE_ID}" >> $GITHUB_OUTPUT - echo "arch_package=${ARCH_PACKAGE}" >> $GITHUB_OUTPUT - echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV - echo "arch_package=${ARCH_PACKAGE}" >> $GITHUB_ENV - - printf "\n ARM SHA256: $(sha256sum ${ARCH_PACKAGE}) \n" - - # SMART: Export Docker images based on S9PK manifest - - name: Export Docker images (manifest-driven + intelligent fallback) - run: | - echo "📦 Exporting Docker images with intelligent detection..." - mkdir -p docker-cache-arm - - # Step 1: Try to get images from S9PK manifest - ARCH_PACKAGE=$(ls *_aarch64.s9pk 2>/dev/null | head -n1) - MANIFEST_IMAGES="" - - if [ -n "$ARCH_PACKAGE" ]; then - echo "📋 Reading manifest from: $ARCH_PACKAGE" - MANIFEST_IMAGES=$(start-cli s9pk inspect "$ARCH_PACKAGE" manifest 2>/dev/null | jq -r '.images | keys[]' 2>/dev/null | tr '\n' ' ' || echo "") - echo "🎯 Manifest images: $MANIFEST_IMAGES" - fi - - # Step 2: Get current Docker images (excluding system images) - CURRENT_IMAGES=$(docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -vE "(|buildkit|qemu-user-static|alpine:latest|ubuntu:latest)" || true) - - echo "🐳 Available Docker images:" - echo "$CURRENT_IMAGES" | head -10 - - # Step 3: Smart export strategy - exported_count=0 - - if [ -n "$MANIFEST_IMAGES" ]; then - # Priority 1: Export images mentioned in manifest - echo "🎯 Exporting manifest-based images..." - for image_key in $MANIFEST_IMAGES; - do - echo "Looking for: $image_key" - matching_images=$(echo "$CURRENT_IMAGES" | grep -i "$image_key" | cut -d' ' -f1 || true) - - if [ -n "$matching_images" ]; then - echo "$matching_images" | while read docker_image; - do - if [ -n "$docker_image" ]; then - safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') - size_info=$(echo "$CURRENT_IMAGES" | grep "^$docker_image " | awk '{print $2}' || echo "unknown") - - echo " 📦 Exporting: $docker_image ($size_info)" - if docker save "$docker_image" -o "docker-cache-arm/${safe_name}.tar" 2>/dev/null; then - tar_size=$(ls -lh "docker-cache-arm/${safe_name}.tar" | awk '{print $5}') - echo " ✅ Saved: $tar_size" - exported_count=$((exported_count + 1)) - else - echo " ❌ Failed to export" - rm -f "docker-cache-arm/${safe_name}.tar" - fi - fi - done - else - echo " ⚠️ Not found in current images: $image_key" - fi - done - fi - - # Priority 2: If no manifest images found, export all relevant images - if [ "$exported_count" -eq 0 ]; then - echo "🔄 No manifest images exported, using fallback strategy..." - echo "$CURRENT_IMAGES" | while read image_line; - do - if [ -n "$image_line" ]; then - docker_image=$(echo "$image_line" | cut -d' ' -f1) - size_info=$(echo "$image_line" | cut -d' ' -f2) - safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') - - echo " 📦 Exporting: $docker_image ($size_info)" - if docker save "$docker_image" -o "docker-cache-arm/${safe_name}.tar" 2>/dev/null; then - tar_size=$(ls -lh "docker-cache-arm/${safe_name}.tar" | awk '{print $5}') - echo " ✅ Saved: $tar_size" - exported_count=$((exported_count + 1)) - else - echo " ❌ Failed" - rm -f "docker-cache-arm/${safe_name}.tar" - fi - fi - done - fi - - # Final summary - if [ "$exported_count" -eq 0 ]; then - echo "No images cached" > docker-cache-arm/.empty - echo "❌ No images were cached!" - else - echo "=== ARM CACHE SUMMARY ===" - echo "✅ Exported $exported_count image(s)" - ls -lah docker-cache-arm/*.tar 2>/dev/null | head -5 - total_size=$(du -sh docker-cache-arm/ | cut -f1) - echo "📊 Total cache size: $total_size" - fi - - - name: Upload ARM package - uses: actions/upload-artifact@v4 - with: - name: ${{ env.arch_package }} - path: ./${{ env.arch_package }} - - - name: Upload Docker cache - uses: actions/upload-artifact@v4 - with: - name: docker-cache-arm - path: docker-cache-arm/ - - BuildIntel: - name: Build S9PK (x86_64) - runs-on: ubuntu-24.04 - outputs: - package_id: ${{ steps.build.outputs.package_id }} - intel_package: ${{ steps.build.outputs.intel_package }} - has_dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }} - build_type: ${{ steps.check-dockerfile.outputs.build_type }} - steps: - - name: Prepare StartOS SDK (Super Cached) - uses: k0gen/sdk@v3-optimization - - - name: Checkout services repository - uses: actions/checkout@v4 - with: - submodules: recursive - - # UNIVERSAL: Detect build strategy (same as ARM) - - name: Detect Docker build strategy - id: check-dockerfile - run: | - if [ -f "Dockerfile" ] || find . -name "Dockerfile*" -type f | grep -q .; then - echo "has_dockerfile=true" >> $GITHUB_OUTPUT - echo "build_type=custom" >> $GITHUB_OUTPUT - echo "🏗️ DETECTED: Custom Docker build (Dockerfile found)" - else - echo "has_dockerfile=false" >> $GITHUB_OUTPUT - echo "build_type=pull" >> $GITHUB_OUTPUT - echo "📥 DETECTED: Docker pull only (no Dockerfile)" - fi - - # CONDITIONAL: Setup BuildKit only for custom builds - - name: Set up Docker Buildx (custom builds only) - if: steps.check-dockerfile.outputs.has_dockerfile == 'true' - uses: docker/setup-buildx-action@v3 - with: - driver-opts: | - image=moby/buildkit:buildx-stable-1 - network=host - - # Cache Node.js dependencies - - name: Cache Node.js dependencies - uses: actions/cache@v4 - id: cache-node-modules - with: - path: | - node_modules - ~/.npm - key: ${{ runner.os }}-${{ runner.arch }}-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-node-modules- - - # Install Node.js dependencies - - name: Install Node.js dependencies - if: steps.cache-node-modules.outputs.cache-hit != 'true' && hashFiles('**/package.json') != '' - run: | - if [ -f "package-lock.json" ]; then - echo "Installing with npm..." - npm ci - elif [ -f "yarn.lock" ]; then - echo "Installing with yarn..." - yarn install --frozen-lockfile - else - echo "Installing with npm (no lockfile)..." - npm install - fi - - # Cache Rust build artifacts - - name: Cache Rust build artifacts - if: hashFiles('**/Cargo.lock') != '' - uses: actions/cache@v4 - id: cache-rust - with: - path: | - target/ - ~/.cargo/registry/ - ~/.cargo/git/ - key: ${{ runner.os }}-${{ runner.arch }}-rust-${{ hashFiles('**/Cargo.lock') }}-v1 - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-rust- - - - name: Build Intel package - id: build - env: - DOCKER_BUILDKIT: 1 - BUILDKIT_PROGRESS: plain - run: | - start-cli init-key - chmod 600 ~/.startos/developer.key.pem - - echo "🏗️ Building Intel package:" - echo " Build type: ${{ steps.check-dockerfile.outputs.build_type }}" - echo " Custom Dockerfile: ${{ steps.check-dockerfile.outputs.has_dockerfile }}" - echo " Node modules cache hit: ${{ steps.cache-node-modules.outputs.cache-hit }}" - echo " Rust cache hit: ${{ steps.cache-rust.outputs.cache-hit }}" - - # CONDITIONAL: Set BuildKit cache with ARM import for custom builds - if [ "${{ steps.check-dockerfile.outputs.has_dockerfile }}" = "true" ]; then - export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build type=gha,scope=intel-build" - export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=intel-build" - echo " Docker BuildKit cache: Enabled with ARM import" - else - echo " Docker strategy: Pull-only (no BuildKit cache needed)" - fi - - time RUST_LOG=debug RUST_BACKTRACE=1 make x86 - - # Find the generated package - INTEL_PACKAGE=$(ls *_x86_64.s9pk | head -n1) - - if [ -z "$INTEL_PACKAGE" ]; then - echo "❌ No x86_64 package found!" - ls -la *.s9pk || echo "No .s9pk files found" - exit 1 - fi - - PACKAGE_ID=$(start-cli s9pk inspect "$INTEL_PACKAGE" manifest | jq -r '.id') - - echo "package_id=${PACKAGE_ID}" >> $GITHUB_OUTPUT - echo "intel_package=${INTEL_PACKAGE}" >> $GITHUB_OUTPUT - echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV - echo "intel_package=${INTEL_PACKAGE}" >> $GITHUB_ENV - - printf "\n Intel SHA256: $(sha256sum ${INTEL_PACKAGE}) \n" - - # SMART: Same intelligent Docker export as ARM - - name: Export Docker images (manifest-driven + intelligent fallback) - run: | - echo "📦 Exporting Docker images with intelligent detection..." - mkdir -p docker-cache-intel - - # Step 1: Try to get images from S9PK manifest - INTEL_PACKAGE=$(ls *_x86_64.s9pk 2>/dev/null | head -n1) - MANIFEST_IMAGES="" - - if [ -n "$INTEL_PACKAGE" ]; then - echo "📋 Reading manifest from: $INTEL_PACKAGE" - MANIFEST_IMAGES=$(start-cli s9pk inspect "$INTEL_PACKAGE" manifest 2>/dev/null | jq -r '.images | keys[]' 2>/dev/null | tr '\n' ' ' || echo "") - echo "🎯 Manifest images: $MANIFEST_IMAGES" - fi - - # Step 2: Get current Docker images (excluding system images) - CURRENT_IMAGES=$(docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -vE "(|buildkit|qemu-user-static|alpine:latest|ubuntu:latest)" || true) - - echo "🐳 Available Docker images:" - echo "$CURRENT_IMAGES" | head -10 - - # Step 3: Smart export strategy - exported_count=0 - - if [ -n "$MANIFEST_IMAGES" ]; then - # Priority 1: Export images mentioned in manifest - echo "🎯 Exporting manifest-based images..." - for image_key in $MANIFEST_IMAGES; - do - echo "Looking for: $image_key" - matching_images=$(echo "$CURRENT_IMAGES" | grep -i "$image_key" | cut -d' ' -f1 || true) - - if [ -n "$matching_images" ]; then - echo "$matching_images" | while read docker_image; - do - if [ -n "$docker_image" ]; then - safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') - size_info=$(echo "$CURRENT_IMAGES" | grep "^$docker_image " | awk '{print $2}' || echo "unknown") - - echo " 📦 Exporting: $docker_image ($size_info)" - if docker save "$docker_image" -o "docker-cache-intel/${safe_name}.tar" 2>/dev/null; then - tar_size=$(ls -lh "docker-cache-intel/${safe_name}.tar" | awk '{print $5}') - echo " ✅ Saved: $tar_size" - exported_count=$((exported_count + 1)) - else - echo " ❌ Failed to export" - rm -f "docker-cache-intel/${safe_name}.tar" - fi - fi - done - else - echo " ⚠️ Not found in current images: $image_key" - fi - done - fi - - # Priority 2: If no manifest images found, export all relevant images - if [ "$exported_count" -eq 0 ]; then - echo "🔄 No manifest images exported, using fallback strategy..." - echo "$CURRENT_IMAGES" | while read image_line; - do - if [ -n "$image_line" ]; then - docker_image=$(echo "$image_line" | cut -d' ' -f1) - size_info=$(echo "$image_line" | cut -d' ' -f2) - safe_name=$(echo $docker_image | sed 's#[:/.]#_#g') - - echo " 📦 Exporting: $docker_image ($size_info)" - if docker save "$docker_image" -o "docker-cache-intel/${safe_name}.tar" 2>/dev/null; then - tar_size=$(ls -lh "docker-cache-intel/${safe_name}.tar" | awk '{print $5}') - echo " ✅ Saved: $tar_size" - exported_count=$((exported_count + 1)) - else - echo " ❌ Failed" - rm -f "docker-cache-intel/${safe_name}.tar" - fi - fi - done - fi - - # Final summary - if [ "$exported_count" -eq 0 ]; then - echo "No images cached" > docker-cache-intel/.empty - echo "❌ No images were cached!" - else - echo "=== INTEL CACHE SUMMARY ===" - echo "✅ Exported $exported_count image(s)" - ls -lah docker-cache-intel/*.tar 2>/dev/null | head -5 - total_size=$(du -sh docker-cache-intel/ | cut -f1) - echo "📊 Total cache size: $total_size" - fi - - - name: Upload Intel package - uses: actions/upload-artifact@v4 - with: - name: ${{ env.intel_package }} - path: ./${{ env.intel_package }} - - - name: Upload Docker cache - uses: actions/upload-artifact@v4 - with: - name: docker-cache-intel - path: docker-cache-intel/ - - BuildUniversal: - name: Build S9PK (Universal) - runs-on: ubuntu-24.04 - needs: [BuildARM, BuildIntel] + release: + uses: start9labs/shared-workflows/.github/workflows/releaseService.yml@master + with: + # FREE_DISK_SPACE: true + REGISTRY: ${{ vars.REGISTRY }} # Optional. Defaults to https://alpha-registry-x.start9.com + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} # Required permissions: - contents: write - steps: - - name: Prepare StartOS SDK - uses: k0gen/sdk@v3-optimization - - - name: Checkout services repository - uses: actions/checkout@v4 - with: - submodules: recursive - - # CONDITIONAL: Setup BuildKit only if needed - - name: Set up Docker Buildx (custom builds only) - if: needs.BuildARM.outputs.has_dockerfile == 'true' || needs.BuildIntel.outputs.has_dockerfile == 'true' - uses: docker/setup-buildx-action@v3 - with: - driver-opts: | - image=moby/buildkit:buildx-stable-1 - network=host - - # Restore build dependencies from Intel build - - name: Restore Node.js dependencies (from Intel build) - uses: actions/cache/restore@v4 - with: - path: | - node_modules - ~/.npm - key: ${{ runner.os }}-X64-node-modules-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-v1 - restore-keys: | - ${{ runner.os }}-X64-node-modules- - - - name: Download Docker cache from ARM - uses: actions/download-artifact@v4 - continue-on-error: true - with: - name: docker-cache-arm - path: docker-cache-arm/ - - - name: Import Docker images from both architectures - run: | - echo "📦 Importing Docker images from ARM build..." - echo " Build type: ${{ needs.BuildARM.outputs.build_type }}" - - imported_count=0 - - # Import ARM cache - if [ -d "docker-cache-arm" ] && [ -n "$(ls -A docker-cache-arm/*.tar 2>/dev/null)" ]; then - echo "Loading ARM Docker cache..." - for tar_file in docker-cache-arm/*.tar; - do - if [ -f "$tar_file" ]; then - image_name=$(basename "$tar_file" .tar) - echo " Loading: $image_name" - if docker load -i "$tar_file" 2>/dev/null; then - echo " ✅ Loaded: $image_name" - imported_count=$((imported_count + 1)) - else - echo " ❌ Failed: $image_name" - fi - fi - done - else - echo "No ARM Docker cache found" - fi - - echo "=== IMPORT SUMMARY ===" - echo "📊 Imported $imported_count image archive(s)" - echo "🐳 Available Docker images after import:" - docker image ls --format "table {{.Repository}} {{.Tag}} {{.Size}}" | head -15 - - - name: Build Universal package (Maximum Cache Optimization) - env: - DOCKER_BUILDKIT: 1 - BUILDKIT_PROGRESS: plain - run: | - start-cli init-key - chmod 600 ~/.startos/developer.key.pem - - echo "⚡ Building universal package with MAXIMUM cache optimization:" - echo " Build type: ${{ needs.BuildARM.outputs.build_type }}" - echo " Custom Dockerfile: ${{ needs.BuildARM.outputs.has_dockerfile }}" - echo " Docker cache: ARM + Intel images available" - echo " Build artifacts: Restored from Intel build" - - # CONDITIONAL: Set BuildKit cache for custom builds - if [ "${{ needs.BuildARM.outputs.has_dockerfile }}" = "true" ]; then - export BUILDKIT_CACHE_FROM="type=gha,scope=arm-build type=gha,scope=intel-build" - export BUILDKIT_CACHE_TO="type=gha,mode=max,scope=universal-build" - echo " Docker BuildKit cache: Importing from ARM + Intel build caches" - else - echo " Docker strategy: Using imported pulled images (no BuildKit cache needed)" - fi - - echo "🚀 Starting universal build..." - time RUST_LOG=debug RUST_BACKTRACE=1 make - - # Find the universal package - UNIVERSAL_PACKAGE=$(ls *.s9pk | grep -v "_aarch64|_x86_64" | head -n1) - - if [ -z "$UNIVERSAL_PACKAGE" ]; then - echo "❌ No universal package found!" - ls -la *.s9pk || echo "No .s9pk files found" - exit 1 - fi - - MANIFEST_JSON=$(start-cli s9pk inspect $UNIVERSAL_PACKAGE manifest) - PACKAGE_ID=$(echo "$MANIFEST_JSON" | jq -r '.id') - PACKAGE_TITLE=$(echo "$MANIFEST_JSON" | jq -r '.title') - echo "package_id=${PACKAGE_ID}" >> $GITHUB_ENV - echo "package_title=${PACKAGE_TITLE}" >> $GITHUB_ENV - echo "universal_package=${UNIVERSAL_PACKAGE}" >> $GITHUB_ENV - printf "\n ⚡ Universal SHA256: $(sha256sum ${UNIVERSAL_PACKAGE}) \n" - - - name: Generate sha256 checksum - run: | - sha256sum ${{ env.package_id }}.s9pk > ${{ env.package_id }}.s9pk.sha256 - shell: bash - - - name: Generate changelog - run: | - echo "## What's Changed" > change-log.txt - echo "" >> change-log.txt - RELEASE_NOTES=$(start-cli s9pk inspect ${{ env.package_id }}.s9pk manifest | jq -r '.releaseNotes') - echo "${RELEASE_NOTES}" >> change-log.txt - echo "## SHA256 Hash" >> change-log.txt - echo '```' >> change-log.txt - sha256sum ${{ env.package_id }}.s9pk >> change-log.txt - echo '```' >> change-log.txt - shell: bash - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - name: ${{ env.package_title }} ${{ github.ref_name }} - prerelease: true - body_path: change-log.txt - files: | - ./${{ env.package_id }}.s9pk - ./${{ env.package_id }}.s9pk.sha256 - - - name: Publish to Registry - env: - S9DEVKEY: ${{ secrets.S9DEVKEY }} - S9REGISTRY: ${{ secrets.S9REGISTRY }} - run: | - if [[ -z "$S9DEVKEY" || -z "$S9REGISTRY" ]]; then - echo "Publish skipped: One or both of S9DEVKEY and S9REGISTRY secrets are not set." - else - echo "Publishing package to registry..." - start-cli --registry https://$S9REGISTRY registry package add ${{ env.package_id }}.s9pk ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.package_id }}.s9pk - fi - shell: bash + contents: write \ No newline at end of file diff --git a/Makefile b/Makefile index d4fd5fe..c2cf7f8 100644 --- a/Makefile +++ b/Makefile @@ -1,87 +1,3 @@ -PACKAGE_ID := $(shell grep -o "id: '[^']*'" startos/manifest.ts | sed "s/id: '\([^']*\)'/\1/") -INGREDIENTS := $(shell start-cli s9pk list-ingredients 2> /dev/null) -CMD_ARCH_GOAL := $(filter aarch64 x86_64 arm x86, $(MAKECMDGOALS)) -ifeq ($(CMD_ARCH_GOAL),) - BUILD := universal - S9PK := $(PACKAGE_ID).s9pk -else - RAW_ARCH := $(firstword $(CMD_ARCH_GOAL)) - ACTUAL_ARCH := $(subst x86,x86_64,$(subst arm,aarch64,$(RAW_ARCH))) - BUILD := $(ACTUAL_ARCH) - S9PK := $(PACKAGE_ID)_$(BUILD).s9pk -endif - -.PHONY: all aarch64 x86_64 arm x86 clean install check-deps check-init package ingredients -.DELETE_ON_ERROR: - -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 '.hardwareRequirements?.arch // ["x86_64", "aarch64"] | 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: $(PACKAGE_ID).s9pk - $(call SUMMARY,$(S9PK)) - -$(BUILD): $(PACKAGE_ID)_$(BUILD).s9pk - $(call SUMMARY,$(S9PK)) - -x86: x86_64 -arm: aarch64 - -$(S9PK): $(INGREDIENTS) .git/HEAD .git/index - @$(MAKE) --no-print-directory ingredients - @echo " Packing '$(S9PK)'..." - BUILD=$(BUILD) start-cli s9pk pack -o $(S9PK) - -ingredients: $(INGREDIENTS) - @echo " Re-evaluating ingredients..." - -install: package | 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; \ - echo "\n🚀 Installing to $$HOST ..."; \ - start-cli package install -s $(S9PK) - -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 build - -node_modules: package-lock.json - npm ci - -package-lock.json: package.json - npm i - -clean: - @echo "Cleaning up build artifacts..." - @rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk javascript node_modules \ No newline at end of file +# overrides to s9pk.mk must precede the include statement +ARCHES := x86 arm +include s9pk.mk \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0bcc9c1..16e0ac6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,12 +6,12 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.44" + "@start9labs/start-sdk": "^0.4.0-beta.48" }, "devDependencies": { - "@types/node": "^22.19.1", + "@types/node": "^22.19.7", "@vercel/ncc": "^0.38.4", - "prettier": "^3.6.2", + "prettier": "^3.8.1", "typescript": "^5.9.3" } }, @@ -49,9 +49,9 @@ } }, "node_modules/@start9labs/start-sdk": { - "version": "0.4.0-beta.44", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.44.tgz", - "integrity": "sha512-7/4YaEqMnaHB6A04HraCtrZNcMWaRwBDE2Lt8y+V7WvkxKt2aQ7wJvgdq4gxwoFFcNDwq1CZSfXPzd7rIc3wQg==", + "version": "0.4.0-beta.48", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.48.tgz", + "integrity": "sha512-beMdwhUffhnbSm3FgkWPJjAWazMhNMzqbHtS6yK2hX6VpP39JxzDVo8vEbJAUfot6LPw+OhOUtPEu6KDaGre6A==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -73,9 +73,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", - "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "version": "22.19.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", + "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "dev": true, "license": "MIT", "dependencies": { @@ -165,9 +165,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 27b2308..daf964a 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,12 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.44" + "@start9labs/start-sdk": "^0.4.0-beta.48" }, "devDependencies": { - "@types/node": "^22.19.1", + "@types/node": "^22.19.7", "@vercel/ncc": "^0.38.4", - "prettier": "^3.6.2", + "prettier": "^3.8.1", "typescript": "^5.9.3" }, "prettier": { diff --git a/s9pk.mk b/s9pk.mk new file mode 100644 index 0000000..8ac5049 --- /dev/null +++ b/s9pk.mk @@ -0,0 +1,103 @@ +# ** 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) +ARCHES ?= x86 arm riscv +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) + +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/HEAD .git/index + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack -o $@ + +$(BASE_NAME)_%.s9pk: $(INGREDIENTS) .git/HEAD .git/index + @$(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; \ + S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \ + if [ -z "$$S9PK" ]; then \ + echo "Error: No .s9pk file found. Run 'make' first."; \ + exit 1; \ + fi; \ + printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \ + start-cli package install -s "$$S9PK" + +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 build + +node_modules: package-lock.json + npm ci + +package-lock.json: package.json + npm i + +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 \ No newline at end of file diff --git a/startos/fileModels/store.yaml.ts b/startos/fileModels/store.yaml.ts index f5c2c87..91e6c55 100644 --- a/startos/fileModels/store.yaml.ts +++ b/startos/fileModels/store.yaml.ts @@ -1,4 +1,5 @@ import { matches, FileHelper, T } from '@start9labs/start-sdk' +import { sdk } from '../sdk' const { object, string } = matches const shape = object({ @@ -8,7 +9,10 @@ const shape = object({ export type StoreType = typeof shape._TYPE export const store = FileHelper.yaml( - '/media/startos/volumes/main/start9/config.yaml', + { + base: sdk.volumes.main, + subpath: '/start9/config.yaml', + }, shape, ) diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..3296f2c --- /dev/null +++ b/startos/i18n/dictionaries/default.ts @@ -0,0 +1,25 @@ +export const DEFAULT_LANG = 'en_US' + +const dict = { + // main.ts + 'Cloudflare tunnel client': 1, + 'Cloudflare tunnel client is running': 2, + 'Cloudflare tunnel client is not running': 3, + + // interfaces.ts + 'Metrics': 100, + 'Prometheus metrics endpoint': 101, + + // actions/setToken.ts + 'Authentication Token': 200, + 'The authentication token for your Cloudflare tunnel.': 201, + 'Set Authentication Token': 202, + 'Set the authentication token for your Cloudflare tunnel.': 203, +} as const + +/** + * Plumbing. DO NOT EDIT. + */ +export type I18nKey = keyof typeof dict +export type LangDict = Record<(typeof dict)[I18nKey], string> +export default dict diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts new file mode 100644 index 0000000..4607240 --- /dev/null +++ b/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,68 @@ +import { LangDict } from './default' + +export default { + es_ES: { + // main.ts + 1: 'Cliente del túnel Cloudflare', + 2: 'El cliente del túnel Cloudflare está ejecutándose', + 3: 'El cliente del túnel Cloudflare no está ejecutándose', + + // interfaces.ts + 100: 'Métricas', + 101: 'Endpoint de métricas Prometheus', + + // actions/setToken.ts + 200: 'Token de autenticación', + 201: 'El token de autenticación para tu túnel Cloudflare.', + 202: 'Establecer token de autenticación', + 203: 'Establecer el token de autenticación para tu túnel Cloudflare.', + }, + de_DE: { + // main.ts + 1: 'Cloudflare-Tunnel-Client', + 2: 'Cloudflare-Tunnel-Client läuft', + 3: 'Cloudflare-Tunnel-Client läuft nicht', + + // interfaces.ts + 100: 'Metriken', + 101: 'Prometheus-Metrik-Endpunkt', + + // actions/setToken.ts + 200: 'Authentifizierungstoken', + 201: 'Das Authentifizierungstoken für Ihren Cloudflare-Tunnel.', + 202: 'Authentifizierungstoken festlegen', + 203: 'Legen Sie das Authentifizierungstoken für Ihren Cloudflare-Tunnel fest.', + }, + pl_PL: { + // main.ts + 1: 'Klient tunelu Cloudflare', + 2: 'Klient tunelu Cloudflare jest uruchomiony', + 3: 'Klient tunelu Cloudflare nie jest uruchomiony', + + // interfaces.ts + 100: 'Metryki', + 101: 'Endpoint metryk Prometheus', + + // actions/setToken.ts + 200: 'Token uwierzytelniania', + 201: 'Token uwierzytelniania dla twojego tunelu Cloudflare.', + 202: 'Ustaw token uwierzytelniania', + 203: 'Ustaw token uwierzytelniania dla twojego tunelu Cloudflare.', + }, + fr_FR: { + // main.ts + 1: 'Client de tunnel Cloudflare', + 2: 'Le client de tunnel Cloudflare est en cours d\'exécution', + 3: 'Le client de tunnel Cloudflare n\'est pas en cours d\'exécution', + + // interfaces.ts + 100: 'Métriques', + 101: 'Point de terminaison des métriques Prometheus', + + // actions/setToken.ts + 200: 'Jeton d\'authentification', + 201: 'Le jeton d\'authentification pour votre tunnel Cloudflare.', + 202: 'Définir le jeton d\'authentification', + 203: 'Définissez le jeton d\'authentification pour votre tunnel Cloudflare.', + }, +} satisfies Record diff --git a/startos/i18n/index.ts b/startos/i18n/index.ts new file mode 100644 index 0000000..04cea20 --- /dev/null +++ b/startos/i18n/index.ts @@ -0,0 +1,8 @@ +/** + * Plumbing. DO NOT EDIT this file. + */ +import { setupI18n } from '@start9labs/start-sdk' +import defaultDict, { DEFAULT_LANG } from './dictionaries/default' +import translations from './dictionaries/translations' + +export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG) diff --git a/startos/index.ts b/startos/index.ts index 1ff9579..8fef41f 100644 --- a/startos/index.ts +++ b/startos/index.ts @@ -6,6 +6,6 @@ export { main } from './main' export { init, uninit } from './init' export { actions } from './actions' import { buildManifest } from '@start9labs/start-sdk' -import { manifest as sdkManifest } from './manifest' +import { manifest as sdkManifest } from './manifest/index' import { versionGraph } from './install/versionGraph' export const manifest = buildManifest(versionGraph, sdkManifest) \ No newline at end of file diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts index 1821944..969c039 100644 --- a/startos/install/versions/index.ts +++ b/startos/install/versions/index.ts @@ -2,7 +2,8 @@ import { v2025_8_1 } from './v2025.8.1' import { v2025_9_1 } from './v2025.9.1' import { v2025_10_1 } from './v2025.10.1' import { v2025_11_1 } from './v2025.11.1' +import { v2026_2_0 } from './v2026.2.0' -export { v2025_11_1 as current } -export const other = [v2025_8_1, v2025_9_1, v2025_10_1] -export const CLOUDFLARED_VERSION = '2025.11.1' \ No newline at end of file +export { v2026_2_0 as current } +export const other = [v2025_8_1, v2025_9_1, v2025_10_1, v2025_11_1] +export const CLOUDFLARED_VERSION = '2026.2.0' \ No newline at end of file diff --git a/startos/install/versions/v2026.2.0.ts b/startos/install/versions/v2026.2.0.ts new file mode 100644 index 0000000..e62401e --- /dev/null +++ b/startos/install/versions/v2026.2.0.ts @@ -0,0 +1,10 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v2026_2_0 = VersionInfo.of({ + version: '2026.2.0:1.0', + releaseNotes: 'Updated cloudflared to 2026.2.0 - [Changelog](https://github.com/cloudflare/cloudflared/blob/2026.2.0/RELEASE_NOTES)', + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/startos/main.ts b/startos/main.ts index a2bc8bd..de74c7f 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -1,12 +1,13 @@ import { store } from './fileModels/store.yaml' import { sdk } from './sdk' +import { i18n } from './i18n' -export const main = sdk.setupMain(async ({ effects, started }) => { +export const main = sdk.setupMain(async ({ effects }) => { console.info('Starting cloudflared...') const conf = (await store.read().const(effects))! - return sdk.Daemons.of(effects, started).addDaemon('primary', { + return sdk.Daemons.of(effects).addDaemon('primary', { subcontainer: await sdk.SubContainer.of( effects, { @@ -35,14 +36,14 @@ export const main = sdk.setupMain(async ({ effects, started }) => { }, }, ready: { - display: 'Cloudflare tunnel client', + display: i18n('Cloudflare tunnel client'), fn: () => sdk.healthCheck.checkWebUrl( effects, 'http://cloudflared.startos:20241/metrics', { - successMessage: 'Cloudflare tunnel client is running', - errorMessage: 'Cloudflare tunnel client is not running', + successMessage: i18n('Cloudflare tunnel client is running'), + errorMessage: i18n('Cloudflare tunnel client is not running'), }, ), }, diff --git a/startos/manifest.ts b/startos/manifest.ts deleted file mode 100644 index f809a14..0000000 --- a/startos/manifest.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { setupManifest } from '@start9labs/start-sdk' -import { ImageSource } from '@start9labs/start-sdk/base/lib/osBindings' -import { SDKImageInputSpec } from '@start9labs/start-sdk/base/lib/types/ManifestTypes' -import { CLOUDFLARED_VERSION } from './install/versions' - -const BUILD = process.env.BUILD || '' - -const architectures = - BUILD === 'x86_64' || BUILD === 'aarch64' ? [BUILD] : ['x86_64', 'aarch64'] - -export const manifest = setupManifest({ - id: 'cloudflared', - title: 'Cloudflare Tunnel', - license: 'Apache 2.0', - wrapperRepo: 'https://github.com/remcoros/cloudflared-startos', - upstreamRepo: 'https://github.com/cloudflare/cloudflared', - supportSite: 'https://github.com/cloudflare/cloudflared/issues', - docsUrl: - 'https://github.com/remcoros/cloudflared-startos/blob/main/instructions.md', - marketingSite: 'https://cloudflare.com/', - donationUrl: 'https://cloudflare.com/', - description: { - short: 'Cloudflare Tunnel client', - long: 'With the Cloudflare Tunnel client you can proxy traffic from the Cloudflare network to your StartOS server.', - }, - volumes: ['main'], - images: { - main: { - arch: architectures, - source: { - dockerBuild: { - dockerfile: 'Dockerfile', - buildArgs: { - CLOUDFLARED_IMAGE: 'cloudflare/cloudflared:' + CLOUDFLARED_VERSION, - }, - }, - } as ImageSource, - } as SDKImageInputSpec, - }, - hardwareRequirements: { - arch: architectures, - }, - alerts: { - install: null, - update: null, - uninstall: null, - restore: null, - start: null, - stop: null, - }, - dependencies: {}, -}) diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..8d9e1b5 --- /dev/null +++ b/startos/manifest/index.ts @@ -0,0 +1,52 @@ +import { setupManifest } from '@start9labs/start-sdk' +import { CLOUDFLARED_VERSION } from '../install/versions' + +export const manifest = setupManifest({ + id: 'cloudflared', + title: 'Cloudflare Tunnel', + license: 'Apache 2.0', + wrapperRepo: 'https://github.com/remcoros/cloudflared-startos', + upstreamRepo: 'https://github.com/cloudflare/cloudflared', + supportSite: 'https://github.com/cloudflare/cloudflared/issues', + docsUrl: + 'https://github.com/remcoros/cloudflared-startos/blob/main/instructions.md', + marketingSite: 'https://cloudflare.com/', + donationUrl: 'https://cloudflare.com/', + description: { + short: { + en_US: 'Cloudflare Tunnel client', + es_ES: 'Cliente de túnel Cloudflare', + de_DE: 'Cloudflare-Tunnel-Client', + pl_PL: 'Klient tunelu Cloudflare', + fr_FR: 'Client de tunnel Cloudflare', + }, + long: { + en_US: + 'With the Cloudflare Tunnel client you can proxy traffic from the Cloudflare network to your StartOS server.', + es_ES: + 'Con el cliente de túnel Cloudflare puedes enviar tráfico proxy desde la red Cloudflare a tu servidor StartOS.', + de_DE: + 'Mit dem Cloudflare-Tunnel-Client können Sie Datenverkehr vom Cloudflare-Netzwerk zu Ihrem StartOS-Server weiterleiten.', + pl_PL: + 'Za pomocą klienta tunelu Cloudflare możesz proxy ruch z sieci Cloudflare do twojego serwera StartOS.', + fr_FR: + 'Avec le client de tunnel Cloudflare, vous pouvez proxifier le trafic du réseau Cloudflare vers votre serveur StartOS.', + }, + }, + volumes: ['main'], + images: { + main: { + source: { + dockerBuild: { + dockerfile: 'Dockerfile', + buildArgs: { + CLOUDFLARED_IMAGE: 'cloudflare/cloudflared:' + CLOUDFLARED_VERSION, + }, + }, + }, + arch: ['x86_64', 'aarch64'], + emulateMissingAs: 'aarch64', + }, + }, + dependencies: {}, +}) From 02531c5fae9cd3aa5f3b94940ced83e5de3980d4 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 11:59:08 +0200 Subject: [PATCH 16/36] chore: upgrade to SDK 1.0.0 and add v2026.3.0 --- package-lock.json | 100 ++++++++++++++++++++++--- package.json | 6 +- startos/fileModels/store.yaml.ts | 9 +-- startos/init/index.ts | 4 +- startos/init/seedStore.ts | 16 ++++ startos/install/versionGraph.ts | 13 ---- startos/install/versions/index.ts | 7 +- startos/install/versions/v2025.10.1.ts | 2 +- startos/install/versions/v2025.11.1.ts | 4 +- startos/install/versions/v2025.8.1.ts | 2 +- startos/install/versions/v2025.9.1.ts | 2 +- startos/install/versions/v2026.2.0.ts | 4 +- startos/install/versions/v2026.3.0.ts | 12 +++ startos/manifest/index.ts | 10 +-- 14 files changed, 144 insertions(+), 47 deletions(-) create mode 100644 startos/init/seedStore.ts create mode 100644 startos/install/versions/v2026.3.0.ts diff --git a/package-lock.json b/package-lock.json index 16e0ac6..de0b46f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.48" + "@start9labs/start-sdk": "1.0.0" }, "devDependencies": { "@types/node": "^22.19.7", @@ -49,9 +49,9 @@ } }, "node_modules/@start9labs/start-sdk": { - "version": "0.4.0-beta.48", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-0.4.0-beta.48.tgz", - "integrity": "sha512-beMdwhUffhnbSm3FgkWPJjAWazMhNMzqbHtS6yK2hX6VpP39JxzDVo8vEbJAUfot6LPw+OhOUtPEu6KDaGre6A==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.0.0.tgz", + "integrity": "sha512-rtAfumVbMy90iw2WRbWH7fGcuwAvvuFfR4YwgSsh5R2Bz9MXtcEfmznwhnrp+ntQ6BOUSQ0wLzePbfsS6kUagg==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -59,11 +59,13 @@ "@noble/hashes": "^1.7.2", "@types/ini": "^4.1.1", "deep-equality-data-structures": "^2.0.0", + "fast-xml-parser": "^5.5.6", "ini": "^5.0.0", "isomorphic-fetch": "^3.0.0", "mime": "^4.0.7", - "ts-matches": "^6.3.2", - "yaml": "^2.7.1" + "yaml": "^2.7.1", + "zod": "^4.3.6", + "zod-deep-partial": "^1.2.0" } }, "node_modules/@types/ini": { @@ -101,6 +103,41 @@ "object-hash": "^3.0.0" } }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.11", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.11.tgz", + "integrity": "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.4.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/ini": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", @@ -164,6 +201,21 @@ "node": ">= 6" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -180,18 +232,24 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "license": "MIT" }, - "node_modules/ts-matches": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-6.5.0.tgz", - "integrity": "sha512-MhuobYhHYn6MlOTPAF/qk3tsRRioPac5ofYn68tc3rAJaGjsw1MsX1MOSep52DkvNJPgNV0F73zfgcQfYTVeyQ==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -246,6 +304,24 @@ "engines": { "node": ">= 14.6" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-deep-partial": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz", + "integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==", + "license": "MIT", + "peerDependencies": { + "zod": "^4.1.13" + } } } } diff --git a/package.json b/package.json index daf964a..6b024a1 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,12 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "^0.4.0-beta.48" + "@start9labs/start-sdk": "1.0.0" }, "devDependencies": { - "@types/node": "^22.19.7", + "@types/node": "^22.19.17", "@vercel/ncc": "^0.38.4", - "prettier": "^3.8.1", + "prettier": "^3.8.2", "typescript": "^5.9.3" }, "prettier": { diff --git a/startos/fileModels/store.yaml.ts b/startos/fileModels/store.yaml.ts index 91e6c55..d5047ca 100644 --- a/startos/fileModels/store.yaml.ts +++ b/startos/fileModels/store.yaml.ts @@ -1,12 +1,11 @@ -import { matches, FileHelper, T } from '@start9labs/start-sdk' +import { z, FileHelper, T } from '@start9labs/start-sdk' import { sdk } from '../sdk' -const { object, string } = matches -const shape = object({ - token: string, +const shape = z.object({ + token: z.string(), }) -export type StoreType = typeof shape._TYPE +export type StoreType = z.infer export const store = FileHelper.yaml( { diff --git a/startos/init/index.ts b/startos/init/index.ts index ea71e15..013d67c 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -3,12 +3,14 @@ import { setInterfaces } from '../interfaces' import { versionGraph } from '../install/versionGraph' import { actions } from '../actions' import { restoreInit } from '../backups' +import { seedStore } from './seedStore' export const init = sdk.setupInit( restoreInit, versionGraph, + seedStore, setInterfaces, - actions + actions, ) export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/init/seedStore.ts b/startos/init/seedStore.ts new file mode 100644 index 0000000..4907a04 --- /dev/null +++ b/startos/init/seedStore.ts @@ -0,0 +1,16 @@ +import { sdk } from '../sdk' +import { createDefaultStore, store } from '../fileModels/store.yaml' +import { setToken } from '../actions/setToken' + +export const seedStore = sdk.setupOnInit(async (effects, kind) => { + if (kind !== 'install') return + + await createDefaultStore(effects) + + const authToken = (await store.read().once())?.token + if (!authToken) { + await sdk.action.createOwnTask(effects, setToken, 'critical', { + reason: 'Set Cloudflare tunnel authentication token', + }) + } +}) diff --git a/startos/install/versionGraph.ts b/startos/install/versionGraph.ts index f1a28cc..5a4fad7 100644 --- a/startos/install/versionGraph.ts +++ b/startos/install/versionGraph.ts @@ -1,20 +1,7 @@ import { VersionGraph } from '@start9labs/start-sdk' import { current, other } from './versions' -import { createDefaultStore, store } from '../fileModels/store.yaml' -import { sdk } from '../sdk' -import { setToken } from '../actions/setToken' export const versionGraph = VersionGraph.of({ current, other, - preInstall: async (effects) => { - await createDefaultStore(effects) - - const authToken = (await store.read().once())?.token - if (!authToken) { - await sdk.action.createOwnTask(effects, setToken, 'critical', { - reason: 'Set Cloudflare tunnel authentication token', - }) - } - }, }) diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts index 969c039..198c3e2 100644 --- a/startos/install/versions/index.ts +++ b/startos/install/versions/index.ts @@ -3,7 +3,8 @@ import { v2025_9_1 } from './v2025.9.1' import { v2025_10_1 } from './v2025.10.1' import { v2025_11_1 } from './v2025.11.1' import { v2026_2_0 } from './v2026.2.0' +import { v2026_3_0 } from './v2026.3.0' -export { v2026_2_0 as current } -export const other = [v2025_8_1, v2025_9_1, v2025_10_1, v2025_11_1] -export const CLOUDFLARED_VERSION = '2026.2.0' \ No newline at end of file +export { v2026_3_0 as current } +export const other = [v2025_8_1, v2025_9_1, v2025_10_1, v2025_11_1, v2026_2_0] +export const CLOUDFLARED_VERSION = '2026.3.0' \ No newline at end of file diff --git a/startos/install/versions/v2025.10.1.ts b/startos/install/versions/v2025.10.1.ts index 3d7099f..8841904 100644 --- a/startos/install/versions/v2025.10.1.ts +++ b/startos/install/versions/v2025.10.1.ts @@ -2,7 +2,7 @@ import { VersionInfo } from '@start9labs/start-sdk' export const v2025_10_1 = VersionInfo.of({ version: '2025.10.1:1.0', - releaseNotes: 'Updated cloudflared to 2025.10.1', + releaseNotes: { en_US: 'Updated cloudflared to 2025.10.1' }, migrations: { up: async ({ effects }) => {}, down: async ({ effects }) => {}, diff --git a/startos/install/versions/v2025.11.1.ts b/startos/install/versions/v2025.11.1.ts index 1679eec..905f66c 100644 --- a/startos/install/versions/v2025.11.1.ts +++ b/startos/install/versions/v2025.11.1.ts @@ -2,7 +2,9 @@ import { VersionInfo } from '@start9labs/start-sdk' export const v2025_11_1 = VersionInfo.of({ version: '2025.11.1:1.0', - releaseNotes: 'Updated cloudflared to 2025.11.1 - [Changelog](https://github.com/cloudflare/cloudflared/blob/2025.11.1/RELEASE_NOTES)', + releaseNotes: { + en_US: 'Updated cloudflared to 2025.11.1 - [Changelog](https://github.com/cloudflare/cloudflared/blob/2025.11.1/RELEASE_NOTES)', + }, migrations: { up: async ({ effects }) => {}, down: async ({ effects }) => {}, diff --git a/startos/install/versions/v2025.8.1.ts b/startos/install/versions/v2025.8.1.ts index 858c8e1..758db08 100644 --- a/startos/install/versions/v2025.8.1.ts +++ b/startos/install/versions/v2025.8.1.ts @@ -2,7 +2,7 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v2025_8_1 = VersionInfo.of({ version: '2025.8.1:1.0', - releaseNotes: 'Revamped for StartOS 0.4.0', + releaseNotes: { en_US: 'Revamped for StartOS 0.4.0' }, migrations: { up: async ({ effects }) => {}, down: IMPOSSIBLE, diff --git a/startos/install/versions/v2025.9.1.ts b/startos/install/versions/v2025.9.1.ts index bcb05e7..3d9ec98 100644 --- a/startos/install/versions/v2025.9.1.ts +++ b/startos/install/versions/v2025.9.1.ts @@ -2,7 +2,7 @@ import { VersionInfo } from '@start9labs/start-sdk' export const v2025_9_1 = VersionInfo.of({ version: '2025.9.1:1.0', - releaseNotes: 'Revamped for StartOS 0.4.0', + releaseNotes: { en_US: 'Revamped for StartOS 0.4.0' }, migrations: { up: async ({ effects }) => {}, down: async ({ effects }) => {}, diff --git a/startos/install/versions/v2026.2.0.ts b/startos/install/versions/v2026.2.0.ts index e62401e..e5c80bf 100644 --- a/startos/install/versions/v2026.2.0.ts +++ b/startos/install/versions/v2026.2.0.ts @@ -2,7 +2,9 @@ import { VersionInfo } from '@start9labs/start-sdk' export const v2026_2_0 = VersionInfo.of({ version: '2026.2.0:1.0', - releaseNotes: 'Updated cloudflared to 2026.2.0 - [Changelog](https://github.com/cloudflare/cloudflared/blob/2026.2.0/RELEASE_NOTES)', + releaseNotes: { + en_US: 'Updated cloudflared to 2026.2.0 - [Changelog](https://github.com/cloudflare/cloudflared/blob/2026.2.0/RELEASE_NOTES)', + }, migrations: { up: async ({ effects }) => {}, down: async ({ effects }) => {}, diff --git a/startos/install/versions/v2026.3.0.ts b/startos/install/versions/v2026.3.0.ts new file mode 100644 index 0000000..f1b879c --- /dev/null +++ b/startos/install/versions/v2026.3.0.ts @@ -0,0 +1,12 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +export const v2026_3_0 = VersionInfo.of({ + version: '2026.3.0:1.0', + releaseNotes: { + en_US: 'Updated cloudflared to 2026.3.0 - [Changelog](https://github.com/cloudflare/cloudflared/blob/2026.3.0/RELEASE_NOTES)', + }, + migrations: { + up: async ({ effects }) => {}, + down: async ({ effects }) => {}, + }, +}) diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index 8d9e1b5..2b6bcc0 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -5,13 +5,13 @@ export const manifest = setupManifest({ id: 'cloudflared', title: 'Cloudflare Tunnel', license: 'Apache 2.0', - wrapperRepo: 'https://github.com/remcoros/cloudflared-startos', + packageRepo: 'https://github.com/remcoros/cloudflared-startos', upstreamRepo: 'https://github.com/cloudflare/cloudflared', - supportSite: 'https://github.com/cloudflare/cloudflared/issues', - docsUrl: + marketingUrl: 'https://cloudflare.com/', + donationUrl: null, + docsUrls: [ 'https://github.com/remcoros/cloudflared-startos/blob/main/instructions.md', - marketingSite: 'https://cloudflare.com/', - donationUrl: 'https://cloudflare.com/', + ], description: { short: { en_US: 'Cloudflare Tunnel client', From 0011e8e4858927e19c179eeafea148abb511e9ea Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 13:13:26 +0200 Subject: [PATCH 17/36] feat: add url-v0 plugin for public hostname routing --- Dockerfile | 3 + package-lock.json | 16 ++-- scripts/cf-login.sh | 52 ++++++++++ startos/actions/addPublicHostname.ts | 120 ++++++++++++++++++++++++ startos/actions/cloudflareLogin.ts | 82 ++++++++++++++++ startos/actions/deletePublicHostname.ts | 59 ++++++++++++ startos/actions/index.ts | 9 +- startos/fileModels/store.yaml.ts | 12 +++ startos/fileModels/tunnel.yaml.ts | 38 ++++++++ startos/init/index.ts | 5 + startos/init/writeConfig.ts | 12 +++ startos/main.ts | 22 +++-- startos/manifest/index.ts | 1 + startos/plugin/url.ts | 38 ++++++++ startos/tunnelToken.ts | 29 ++++++ 15 files changed, 483 insertions(+), 15 deletions(-) create mode 100644 scripts/cf-login.sh create mode 100644 startos/actions/addPublicHostname.ts create mode 100644 startos/actions/cloudflareLogin.ts create mode 100644 startos/actions/deletePublicHostname.ts create mode 100644 startos/fileModels/tunnel.yaml.ts create mode 100644 startos/init/writeConfig.ts create mode 100644 startos/plugin/url.ts create mode 100644 startos/tunnelToken.ts diff --git a/Dockerfile b/Dockerfile index f367227..623ef82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,3 +21,6 @@ RUN \ # add cloudflared binary COPY --chmod=0755 --from=cloudflared /usr/local/bin/cloudflared /usr/local/bin/cloudflared + +# add login helper script +COPY --chmod=0755 scripts/cf-login.sh /usr/local/bin/cf-login.sh diff --git a/package-lock.json b/package-lock.json index de0b46f..7d84e76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "@start9labs/start-sdk": "1.0.0" }, "devDependencies": { - "@types/node": "^22.19.7", + "@types/node": "^22.19.17", "@vercel/ncc": "^0.38.4", - "prettier": "^3.8.1", + "prettier": "^3.8.2", "typescript": "^5.9.3" } }, @@ -75,9 +75,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", - "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -217,9 +217,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", "dev": true, "license": "MIT", "bin": { diff --git a/scripts/cf-login.sh b/scripts/cf-login.sh new file mode 100644 index 0000000..7158221 --- /dev/null +++ b/scripts/cf-login.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Starts `cloudflared tunnel login`, extracts the auth URL and writes it to the +# volume so the StartOS action can return it to the user. Then waits up to 10 +# minutes for the user to authorize (cert.pem is written to /root/.cloudflared/). + +set -e + +URL_FILE="/root/data/start9/login-url.txt" +LOG_FILE="/tmp/cf-login.log" + +rm -f "$URL_FILE" + +# Start cloudflared login in the background; cert lands in /root/.cloudflared/ +cloudflared tunnel login >"$LOG_FILE" 2>&1 & +CF_PID=$! + +# Extract auth URL from the log as soon as it appears (timeout 30s) +i=0 +while [ $i -lt 30 ]; do + URL=$(grep -oE 'https://dash\.cloudflare\.com[^[:space:]"]+' "$LOG_FILE" 2>/dev/null | head -1) + if [ -n "$URL" ]; then + echo "$URL" >"$URL_FILE" + break + fi + sleep 1 + i=$((i + 1)) +done + +if [ -z "$URL" ]; then + echo "ERROR: could not extract auth URL after 30s" + cat "$LOG_FILE" + kill "$CF_PID" 2>/dev/null || true + exit 1 +fi + +# Wait for auth to complete (user authorizes in browser) +wait "$CF_PID" +EXIT_CODE=$? + +cat "$LOG_FILE" + +if [ $EXIT_CODE -ne 0 ]; then + echo "cloudflared login failed (exit $EXIT_CODE)" + exit 1 +fi + +if [ ! -f "/root/.cloudflared/cert.pem" ]; then + echo "Login appeared to succeed but cert.pem was not found" + exit 1 +fi + +echo "Login successful" diff --git a/startos/actions/addPublicHostname.ts b/startos/actions/addPublicHostname.ts new file mode 100644 index 0000000..a153f4b --- /dev/null +++ b/startos/actions/addPublicHostname.ts @@ -0,0 +1,120 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +import { writeTunnelConfig } from '../fileModels/tunnel.yaml' +import { decodeTunnelToken } from '../tunnelToken' + +const { InputSpec, Value } = sdk + +const CERT_PATH = '/root/.cloudflared/cert.pem' + +const inputSpec = InputSpec.of({ + urlPluginMetadata: Value.hidden<{ + packageId: string + interfaceId: string + hostId: string + internalPort: number + }>(), + hostname: Value.text({ + name: 'Public Hostname', + description: + 'The public hostname to route to this service (e.g. myapp.example.com). Must be on a domain managed by Cloudflare.', + required: true, + default: '', + placeholder: 'myapp.example.com', + masked: false, + inputmode: 'url', + patterns: [ + { + regex: '^[a-zA-Z0-9][a-zA-Z0-9\\-\\.]+\\.[a-zA-Z]{2,}$', + description: 'Must be a valid hostname (e.g. myapp.example.com)', + }, + ], + }), +}) + +export const addPublicHostname = sdk.Action.withInput( + 'add-public-hostname', + + async () => ({ + name: 'Add Public Hostname', + description: 'Route a public Cloudflare hostname to this service', + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'hidden', + }), + + inputSpec, + async () => null, + + async ({ effects, input }) => { + const { packageId, internalPort, interfaceId, hostId } = input.urlPluginMetadata + const hostname = input.hostname.trim().toLowerCase() + const host = packageId === 'STARTOS' ? 'startos' : `${packageId}.startos` + const service = `http://${host}:${internalPort}` + + const conf = await store.read().once() + if (!conf?.token) { + throw new Error('No tunnel token configured. Run "Set Authentication Token" first.') + } + + const credentials = decodeTunnelToken(conf.token) + + // Persist ingress entry and regenerate config + await store.merge(effects, { + ingress: { + [hostname]: { + packageId: packageId === 'STARTOS' ? null : packageId, + hostId, + interfaceId, + internalPort, + service, + }, + }, + }) + const updated = await store.read().once() + await writeTunnelConfig(effects, updated?.ingress ?? {}) + + // Create DNS route automatically if logged in, otherwise log manual instructions + let certExists = false + try { + await sdk.volumes.main.readFile('/.cloudflared/cert.pem') + certExists = true + } catch {} + + if (certExists) { + await sdk.SubContainer.withTemp( + effects, + { imageId: 'main' }, + sdk.Mounts.of() + .mountVolume({ volumeId: 'main', subpath: null, mountpoint: '/root/data', readonly: false }) + .mountVolume({ volumeId: 'main', subpath: '.cloudflared', mountpoint: '/root/.cloudflared', readonly: true }), + 'route-dns', + async (sub) => { + const result = await sub.exec( + [ + '/usr/local/bin/cloudflared', '--no-autoupdate', + `--origincert=${CERT_PATH}`, + 'tunnel', 'route', 'dns', '--overwrite-dns', + credentials.TunnelID, hostname, + ], + {}, 30_000, + ) + if (result.stdout) console.info(result.stdout) + if (result.stderr) console.info(result.stderr) + if (result.exitCode !== 0) { + console.error( + `DNS route creation failed. Add CNAME manually: ${hostname} → ${credentials.TunnelID}.cfargotunnel.com`, + ) + } + }, + ) + } else { + console.info( + `Not logged in — add CNAME manually: ${hostname} → ${credentials.TunnelID}.cfargotunnel.com`, + ) + } + + await effects.restart() + }, +) diff --git a/startos/actions/cloudflareLogin.ts b/startos/actions/cloudflareLogin.ts new file mode 100644 index 0000000..77d5fae --- /dev/null +++ b/startos/actions/cloudflareLogin.ts @@ -0,0 +1,82 @@ +import { sdk } from '../sdk' +import { certPem } from '../fileModels/tunnel.yaml' + +const LOGIN_URL_PATH = '/start9/login-url.txt' + +const mounts = sdk.Mounts.of() + .mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/root/data', + readonly: false, + }) + .mountVolume({ + volumeId: 'main', + subpath: '.cloudflared', + mountpoint: '/root/.cloudflared', + readonly: false, + }) + +export const cloudflareLogin = sdk.Action.withoutInput( + 'cloudflare-login', + + async ({ effects }) => { + const loggedIn = !!(await certPem.read().const(effects)) + return { + name: `Login to Cloudflare (currently ${loggedIn ? '' : 'not '}logged in)`, + description: + 'Authenticates with your Cloudflare account so DNS routes can be created automatically. ' + + 'Returns an authorization URL — visit it in your browser to complete login.', + warning: null, + allowedStatuses: 'any', + group: 'Configuration', + visibility: 'enabled', + } + }, + + async ({ effects }) => { + // Clear any stale URL file before starting + await sdk.volumes.main.writeFile(LOGIN_URL_PATH, 'pending').catch(() => {}) + + // Fire and forget — cf-login.sh starts cloudflared login, extracts the auth URL, + // writes it to the volume, then waits up to 10 min for auth to complete + sdk.SubContainer.withTemp(effects, { imageId: 'main' }, mounts, 'cf-login', + async (sub) => { + const result = await sub.exec(['/usr/local/bin/cf-login.sh'], {}, 10 * 60 * 1000) + if (result.stdout) console.info(result.stdout) + if (result.stderr) console.info(result.stderr) + if (result.exitCode !== 0) { + console.error(`cf-login.sh exited with code ${result.exitCode}`) + } + }, + ).catch((e) => console.error(`cf-login error: ${String(e)}`)) + + // Poll for the URL written by cf-login.sh (up to 30s) + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2000)) + try { + const url = (await sdk.volumes.main.readFile(LOGIN_URL_PATH)).toString().trim() + if (url.startsWith('https://dash.cloudflare.com')) { + return { + version: '1' as const, + title: 'Cloudflare Authorization', + message: + 'Visit the URL below to authorize. After authorizing, DNS routes will be created automatically when you add a public hostname.', + result: { + type: 'single' as const, + value: url, + copyable: true, + qr: false, + masked: false, + }, + } + } + } catch { + // not written yet + } + } + + throw new Error('Timed out waiting for Cloudflare auth URL. Check the service logs.') + }, +) diff --git a/startos/actions/deletePublicHostname.ts b/startos/actions/deletePublicHostname.ts new file mode 100644 index 0000000..1b8e3e4 --- /dev/null +++ b/startos/actions/deletePublicHostname.ts @@ -0,0 +1,59 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +import { writeTunnelConfig } from '../fileModels/tunnel.yaml' + +const { InputSpec, Value } = sdk + +const inputSpec = InputSpec.of({ + urlPluginMetadata: Value.hidden<{ + interfaceId: string + packageId: string | null + hostId: string + internalPort: number + ssl: boolean + public: boolean + hostname: string + port: number | null + info: unknown + }>(), +}) + +export const deletePublicHostname = sdk.Action.withInput( + // id + 'delete-public-hostname', + + // metadata + async () => ({ + name: 'Delete Public Hostname', + description: 'Remove a Cloudflare public hostname route', + warning: 'This will stop routing traffic from this hostname to the service.', + allowedStatuses: 'any', + group: null, + visibility: 'hidden', + }), + + // input spec + inputSpec, + + // pre-fill + async () => null, + + // execution + async ({ effects, input }) => { + const { hostname } = input.urlPluginMetadata + + // Remove from store (undefined → merge removes the key) + await store.merge(effects, { + ingress: { [hostname]: undefined } as any, + }) + + // Regenerate config file + const updated = await store.read().once() + await writeTunnelConfig(effects, updated?.ingress ?? {}) + + // Restart the daemon so cloudflared picks up the change + await effects.restart() + + console.info(`Public hostname ${hostname} removed.`) + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts index c80a925..1868c19 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -1,4 +1,11 @@ import { sdk } from '../sdk' import { setToken } from './setToken' +import { addPublicHostname } from './addPublicHostname' +import { deletePublicHostname } from './deletePublicHostname' +import { cloudflareLogin } from './cloudflareLogin' -export const actions = sdk.Actions.of().addAction(setToken) +export const actions = sdk.Actions.of() + .addAction(setToken) + .addAction(cloudflareLogin) + .addAction(addPublicHostname) + .addAction(deletePublicHostname) diff --git a/startos/fileModels/store.yaml.ts b/startos/fileModels/store.yaml.ts index d5047ca..af42d04 100644 --- a/startos/fileModels/store.yaml.ts +++ b/startos/fileModels/store.yaml.ts @@ -1,8 +1,19 @@ 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'), // fallback for entries saved before hostId was added + interfaceId: z.string(), + internalPort: z.number(), + service: z.string(), // e.g. "http://nextcloud.startos:80" +}) + +export type IngressEntry = z.infer + const shape = z.object({ token: z.string(), + ingress: z.record(z.string(), ingressEntryShape.nullable()).catch({}), }) export type StoreType = z.infer @@ -21,6 +32,7 @@ export const createDefaultStore = async (effects: T.Effects) => { if (!conf) { await store.write(effects, { token: '', + ingress: {}, }) } } diff --git a/startos/fileModels/tunnel.yaml.ts b/startos/fileModels/tunnel.yaml.ts new file mode 100644 index 0000000..57865fe --- /dev/null +++ b/startos/fileModels/tunnel.yaml.ts @@ -0,0 +1,38 @@ +import { T } from '@start9labs/start-sdk' +import { sdk } from '../sdk' +import { IngressEntry } from './store.yaml' +import { FileHelper } from '@start9labs/start-sdk' + +/** + * FileHelper for cert.pem — used only for .const() reactive watching. + * When cert.pem is created/deleted, action metadata re-evaluates. + */ +export const certPem = FileHelper.string({ + base: sdk.volumes.main, + subpath: '/.cloudflared/cert.pem', +}) + +export const TUNNEL_CONFIG_PATH = '/start9/tunnel.yaml' + +/** + * Build and write the cloudflared tunnel config YAML to the volume. + * We only ever write this file — cloudflared reads it once at startup via --config. + */ +export async function writeTunnelConfig( + effects: T.Effects, + ingress: Record, +): Promise { + const lines: string[] = ['ingress:'] + + for (const [hostname, entry] of Object.entries(ingress)) { + if (!entry) continue + lines.push(` - hostname: ${hostname}`) + lines.push(` service: ${entry.service}`) + } + + // Required catch-all rule + lines.push(' - service: http_status:404') + lines.push('') + + await sdk.volumes.main.writeFile(TUNNEL_CONFIG_PATH, lines.join('\n')) +} diff --git a/startos/init/index.ts b/startos/init/index.ts index 013d67c..f5c7ba0 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -4,13 +4,18 @@ import { versionGraph } from '../install/versionGraph' import { actions } from '../actions' import { restoreInit } from '../backups' import { seedStore } from './seedStore' +import { writeConfig } from './writeConfig' +import { exportUrls, registerUrlPlugin } from '../plugin/url' export const init = sdk.setupInit( restoreInit, versionGraph, seedStore, + writeConfig, setInterfaces, actions, + registerUrlPlugin, + exportUrls, ) export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/init/writeConfig.ts b/startos/init/writeConfig.ts new file mode 100644 index 0000000..7c4c30c --- /dev/null +++ b/startos/init/writeConfig.ts @@ -0,0 +1,12 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +import { writeTunnelConfig } from '../fileModels/tunnel.yaml' + +/** + * (Re)generate the cloudflared tunnel.yaml from the store before every start. + * Runs on all init kinds (install, update, restore, null/restart). + */ +export const writeConfig = sdk.setupOnInit(async (effects) => { + const conf = await store.read().once() + await writeTunnelConfig(effects, conf?.ingress ?? {}) +}) diff --git a/startos/main.ts b/startos/main.ts index de74c7f..4eb9b16 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -1,4 +1,5 @@ import { store } from './fileModels/store.yaml' +import { TUNNEL_CONFIG_PATH } from './fileModels/tunnel.yaml' import { sdk } from './sdk' import { i18n } from './i18n' @@ -13,12 +14,19 @@ export const main = sdk.setupMain(async ({ effects }) => { { imageId: 'main', }, - sdk.Mounts.of().mountVolume({ - volumeId: 'main', - subpath: null, - mountpoint: '/root/data', - readonly: false, - }), + sdk.Mounts.of() + .mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/root/data', + readonly: false, + }) + .mountVolume({ + volumeId: 'main', + subpath: '.cloudflared', + mountpoint: '/root/.cloudflared', + readonly: true, + }), 'main', ), exec: { @@ -29,6 +37,8 @@ export const main = sdk.setupMain(async ({ effects }) => { '--metrics', '0.0.0.0:20241', 'tunnel', + '--config', + `/root/data${TUNNEL_CONFIG_PATH}`, 'run', ], env: { diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index 2b6bcc0..11f0d70 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -49,4 +49,5 @@ export const manifest = setupManifest({ }, }, dependencies: {}, + plugins: ['url-v0'], }) diff --git a/startos/plugin/url.ts b/startos/plugin/url.ts new file mode 100644 index 0000000..d1e9fc5 --- /dev/null +++ b/startos/plugin/url.ts @@ -0,0 +1,38 @@ +import { addPublicHostname } from '../actions/addPublicHostname' +import { deletePublicHostname } from '../actions/deletePublicHostname' +import { store } from '../fileModels/store.yaml' +import { sdk } from '../sdk' + +export const registerUrlPlugin = sdk.setupOnInit(async (effects) => + sdk.plugin.url.register(effects, { tableAction: addPublicHostname }), +) + +export const exportUrls = sdk.plugin.url.setupExportedUrls( + async ({ effects }) => { + const ingress = + (await store.read((s) => s.ingress).const(effects)) ?? {} + + for (const [hostname, entry] of Object.entries(ingress)) { + if (!entry) continue + + await sdk.plugin.url + .exportUrl(effects, { + hostnameInfo: { + packageId: entry.packageId, + hostId: entry.hostId, + internalPort: entry.internalPort, + ssl: false, + public: true, + hostname, + port: 80, + info: null, + }, + removeAction: deletePublicHostname, + overflowActions: [], + }) + .catch((e) => { + console.error(`Failed to export url for ${hostname}:`, e) + }) + } + }, +) diff --git a/startos/tunnelToken.ts b/startos/tunnelToken.ts new file mode 100644 index 0000000..b20c989 --- /dev/null +++ b/startos/tunnelToken.ts @@ -0,0 +1,29 @@ +/** + * The TUNNEL_TOKEN is base64-encoded JSON with short field names: + * { a: accountTag, t: tunnelId, s: tunnelSecret } + * Decoded via base64.StdEncoding per cloudflared source. + */ +export type TunnelCredentials = { + AccountTag: string + TunnelID: string + TunnelSecret: string +} + +export function decodeTunnelToken(token: string): TunnelCredentials { + try { + const json = Buffer.from(token, 'base64').toString('utf8') + const parsed = JSON.parse(json) + // Short field names used in the wire format + const accountTag = parsed.a + const tunnelID = parsed.t + const tunnelSecret = parsed.s + if (!accountTag || !tunnelID || !tunnelSecret) { + throw new Error('Missing required fields in tunnel token') + } + return { AccountTag: accountTag, TunnelID: tunnelID, TunnelSecret: tunnelSecret } + } catch (e) { + throw new Error( + `Invalid tunnel token: could not decode credentials. Make sure you pasted the full token from the Cloudflare dashboard.`, + ) + } +} From 1e7bd0dbe1a3cb8b053f3caed5bd39fbb08a1e37 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 15:08:49 +0200 Subject: [PATCH 18/36] feat: replace token input with cloudflared login + select tunnel flow --- startos/actions/addPublicHostname.ts | 12 +- startos/actions/index.ts | 6 +- startos/actions/selectTunnel.ts | 157 ++++++++++++++++++++++ startos/actions/setToken.ts | 54 -------- startos/cfRunner.ts | 51 +++++++ startos/fileModels/store.yaml.ts | 27 +++- startos/fileModels/tunnel.yaml.ts | 1 - startos/i18n/dictionaries/default.ts | 6 - startos/i18n/dictionaries/translations.ts | 24 ---- startos/init/index.ts | 4 + startos/init/seedStore.ts | 11 +- startos/init/setupTasks.ts | 28 ++++ startos/init/setupZoneInfo.ts | 70 ++++++++++ startos/main.ts | 5 + startos/plugin/url.ts | 4 +- 15 files changed, 355 insertions(+), 105 deletions(-) create mode 100644 startos/actions/selectTunnel.ts delete mode 100644 startos/actions/setToken.ts create mode 100644 startos/cfRunner.ts create mode 100644 startos/init/setupTasks.ts create mode 100644 startos/init/setupZoneInfo.ts diff --git a/startos/actions/addPublicHostname.ts b/startos/actions/addPublicHostname.ts index a153f4b..1de6842 100644 --- a/startos/actions/addPublicHostname.ts +++ b/startos/actions/addPublicHostname.ts @@ -45,7 +45,17 @@ export const addPublicHostname = sdk.Action.withInput( }), inputSpec, - async () => null, + async ({ effects, prefill }) => { + const p = prefill as typeof inputSpec._PARTIAL + const conf = await store.read().once() + const zone = conf?.zoneInfo?.zoneName + const suggestedHost = p?.urlPluginMetadata?.packageId + const suggested = + zone && suggestedHost && suggestedHost !== 'STARTOS' + ? `${suggestedHost}.${zone}` + : undefined + return suggested ? { hostname: suggested } : null + }, async ({ effects, input }) => { const { packageId, internalPort, interfaceId, hostId } = input.urlPluginMetadata diff --git a/startos/actions/index.ts b/startos/actions/index.ts index 1868c19..624e54b 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -1,11 +1,11 @@ import { sdk } from '../sdk' -import { setToken } from './setToken' +import { cloudflareLogin } from './cloudflareLogin' +import { selectTunnel } from './selectTunnel' import { addPublicHostname } from './addPublicHostname' import { deletePublicHostname } from './deletePublicHostname' -import { cloudflareLogin } from './cloudflareLogin' export const actions = sdk.Actions.of() - .addAction(setToken) .addAction(cloudflareLogin) + .addAction(selectTunnel) .addAction(addPublicHostname) .addAction(deletePublicHostname) diff --git a/startos/actions/selectTunnel.ts b/startos/actions/selectTunnel.ts new file mode 100644 index 0000000..9b4e221 --- /dev/null +++ b/startos/actions/selectTunnel.ts @@ -0,0 +1,157 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +import { certPem } from '../fileModels/tunnel.yaml' +import { runCf } from '../cfRunner' + +const { InputSpec, Value, Variants } = sdk + +/** + * Parse `cloudflared tunnel list --output json` output. + * Returns array of { id, name }. + */ +function parseTunnelList(stdout: string): Array<{ id: string; name: string }> { + try { + const parsed = JSON.parse(stdout.trim()) + if (Array.isArray(parsed)) { + return parsed + .filter((t: any) => t.id && t.name && (t.deleted_at?.startsWith('0001') ?? true)) + .map((t: any) => ({ id: String(t.id), name: String(t.name) })) + } + } catch { + // fallback: parse text table (ID NAME CREATED ...) + const lines = stdout.split('\n').filter(Boolean) + const result: Array<{ id: string; name: string }> = [] + for (const line of lines) { + const parts = line.trim().split(/\s+/) + // UUID pattern check + if (parts[0]?.match(/^[0-9a-f-]{36}$/)) { + result.push({ id: parts[0], name: parts[1] }) + } + } + return result + } + return [] +} + +const newTunnelSpec = InputSpec.of({ + name: Value.text({ + name: 'Tunnel Name', + description: 'A name for your new Cloudflare tunnel.', + required: true, + default: null, + placeholder: 'my-server', + masked: false, + inputmode: 'text', + }), +}) + +export const selectTunnel = sdk.Action.withInput( + 'select-tunnel', + + async ({ effects }) => { + const loggedIn = !!(await certPem.read().const(effects)) + if (!loggedIn) { + return { + name: 'Select Tunnel', + description: 'Login to Cloudflare first before selecting a tunnel.', + warning: null, + allowedStatuses: 'any' as const, + group: 'Configuration', + visibility: { disabled: 'Login to Cloudflare first' } as const, + } + } + const conf = await store.read().const(effects) + const current = conf?.tunnel?.name ?? 'none' + return { + name: `Select Tunnel (currently: ${current})`, + description: + 'Choose which Cloudflare tunnel this server runs. You can select an existing tunnel or create a new one.', + warning: null, + allowedStatuses: 'any', + group: 'Configuration', + visibility: 'enabled', + } + }, + + InputSpec.of({ + tunnel: Value.dynamicUnion(async ({ effects }) => { + // Fetch list of available tunnels + let tunnels: Array<{ id: string; name: string }> = [] + try { + const stdout = await runCf(effects!, ['tunnel', 'list', '--output', 'json']) + tunnels = parseTunnelList(stdout) + } catch (e) { + console.error(`Failed to list tunnels: ${String(e)}`) + } + + const variants: Record }> = {} + + for (const t of tunnels) { + variants[t.id] = { + name: t.name, + spec: InputSpec.of({}), + } + } + + // 'Create new tunnel' always at the bottom + variants['new'] = { + name: 'Create new tunnel', + spec: newTunnelSpec, + } + + return { + name: 'Tunnel', + default: tunnels[0]?.id ?? 'new', + disabled: false, + variants: Variants.of(variants), + } + }), + }), + + // pre-fill with current tunnel selection + async ({ effects }) => { + const conf = await store.read().once() + return { + tunnel: conf?.tunnel + ? { selection: conf.tunnel.id, value: {} } + : { selection: 'new', value: { name: '' } }, + } + }, + + async ({ effects, input }) => { + const selection = (input.tunnel as { selection: string; value: { name?: string } }) + let tunnelId: string + let tunnelName: string + + if (selection.selection === 'new') { + const name = selection.value.name?.trim() + if (!name) throw new Error('Tunnel name is required.') + + // Create the tunnel + const stdout = await runCf(effects, ['tunnel', 'create', '--output', 'json', name]) + const created = JSON.parse(stdout.trim()) + tunnelId = created.id + tunnelName = created.name + console.info(`Created tunnel: ${tunnelName} (${tunnelId})`) + } else { + tunnelId = selection.selection + // Find name from the list + const listOut = await runCf(effects, ['tunnel', 'list', '--output', 'json']) + const tunnels = parseTunnelList(listOut) + const found = tunnels.find((t) => t.id === tunnelId) + tunnelName = found?.name ?? tunnelId + } + + // Fetch the token for this tunnel and store it + const token = (await runCf(effects, ['tunnel', 'token', tunnelId])).trim() + + await store.merge(effects, { + tunnel: { id: tunnelId, name: tunnelName }, + token, + }) + + console.info(`Tunnel set to: ${tunnelName} (${tunnelId})`) + + await effects.restart() + }, +) diff --git a/startos/actions/setToken.ts b/startos/actions/setToken.ts deleted file mode 100644 index 390bde2..0000000 --- a/startos/actions/setToken.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { sdk } from '../sdk' -import { createDefaultStore, store } from '../fileModels/store.yaml' - -const { InputSpec, Value } = sdk - -const inputSpec = InputSpec.of({ - token: Value.text({ - name: 'Authentication Token', - description: 'The authentication token for your Cloudflare tunnel.', - required: true, - default: '', - placeholder: '', - masked: true, - inputmode: 'text', - }), -}) - -export const setToken = sdk.Action.withInput( - // id - 'set-token', - - // metadata - async ({ effects }) => ({ - name: 'Set Authentication Token', - description: 'Set the authentication token for your Cloudflare tunnel.', - warning: null, - allowedStatuses: 'any', - group: 'Configuration', - visibility: 'enabled', - }), - - // form input specification - inputSpec, - - // optionally pre-fill the input form - async ({ effects }) => { - let settings = await store.read().once() - if (!settings) { - await createDefaultStore(effects) - settings = (await store.read().once())! - } - - return { - token: settings.token, - } - }, - - // the execution function - async ({ effects, input }) => { - await store.merge(effects, { - token: input.token, - }) - }, -) diff --git a/startos/cfRunner.ts b/startos/cfRunner.ts new file mode 100644 index 0000000..3b19b6c --- /dev/null +++ b/startos/cfRunner.ts @@ -0,0 +1,51 @@ +import { sdk } from './sdk' +import { T } from '@start9labs/start-sdk' + +/** + * Standard mounts for cloudflared CLI subcontainers. + * Mounts the main volume at /root/data and .cloudflared at /root/.cloudflared. + */ +export const cfMounts = sdk.Mounts.of() + .mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/root/data', + readonly: false, + }) + .mountVolume({ + volumeId: 'main', + subpath: '.cloudflared', + mountpoint: '/root/.cloudflared', + readonly: true, + }) + +/** + * Run a cloudflared command in a temp subcontainer and return stdout as a string. + * Throws if exit code is non-zero. + */ +export async function runCf( + effects: T.Effects, + args: string[], + timeoutMs = 30_000, +): Promise { + return sdk.SubContainer.withTemp( + effects, + { imageId: 'main' }, + cfMounts, + 'cf-cmd', + async (sub) => { + const result = await sub.exec( + ['/usr/local/bin/cloudflared', '--no-autoupdate', ...args], + {}, + timeoutMs, + ) + 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}`, + ) + } + return result.stdout.toString() + }, + ) +} diff --git a/startos/fileModels/store.yaml.ts b/startos/fileModels/store.yaml.ts index af42d04..9e566c1 100644 --- a/startos/fileModels/store.yaml.ts +++ b/startos/fileModels/store.yaml.ts @@ -3,16 +3,34 @@ import { sdk } from '../sdk' export const ingressEntryShape = z.object({ packageId: z.string().nullable(), - hostId: z.string().catch('main'), // fallback for entries saved before hostId was added + hostId: z.string().catch('main'), interfaceId: z.string(), internalPort: z.number(), - service: z.string(), // e.g. "http://nextcloud.startos:80" + service: z.string(), }) export type IngressEntry = z.infer +export const tunnelInfoShape = z.object({ + id: z.string(), + name: z.string(), +}) + +export type TunnelInfo = z.infer + +export const zoneInfoShape = z.object({ + zoneId: z.string(), + zoneName: z.string(), + accountId: z.string(), + apiToken: z.string(), +}) + +export type ZoneInfo = z.infer + const shape = z.object({ - token: z.string(), + token: z.string().catch(''), + tunnel: tunnelInfoShape.nullable().catch(null), + zoneInfo: zoneInfoShape.nullable().catch(null), ingress: z.record(z.string(), ingressEntryShape.nullable()).catch({}), }) @@ -27,11 +45,12 @@ export const store = FileHelper.yaml( ) export const createDefaultStore = async (effects: T.Effects) => { - // check if the file exists (from previous installs or upgrades) const conf = await store.read().once() if (!conf) { await store.write(effects, { token: '', + tunnel: null, + zoneInfo: null, ingress: {}, }) } diff --git a/startos/fileModels/tunnel.yaml.ts b/startos/fileModels/tunnel.yaml.ts index 57865fe..acb4e3d 100644 --- a/startos/fileModels/tunnel.yaml.ts +++ b/startos/fileModels/tunnel.yaml.ts @@ -5,7 +5,6 @@ import { FileHelper } from '@start9labs/start-sdk' /** * FileHelper for cert.pem — used only for .const() reactive watching. - * When cert.pem is created/deleted, action metadata re-evaluates. */ export const certPem = FileHelper.string({ base: sdk.volumes.main, diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index 3296f2c..e5b0c98 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -9,12 +9,6 @@ const dict = { // interfaces.ts 'Metrics': 100, 'Prometheus metrics endpoint': 101, - - // actions/setToken.ts - 'Authentication Token': 200, - 'The authentication token for your Cloudflare tunnel.': 201, - 'Set Authentication Token': 202, - 'Set the authentication token for your Cloudflare tunnel.': 203, } as const /** diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index 4607240..54e09f0 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -10,12 +10,6 @@ export default { // interfaces.ts 100: 'Métricas', 101: 'Endpoint de métricas Prometheus', - - // actions/setToken.ts - 200: 'Token de autenticación', - 201: 'El token de autenticación para tu túnel Cloudflare.', - 202: 'Establecer token de autenticación', - 203: 'Establecer el token de autenticación para tu túnel Cloudflare.', }, de_DE: { // main.ts @@ -26,12 +20,6 @@ export default { // interfaces.ts 100: 'Metriken', 101: 'Prometheus-Metrik-Endpunkt', - - // actions/setToken.ts - 200: 'Authentifizierungstoken', - 201: 'Das Authentifizierungstoken für Ihren Cloudflare-Tunnel.', - 202: 'Authentifizierungstoken festlegen', - 203: 'Legen Sie das Authentifizierungstoken für Ihren Cloudflare-Tunnel fest.', }, pl_PL: { // main.ts @@ -42,12 +30,6 @@ export default { // interfaces.ts 100: 'Metryki', 101: 'Endpoint metryk Prometheus', - - // actions/setToken.ts - 200: 'Token uwierzytelniania', - 201: 'Token uwierzytelniania dla twojego tunelu Cloudflare.', - 202: 'Ustaw token uwierzytelniania', - 203: 'Ustaw token uwierzytelniania dla twojego tunelu Cloudflare.', }, fr_FR: { // main.ts @@ -58,11 +40,5 @@ export default { // interfaces.ts 100: 'Métriques', 101: 'Point de terminaison des métriques Prometheus', - - // actions/setToken.ts - 200: 'Jeton d\'authentification', - 201: 'Le jeton d\'authentification pour votre tunnel Cloudflare.', - 202: 'Définir le jeton d\'authentification', - 203: 'Définissez le jeton d\'authentification pour votre tunnel Cloudflare.', }, } satisfies Record diff --git a/startos/init/index.ts b/startos/init/index.ts index f5c7ba0..88c3e62 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -6,15 +6,19 @@ import { restoreInit } from '../backups' import { seedStore } from './seedStore' import { writeConfig } from './writeConfig' import { exportUrls, registerUrlPlugin } from '../plugin/url' +import { setupTasks } from './setupTasks' +import { setupZoneInfo } from './setupZoneInfo' export const init = sdk.setupInit( restoreInit, versionGraph, seedStore, + setupZoneInfo, writeConfig, setInterfaces, actions, registerUrlPlugin, + setupTasks, exportUrls, ) diff --git a/startos/init/seedStore.ts b/startos/init/seedStore.ts index 4907a04..b02ce66 100644 --- a/startos/init/seedStore.ts +++ b/startos/init/seedStore.ts @@ -1,16 +1,7 @@ import { sdk } from '../sdk' -import { createDefaultStore, store } from '../fileModels/store.yaml' -import { setToken } from '../actions/setToken' +import { createDefaultStore } from '../fileModels/store.yaml' export const seedStore = sdk.setupOnInit(async (effects, kind) => { if (kind !== 'install') return - await createDefaultStore(effects) - - const authToken = (await store.read().once())?.token - if (!authToken) { - await sdk.action.createOwnTask(effects, setToken, 'critical', { - reason: 'Set Cloudflare tunnel authentication token', - }) - } }) diff --git a/startos/init/setupTasks.ts b/startos/init/setupTasks.ts new file mode 100644 index 0000000..807a3ad --- /dev/null +++ b/startos/init/setupTasks.ts @@ -0,0 +1,28 @@ +import { sdk } from '../sdk' +import { certPem } from '../fileModels/tunnel.yaml' +import { store } from '../fileModels/store.yaml' +import { cloudflareLogin } from '../actions/cloudflareLogin' +import { selectTunnel } from '../actions/selectTunnel' + +/** + * Reactively manage required tasks. + * Re-runs whenever cert.pem or the store changes. + * - No cert.pem → task to login + * - No tunnel selected → task to select tunnel + */ +export const setupTasks = sdk.setupOnInit(async (effects) => { + const loggedIn = !!(await certPem.read().const(effects)) + if (!loggedIn) { + await sdk.action.createOwnTask(effects, cloudflareLogin, 'critical', { + reason: 'Login to Cloudflare to manage tunnels and DNS routes', + }) + return + } + + const conf = await store.read().const(effects) + if (!conf?.tunnel) { + await sdk.action.createOwnTask(effects, selectTunnel, 'critical', { + reason: 'Select or create a Cloudflare tunnel for this server', + }) + } +}) diff --git a/startos/init/setupZoneInfo.ts b/startos/init/setupZoneInfo.ts new file mode 100644 index 0000000..2aed0ac --- /dev/null +++ b/startos/init/setupZoneInfo.ts @@ -0,0 +1,70 @@ +import { sdk } from '../sdk' +import { certPem } from '../fileModels/tunnel.yaml' +import { store, ZoneInfo } from '../fileModels/store.yaml' + +/** + * Decode the zone credentials from cert.pem and fetch the zone name from + * the Cloudflare API. Runs reactively when cert.pem changes. + * Stores the result so we only fetch once (or when the cert changes). + */ +export const setupZoneInfo = sdk.setupOnInit(async (effects) => { + const cert = await certPem.read().const(effects) + if (!cert) { + // Not logged in — clear any stale zone info + const conf = await store.read().once() + if (conf?.zoneInfo) { + await store.merge(effects, { zoneInfo: null }) + } + return + } + + // Avoid re-fetching if we already have zone info from the same cert + const existing = await store.read().once() + + // Decode cert.pem: PEM-wrapped base64 JSON { zoneID, accountID, apiToken } + let decoded: any + try { + const lines = cert + .split('\n') + .filter((l) => l && !l.startsWith('-----')) + decoded = JSON.parse( + Buffer.from(lines.join(''), 'base64').toString('utf8'), + ) + } catch (e) { + console.error(`Failed to decode cert.pem: ${String(e)}`) + return + } + + if (existing?.zoneInfo?.zoneId === decoded.zoneID) return + + // Fetch zone name from Cloudflare API + let zoneInfo: ZoneInfo + try { + const resp = await fetch( + `https://api.cloudflare.com/client/v4/zones/${decoded.zoneID}`, + { + headers: { + Authorization: `Bearer ${decoded.apiToken}`, + 'Content-Type': 'application/json', + }, + }, + ) + const data = (await resp.json()) as any + if (!data.success) { + throw new Error(`CF API error: ${JSON.stringify(data.errors)}`) + } + + zoneInfo = { + zoneId: decoded.zoneID, + zoneName: data.result.name, + accountId: decoded.accountID, + apiToken: decoded.apiToken, + } + } catch (e) { + console.error(`Failed to fetch zone info from cert.pem: ${String(e)}`) + return + } + + await store.merge(effects, { zoneInfo }) + console.info(`Zone info stored: ${zoneInfo.zoneName} (${zoneInfo.zoneId})`) +}) diff --git a/startos/main.ts b/startos/main.ts index 4eb9b16..9034748 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -8,6 +8,11 @@ export const main = sdk.setupMain(async ({ effects }) => { const conf = (await store.read().const(effects))! + if (!conf.token) { + console.info('No tunnel token configured — waiting for tunnel selection') + return sdk.Daemons.of(effects) + } + return sdk.Daemons.of(effects).addDaemon('primary', { subcontainer: await sdk.SubContainer.of( effects, diff --git a/startos/plugin/url.ts b/startos/plugin/url.ts index d1e9fc5..86f6b1f 100644 --- a/startos/plugin/url.ts +++ b/startos/plugin/url.ts @@ -21,10 +21,10 @@ export const exportUrls = sdk.plugin.url.setupExportedUrls( packageId: entry.packageId, hostId: entry.hostId, internalPort: entry.internalPort, - ssl: false, + ssl: true, public: true, hostname, - port: 80, + port: 443, info: null, }, removeAction: deletePublicHostname, From e1b60adc823d09863142250bf2fdb808fa19ccce Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 16:08:20 +0200 Subject: [PATCH 19/36] chore: polish for release - version bump, README, CI workflows, action names, em dash cleanup, delete DNS on remove --- .../workflows/{buildService.yml => build.yml} | 11 +- .github/workflows/release.yml | 20 +++ .github/workflows/releaseService.yml | 17 --- .github/workflows/tagAndRelease.yml | 29 ++++ README.md | 127 +++++++++++++----- startos/actions/addPublicHostname.ts | 6 +- startos/actions/cloudflareLogin.ts | 12 +- startos/actions/deletePublicHostname.ts | 54 +++++++- startos/actions/selectTunnel.ts | 19 +-- startos/fileModels/tunnel.yaml.ts | 67 ++++++++- startos/i18n/dictionaries/default.ts | 6 +- startos/i18n/dictionaries/translations.ts | 20 +-- startos/init/setupZoneInfo.ts | 2 +- startos/init/writeConfig.ts | 2 +- startos/install/versions/v2026.3.0.ts | 4 +- startos/main.ts | 8 +- 16 files changed, 301 insertions(+), 103 deletions(-) rename .github/workflows/{buildService.yml => build.yml} (52%) create mode 100644 .github/workflows/release.yml delete mode 100644 .github/workflows/releaseService.yml create mode 100644 .github/workflows/tagAndRelease.yml diff --git a/.github/workflows/buildService.yml b/.github/workflows/build.yml similarity index 52% rename from .github/workflows/buildService.yml rename to .github/workflows/build.yml index 44e296e..e5ab77d 100644 --- a/.github/workflows/buildService.yml +++ b/.github/workflows/build.yml @@ -1,13 +1,10 @@ -name: Build Service +name: Build on: workflow_dispatch: pull_request: paths-ignore: ['*.md'] - branches: ['main', 'master', 'update/040'] - push: - paths-ignore: ['*.md'] - branches: ['main', 'master', 'update/040'] + branches: ['master', 'main', 'update/040'] concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} @@ -16,8 +13,8 @@ concurrency: jobs: build: if: github.event.pull_request.draft == false - uses: start9labs/shared-workflows/.github/workflows/buildService.yml@master + uses: start9labs/shared-workflows/.github/workflows/build.yml@master # with: # FREE_DISK_SPACE: true secrets: - DEV_KEY: ${{ secrets.DEV_KEY }} \ No newline at end of file + DEV_KEY: ${{ secrets.DEV_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3b83acf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,20 @@ +name: Release + +on: + push: + tags: + - 'v*.*' + +jobs: + release: + uses: start9labs/shared-workflows/.github/workflows/release.yml@master + with: + # FREE_DISK_SPACE: true + RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }} + S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }} + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + permissions: + contents: write diff --git a/.github/workflows/releaseService.yml b/.github/workflows/releaseService.yml deleted file mode 100644 index a447cae..0000000 --- a/.github/workflows/releaseService.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Release Service - -on: - push: - tags: - - 'v*.*' - -jobs: - release: - uses: start9labs/shared-workflows/.github/workflows/releaseService.yml@master - with: - # FREE_DISK_SPACE: true - REGISTRY: ${{ vars.REGISTRY }} # Optional. Defaults to https://alpha-registry-x.start9.com - secrets: - DEV_KEY: ${{ secrets.DEV_KEY }} # Required - permissions: - contents: write \ No newline at end of file diff --git a/.github/workflows/tagAndRelease.yml b/.github/workflows/tagAndRelease.yml new file mode 100644 index 0000000..c7e6bf0 --- /dev/null +++ b/.github/workflows/tagAndRelease.yml @@ -0,0 +1,29 @@ +name: Tag and Release + +# Disabled — uncomment 'on:' block and remove 'on: workflow_dispatch' to enable auto-tagging on merge to master +on: + workflow_dispatch: + +# on: +# push: +# branches: ['master'] +# paths-ignore: ['*.md'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tag: + uses: start9labs/shared-workflows/.github/workflows/tagAndRelease.yml@master + with: + REFERENCE_REGISTRY: ${{ vars.REFERENCE_REGISTRY }} + # FREE_DISK_SPACE: true + RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }} + S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }} + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + permissions: + contents: write diff --git a/README.md b/README.md index 12832c1..013091e 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,114 @@

- Project Logo + Cloudflare Tunnel Logo

-# Cloudflare Tunnel client for StartOS +# Cloudflare Tunnel on StartOS -Cloudflare Tunnel for StartOS +> **Upstream docs:** +> +> Everything not listed in this document should behave the same as upstream cloudflared. If a feature, setting, or behavior is not mentioned here, the upstream documentation is accurate and fully applicable. -## Dependencies +Cloudflare Tunnel (cloudflared) creates an outbound-only connection from your StartOS server to the Cloudflare edge network, allowing you to expose services publicly via your own domain without opening any inbound ports or configuring a router. + +Upstream repo: + +--- + +## Table of Contents + +- [Image and Container Runtime](#image-and-container-runtime) +- [Volume and Data Layout](#volume-and-data-layout) +- [Installation and First-Run Flow](#installation-and-first-run-flow) +- [Configuration Management](#configuration-management) +- [Network Access and Interfaces](#network-access-and-interfaces) +- [Actions](#actions) +- [URL Plugin](#url-plugin) +- [Backups and Restore](#backups-and-restore) +- [Health Checks](#health-checks) +- [Dependencies](#dependencies) +- [Limitations and Differences](#limitations-and-differences) + +--- + +## Image and Container Runtime + +- Base image: `cloudflare/cloudflared:` copied into `debian:12-slim` +- Architectures: `x86_64`, `aarch64` (aarch64 emulated if missing) +- Entrypoint: `cloudflared tunnel --config /root/data/start9/tunnel.yaml run` +- Autoupdate disabled via `--no-autoupdate` + +## Volume and Data Layout -Install the system dependencies below to build this project by following the instructions in the provided links. You can also find detailed steps to setup your environment in the service packaging [documentation](https://docs.start9.com/latest/developer-docs/packaging#development-environment). +All persistent data is stored in the `main` volume, mounted at `/root/data`: -- [docker](https://docs.docker.com/get-docker) -- [docker-buildx](https://docs.docker.com/buildx/working-with-buildx/) -- [yq](https://mikefarah.gitbook.io/yq) -- [deno](https://deno.land/) -- [make](https://www.gnu.org/software/make/) -- [start-sdk](https://github.com/Start9Labs/start-os/tree/sdk) +| Path | Contents | +|---|---| +| `/root/data/start9/config.yaml` | Package store (token, tunnel info, zone info, ingress map) | +| `/root/data/start9/tunnel.yaml` | Generated cloudflared ingress config (written on every start) | +| `/root/data/start9/login-url.txt` | Temporary: Cloudflare auth URL during login flow | +| `/root/data/.cloudflared/cert.pem` | Cloudflare origin certificate (written by login) | -## Cloning +## Installation and First-Run Flow -Clone the repository locally. +1. **Login to Cloudflare** - Run the "Login to Cloudflare" action. A Cloudflare authorization URL is returned. Visit it in your browser and select the DNS zone you want to use. +2. **Select Tunnel** - Run the "Select Tunnel" action. Choose an existing tunnel or create a new one. The tunnel token is retrieved and stored automatically. +3. The service starts and begins proxying traffic through your tunnel. -``` -git clone git@github.com:remcoros/cloudflared-startos.git -cd cloudflared-startos -``` +No manual token management is required. -## Building +## Configuration Management -To build the **Cloudflare Tunnel** service as a universal package, run the following command: +- The tunnel token is retrieved automatically via `cloudflared tunnel token` after tunnel selection - no manual token input needed. +- Ingress rules are stored in `start9/config.yaml` and written to `start9/tunnel.yaml` before each start. +- Dashboard-configured public hostnames are fetched from the Cloudflare API on each start and merged with locally-configured ones. Local rules take precedence if a hostname appears in both. +- Cloudflare credentials (`cert.pem`) are stored in the `main` volume at `.cloudflared/cert.pem`. -``` -make -``` +## Network Access and Interfaces -## Installing (on StartOS) +- **Metrics** - Prometheus metrics endpoint at `http://cloudflared.startos:20241/metrics` (internal only, not exposed publicly). +- All public traffic is routed inbound through the Cloudflare edge. No inbound ports need to be opened on your router. -Before installation, define `host: https://server-name.local` in your `~/.embassy/config.yaml` config file then run the following commands to determine successful install: +## Actions -> Change server-name.local to your Start9 server address +| Action | When available | Purpose | +|---|---|---| +| Login to Cloudflare | Always | Start the Cloudflare login flow; returns an authorization URL to visit in your browser | +| Select Tunnel | When logged in | Choose an existing tunnel or create a new one | -``` -start-cli auth login -#Enter your StartOS password -make install -``` +## URL Plugin -**Tip:** You can also install the cloudflared.s9pk by sideloading it under the **StartOS > System > Sideload a Service** section. +Cloudflare Tunnel registers as a `url-v0` URL plugin. This means any other installed service can add a public Cloudflare hostname directly from its URL list. + +**Adding a hostname:** +- Open any service → URLs → Add URL → select Cloudflare Tunnel +- Enter a hostname (e.g. `myapp.example.com`) - pre-filled with `packageid.yourdomain.com` if a zone is configured +- If logged in, a DNS CNAME record is created automatically pointing to your tunnel +- If not logged in, create the CNAME manually: `hostname → .cfargotunnel.com` (proxied) + +**Removing a hostname:** +- Open the service → URLs → remove the Cloudflare URL +- The ingress rule and DNS CNAME record are removed automatically + +**Dashboard hostnames:** +- Public hostnames configured in the Cloudflare Zero Trust dashboard are automatically merged into the tunnel config on each start. They do not appear in the StartOS URL list but work normally. + +## Backups and Restore + +The entire `main` volume is backed up, including `cert.pem`, the tunnel token, zone info, and all ingress entries. After restore, the service starts immediately with all previous configuration intact. + +## Health Checks + +- **Cloudflare tunnel** - polls `http://cloudflared.startos:20241/metrics` every 30 seconds +- Service is considered healthy when the metrics endpoint responds successfully + +## Dependencies -## Verify Install +None. -Go to your StartOS Services page, select **Cloudflare Tunnel**, configure and start the service. +## Limitations and Differences -**Done!** +1. **Single DNS zone** - logging in authorizes one Cloudflare DNS zone. Hostnames on other zones can still be added but DNS records must be created manually for those zones. +2. **No tunnel management UI** - tunnels are managed via the StartOS actions interface, not a web UI. For advanced tunnel configuration, use the Cloudflare Zero Trust dashboard. +3. **Local config takes precedence over dashboard** - if a hostname is configured both locally and in the dashboard, the local entry wins. Dashboard-only entries are merged in automatically. +4. **Autoupdate disabled** - `--no-autoupdate` is set; updates are delivered via new package versions. +5. **Metrics endpoint is internal only** - the Prometheus metrics endpoint is not proxied through the tunnel. diff --git a/startos/actions/addPublicHostname.ts b/startos/actions/addPublicHostname.ts index 1de6842..41659fb 100644 --- a/startos/actions/addPublicHostname.ts +++ b/startos/actions/addPublicHostname.ts @@ -83,7 +83,7 @@ export const addPublicHostname = sdk.Action.withInput( }, }) const updated = await store.read().once() - await writeTunnelConfig(effects, updated?.ingress ?? {}) + await writeTunnelConfig(effects, updated ?? { ingress: {}, tunnel: null, zoneInfo: null }) // Create DNS route automatically if logged in, otherwise log manual instructions let certExists = false @@ -114,14 +114,14 @@ export const addPublicHostname = sdk.Action.withInput( if (result.stderr) console.info(result.stderr) if (result.exitCode !== 0) { console.error( - `DNS route creation failed. Add CNAME manually: ${hostname} → ${credentials.TunnelID}.cfargotunnel.com`, + `DNS route creation failed. Add CNAME manually: ${hostname} -> ${credentials.TunnelID}.cfargotunnel.com`, ) } }, ) } else { console.info( - `Not logged in — add CNAME manually: ${hostname} → ${credentials.TunnelID}.cfargotunnel.com`, + `Not logged in - add CNAME manually: ${hostname} -> ${credentials.TunnelID}.cfargotunnel.com`, ) } diff --git a/startos/actions/cloudflareLogin.ts b/startos/actions/cloudflareLogin.ts index 77d5fae..1eaa023 100644 --- a/startos/actions/cloudflareLogin.ts +++ b/startos/actions/cloudflareLogin.ts @@ -1,4 +1,5 @@ import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' import { certPem } from '../fileModels/tunnel.yaml' const LOGIN_URL_PATH = '/start9/login-url.txt' @@ -22,11 +23,16 @@ export const cloudflareLogin = sdk.Action.withoutInput( async ({ effects }) => { const loggedIn = !!(await certPem.read().const(effects)) + const conf = await store.read().const(effects) + const zoneName = conf?.zoneInfo?.zoneName + const nameLabel = loggedIn + ? `Cloudflare Account: Logged in${zoneName ? ` (${zoneName})` : ''}` + : 'Cloudflare Account: Not logged in' return { - name: `Login to Cloudflare (currently ${loggedIn ? '' : 'not '}logged in)`, + name: nameLabel, description: 'Authenticates with your Cloudflare account so DNS routes can be created automatically. ' + - 'Returns an authorization URL — visit it in your browser to complete login.', + 'Returns an authorization URL - visit it in your browser to complete login.', warning: null, allowedStatuses: 'any', group: 'Configuration', @@ -38,7 +44,7 @@ export const cloudflareLogin = sdk.Action.withoutInput( // Clear any stale URL file before starting await sdk.volumes.main.writeFile(LOGIN_URL_PATH, 'pending').catch(() => {}) - // Fire and forget — cf-login.sh starts cloudflared login, extracts the auth URL, + // Fire and forget - cf-login.sh starts cloudflared login, extracts the auth URL, // writes it to the volume, then waits up to 10 min for auth to complete sdk.SubContainer.withTemp(effects, { imageId: 'main' }, mounts, 'cf-login', async (sub) => { diff --git a/startos/actions/deletePublicHostname.ts b/startos/actions/deletePublicHostname.ts index 1b8e3e4..7b990e4 100644 --- a/startos/actions/deletePublicHostname.ts +++ b/startos/actions/deletePublicHostname.ts @@ -26,7 +26,7 @@ export const deletePublicHostname = sdk.Action.withInput( async () => ({ name: 'Delete Public Hostname', description: 'Remove a Cloudflare public hostname route', - warning: 'This will stop routing traffic from this hostname to the service.', + warning: 'This will remove the hostname from the tunnel config and delete the DNS record from Cloudflare.', allowedStatuses: 'any', group: null, visibility: 'hidden', @@ -42,18 +42,64 @@ export const deletePublicHostname = sdk.Action.withInput( async ({ effects, input }) => { const { hostname } = input.urlPluginMetadata - // Remove from store (undefined → merge removes the key) + // Remove from store await store.merge(effects, { ingress: { [hostname]: undefined } as any, }) // Regenerate config file const updated = await store.read().once() - await writeTunnelConfig(effects, updated?.ingress ?? {}) + await writeTunnelConfig(effects, updated ?? { ingress: {}, tunnel: null, zoneInfo: null }) + + // Delete the DNS CNAME record from Cloudflare if we have credentials + const zoneInfo = updated?.zoneInfo + if (zoneInfo) { + try { + // Look up the DNS record ID by hostname + const listResp = await fetch( + `https://api.cloudflare.com/client/v4/zones/${zoneInfo.zoneId}/dns_records?name=${hostname}&type=CNAME`, + { + headers: { + Authorization: `Bearer ${zoneInfo.apiToken}`, + 'Content-Type': 'application/json', + }, + }, + ) + const listData = (await listResp.json()) as any + const records: Array<{ id: string }> = listData.result ?? [] + + for (const record of records) { + const delResp = await fetch( + `https://api.cloudflare.com/client/v4/zones/${zoneInfo.zoneId}/dns_records/${record.id}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${zoneInfo.apiToken}`, + 'Content-Type': 'application/json', + }, + }, + ) + const delData = (await delResp.json()) as any + if (delData.success) { + console.info(`DNS CNAME record deleted for ${hostname}`) + } else { + console.error(`Failed to delete DNS record for ${hostname}: ${JSON.stringify(delData.errors)}`) + } + } + + if (records.length === 0) { + console.info(`No DNS CNAME record found for ${hostname} - nothing to delete`) + } + } catch (e) { + console.error(`Error deleting DNS record for ${hostname}: ${String(e)}`) + } + } else { + console.info(`No zone credentials available - DNS record for ${hostname} must be deleted manually`) + } // Restart the daemon so cloudflared picks up the change await effects.restart() - console.info(`Public hostname ${hostname} removed.`) + console.info(`Public hostname ${hostname} removed`) }, ) diff --git a/startos/actions/selectTunnel.ts b/startos/actions/selectTunnel.ts index 9b4e221..aae3ab5 100644 --- a/startos/actions/selectTunnel.ts +++ b/startos/actions/selectTunnel.ts @@ -61,9 +61,12 @@ export const selectTunnel = sdk.Action.withInput( } } const conf = await store.read().const(effects) - const current = conf?.tunnel?.name ?? 'none' + const current = conf?.tunnel?.name + const nameLabel = current + ? `Cloudflare Tunnel: ${current}` + : 'Cloudflare Tunnel: Not selected' return { - name: `Select Tunnel (currently: ${current})`, + name: nameLabel, description: 'Choose which Cloudflare tunnel this server runs. You can select an existing tunnel or create a new one.', warning: null, @@ -122,29 +125,29 @@ export const selectTunnel = sdk.Action.withInput( const selection = (input.tunnel as { selection: string; value: { name?: string } }) let tunnelId: string let tunnelName: string + let token: string if (selection.selection === 'new') { const name = selection.value.name?.trim() if (!name) throw new Error('Tunnel name is required.') - // Create the tunnel + // Create the tunnel - response includes the token directly const stdout = await runCf(effects, ['tunnel', 'create', '--output', 'json', name]) const created = JSON.parse(stdout.trim()) tunnelId = created.id tunnelName = created.name - console.info(`Created tunnel: ${tunnelName} (${tunnelId})`) + token = created.token } else { tunnelId = selection.selection - // Find name from the list + // Find name from the cached list const listOut = await runCf(effects, ['tunnel', 'list', '--output', 'json']) const tunnels = parseTunnelList(listOut) const found = tunnels.find((t) => t.id === tunnelId) tunnelName = found?.name ?? tunnelId + // Fetch token for existing tunnel + token = (await runCf(effects, ['tunnel', 'token', tunnelId])).trim() } - // Fetch the token for this tunnel and store it - const token = (await runCf(effects, ['tunnel', 'token', tunnelId])).trim() - await store.merge(effects, { tunnel: { id: tunnelId, name: tunnelName }, token, diff --git a/startos/fileModels/tunnel.yaml.ts b/startos/fileModels/tunnel.yaml.ts index acb4e3d..73507ca 100644 --- a/startos/fileModels/tunnel.yaml.ts +++ b/startos/fileModels/tunnel.yaml.ts @@ -1,10 +1,10 @@ import { T } from '@start9labs/start-sdk' import { sdk } from '../sdk' -import { IngressEntry } from './store.yaml' +import { IngressEntry, StoreType } from './store.yaml' import { FileHelper } from '@start9labs/start-sdk' /** - * FileHelper for cert.pem — used only for .const() reactive watching. + * FileHelper for cert.pem - used only for .const() reactive watching. */ export const certPem = FileHelper.string({ base: sdk.volumes.main, @@ -13,23 +13,78 @@ export const certPem = FileHelper.string({ export const TUNNEL_CONFIG_PATH = '/start9/tunnel.yaml' +/** + * Fetch ingress rules from the Cloudflare API (dashboard-managed config). + * Returns array of { hostname?, service } - excludes the catch-all. + */ +async function fetchRemoteIngress( + accountId: string, + tunnelId: string, + apiToken: string, +): Promise> { + try { + const resp = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }, + }, + ) + const data = (await resp.json()) as any + if (!data.success) return [] + return ( + (data.result?.config?.ingress as Array<{ hostname?: string; service: string }>) ?? [] + ).filter((r) => r.hostname) // exclude catch-all + } catch (e) { + console.error(`Failed to fetch remote ingress: ${String(e)}`) + return [] + } +} + /** * Build and write the cloudflared tunnel config YAML to the volume. - * We only ever write this file — cloudflared reads it once at startup via --config. + * Merges local store ingress with dashboard-configured remote ingress. + * Local rules take precedence; remote-only rules are appended. + * cloudflared reads this once at startup via --config. */ export async function writeTunnelConfig( effects: T.Effects, - ingress: Record, + conf: Pick, ): Promise { + const localIngress = conf.ingress ?? {} + + // Fetch remote (dashboard) ingress if we have credentials + let remoteRules: Array<{ hostname: string; service: string }> = [] + if (conf.tunnel && conf.zoneInfo) { + const raw = await fetchRemoteIngress( + conf.zoneInfo.accountId, + conf.tunnel.id, + conf.zoneInfo.apiToken, + ) + remoteRules = raw.filter((r): r is { hostname: string; service: string } => !!r.hostname) + } + const lines: string[] = ['ingress:'] - for (const [hostname, entry] of Object.entries(ingress)) { + // Local rules first (take precedence) + const localHostnames = new Set() + for (const [hostname, entry] of Object.entries(localIngress)) { if (!entry) continue lines.push(` - hostname: ${hostname}`) lines.push(` service: ${entry.service}`) + localHostnames.add(hostname) + } + + // Remote rules that aren't already covered locally + for (const rule of remoteRules) { + if (localHostnames.has(rule.hostname)) continue + lines.push(` - hostname: ${rule.hostname}`) + lines.push(` service: ${rule.service}`) } - // Required catch-all rule + // Required catch-all lines.push(' - service: http_status:404') lines.push('') diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index e5b0c98..ac03cd8 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -2,9 +2,9 @@ export const DEFAULT_LANG = 'en_US' const dict = { // main.ts - 'Cloudflare tunnel client': 1, - 'Cloudflare tunnel client is running': 2, - 'Cloudflare tunnel client is not running': 3, + 'Cloudflare tunnel': 1, + 'Cloudflare tunnel is running': 2, + 'Cloudflare tunnel is not running': 3, // interfaces.ts 'Metrics': 100, diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index 54e09f0..87c7a1f 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -3,9 +3,9 @@ import { LangDict } from './default' export default { es_ES: { // main.ts - 1: 'Cliente del túnel Cloudflare', - 2: 'El cliente del túnel Cloudflare está ejecutándose', - 3: 'El cliente del túnel Cloudflare no está ejecutándose', + 1: 'Túnel Cloudflare', + 2: 'El túnel Cloudflare está ejecutándose', + 3: 'El túnel Cloudflare no está ejecutándose', // interfaces.ts 100: 'Métricas', @@ -13,9 +13,9 @@ export default { }, de_DE: { // main.ts - 1: 'Cloudflare-Tunnel-Client', - 2: 'Cloudflare-Tunnel-Client läuft', - 3: 'Cloudflare-Tunnel-Client läuft nicht', + 1: 'Cloudflare-Tunnel', + 2: 'Cloudflare-Tunnel läuft', + 3: 'Cloudflare-Tunnel läuft nicht', // interfaces.ts 100: 'Metriken', @@ -23,9 +23,9 @@ export default { }, pl_PL: { // main.ts - 1: 'Klient tunelu Cloudflare', - 2: 'Klient tunelu Cloudflare jest uruchomiony', - 3: 'Klient tunelu Cloudflare nie jest uruchomiony', + 1: 'Tunel Cloudflare', + 2: 'Tunel Cloudflare jest uruchomiony', + 3: 'Tunel Cloudflare nie jest uruchomiony', // interfaces.ts 100: 'Metryki', @@ -33,7 +33,7 @@ export default { }, fr_FR: { // main.ts - 1: 'Client de tunnel Cloudflare', + 1: 'Tunnel Cloudflare', 2: 'Le client de tunnel Cloudflare est en cours d\'exécution', 3: 'Le client de tunnel Cloudflare n\'est pas en cours d\'exécution', diff --git a/startos/init/setupZoneInfo.ts b/startos/init/setupZoneInfo.ts index 2aed0ac..d17fbc6 100644 --- a/startos/init/setupZoneInfo.ts +++ b/startos/init/setupZoneInfo.ts @@ -10,7 +10,7 @@ import { store, ZoneInfo } from '../fileModels/store.yaml' export const setupZoneInfo = sdk.setupOnInit(async (effects) => { const cert = await certPem.read().const(effects) if (!cert) { - // Not logged in — clear any stale zone info + // Not logged in - clear any stale zone info const conf = await store.read().once() if (conf?.zoneInfo) { await store.merge(effects, { zoneInfo: null }) diff --git a/startos/init/writeConfig.ts b/startos/init/writeConfig.ts index 7c4c30c..36f42af 100644 --- a/startos/init/writeConfig.ts +++ b/startos/init/writeConfig.ts @@ -8,5 +8,5 @@ import { writeTunnelConfig } from '../fileModels/tunnel.yaml' */ export const writeConfig = sdk.setupOnInit(async (effects) => { const conf = await store.read().once() - await writeTunnelConfig(effects, conf?.ingress ?? {}) + await writeTunnelConfig(effects, conf ?? { ingress: {}, tunnel: null, zoneInfo: null }) }) diff --git a/startos/install/versions/v2026.3.0.ts b/startos/install/versions/v2026.3.0.ts index f1b879c..2d22635 100644 --- a/startos/install/versions/v2026.3.0.ts +++ b/startos/install/versions/v2026.3.0.ts @@ -1,9 +1,9 @@ import { VersionInfo } from '@start9labs/start-sdk' export const v2026_3_0 = VersionInfo.of({ - version: '2026.3.0:1.0', + version: '2026.3.0:2.0', releaseNotes: { - en_US: 'Updated cloudflared to 2026.3.0 - [Changelog](https://github.com/cloudflare/cloudflared/blob/2026.3.0/RELEASE_NOTES)', + en_US: 'Revamped setup: login to Cloudflare, select/create tunnel, automatic DNS routes, and URL plugin for exposing services via public hostnames', }, migrations: { up: async ({ effects }) => {}, diff --git a/startos/main.ts b/startos/main.ts index 9034748..efe2faf 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -9,7 +9,7 @@ export const main = sdk.setupMain(async ({ effects }) => { const conf = (await store.read().const(effects))! if (!conf.token) { - console.info('No tunnel token configured — waiting for tunnel selection') + console.info('No tunnel token configured - waiting for tunnel selection') return sdk.Daemons.of(effects) } @@ -51,14 +51,14 @@ export const main = sdk.setupMain(async ({ effects }) => { }, }, ready: { - display: i18n('Cloudflare tunnel client'), + display: i18n('Cloudflare tunnel'), fn: () => sdk.healthCheck.checkWebUrl( effects, 'http://cloudflared.startos:20241/metrics', { - successMessage: i18n('Cloudflare tunnel client is running'), - errorMessage: i18n('Cloudflare tunnel client is not running'), + successMessage: i18n('Cloudflare tunnel is running'), + errorMessage: i18n('Cloudflare tunnel is not running'), }, ), }, From e7aaa2db2da62733b0adde54005875045884f654 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 16:13:41 +0200 Subject: [PATCH 20/36] refactor: consolidate versions --- startos/index.ts | 2 +- startos/init/index.ts | 2 +- startos/install/versionGraph.ts | 7 ------- startos/install/versions/index.ts | 10 ---------- startos/install/versions/v2025.10.1.ts | 10 ---------- startos/install/versions/v2025.11.1.ts | 12 ------------ startos/install/versions/v2025.8.1.ts | 10 ---------- startos/install/versions/v2025.9.1.ts | 10 ---------- startos/install/versions/v2026.2.0.ts | 12 ------------ startos/manifest/index.ts | 2 +- startos/versions/index.ts | 9 +++++++++ startos/{install => }/versions/v2026.3.0.ts | 6 +++--- 12 files changed, 15 insertions(+), 77 deletions(-) delete mode 100644 startos/install/versionGraph.ts delete mode 100644 startos/install/versions/index.ts delete mode 100644 startos/install/versions/v2025.10.1.ts delete mode 100644 startos/install/versions/v2025.11.1.ts delete mode 100644 startos/install/versions/v2025.8.1.ts delete mode 100644 startos/install/versions/v2025.9.1.ts delete mode 100644 startos/install/versions/v2026.2.0.ts create mode 100644 startos/versions/index.ts rename startos/{install => }/versions/v2026.3.0.ts (70%) diff --git a/startos/index.ts b/startos/index.ts index 8fef41f..11acb95 100644 --- a/startos/index.ts +++ b/startos/index.ts @@ -7,5 +7,5 @@ export { init, uninit } from './init' export { actions } from './actions' import { buildManifest } from '@start9labs/start-sdk' import { manifest as sdkManifest } from './manifest/index' -import { versionGraph } from './install/versionGraph' +import { versionGraph } from './versions' export const manifest = buildManifest(versionGraph, sdkManifest) \ No newline at end of file diff --git a/startos/init/index.ts b/startos/init/index.ts index 88c3e62..c1f4d53 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -1,6 +1,6 @@ import { sdk } from '../sdk' import { setInterfaces } from '../interfaces' -import { versionGraph } from '../install/versionGraph' +import { versionGraph } from '../versions' import { actions } from '../actions' import { restoreInit } from '../backups' import { seedStore } from './seedStore' diff --git a/startos/install/versionGraph.ts b/startos/install/versionGraph.ts deleted file mode 100644 index 5a4fad7..0000000 --- a/startos/install/versionGraph.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { VersionGraph } from '@start9labs/start-sdk' -import { current, other } from './versions' - -export const versionGraph = VersionGraph.of({ - current, - other, -}) diff --git a/startos/install/versions/index.ts b/startos/install/versions/index.ts deleted file mode 100644 index 198c3e2..0000000 --- a/startos/install/versions/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { v2025_8_1 } from './v2025.8.1' -import { v2025_9_1 } from './v2025.9.1' -import { v2025_10_1 } from './v2025.10.1' -import { v2025_11_1 } from './v2025.11.1' -import { v2026_2_0 } from './v2026.2.0' -import { v2026_3_0 } from './v2026.3.0' - -export { v2026_3_0 as current } -export const other = [v2025_8_1, v2025_9_1, v2025_10_1, v2025_11_1, v2026_2_0] -export const CLOUDFLARED_VERSION = '2026.3.0' \ No newline at end of file diff --git a/startos/install/versions/v2025.10.1.ts b/startos/install/versions/v2025.10.1.ts deleted file mode 100644 index 8841904..0000000 --- a/startos/install/versions/v2025.10.1.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { VersionInfo } from '@start9labs/start-sdk' - -export const v2025_10_1 = VersionInfo.of({ - version: '2025.10.1:1.0', - releaseNotes: { en_US: 'Updated cloudflared to 2025.10.1' }, - migrations: { - up: async ({ effects }) => {}, - down: async ({ effects }) => {}, - }, -}) diff --git a/startos/install/versions/v2025.11.1.ts b/startos/install/versions/v2025.11.1.ts deleted file mode 100644 index 905f66c..0000000 --- a/startos/install/versions/v2025.11.1.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { VersionInfo } from '@start9labs/start-sdk' - -export const v2025_11_1 = VersionInfo.of({ - version: '2025.11.1:1.0', - releaseNotes: { - en_US: 'Updated cloudflared to 2025.11.1 - [Changelog](https://github.com/cloudflare/cloudflared/blob/2025.11.1/RELEASE_NOTES)', - }, - migrations: { - up: async ({ effects }) => {}, - down: async ({ effects }) => {}, - }, -}) diff --git a/startos/install/versions/v2025.8.1.ts b/startos/install/versions/v2025.8.1.ts deleted file mode 100644 index 758db08..0000000 --- a/startos/install/versions/v2025.8.1.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' - -export const v2025_8_1 = VersionInfo.of({ - version: '2025.8.1:1.0', - releaseNotes: { en_US: 'Revamped for StartOS 0.4.0' }, - migrations: { - up: async ({ effects }) => {}, - down: IMPOSSIBLE, - }, -}) diff --git a/startos/install/versions/v2025.9.1.ts b/startos/install/versions/v2025.9.1.ts deleted file mode 100644 index 3d9ec98..0000000 --- a/startos/install/versions/v2025.9.1.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { VersionInfo } from '@start9labs/start-sdk' - -export const v2025_9_1 = VersionInfo.of({ - version: '2025.9.1:1.0', - releaseNotes: { en_US: 'Revamped for StartOS 0.4.0' }, - migrations: { - up: async ({ effects }) => {}, - down: async ({ effects }) => {}, - }, -}) diff --git a/startos/install/versions/v2026.2.0.ts b/startos/install/versions/v2026.2.0.ts deleted file mode 100644 index e5c80bf..0000000 --- a/startos/install/versions/v2026.2.0.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { VersionInfo } from '@start9labs/start-sdk' - -export const v2026_2_0 = VersionInfo.of({ - version: '2026.2.0:1.0', - releaseNotes: { - en_US: 'Updated cloudflared to 2026.2.0 - [Changelog](https://github.com/cloudflare/cloudflared/blob/2026.2.0/RELEASE_NOTES)', - }, - migrations: { - up: async ({ effects }) => {}, - down: async ({ effects }) => {}, - }, -}) diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index 11f0d70..4e38b86 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -1,5 +1,5 @@ import { setupManifest } from '@start9labs/start-sdk' -import { CLOUDFLARED_VERSION } from '../install/versions' +import { CLOUDFLARED_VERSION } from '../versions' export const manifest = setupManifest({ id: 'cloudflared', diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 0000000..74f77a2 --- /dev/null +++ b/startos/versions/index.ts @@ -0,0 +1,9 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { v2026_3_0 } from './v2026.3.0' + +export const versionGraph = VersionGraph.of({ + current: v2026_3_0, + other: [], +}) + +export const CLOUDFLARED_VERSION = '2026.3.0' diff --git a/startos/install/versions/v2026.3.0.ts b/startos/versions/v2026.3.0.ts similarity index 70% rename from startos/install/versions/v2026.3.0.ts rename to startos/versions/v2026.3.0.ts index 2d22635..a62e2a7 100644 --- a/startos/install/versions/v2026.3.0.ts +++ b/startos/versions/v2026.3.0.ts @@ -1,12 +1,12 @@ -import { VersionInfo } from '@start9labs/start-sdk' +import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v2026_3_0 = VersionInfo.of({ - version: '2026.3.0:2.0', + version: '2026.3.0:1.0', releaseNotes: { en_US: 'Revamped setup: login to Cloudflare, select/create tunnel, automatic DNS routes, and URL plugin for exposing services via public hostnames', }, migrations: { up: async ({ effects }) => {}, - down: async ({ effects }) => {}, + down: IMPOSSIBLE, }, }) From 60f435b19516c3c7dae7df0baefd55f094b04590 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 17:00:42 +0200 Subject: [PATCH 21/36] feat: switch to CF API for ingress management, drop local tunnel.yaml --- startos/actions/addPublicHostname.ts | 122 ++++++++++++++++++------ startos/actions/cloudflareLogin.ts | 2 +- startos/actions/deletePublicHostname.ts | 59 +++--------- startos/actions/selectTunnel.ts | 19 ++-- startos/cfApi.ts | 109 +++++++++++++++++++++ startos/fileModels/certPem.ts | 11 +++ startos/fileModels/store.yaml.ts | 2 - startos/fileModels/tunnel.yaml.ts | 92 ------------------ startos/init/index.ts | 2 - startos/init/setupTasks.ts | 2 +- startos/init/setupZoneInfo.ts | 2 +- startos/init/writeConfig.ts | 12 --- startos/main.ts | 16 ++-- startos/tunnelToken.ts | 29 ------ 14 files changed, 247 insertions(+), 232 deletions(-) create mode 100644 startos/cfApi.ts create mode 100644 startos/fileModels/certPem.ts delete mode 100644 startos/fileModels/tunnel.yaml.ts delete mode 100644 startos/init/writeConfig.ts delete mode 100644 startos/tunnelToken.ts diff --git a/startos/actions/addPublicHostname.ts b/startos/actions/addPublicHostname.ts index 41659fb..a6c6b74 100644 --- a/startos/actions/addPublicHostname.ts +++ b/startos/actions/addPublicHostname.ts @@ -1,9 +1,8 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { writeTunnelConfig } from '../fileModels/tunnel.yaml' -import { decodeTunnelToken } from '../tunnelToken' +import { pushIngressToApi } from '../cfApi' -const { InputSpec, Value } = sdk +const { InputSpec, Value, Variants } = sdk const CERT_PATH = '/root/.cloudflared/cert.pem' @@ -14,22 +13,42 @@ const inputSpec = InputSpec.of({ hostId: string internalPort: number }>(), - hostname: Value.text({ - name: 'Public Hostname', - description: - 'The public hostname to route to this service (e.g. myapp.example.com). Must be on a domain managed by Cloudflare.', + subdomain: Value.text({ + name: 'Subdomain', + description: 'The subdomain to route to this service (e.g. myapp).', required: true, - default: '', - placeholder: 'myapp.example.com', + default: null, + placeholder: 'myapp', masked: false, - inputmode: 'url', + inputmode: 'text', patterns: [ { - regex: '^[a-zA-Z0-9][a-zA-Z0-9\\-\\.]+\\.[a-zA-Z]{2,}$', - description: 'Must be a valid hostname (e.g. myapp.example.com)', + regex: '^[a-zA-Z0-9][a-zA-Z0-9\\-]*$', + description: 'Subdomain only, no dots (e.g. myapp)', }, ], }), + domain: Value.dynamicUnion(async ({ effects }) => { + const conf = await store.read().once() + const zones: Record }> = {} + + if (conf?.zoneInfo?.zoneName) { + const key = conf.zoneInfo.zoneId + zones[key] = { name: conf.zoneInfo.zoneName, spec: InputSpec.of({}) } + } + + // Fallback if no zone info yet + if (Object.keys(zones).length === 0) { + zones['manual'] = { name: 'Login to Cloudflare to see your domains', spec: InputSpec.of({}) } + } + + return { + name: 'Domain', + default: Object.keys(zones)[0], + disabled: false, + variants: Variants.of(zones), + } + }), }) export const addPublicHostname = sdk.Action.withInput( @@ -45,32 +64,50 @@ export const addPublicHostname = sdk.Action.withInput( }), inputSpec, + + // pre-fill subdomain from packageId async ({ effects, prefill }) => { const p = prefill as typeof inputSpec._PARTIAL - const conf = await store.read().once() - const zone = conf?.zoneInfo?.zoneName const suggestedHost = p?.urlPluginMetadata?.packageId - const suggested = - zone && suggestedHost && suggestedHost !== 'STARTOS' - ? `${suggestedHost}.${zone}` - : undefined - return suggested ? { hostname: suggested } : null + return suggestedHost && suggestedHost !== 'STARTOS' + ? { subdomain: suggestedHost } + : null }, async ({ effects, input }) => { const { packageId, internalPort, interfaceId, hostId } = input.urlPluginMetadata - const hostname = input.hostname.trim().toLowerCase() + const subdomain = input.subdomain.trim().toLowerCase() + const domainSelection = (input.domain as { selection: string; value: {} }) + + const conf = await store.read().once() + + // Resolve the domain name from the selection + const zoneName = conf?.zoneInfo?.zoneName + if (!zoneName) { + return { + version: '1' as const, + title: 'Not Logged In', + message: 'Login to Cloudflare first (run "Cloudflare Account" action), then add a public hostname.', + result: null, + } + } + + const hostname = `${subdomain}.${zoneName}` const host = packageId === 'STARTOS' ? 'startos' : `${packageId}.startos` const service = `http://${host}:${internalPort}` - const conf = await store.read().once() - if (!conf?.token) { - throw new Error('No tunnel token configured. Run "Set Authentication Token" first.') + if (!conf?.tunnel) { + return { + version: '1' as const, + title: 'No Tunnel Configured', + message: 'Select a Cloudflare tunnel first (run "Cloudflare Tunnel" action).', + result: null, + } } - const credentials = decodeTunnelToken(conf.token) + const tunnelId = conf.tunnel.id - // Persist ingress entry and regenerate config + // Persist ingress entry and push to Cloudflare API await store.merge(effects, { ingress: { [hostname]: { @@ -83,15 +120,23 @@ export const addPublicHostname = sdk.Action.withInput( }, }) const updated = await store.read().once() - await writeTunnelConfig(effects, updated ?? { ingress: {}, tunnel: null, zoneInfo: null }) + if (conf.zoneInfo) { + await pushIngressToApi( + conf.zoneInfo.accountId, + tunnelId, + conf.zoneInfo.apiToken, + updated?.ingress ?? {}, + ) + } - // Create DNS route automatically if logged in, otherwise log manual instructions + // Create DNS route automatically if logged in let certExists = false try { await sdk.volumes.main.readFile('/.cloudflared/cert.pem') certExists = true } catch {} + let dnsCreated = false if (certExists) { await sdk.SubContainer.withTemp( effects, @@ -106,7 +151,7 @@ export const addPublicHostname = sdk.Action.withInput( '/usr/local/bin/cloudflared', '--no-autoupdate', `--origincert=${CERT_PATH}`, 'tunnel', 'route', 'dns', '--overwrite-dns', - credentials.TunnelID, hostname, + tunnelId, hostname, ], {}, 30_000, ) @@ -114,17 +159,34 @@ export const addPublicHostname = sdk.Action.withInput( if (result.stderr) console.info(result.stderr) if (result.exitCode !== 0) { console.error( - `DNS route creation failed. Add CNAME manually: ${hostname} -> ${credentials.TunnelID}.cfargotunnel.com`, + `DNS route creation failed. Add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`, ) + } else { + dnsCreated = true } }, ) } else { console.info( - `Not logged in - add CNAME manually: ${hostname} -> ${credentials.TunnelID}.cfargotunnel.com`, + `Not logged in - add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`, ) } await effects.restart() + + return { + version: '1' as const, + title: 'Public Hostname Added', + message: dnsCreated + ? `${hostname} is now routed to this service. DNS record created automatically.` + : `${hostname} is now routed to this service. Add a CNAME record manually: ${hostname} -> ${tunnelId}.cfargotunnel.com (proxied).`, + result: { + type: 'single' as const, + value: `https://${hostname}`, + copyable: true, + qr: false, + masked: false, + }, + } }, ) diff --git a/startos/actions/cloudflareLogin.ts b/startos/actions/cloudflareLogin.ts index 1eaa023..4f240f5 100644 --- a/startos/actions/cloudflareLogin.ts +++ b/startos/actions/cloudflareLogin.ts @@ -1,6 +1,6 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { certPem } from '../fileModels/tunnel.yaml' +import { certPem } from '../fileModels/certPem' const LOGIN_URL_PATH = '/start9/login-url.txt' diff --git a/startos/actions/deletePublicHostname.ts b/startos/actions/deletePublicHostname.ts index 7b990e4..a4ef625 100644 --- a/startos/actions/deletePublicHostname.ts +++ b/startos/actions/deletePublicHostname.ts @@ -1,6 +1,6 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { writeTunnelConfig } from '../fileModels/tunnel.yaml' +import { pushIngressToApi, deleteDnsRecord } from '../cfApi' const { InputSpec, Value } = sdk @@ -42,59 +42,26 @@ export const deletePublicHostname = sdk.Action.withInput( async ({ effects, input }) => { const { hostname } = input.urlPluginMetadata - // Remove from store + // Remove from store and push updated ingress to CF API await store.merge(effects, { ingress: { [hostname]: undefined } as any, }) - - // Regenerate config file const updated = await store.read().once() - await writeTunnelConfig(effects, updated ?? { ingress: {}, tunnel: null, zoneInfo: null }) + if (updated?.zoneInfo && updated.tunnel) { + await pushIngressToApi( + updated.zoneInfo.accountId, + updated.tunnel.id, + updated.zoneInfo.apiToken, + updated.ingress ?? {}, + ) + } - // Delete the DNS CNAME record from Cloudflare if we have credentials + // Delete DNS record const zoneInfo = updated?.zoneInfo if (zoneInfo) { - try { - // Look up the DNS record ID by hostname - const listResp = await fetch( - `https://api.cloudflare.com/client/v4/zones/${zoneInfo.zoneId}/dns_records?name=${hostname}&type=CNAME`, - { - headers: { - Authorization: `Bearer ${zoneInfo.apiToken}`, - 'Content-Type': 'application/json', - }, - }, - ) - const listData = (await listResp.json()) as any - const records: Array<{ id: string }> = listData.result ?? [] - - for (const record of records) { - const delResp = await fetch( - `https://api.cloudflare.com/client/v4/zones/${zoneInfo.zoneId}/dns_records/${record.id}`, - { - method: 'DELETE', - headers: { - Authorization: `Bearer ${zoneInfo.apiToken}`, - 'Content-Type': 'application/json', - }, - }, - ) - const delData = (await delResp.json()) as any - if (delData.success) { - console.info(`DNS CNAME record deleted for ${hostname}`) - } else { - console.error(`Failed to delete DNS record for ${hostname}: ${JSON.stringify(delData.errors)}`) - } - } - - if (records.length === 0) { - console.info(`No DNS CNAME record found for ${hostname} - nothing to delete`) - } - } catch (e) { - console.error(`Error deleting DNS record for ${hostname}: ${String(e)}`) - } + await deleteDnsRecord(zoneInfo.zoneId, hostname, zoneInfo.apiToken) } else { - console.info(`No zone credentials available - DNS record for ${hostname} must be deleted manually`) + console.info(`No zone credentials - delete DNS record for ${hostname} manually`) } // Restart the daemon so cloudflared picks up the change diff --git a/startos/actions/selectTunnel.ts b/startos/actions/selectTunnel.ts index aae3ab5..550c96e 100644 --- a/startos/actions/selectTunnel.ts +++ b/startos/actions/selectTunnel.ts @@ -1,6 +1,6 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { certPem } from '../fileModels/tunnel.yaml' +import { certPem } from '../fileModels/certPem' import { runCf } from '../cfRunner' const { InputSpec, Value, Variants } = sdk @@ -125,32 +125,35 @@ export const selectTunnel = sdk.Action.withInput( const selection = (input.tunnel as { selection: string; value: { name?: string } }) let tunnelId: string let tunnelName: string - let token: string if (selection.selection === 'new') { const name = selection.value.name?.trim() if (!name) throw new Error('Tunnel name is required.') - // Create the tunnel - response includes the token directly + // Create the tunnel - response includes id and name const stdout = await runCf(effects, ['tunnel', 'create', '--output', 'json', name]) const created = JSON.parse(stdout.trim()) tunnelId = created.id tunnelName = created.name - token = created.token } else { tunnelId = selection.selection - // Find name from the cached list const listOut = await runCf(effects, ['tunnel', 'list', '--output', 'json']) const tunnels = parseTunnelList(listOut) const found = tunnels.find((t) => t.id === tunnelId) tunnelName = found?.name ?? tunnelId - // Fetch token for existing tunnel - token = (await runCf(effects, ['tunnel', 'token', tunnelId])).trim() } + // Save credentials JSON to volume (delete first - cloudflared refuses to overwrite) + const credFile = `/root/.cloudflared/${tunnelId}.json` + const credSubpath = `/.cloudflared/${tunnelId}.json` + try { + const { unlink } = await import('node:fs/promises') + await unlink(sdk.volumes.main.subpath(credSubpath)) + } catch { /* file didn't exist, that's fine */ } + await runCf(effects, ['tunnel', 'token', `--cred-file=${credFile}`, tunnelId]) + await store.merge(effects, { tunnel: { id: tunnelId, name: tunnelName }, - token, }) console.info(`Tunnel set to: ${tunnelName} (${tunnelId})`) diff --git a/startos/cfApi.ts b/startos/cfApi.ts new file mode 100644 index 0000000..5081fb8 --- /dev/null +++ b/startos/cfApi.ts @@ -0,0 +1,109 @@ +import { IngressEntry } from './fileModels/store.yaml' + +const CF_API = 'https://api.cloudflare.com/client/v4' + +function authHeaders(apiToken: string) { + return { + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + } +} + +/** + * 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. + */ +export async function pushIngressToApi( + accountId: string, + tunnelId: string, + apiToken: string, + ingress: Record, +): Promise { + const rules: Array<{ hostname?: string; service: string }> = [] + + for (const [hostname, entry] of Object.entries(ingress)) { + if (!entry) continue + rules.push({ hostname, service: entry.service }) + } + + // Required catch-all + rules.push({ service: 'http_status:404' }) + + const resp = await fetch( + `${CF_API}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, + { + method: 'PUT', + headers: authHeaders(apiToken), + body: JSON.stringify({ + config: { + ingress: rules, + 'warp-routing': { enabled: false }, + }, + }), + }, + ) + + const data = (await resp.json()) as any + if (!data.success) { + throw new Error(`CF API error: ${JSON.stringify(data.errors)}`) + } +} + +/** + * Fetch current ingress rules from the Cloudflare API. + * Returns only hostname-bearing rules (excludes catch-all). + */ +export async function fetchIngressFromApi( + accountId: string, + tunnelId: string, + apiToken: string, +): Promise> { + try { + const resp = await fetch( + `${CF_API}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, + { headers: authHeaders(apiToken) }, + ) + const data = (await resp.json()) as any + if (!data.success) return [] + return ( + (data.result?.config?.ingress as Array<{ hostname?: string; service: string }>) ?? [] + ).filter((r): r is { hostname: string; service: string } => !!r.hostname) + } catch (e) { + console.error(`Failed to fetch ingress from CF API: ${String(e)}`) + return [] + } +} + +/** + * Delete a DNS CNAME record for a hostname from Cloudflare DNS. + */ +export async function deleteDnsRecord( + zoneId: string, + hostname: string, + apiToken: string, +): Promise { + const listResp = await fetch( + `${CF_API}/zones/${zoneId}/dns_records?name=${hostname}&type=CNAME`, + { headers: authHeaders(apiToken) }, + ) + const listData = (await listResp.json()) as any + const records: Array<{ id: string }> = listData.result ?? [] + + for (const record of records) { + const delResp = await fetch( + `${CF_API}/zones/${zoneId}/dns_records/${record.id}`, + { method: 'DELETE', headers: authHeaders(apiToken) }, + ) + const delData = (await delResp.json()) as any + if (delData.success) { + console.info(`DNS record deleted for ${hostname}`) + } else { + console.error(`Failed to delete DNS record for ${hostname}: ${JSON.stringify(delData.errors)}`) + } + } + + if (records.length === 0) { + console.info(`No DNS CNAME record found for ${hostname}`) + } +} diff --git a/startos/fileModels/certPem.ts b/startos/fileModels/certPem.ts new file mode 100644 index 0000000..ac72553 --- /dev/null +++ b/startos/fileModels/certPem.ts @@ -0,0 +1,11 @@ +import { FileHelper } from '@start9labs/start-sdk' +import { sdk } from '../sdk' + +/** + * FileHelper for cert.pem - used only for .const() reactive watching. + * When cert.pem is created/deleted, action metadata re-evaluates. + */ +export const certPem = FileHelper.string({ + base: sdk.volumes.main, + subpath: '/.cloudflared/cert.pem', +}) diff --git a/startos/fileModels/store.yaml.ts b/startos/fileModels/store.yaml.ts index 9e566c1..85bc6ca 100644 --- a/startos/fileModels/store.yaml.ts +++ b/startos/fileModels/store.yaml.ts @@ -28,7 +28,6 @@ export const zoneInfoShape = z.object({ export type ZoneInfo = z.infer const shape = z.object({ - token: z.string().catch(''), tunnel: tunnelInfoShape.nullable().catch(null), zoneInfo: zoneInfoShape.nullable().catch(null), ingress: z.record(z.string(), ingressEntryShape.nullable()).catch({}), @@ -48,7 +47,6 @@ export const createDefaultStore = async (effects: T.Effects) => { const conf = await store.read().once() if (!conf) { await store.write(effects, { - token: '', tunnel: null, zoneInfo: null, ingress: {}, diff --git a/startos/fileModels/tunnel.yaml.ts b/startos/fileModels/tunnel.yaml.ts deleted file mode 100644 index 73507ca..0000000 --- a/startos/fileModels/tunnel.yaml.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { T } from '@start9labs/start-sdk' -import { sdk } from '../sdk' -import { IngressEntry, StoreType } from './store.yaml' -import { FileHelper } from '@start9labs/start-sdk' - -/** - * FileHelper for cert.pem - used only for .const() reactive watching. - */ -export const certPem = FileHelper.string({ - base: sdk.volumes.main, - subpath: '/.cloudflared/cert.pem', -}) - -export const TUNNEL_CONFIG_PATH = '/start9/tunnel.yaml' - -/** - * Fetch ingress rules from the Cloudflare API (dashboard-managed config). - * Returns array of { hostname?, service } - excludes the catch-all. - */ -async function fetchRemoteIngress( - accountId: string, - tunnelId: string, - apiToken: string, -): Promise> { - try { - const resp = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, - { - headers: { - Authorization: `Bearer ${apiToken}`, - 'Content-Type': 'application/json', - }, - }, - ) - const data = (await resp.json()) as any - if (!data.success) return [] - return ( - (data.result?.config?.ingress as Array<{ hostname?: string; service: string }>) ?? [] - ).filter((r) => r.hostname) // exclude catch-all - } catch (e) { - console.error(`Failed to fetch remote ingress: ${String(e)}`) - return [] - } -} - -/** - * Build and write the cloudflared tunnel config YAML to the volume. - * Merges local store ingress with dashboard-configured remote ingress. - * Local rules take precedence; remote-only rules are appended. - * cloudflared reads this once at startup via --config. - */ -export async function writeTunnelConfig( - effects: T.Effects, - conf: Pick, -): Promise { - const localIngress = conf.ingress ?? {} - - // Fetch remote (dashboard) ingress if we have credentials - let remoteRules: Array<{ hostname: string; service: string }> = [] - if (conf.tunnel && conf.zoneInfo) { - const raw = await fetchRemoteIngress( - conf.zoneInfo.accountId, - conf.tunnel.id, - conf.zoneInfo.apiToken, - ) - remoteRules = raw.filter((r): r is { hostname: string; service: string } => !!r.hostname) - } - - const lines: string[] = ['ingress:'] - - // Local rules first (take precedence) - const localHostnames = new Set() - for (const [hostname, entry] of Object.entries(localIngress)) { - if (!entry) continue - lines.push(` - hostname: ${hostname}`) - lines.push(` service: ${entry.service}`) - localHostnames.add(hostname) - } - - // Remote rules that aren't already covered locally - for (const rule of remoteRules) { - if (localHostnames.has(rule.hostname)) continue - lines.push(` - hostname: ${rule.hostname}`) - lines.push(` service: ${rule.service}`) - } - - // Required catch-all - lines.push(' - service: http_status:404') - lines.push('') - - await sdk.volumes.main.writeFile(TUNNEL_CONFIG_PATH, lines.join('\n')) -} diff --git a/startos/init/index.ts b/startos/init/index.ts index c1f4d53..de44b68 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -4,7 +4,6 @@ import { versionGraph } from '../versions' import { actions } from '../actions' import { restoreInit } from '../backups' import { seedStore } from './seedStore' -import { writeConfig } from './writeConfig' import { exportUrls, registerUrlPlugin } from '../plugin/url' import { setupTasks } from './setupTasks' import { setupZoneInfo } from './setupZoneInfo' @@ -14,7 +13,6 @@ export const init = sdk.setupInit( versionGraph, seedStore, setupZoneInfo, - writeConfig, setInterfaces, actions, registerUrlPlugin, diff --git a/startos/init/setupTasks.ts b/startos/init/setupTasks.ts index 807a3ad..20e03f7 100644 --- a/startos/init/setupTasks.ts +++ b/startos/init/setupTasks.ts @@ -1,5 +1,5 @@ import { sdk } from '../sdk' -import { certPem } from '../fileModels/tunnel.yaml' +import { certPem } from '../fileModels/certPem' import { store } from '../fileModels/store.yaml' import { cloudflareLogin } from '../actions/cloudflareLogin' import { selectTunnel } from '../actions/selectTunnel' diff --git a/startos/init/setupZoneInfo.ts b/startos/init/setupZoneInfo.ts index d17fbc6..bee69fd 100644 --- a/startos/init/setupZoneInfo.ts +++ b/startos/init/setupZoneInfo.ts @@ -1,5 +1,5 @@ import { sdk } from '../sdk' -import { certPem } from '../fileModels/tunnel.yaml' +import { certPem } from '../fileModels/certPem' import { store, ZoneInfo } from '../fileModels/store.yaml' /** diff --git a/startos/init/writeConfig.ts b/startos/init/writeConfig.ts deleted file mode 100644 index 36f42af..0000000 --- a/startos/init/writeConfig.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { sdk } from '../sdk' -import { store } from '../fileModels/store.yaml' -import { writeTunnelConfig } from '../fileModels/tunnel.yaml' - -/** - * (Re)generate the cloudflared tunnel.yaml from the store before every start. - * Runs on all init kinds (install, update, restore, null/restart). - */ -export const writeConfig = sdk.setupOnInit(async (effects) => { - const conf = await store.read().once() - await writeTunnelConfig(effects, conf ?? { ingress: {}, tunnel: null, zoneInfo: null }) -}) diff --git a/startos/main.ts b/startos/main.ts index efe2faf..9c130ad 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -1,5 +1,4 @@ import { store } from './fileModels/store.yaml' -import { TUNNEL_CONFIG_PATH } from './fileModels/tunnel.yaml' import { sdk } from './sdk' import { i18n } from './i18n' @@ -8,11 +7,13 @@ export const main = sdk.setupMain(async ({ effects }) => { const conf = (await store.read().const(effects))! - if (!conf.token) { - console.info('No tunnel token configured - waiting for tunnel selection') + if (!conf.tunnel) { + console.info('No tunnel configured - waiting for tunnel selection') return sdk.Daemons.of(effects) } + const credFile = `/root/.cloudflared/${conf.tunnel.id}.json` + return sdk.Daemons.of(effects).addDaemon('primary', { subcontainer: await sdk.SubContainer.of( effects, @@ -42,13 +43,12 @@ export const main = sdk.setupMain(async ({ effects }) => { '--metrics', '0.0.0.0:20241', 'tunnel', - '--config', - `/root/data${TUNNEL_CONFIG_PATH}`, + '--credentials-file', + credFile, 'run', + conf.tunnel.id, ], - env: { - TUNNEL_TOKEN: conf.token, - }, + env: {}, }, ready: { display: i18n('Cloudflare tunnel'), diff --git a/startos/tunnelToken.ts b/startos/tunnelToken.ts deleted file mode 100644 index b20c989..0000000 --- a/startos/tunnelToken.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * The TUNNEL_TOKEN is base64-encoded JSON with short field names: - * { a: accountTag, t: tunnelId, s: tunnelSecret } - * Decoded via base64.StdEncoding per cloudflared source. - */ -export type TunnelCredentials = { - AccountTag: string - TunnelID: string - TunnelSecret: string -} - -export function decodeTunnelToken(token: string): TunnelCredentials { - try { - const json = Buffer.from(token, 'base64').toString('utf8') - const parsed = JSON.parse(json) - // Short field names used in the wire format - const accountTag = parsed.a - const tunnelID = parsed.t - const tunnelSecret = parsed.s - if (!accountTag || !tunnelID || !tunnelSecret) { - throw new Error('Missing required fields in tunnel token') - } - return { AccountTag: accountTag, TunnelID: tunnelID, TunnelSecret: tunnelSecret } - } catch (e) { - throw new Error( - `Invalid tunnel token: could not decode credentials. Make sure you pasted the full token from the Cloudflare dashboard.`, - ) - } -} From 3cb8e98558f263a44e21faa3ac825b36295f1dc3 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 17:17:29 +0200 Subject: [PATCH 22/36] feat: infer server name from mDNS for new tunnel default --- startos/actions/selectTunnel.ts | 42 ++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/startos/actions/selectTunnel.ts b/startos/actions/selectTunnel.ts index 550c96e..0fe8687 100644 --- a/startos/actions/selectTunnel.ts +++ b/startos/actions/selectTunnel.ts @@ -33,17 +33,18 @@ function parseTunnelList(stdout: string): Array<{ id: string; name: string }> { return [] } -const newTunnelSpec = InputSpec.of({ - name: Value.text({ - name: 'Tunnel Name', - description: 'A name for your new Cloudflare tunnel.', - required: true, - default: null, - placeholder: 'my-server', - masked: false, - inputmode: 'text', - }), -}) +const newTunnelSpec = (serverName: string | null) => + InputSpec.of({ + name: Value.text({ + name: 'Tunnel Name', + description: 'A name for your new Cloudflare tunnel.', + required: true, + default: serverName, + placeholder: 'my-server', + masked: false, + inputmode: 'text', + }), + }) export const selectTunnel = sdk.Action.withInput( 'select-tunnel', @@ -87,7 +88,20 @@ export const selectTunnel = sdk.Action.withInput( console.error(`Failed to list tunnels: ${String(e)}`) } - const variants: Record }> = {} + // Infer server name from mDNS for new tunnel default + let serverName: string | null = null + try { + const mdnsUrl = await sdk.serviceInterface + .getOwn(effects!, 'metrics', (iface) => + iface?.addressInfo?.nonLocal.filter({ kind: 'mdns' })?.format()[0], + ) + .once() + if (mdnsUrl) { + serverName = new URL(mdnsUrl).hostname.replace(/\.local$/, '') + } + } catch {} + + const variants: Record }> = {} for (const t of tunnels) { variants[t.id] = { @@ -99,7 +113,7 @@ export const selectTunnel = sdk.Action.withInput( // 'Create new tunnel' always at the bottom variants['new'] = { name: 'Create new tunnel', - spec: newTunnelSpec, + spec: newTunnelSpec(serverName), } return { @@ -117,7 +131,7 @@ export const selectTunnel = sdk.Action.withInput( return { tunnel: conf?.tunnel ? { selection: conf.tunnel.id, value: {} } - : { selection: 'new', value: { name: '' } }, + : { selection: 'new', value: {} }, } }, From 07132dd12f26cdb009fe4f742f6afaf7857adab3 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 18:22:29 +0200 Subject: [PATCH 23/36] feat: multi-zone support with per-zone cert files and full i18n --- startos/actions/addPublicHostname.ts | 95 ++++++------- startos/actions/cloudflareLogin.ts | 15 +-- startos/actions/deletePublicHostname.ts | 30 +++-- startos/actions/index.ts | 2 + startos/actions/removeZone.ts | 77 +++++++++++ startos/actions/selectTunnel.ts | 44 +++--- startos/cfRunner.ts | 4 +- startos/fileModels/certPem.ts | 15 +++ startos/fileModels/store.yaml.ts | 5 +- startos/i18n/dictionaries/default.ts | 38 ++++++ startos/i18n/dictionaries/translations.ts | 156 +++++++++++++++++++++- startos/init/index.ts | 4 +- startos/init/setupTasks.ts | 16 +-- startos/init/setupZoneInfo.ts | 70 ---------- startos/init/setupZones.ts | 68 ++++++++++ 15 files changed, 465 insertions(+), 174 deletions(-) create mode 100644 startos/actions/removeZone.ts delete mode 100644 startos/init/setupZoneInfo.ts create mode 100644 startos/init/setupZones.ts diff --git a/startos/actions/addPublicHostname.ts b/startos/actions/addPublicHostname.ts index a6c6b74..8871060 100644 --- a/startos/actions/addPublicHostname.ts +++ b/startos/actions/addPublicHostname.ts @@ -1,11 +1,11 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' import { pushIngressToApi } from '../cfApi' +import { zoneCertSubpath } from '../fileModels/certPem' +import { i18n } from '../i18n' const { InputSpec, Value, Variants } = sdk -const CERT_PATH = '/root/.cloudflared/cert.pem' - const inputSpec = InputSpec.of({ urlPluginMetadata: Value.hidden<{ packageId: string @@ -14,8 +14,8 @@ const inputSpec = InputSpec.of({ internalPort: number }>(), subdomain: Value.text({ - name: 'Subdomain', - description: 'The subdomain to route to this service (e.g. myapp).', + name: i18n('Subdomain'), + description: i18n('The subdomain to route to this service (e.g. myapp).'), required: true, default: null, placeholder: 'myapp', @@ -24,29 +24,29 @@ const inputSpec = InputSpec.of({ patterns: [ { regex: '^[a-zA-Z0-9][a-zA-Z0-9\\-]*$', - description: 'Subdomain only, no dots (e.g. myapp)', + description: i18n('Subdomain only, no dots (e.g. myapp)'), }, ], }), domain: Value.dynamicUnion(async ({ effects }) => { const conf = await store.read().once() - const zones: Record }> = {} + const zones = conf?.zones ?? {} + const variants: Record }> = {} - if (conf?.zoneInfo?.zoneName) { - const key = conf.zoneInfo.zoneId - zones[key] = { name: conf.zoneInfo.zoneName, spec: InputSpec.of({}) } + for (const [id, z] of Object.entries(zones)) { + if (!z) continue + variants[id] = { name: z.zoneName, spec: InputSpec.of({}) } } - // Fallback if no zone info yet - if (Object.keys(zones).length === 0) { - zones['manual'] = { name: 'Login to Cloudflare to see your domains', spec: InputSpec.of({}) } + if (Object.keys(variants).length === 0) { + variants['none'] = { name: i18n('Login to Cloudflare to see your domains'), spec: InputSpec.of({}) } } return { - name: 'Domain', - default: Object.keys(zones)[0], + name: i18n('Domain'), + default: Object.keys(variants)[0], disabled: false, - variants: Variants.of(zones), + variants: Variants.of(variants), } }), }) @@ -55,8 +55,8 @@ export const addPublicHostname = sdk.Action.withInput( 'add-public-hostname', async () => ({ - name: 'Add Public Hostname', - description: 'Route a public Cloudflare hostname to this service', + name: i18n('Add Public Hostname'), + description: i18n('Route a public Cloudflare hostname to this service'), warning: null, allowedStatuses: 'any', group: null, @@ -77,34 +77,32 @@ export const addPublicHostname = sdk.Action.withInput( async ({ effects, input }) => { const { packageId, internalPort, interfaceId, hostId } = input.urlPluginMetadata const subdomain = input.subdomain.trim().toLowerCase() - const domainSelection = (input.domain as { selection: string; value: {} }) + const zoneId = (input.domain as { selection: string }).selection const conf = await store.read().once() - // Resolve the domain name from the selection - const zoneName = conf?.zoneInfo?.zoneName - if (!zoneName) { + if (zoneId === 'none' || !conf?.zones?.[zoneId]) { return { version: '1' as const, - title: 'Not Logged In', - message: 'Login to Cloudflare first (run "Cloudflare Account" action), then add a public hostname.', + title: i18n('No Zone Configured'), + message: i18n('Login to Cloudflare first (run "Login to Cloudflare" action) to configure a DNS zone.'), result: null, } } - const hostname = `${subdomain}.${zoneName}` - const host = packageId === 'STARTOS' ? 'startos' : `${packageId}.startos` - const service = `http://${host}:${internalPort}` - - if (!conf?.tunnel) { + if (!conf.tunnel) { return { version: '1' as const, - title: 'No Tunnel Configured', - message: 'Select a Cloudflare tunnel first (run "Cloudflare Tunnel" action).', + title: i18n('No Tunnel Configured'), + message: i18n('Select a Cloudflare tunnel first (run "Cloudflare Tunnel" action).'), result: null, } } + const zone = conf.zones[zoneId] + const hostname = `${subdomain}.${zone.zoneName}` + const host = packageId === 'STARTOS' ? 'startos' : `${packageId}.startos` + const service = `http://${host}:${internalPort}` const tunnelId = conf.tunnel.id // Persist ingress entry and push to Cloudflare API @@ -116,23 +114,18 @@ export const addPublicHostname = sdk.Action.withInput( interfaceId, internalPort, service, + zoneId, }, }, }) const updated = await store.read().once() - if (conf.zoneInfo) { - await pushIngressToApi( - conf.zoneInfo.accountId, - tunnelId, - conf.zoneInfo.apiToken, - updated?.ingress ?? {}, - ) - } + await pushIngressToApi(zone.accountId, tunnelId, zone.apiToken, updated?.ingress ?? {}) - // Create DNS route automatically if logged in + // Create DNS CNAME via cloudflared CLI using zone-specific cert + const certSubpath = zoneCertSubpath(zoneId) let certExists = false try { - await sdk.volumes.main.readFile('/.cloudflared/cert.pem') + await sdk.volumes.main.readFile(certSubpath) certExists = true } catch {} @@ -146,10 +139,11 @@ export const addPublicHostname = sdk.Action.withInput( .mountVolume({ volumeId: 'main', subpath: '.cloudflared', mountpoint: '/root/.cloudflared', readonly: true }), 'route-dns', async (sub) => { + const certPath = `/root/.cloudflared/zone-${zoneId}.pem` const result = await sub.exec( [ '/usr/local/bin/cloudflared', '--no-autoupdate', - `--origincert=${CERT_PATH}`, + `--origincert=${certPath}`, 'tunnel', 'route', 'dns', '--overwrite-dns', tunnelId, hostname, ], @@ -157,29 +151,22 @@ export const addPublicHostname = sdk.Action.withInput( ) if (result.stdout) console.info(result.stdout) if (result.stderr) console.info(result.stderr) - if (result.exitCode !== 0) { - console.error( - `DNS route creation failed. Add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`, - ) - } else { - dnsCreated = true - } + if (result.exitCode === 0) dnsCreated = true + else console.error(`DNS route creation failed. Add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`) }, ) } else { - console.info( - `Not logged in - add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`, - ) + console.info(`No cert for zone ${zoneId} - add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`) } await effects.restart() return { version: '1' as const, - title: 'Public Hostname Added', + title: i18n('Public Hostname Added'), message: dnsCreated - ? `${hostname} is now routed to this service. DNS record created automatically.` - : `${hostname} is now routed to this service. Add a CNAME record manually: ${hostname} -> ${tunnelId}.cfargotunnel.com (proxied).`, + ? `${hostname} is now routed to this service. ${i18n('DNS record created automatically.')}` + : `${hostname} is now routed to this service. ${i18n('Add a CNAME record manually in the Cloudflare dashboard (proxied).')} ${hostname} -> ${tunnelId}.cfargotunnel.com`, result: { type: 'single' as const, value: `https://${hostname}`, diff --git a/startos/actions/cloudflareLogin.ts b/startos/actions/cloudflareLogin.ts index 4f240f5..6cfe050 100644 --- a/startos/actions/cloudflareLogin.ts +++ b/startos/actions/cloudflareLogin.ts @@ -1,6 +1,6 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { certPem } from '../fileModels/certPem' +import { i18n } from '../i18n' const LOGIN_URL_PATH = '/start9/login-url.txt' @@ -22,17 +22,14 @@ export const cloudflareLogin = sdk.Action.withoutInput( 'cloudflare-login', async ({ effects }) => { - const loggedIn = !!(await certPem.read().const(effects)) const conf = await store.read().const(effects) - const zoneName = conf?.zoneInfo?.zoneName - const nameLabel = loggedIn - ? `Cloudflare Account: Logged in${zoneName ? ` (${zoneName})` : ''}` - : 'Cloudflare Account: Not logged in' + const zoneNames = Object.values(conf?.zones ?? {}).filter(Boolean).map((z) => z!.zoneName) + const nameLabel = zoneNames.length === 0 + ? i18n('Login to Cloudflare') + : i18n('Add DNS Zone') return { name: nameLabel, - description: - 'Authenticates with your Cloudflare account so DNS routes can be created automatically. ' + - 'Returns an authorization URL - visit it in your browser to complete login.', + description: i18n('Authenticates with a Cloudflare DNS zone. Run this action again to add additional zones.'), warning: null, allowedStatuses: 'any', group: 'Configuration', diff --git a/startos/actions/deletePublicHostname.ts b/startos/actions/deletePublicHostname.ts index a4ef625..985b164 100644 --- a/startos/actions/deletePublicHostname.ts +++ b/startos/actions/deletePublicHostname.ts @@ -1,6 +1,7 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' import { pushIngressToApi, deleteDnsRecord } from '../cfApi' +import { i18n } from '../i18n' const { InputSpec, Value } = sdk @@ -24,9 +25,9 @@ export const deletePublicHostname = sdk.Action.withInput( // metadata async () => ({ - name: 'Delete Public Hostname', - description: 'Remove a Cloudflare public hostname route', - warning: 'This will remove the hostname from the tunnel config and delete the DNS record from Cloudflare.', + name: i18n('Delete Public Hostname'), + description: i18n('Remove a Cloudflare public hostname route'), + warning: i18n('This will remove this hostname from your Cloudflare tunnel and delete the DNS record from Cloudflare.'), allowedStatuses: 'any', group: null, visibility: 'hidden', @@ -42,26 +43,33 @@ export const deletePublicHostname = sdk.Action.withInput( async ({ effects, input }) => { const { hostname } = input.urlPluginMetadata + // Read before mutating so we can look up the zone for DNS deletion + const conf = await store.read().once() + // Remove from store and push updated ingress to CF API await store.merge(effects, { ingress: { [hostname]: undefined } as any, }) const updated = await store.read().once() - if (updated?.zoneInfo && updated.tunnel) { + const accountId = Object.values(updated?.zones ?? {})[0]?.accountId ?? '' + if (updated?.tunnel && accountId) { + const anyZoneToken = Object.values(updated.zones ?? {})[0]?.apiToken ?? '' await pushIngressToApi( - updated.zoneInfo.accountId, + accountId, updated.tunnel.id, - updated.zoneInfo.apiToken, + anyZoneToken, updated.ingress ?? {}, ) } - // Delete DNS record - const zoneInfo = updated?.zoneInfo - if (zoneInfo) { - await deleteDnsRecord(zoneInfo.zoneId, hostname, zoneInfo.apiToken) + // Delete DNS record using the zone-specific token from the ingress entry + const entry = conf?.ingress?.[hostname] + const zoneId = entry?.zoneId + const zone = zoneId ? updated?.zones?.[zoneId] : undefined + if (zone) { + await deleteDnsRecord(zoneId!, hostname, zone.apiToken) } else { - console.info(`No zone credentials - delete DNS record for ${hostname} manually`) + console.info(`No zone info for ${hostname} - delete DNS record manually`) } // Restart the daemon so cloudflared picks up the change diff --git a/startos/actions/index.ts b/startos/actions/index.ts index 624e54b..e91b54b 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -1,11 +1,13 @@ import { sdk } from '../sdk' import { cloudflareLogin } from './cloudflareLogin' import { selectTunnel } from './selectTunnel' +import { removeZone } from './removeZone' import { addPublicHostname } from './addPublicHostname' import { deletePublicHostname } from './deletePublicHostname' export const actions = sdk.Actions.of() .addAction(cloudflareLogin) .addAction(selectTunnel) + .addAction(removeZone) .addAction(addPublicHostname) .addAction(deletePublicHostname) diff --git a/startos/actions/removeZone.ts b/startos/actions/removeZone.ts new file mode 100644 index 0000000..738b2fa --- /dev/null +++ b/startos/actions/removeZone.ts @@ -0,0 +1,77 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +import { zoneCertSubpath } from '../fileModels/certPem' +import { unlink } from 'node:fs/promises' +import { i18n } from '../i18n' + +const { InputSpec, Value, Variants } = sdk + +export const removeZone = sdk.Action.withInput( + 'remove-zone', + + async ({ effects }) => { + const conf = await store.read().const(effects) + const zones = conf?.zones ?? {} + const count = Object.keys(zones).length + return { + name: i18n('Remove DNS Zone'), + description: i18n('Remove a Cloudflare DNS zone from this package. Existing public hostnames for this zone will no longer have automatic DNS management.'), + warning: i18n('Existing DNS records and ingress rules in Cloudflare will NOT be deleted.'), + allowedStatuses: 'any' as const, + group: 'Configuration', + visibility: count === 0 ? ({ disabled: i18n('No zones configured') } as const) : ('enabled' as const), + } + }, + + InputSpec.of({ + zoneId: Value.dynamicUnion(async ({ effects }) => { + const conf = await store.read().once() + const zones = conf?.zones ?? {} + const variants: Record }> = {} + for (const [id, z] of Object.entries(zones)) { + if (!z) continue + variants[id] = { name: z.zoneName, spec: InputSpec.of({}) } + } + if (Object.keys(variants).length === 0) { + variants['none'] = { name: 'No zones configured', spec: InputSpec.of({}) } + } + return { + name: 'Zone', + default: Object.keys(variants)[0], + disabled: false, + variants: Variants.of(variants), + } + }), + }), + + async () => null, + + async ({ effects, input }) => { + const zoneId = (input.zoneId as { selection: string }).selection + if (zoneId === 'none') return + + // Remove all ingress entries for this zone + const conf = await store.read().once() + const staleIngress: Record = {} + for (const [hostname, entry] of Object.entries(conf?.ingress ?? {})) { + if (entry?.zoneId === zoneId) { + staleIngress[hostname] = undefined + } + } + if (Object.keys(staleIngress).length > 0) { + await store.merge(effects, { ingress: staleIngress as any }) + } + + // Remove zone-specific cert file + try { + await unlink(sdk.volumes.main.subpath(zoneCertSubpath(zoneId))) + } catch {} + + // Remove from store + await store.merge(effects, { + zones: { [zoneId]: undefined } as any, + }) + + console.info(`Zone ${zoneId} removed`) + }, +) diff --git a/startos/actions/selectTunnel.ts b/startos/actions/selectTunnel.ts index 0fe8687..64f556b 100644 --- a/startos/actions/selectTunnel.ts +++ b/startos/actions/selectTunnel.ts @@ -1,7 +1,7 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { certPem } from '../fileModels/certPem' import { runCf } from '../cfRunner' +import { i18n } from '../i18n' const { InputSpec, Value, Variants } = sdk @@ -37,7 +37,7 @@ const newTunnelSpec = (serverName: string | null) => InputSpec.of({ name: Value.text({ name: 'Tunnel Name', - description: 'A name for your new Cloudflare tunnel.', + description: i18n('A name for your new Cloudflare tunnel.'), required: true, default: serverName, placeholder: 'my-server', @@ -50,26 +50,25 @@ export const selectTunnel = sdk.Action.withInput( 'select-tunnel', async ({ effects }) => { - const loggedIn = !!(await certPem.read().const(effects)) - if (!loggedIn) { + const conf = await store.read().const(effects) + const hasZone = Object.keys(conf?.zones ?? {}).length > 0 + if (!hasZone) { return { name: 'Select Tunnel', - description: 'Login to Cloudflare first before selecting a tunnel.', + description: i18n('Login to Cloudflare first to configure a zone'), warning: null, allowedStatuses: 'any' as const, group: 'Configuration', - visibility: { disabled: 'Login to Cloudflare first' } as const, + visibility: { disabled: i18n('Login to Cloudflare first to configure a zone') } as const, } } - const conf = await store.read().const(effects) const current = conf?.tunnel?.name const nameLabel = current ? `Cloudflare Tunnel: ${current}` - : 'Cloudflare Tunnel: Not selected' + : i18n('Cloudflare Tunnel: Not selected') return { name: nameLabel, - description: - 'Choose which Cloudflare tunnel this server runs. You can select an existing tunnel or create a new one.', + description: i18n('Choose which Cloudflare tunnel this server runs. You can select an existing tunnel or create a new one.'), warning: null, allowedStatuses: 'any', group: 'Configuration', @@ -79,10 +78,15 @@ export const selectTunnel = sdk.Action.withInput( InputSpec.of({ tunnel: Value.dynamicUnion(async ({ effects }) => { + // Find first zone cert for origincert-required commands + const confForList = await store.read().once() + const firstZoneIdForList = Object.keys(confForList?.zones ?? {}).find((id) => confForList!.zones[id]) + const certForList = firstZoneIdForList ? `/root/.cloudflared/zone-${firstZoneIdForList}.pem` : undefined + // Fetch list of available tunnels let tunnels: Array<{ id: string; name: string }> = [] try { - const stdout = await runCf(effects!, ['tunnel', 'list', '--output', 'json']) + const stdout = await runCf(effects!, ['tunnel', 'list', '--output', 'json'], 30_000, certForList) tunnels = parseTunnelList(stdout) } catch (e) { console.error(`Failed to list tunnels: ${String(e)}`) @@ -101,6 +105,11 @@ export const selectTunnel = sdk.Action.withInput( } } catch {} + // Default to currently selected tunnel if one is configured + const conf = await store.read().once() + const selectedId = conf?.tunnel?.id + const defaultId = selectedId ?? tunnels[0]?.id ?? 'new' + const variants: Record }> = {} for (const t of tunnels) { @@ -118,7 +127,7 @@ export const selectTunnel = sdk.Action.withInput( return { name: 'Tunnel', - default: tunnels[0]?.id ?? 'new', + default: defaultId, disabled: false, variants: Variants.of(variants), } @@ -140,18 +149,23 @@ export const selectTunnel = sdk.Action.withInput( let tunnelId: string let tunnelName: string + // Find first zone cert for origincert-required commands + const conf = await store.read().once() + const firstZoneId = Object.keys(conf?.zones ?? {}).find((id) => conf!.zones[id]) + const origincert = firstZoneId ? `/root/.cloudflared/zone-${firstZoneId}.pem` : undefined + if (selection.selection === 'new') { const name = selection.value.name?.trim() if (!name) throw new Error('Tunnel name is required.') // Create the tunnel - response includes id and name - const stdout = await runCf(effects, ['tunnel', 'create', '--output', 'json', name]) + const stdout = await runCf(effects, ['tunnel', 'create', '--output', 'json', name], 30_000, origincert) const created = JSON.parse(stdout.trim()) tunnelId = created.id tunnelName = created.name } else { tunnelId = selection.selection - const listOut = await runCf(effects, ['tunnel', 'list', '--output', 'json']) + const listOut = await runCf(effects, ['tunnel', 'list', '--output', 'json'], 30_000, origincert) const tunnels = parseTunnelList(listOut) const found = tunnels.find((t) => t.id === tunnelId) tunnelName = found?.name ?? tunnelId @@ -164,7 +178,7 @@ export const selectTunnel = sdk.Action.withInput( const { unlink } = await import('node:fs/promises') await unlink(sdk.volumes.main.subpath(credSubpath)) } catch { /* file didn't exist, that's fine */ } - await runCf(effects, ['tunnel', 'token', `--cred-file=${credFile}`, tunnelId]) + await runCf(effects, ['tunnel', 'token', `--cred-file=${credFile}`, tunnelId], 30_000, origincert) await store.merge(effects, { tunnel: { id: tunnelId, name: tunnelName }, diff --git a/startos/cfRunner.ts b/startos/cfRunner.ts index 3b19b6c..3210108 100644 --- a/startos/cfRunner.ts +++ b/startos/cfRunner.ts @@ -27,7 +27,9 @@ export async function runCf( effects: T.Effects, args: string[], timeoutMs = 30_000, + origincert?: string, ): Promise { + const certArgs = origincert ? [`--origincert=${origincert}`] : [] return sdk.SubContainer.withTemp( effects, { imageId: 'main' }, @@ -35,7 +37,7 @@ export async function runCf( 'cf-cmd', async (sub) => { const result = await sub.exec( - ['/usr/local/bin/cloudflared', '--no-autoupdate', ...args], + ['/usr/local/bin/cloudflared', '--no-autoupdate', ...certArgs, ...args], {}, timeoutMs, ) diff --git a/startos/fileModels/certPem.ts b/startos/fileModels/certPem.ts index ac72553..5f59791 100644 --- a/startos/fileModels/certPem.ts +++ b/startos/fileModels/certPem.ts @@ -9,3 +9,18 @@ export const certPem = FileHelper.string({ base: sdk.volumes.main, subpath: '/.cloudflared/cert.pem', }) + +/** Decode the cloudflared cert.pem into its JSON fields. */ +export function decodeCert(cert: string): { + zoneID: string + accountID: string + apiToken: string +} { + const lines = cert.split('\n').filter((l) => l && !l.startsWith('-----')) + return JSON.parse(Buffer.from(lines.join(''), 'base64').toString('utf8')) +} + +/** Path within the .cloudflared volume subpath for a zone-specific cert. */ +export function zoneCertSubpath(zoneId: string): string { + return `/.cloudflared/zone-${zoneId}.pem` +} diff --git a/startos/fileModels/store.yaml.ts b/startos/fileModels/store.yaml.ts index 85bc6ca..03eaad2 100644 --- a/startos/fileModels/store.yaml.ts +++ b/startos/fileModels/store.yaml.ts @@ -7,6 +7,7 @@ export const ingressEntryShape = z.object({ interfaceId: z.string(), internalPort: z.number(), service: z.string(), + zoneId: z.string().catch(''), // which zone's DNS this hostname was created in }) export type IngressEntry = z.infer @@ -29,7 +30,7 @@ export type ZoneInfo = z.infer const shape = z.object({ tunnel: tunnelInfoShape.nullable().catch(null), - zoneInfo: zoneInfoShape.nullable().catch(null), + zones: z.record(z.string(), zoneInfoShape.nullish()).catch({}), ingress: z.record(z.string(), ingressEntryShape.nullable()).catch({}), }) @@ -48,7 +49,7 @@ export const createDefaultStore = async (effects: T.Effects) => { if (!conf) { await store.write(effects, { tunnel: null, - zoneInfo: null, + zones: {}, ingress: {}, }) } diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index ac03cd8..80a4b3a 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -9,6 +9,44 @@ const dict = { // interfaces.ts 'Metrics': 100, 'Prometheus metrics endpoint': 101, + + // actions/cloudflareLogin.ts + 'Login to Cloudflare': 200, + 'Add DNS Zone': 201, + 'Authenticates with a Cloudflare DNS zone. Run this action again to add additional zones.': 202, + + // actions/selectTunnel.ts + 'A name for your new Cloudflare tunnel.': 209, + 'Cloudflare Tunnel: Not selected': 210, + 'Choose which Cloudflare tunnel this server runs. You can select an existing tunnel or create a new one.': 211, + 'Login to Cloudflare first to configure a zone': 212, + + // actions/removeZone.ts + 'Remove DNS Zone': 220, + 'Remove a Cloudflare DNS zone from this package. Existing public hostnames for this zone will no longer have automatic DNS management.': 221, + 'Existing DNS records and ingress rules in Cloudflare will NOT be deleted.': 222, + 'No zones configured': 223, + + // actions/addPublicHostname.ts + 'Add Public Hostname': 230, + 'Route a public Cloudflare hostname to this service': 231, + 'Subdomain': 232, + 'The subdomain to route to this service (e.g. myapp).': 233, + 'Subdomain only, no dots (e.g. myapp)': 234, + 'Domain': 235, + 'Login to Cloudflare to see your domains': 236, + 'No Zone Configured': 237, + 'Login to Cloudflare first (run "Login to Cloudflare" action) to configure a DNS zone.': 238, + 'No Tunnel Configured': 239, + 'Select a Cloudflare tunnel first (run "Cloudflare Tunnel" action).': 240, + 'Public Hostname Added': 241, + 'DNS record created automatically.': 242, + 'Add a CNAME record manually in the Cloudflare dashboard (proxied).': 243, + + // actions/deletePublicHostname.ts + 'Delete Public Hostname': 250, + 'Remove a Cloudflare public hostname route': 251, + 'This will remove this hostname from your Cloudflare tunnel and delete the DNS record from Cloudflare.': 252, } as const /** diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index 87c7a1f..63dc76f 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -10,6 +10,44 @@ export default { // interfaces.ts 100: 'Métricas', 101: 'Endpoint de métricas Prometheus', + + // actions/cloudflareLogin.ts + 200: 'Iniciar sesión en Cloudflare', + 201: 'Agregar zona DNS', + 202: 'Autentica con una zona DNS de Cloudflare. Ejecuta esta acción de nuevo para agregar zonas adicionales.', + 209: 'Un nombre para tu nuevo túnel Cloudflare.', + + // actions/selectTunnel.ts + 210: 'Túnel Cloudflare: No seleccionado', + 211: 'Elige qué túnel Cloudflare usa este servidor. Puedes seleccionar un túnel existente o crear uno nuevo.', + 212: 'Inicia sesión en Cloudflare primero para configurar una zona', + + // actions/removeZone.ts + 220: 'Eliminar zona DNS', + 221: 'Elimina una zona DNS de Cloudflare de este paquete. Los nombres de host públicos existentes para esta zona ya no tendrán gestión DNS automática.', + 222: 'Los registros DNS y las reglas de entrada existentes en Cloudflare NO serán eliminados.', + 223: 'No hay zonas configuradas', + + // actions/addPublicHostname.ts + 230: 'Agregar nombre de host público', + 231: 'Enruta un nombre de host público de Cloudflare a este servicio', + 232: 'Subdominio', + 233: 'El subdominio para enrutar a este servicio (p. ej. miapp).', + 234: 'Solo subdominio, sin puntos (p. ej. miapp)', + 235: 'Dominio', + 236: 'Inicia sesión en Cloudflare para ver tus dominios', + 237: 'Sin zona configurada', + 238: 'Inicia sesión en Cloudflare primero (ejecuta la acción "Iniciar sesión en Cloudflare") para configurar una zona DNS.', + 239: 'Sin túnel configurado', + 240: 'Selecciona primero un túnel de Cloudflare (ejecuta la acción "Túnel Cloudflare").', + 241: 'Nombre de host público agregado', + 242: 'Registro DNS creado automáticamente.', + 243: 'Agrega un registro CNAME manualmente en el panel de Cloudflare (con proxy).', + + // actions/deletePublicHostname.ts + 250: 'Eliminar nombre de host público', + 251: 'Elimina una ruta de nombre de host público de Cloudflare', + 252: 'Esto eliminará este nombre de host de tu túnel Cloudflare y el registro DNS de Cloudflare.', }, de_DE: { // main.ts @@ -20,6 +58,44 @@ export default { // interfaces.ts 100: 'Metriken', 101: 'Prometheus-Metrik-Endpunkt', + + // actions/cloudflareLogin.ts + 200: 'Bei Cloudflare anmelden', + 201: 'DNS-Zone hinzufügen', + 202: 'Authentifiziert mit einer Cloudflare-DNS-Zone. Führe diese Aktion erneut aus, um weitere Zonen hinzuzufügen.', + 209: 'Ein Name für Ihren neuen Cloudflare-Tunnel.', + + // actions/selectTunnel.ts + 210: 'Cloudflare-Tunnel: Nicht ausgewählt', + 211: 'Wähle, welchen Cloudflare-Tunnel dieser Server verwendet. Du kannst einen vorhandenen Tunnel auswählen oder einen neuen erstellen.', + 212: 'Melde dich zuerst bei Cloudflare an, um eine Zone zu konfigurieren', + + // actions/removeZone.ts + 220: 'DNS-Zone entfernen', + 221: 'Entfernt eine Cloudflare-DNS-Zone aus diesem Paket. Für diese Zone werden öffentliche Hostnamen nicht mehr automatisch verwaltet.', + 222: 'Vorhandene DNS-Einträge und Ingress-Regeln in Cloudflare werden NICHT gelöscht.', + 223: 'Keine Zonen konfiguriert', + + // actions/addPublicHostname.ts + 230: 'Öffentlichen Hostnamen hinzufügen', + 231: 'Leitet einen öffentlichen Cloudflare-Hostnamen zu diesem Dienst weiter', + 232: 'Subdomain', + 233: 'Die Subdomain, die zu diesem Dienst weitergeleitet werden soll (z.B. meinapp).', + 234: 'Nur Subdomain, ohne Punkte (z.B. meinapp)', + 235: 'Domain', + 236: 'Melde dich bei Cloudflare an, um deine Domains zu sehen', + 237: 'Keine Zone konfiguriert', + 238: 'Melde dich zuerst bei Cloudflare an (führe die Aktion "Bei Cloudflare anmelden" aus), um eine DNS-Zone zu konfigurieren.', + 239: 'Kein Tunnel konfiguriert', + 240: 'Wähle zuerst einen Cloudflare-Tunnel (führe die Aktion "Cloudflare-Tunnel" aus).', + 241: 'Öffentlicher Hostname hinzugefügt', + 242: 'DNS-Eintrag automatisch erstellt.', + 243: 'Füge manuell einen CNAME-Eintrag im Cloudflare-Dashboard hinzu (mit Proxy).', + + // actions/deletePublicHostname.ts + 250: 'Öffentlichen Hostnamen löschen', + 251: 'Entfernt eine öffentliche Cloudflare-Hostname-Route', + 252: 'Dadurch wird dieser Hostname aus Ihrem Cloudflare-Tunnel und der DNS-Eintrag aus Cloudflare entfernt.', }, pl_PL: { // main.ts @@ -30,15 +106,91 @@ export default { // interfaces.ts 100: 'Metryki', 101: 'Endpoint metryk Prometheus', + + // actions/cloudflareLogin.ts + 200: 'Zaloguj się do Cloudflare', + 201: 'Dodaj strefę DNS', + 202: 'Uwierzytelnia się ze strefą DNS Cloudflare. Uruchom tę akcję ponownie, aby dodać kolejne strefy.', + 209: 'Nazwa dla twojego nowego tunelu Cloudflare.', + + // actions/selectTunnel.ts + 210: 'Tunel Cloudflare: Nie wybrany', + 211: 'Wybierz, którego tunelu Cloudflare używa ten serwer. Możesz wybrać istniejący tunel lub utworzyć nowy.', + 212: 'Najpierw zaloguj się do Cloudflare, aby skonfigurować strefę', + + // actions/removeZone.ts + 220: 'Usuń strefę DNS', + 221: 'Usuwa strefę DNS Cloudflare z tego pakietu. Istniejące publiczne nazwy hostów dla tej strefy nie będą już miały automatycznego zarządzania DNS.', + 222: 'Istniejące rekordy DNS i reguły przychodzące w Cloudflare NIE zostaną usunięte.', + 223: 'Brak skonfigurowanych stref', + + // actions/addPublicHostname.ts + 230: 'Dodaj publiczną nazwę hosta', + 231: 'Kieruje publiczną nazwę hosta Cloudflare do tej usługi', + 232: 'Subdomena', + 233: 'Subdomena do kierowania do tej usługi (np. mojapp).', + 234: 'Tylko subdomena, bez kropek (np. mojapp)', + 235: 'Domena', + 236: 'Zaloguj się do Cloudflare, aby zobaczyć swoje domeny', + 237: 'Brak skonfigurowanej strefy', + 238: 'Najpierw zaloguj się do Cloudflare (uruchom akcję "Zaloguj się do Cloudflare"), aby skonfigurować strefę DNS.', + 239: 'Brak skonfigurowanego tunelu', + 240: 'Najpierw wybierz tunel Cloudflare (uruchom akcję "Tunel Cloudflare").', + 241: 'Publiczna nazwa hosta dodana', + 242: 'Rekord DNS utworzony automatycznie.', + 243: 'Dodaj ręcznie rekord CNAME w panelu Cloudflare (z proxy).', + + // actions/deletePublicHostname.ts + 250: 'Usuń publiczną nazwę hosta', + 251: 'Usuwa trasę publicznej nazwy hosta Cloudflare', + 252: 'Spowoduje to usunięcie tej nazwy hosta z tunelu Cloudflare i rekordu DNS z Cloudflare.', }, fr_FR: { // main.ts 1: 'Tunnel Cloudflare', - 2: 'Le client de tunnel Cloudflare est en cours d\'exécution', - 3: 'Le client de tunnel Cloudflare n\'est pas en cours d\'exécution', + 2: 'Le tunnel Cloudflare est en cours d\'exécution', + 3: 'Le tunnel Cloudflare n\'est pas en cours d\'exécution', // interfaces.ts 100: 'Métriques', 101: 'Point de terminaison des métriques Prometheus', + + // actions/cloudflareLogin.ts + 200: 'Se connecter à Cloudflare', + 201: 'Ajouter une zone DNS', + 202: 'S\'authentifie avec une zone DNS Cloudflare. Relancez cette action pour ajouter des zones supplémentaires.', + 209: 'Un nom pour votre nouveau tunnel Cloudflare.', + + // actions/selectTunnel.ts + 210: 'Tunnel Cloudflare : Non sélectionné', + 211: 'Choisissez quel tunnel Cloudflare ce serveur utilise. Vous pouvez sélectionner un tunnel existant ou en créer un nouveau.', + 212: 'Connectez-vous d\'abord à Cloudflare pour configurer une zone', + + // actions/removeZone.ts + 220: 'Supprimer la zone DNS', + 221: 'Supprime une zone DNS Cloudflare de ce paquet. Les noms d\'hôtes publics existants pour cette zone n\'auront plus de gestion DNS automatique.', + 222: 'Les enregistrements DNS et les règles d\'entrée existants dans Cloudflare ne seront PAS supprimés.', + 223: 'Aucune zone configurée', + + // actions/addPublicHostname.ts + 230: 'Ajouter un nom d\'hôte public', + 231: 'Achemine un nom d\'hôte public Cloudflare vers ce service', + 232: 'Sous-domaine', + 233: 'Le sous-domaine à acheminer vers ce service (ex. monapp).', + 234: 'Sous-domaine uniquement, sans points (ex. monapp)', + 235: 'Domaine', + 236: 'Connectez-vous à Cloudflare pour voir vos domaines', + 237: 'Aucune zone configurée', + 238: 'Connectez-vous d\'abord à Cloudflare (lancez l\'action "Se connecter à Cloudflare") pour configurer une zone DNS.', + 239: 'Aucun tunnel configuré', + 240: 'Sélectionnez d\'abord un tunnel Cloudflare (lancez l\'action "Tunnel Cloudflare").', + 241: 'Nom d\'hôte public ajouté', + 242: 'Enregistrement DNS créé automatiquement.', + 243: 'Ajoutez manuellement un enregistrement CNAME dans le tableau de bord Cloudflare (avec proxy).', + + // actions/deletePublicHostname.ts + 250: 'Supprimer le nom d\'hôte public', + 251: 'Supprime une route de nom d\'hôte public Cloudflare', + 252: 'Cela supprimera ce nom d\'hôte de votre tunnel Cloudflare et l\'enregistrement DNS de Cloudflare.', }, } satisfies Record diff --git a/startos/init/index.ts b/startos/init/index.ts index de44b68..c31a7ba 100644 --- a/startos/init/index.ts +++ b/startos/init/index.ts @@ -6,13 +6,13 @@ import { restoreInit } from '../backups' import { seedStore } from './seedStore' import { exportUrls, registerUrlPlugin } from '../plugin/url' import { setupTasks } from './setupTasks' -import { setupZoneInfo } from './setupZoneInfo' +import { setupZones } from './setupZones' export const init = sdk.setupInit( restoreInit, versionGraph, seedStore, - setupZoneInfo, + setupZones, setInterfaces, actions, registerUrlPlugin, diff --git a/startos/init/setupTasks.ts b/startos/init/setupTasks.ts index 20e03f7..f3d2e0f 100644 --- a/startos/init/setupTasks.ts +++ b/startos/init/setupTasks.ts @@ -1,25 +1,25 @@ import { sdk } from '../sdk' -import { certPem } from '../fileModels/certPem' import { store } from '../fileModels/store.yaml' import { cloudflareLogin } from '../actions/cloudflareLogin' import { selectTunnel } from '../actions/selectTunnel' /** * Reactively manage required tasks. - * Re-runs whenever cert.pem or the store changes. - * - No cert.pem → task to login - * - No tunnel selected → task to select tunnel + * Re-runs whenever the store changes (zones or tunnel). + * - No zones configured -> task to login + * - No tunnel selected -> task to select tunnel */ export const setupTasks = sdk.setupOnInit(async (effects) => { - const loggedIn = !!(await certPem.read().const(effects)) - if (!loggedIn) { + const conf = await store.read().const(effects) + const hasZone = Object.keys(conf?.zones ?? {}).length > 0 + + if (!hasZone) { await sdk.action.createOwnTask(effects, cloudflareLogin, 'critical', { - reason: 'Login to Cloudflare to manage tunnels and DNS routes', + reason: 'Login to Cloudflare to configure a DNS zone', }) return } - const conf = await store.read().const(effects) if (!conf?.tunnel) { await sdk.action.createOwnTask(effects, selectTunnel, 'critical', { reason: 'Select or create a Cloudflare tunnel for this server', diff --git a/startos/init/setupZoneInfo.ts b/startos/init/setupZoneInfo.ts deleted file mode 100644 index bee69fd..0000000 --- a/startos/init/setupZoneInfo.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { sdk } from '../sdk' -import { certPem } from '../fileModels/certPem' -import { store, ZoneInfo } from '../fileModels/store.yaml' - -/** - * Decode the zone credentials from cert.pem and fetch the zone name from - * the Cloudflare API. Runs reactively when cert.pem changes. - * Stores the result so we only fetch once (or when the cert changes). - */ -export const setupZoneInfo = sdk.setupOnInit(async (effects) => { - const cert = await certPem.read().const(effects) - if (!cert) { - // Not logged in - clear any stale zone info - const conf = await store.read().once() - if (conf?.zoneInfo) { - await store.merge(effects, { zoneInfo: null }) - } - return - } - - // Avoid re-fetching if we already have zone info from the same cert - const existing = await store.read().once() - - // Decode cert.pem: PEM-wrapped base64 JSON { zoneID, accountID, apiToken } - let decoded: any - try { - const lines = cert - .split('\n') - .filter((l) => l && !l.startsWith('-----')) - decoded = JSON.parse( - Buffer.from(lines.join(''), 'base64').toString('utf8'), - ) - } catch (e) { - console.error(`Failed to decode cert.pem: ${String(e)}`) - return - } - - if (existing?.zoneInfo?.zoneId === decoded.zoneID) return - - // Fetch zone name from Cloudflare API - let zoneInfo: ZoneInfo - try { - const resp = await fetch( - `https://api.cloudflare.com/client/v4/zones/${decoded.zoneID}`, - { - headers: { - Authorization: `Bearer ${decoded.apiToken}`, - 'Content-Type': 'application/json', - }, - }, - ) - const data = (await resp.json()) as any - if (!data.success) { - throw new Error(`CF API error: ${JSON.stringify(data.errors)}`) - } - - zoneInfo = { - zoneId: decoded.zoneID, - zoneName: data.result.name, - accountId: decoded.accountID, - apiToken: decoded.apiToken, - } - } catch (e) { - console.error(`Failed to fetch zone info from cert.pem: ${String(e)}`) - return - } - - await store.merge(effects, { zoneInfo }) - console.info(`Zone info stored: ${zoneInfo.zoneName} (${zoneInfo.zoneId})`) -}) diff --git a/startos/init/setupZones.ts b/startos/init/setupZones.ts new file mode 100644 index 0000000..4ed4a5d --- /dev/null +++ b/startos/init/setupZones.ts @@ -0,0 +1,68 @@ +import { sdk } from '../sdk' +import { certPem, decodeCert, zoneCertSubpath } from '../fileModels/certPem' +import { store } from '../fileModels/store.yaml' + +const CF_API = 'https://api.cloudflare.com/client/v4' + +/** + * Runs reactively when cert.pem changes (new login). + * Decodes the cert, fetches zone name, copies cert to a zone-specific file, + * and adds the zone to the store's zones map. + * Existing zones are preserved. + */ +export const setupZones = sdk.setupOnInit(async (effects) => { + const cert = await certPem.read().const(effects) + if (!cert) return + + // Decode cert + let decoded: { zoneID: string; accountID: string; apiToken: string } + try { + decoded = decodeCert(cert) + } catch (e) { + console.error(`Failed to decode cert.pem: ${String(e)}`) + return + } + + // Skip if this zone is already registered + const existing = await store.read().once() + if (existing?.zones?.[decoded.zoneID]) return + + // Fetch zone name from Cloudflare API + let zoneName: string + try { + const resp = await fetch(`${CF_API}/zones/${decoded.zoneID}`, { + headers: { + Authorization: `Bearer ${decoded.apiToken}`, + 'Content-Type': 'application/json', + }, + }) + const data = (await resp.json()) as any + if (!data.success) throw new Error(JSON.stringify(data.errors)) + zoneName = data.result.name + } catch (e) { + console.error(`Failed to fetch zone name: ${String(e)}`) + return + } + + // Copy cert.pem to a zone-specific file so it survives future logins, + // then delete cert.pem so the state is always in the zones map + await sdk.volumes.main.writeFile(zoneCertSubpath(decoded.zoneID), cert) + try { + const { unlink } = await import('node:fs/promises') + await unlink(sdk.volumes.main.subpath('/.cloudflared/cert.pem')) + } catch {} + + // Store the zone + await store.merge(effects, { + zones: { + [decoded.zoneID]: { + zoneId: decoded.zoneID, + zoneName, + accountId: decoded.accountID, + apiToken: decoded.apiToken, + }, + }, + }) + + console.info(`Zone registered: ${zoneName} (${decoded.zoneID})`) +}) From e7f8eec7dffe5b96ee7cebcb4519aaf433fcdaa7 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 19:27:55 +0200 Subject: [PATCH 24/36] feat: add Import Public Hostnames action --- startos/actions/importPublicHostnames.ts | 179 ++++++++++++++++++++++ startos/actions/index.ts | 2 + startos/i18n/dictionaries/default.ts | 6 + startos/i18n/dictionaries/translations.ts | 26 +++- 4 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 startos/actions/importPublicHostnames.ts diff --git a/startos/actions/importPublicHostnames.ts b/startos/actions/importPublicHostnames.ts new file mode 100644 index 0000000..bf88548 --- /dev/null +++ b/startos/actions/importPublicHostnames.ts @@ -0,0 +1,179 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +import { fetchIngressFromApi } from '../cfApi' +import { i18n } from '../i18n' + +/** + * 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 + } +} + +export const importPublicHostnames = sdk.Action.withoutInput( + 'import-public-hostnames', + + async () => ({ + name: i18n('Import Public Hostnames'), + description: i18n( + 'Scan existing public hostnames from your Cloudflare tunnel and add URLs to matching installed services.', + ), + warning: i18n( + 'This will scan existing public hostnames from the Cloudflare tunnel and add URLs to matching installed services.', + ), + allowedStatuses: 'any', + group: 'Import', + visibility: 'enabled', + }), + + async ({ effects }) => { + const conf = await store.read().once() + + if (!conf?.tunnel) { + return { + version: '1' as const, + title: i18n('No Tunnel Configured'), + message: i18n('Select a Cloudflare tunnel first (run "Cloudflare Tunnel" action).'), + result: null, + } + } + + // Need at least one zone to make API calls + const firstZone = Object.values(conf.zones ?? {}).find(Boolean) + if (!firstZone) { + return { + version: '1' as const, + title: i18n('No Zone Configured'), + message: i18n('Login to Cloudflare first (run "Login to Cloudflare" action) to configure a DNS zone.'), + result: null, + } + } + + const existingHostnames = new Set( + Object.keys(conf.ingress ?? {}).filter((h) => !!conf.ingress?.[h]), + ) + + // Fetch all ingress rules from Cloudflare + const cfRules = await fetchIngressFromApi( + firstZone.accountId, + conf.tunnel.id, + firstZone.apiToken, + ) + + // Only consider rules not already tracked locally + const newRules = cfRules.filter((r) => !existingHostnames.has(r.hostname)) + + if (newRules.length === 0) { + return { + version: '1' as const, + title: i18n('Import Public Hostnames'), + message: i18n('No new public hostnames found in Cloudflare that are not already tracked.'), + result: null, + } + } + + // Get all installed packages and their interfaces + const packageIds = await effects.getInstalledPackages() + const interfaceMap: Map = new Map() + + for (const pkgId of packageIds) { + try { + const interfaces = await effects.listServiceInterfaces({ packageId: pkgId }) + for (const [ifaceId, iface] of Object.entries(interfaces)) { + 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 }) + } + } catch { + // package may not be running / no interfaces yet — skip + } + } + + // Build a lookup of known zone names → zoneId so we can filter and tag hostnames + const knownZones = Object.entries(conf.zones ?? {}) + .filter((e): e is [string, NonNullable] => !!e[1]) + + let imported = 0 + const skipped: string[] = [] + const ingressUpdates: Record = {} + + for (const rule of newRules) { + // 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) + if (!matchedZone) { + skipped.push(`${rule.hostname} (not in any configured zone)`) + continue + } + const zoneId = matchedZone[0] + + const parsed = parseServiceUrl(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 + + let match: { packageId: string | null; interfaceId: string; hostId: string; internalPort: number } | undefined + + if (packageId) { + const candidates = interfaceMap.get(key) + match = candidates?.[0] // take the first matching interface for this package+port + } + + if (!match && packageId) { + skipped.push(`${rule.hostname} (no matching interface found for ${packageId}:${internalPort})`) + continue + } + + ingressUpdates[rule.hostname] = { + packageId: packageId ?? null, + hostId: match?.hostId ?? 'main', + interfaceId: match?.interfaceId ?? 'main', + internalPort, + service: rule.service, + zoneId, + } + imported++ + } + + if (imported > 0) { + await store.merge(effects, { ingress: ingressUpdates }) + await effects.restart() + } + + const lines: string[] = [] + if (imported > 0) lines.push(`Imported ${imported} hostname${imported === 1 ? '' : 's'}: ${Object.keys(ingressUpdates).join(', ')}`) + if (skipped.length > 0) lines.push(`Skipped ${skipped.length}: ${skipped.join('; ')}`) + + return { + version: '1' as const, + title: i18n('Import Public Hostnames'), + message: lines.join('\n') || i18n('No new public hostnames found in Cloudflare that are not already tracked.'), + result: null, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts index e91b54b..faf756d 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -4,6 +4,7 @@ import { selectTunnel } from './selectTunnel' import { removeZone } from './removeZone' import { addPublicHostname } from './addPublicHostname' import { deletePublicHostname } from './deletePublicHostname' +import { importPublicHostnames } from './importPublicHostnames' export const actions = sdk.Actions.of() .addAction(cloudflareLogin) @@ -11,3 +12,4 @@ export const actions = sdk.Actions.of() .addAction(removeZone) .addAction(addPublicHostname) .addAction(deletePublicHostname) + .addAction(importPublicHostnames) diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index 80a4b3a..baa8cb6 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -43,6 +43,12 @@ const dict = { 'DNS record created automatically.': 242, 'Add a CNAME record manually in the Cloudflare dashboard (proxied).': 243, + // actions/importPublicHostnames.ts + 'Import Public Hostnames': 260, + '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, + // actions/deletePublicHostname.ts 'Delete Public Hostname': 250, 'Remove a Cloudflare public hostname route': 251, diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index 63dc76f..3702349 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -44,6 +44,12 @@ export default { 242: 'Registro DNS creado automáticamente.', 243: 'Agrega un registro CNAME manualmente en el panel de Cloudflare (con proxy).', + // actions/importPublicHostnames.ts + 260: 'Importar nombres de host públicos', + 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.', + // actions/deletePublicHostname.ts 250: 'Eliminar nombre de host público', 251: 'Elimina una ruta de nombre de host público de Cloudflare', @@ -92,6 +98,12 @@ export default { 242: 'DNS-Eintrag automatisch erstellt.', 243: 'Füge manuell einen CNAME-Eintrag im Cloudflare-Dashboard hinzu (mit Proxy).', + // actions/importPublicHostnames.ts + 260: 'Öffentliche Hostnamen importieren', + 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.', + // actions/deletePublicHostname.ts 250: 'Öffentlichen Hostnamen löschen', 251: 'Entfernt eine öffentliche Cloudflare-Hostname-Route', @@ -140,6 +152,12 @@ export default { 242: 'Rekord DNS utworzony automatycznie.', 243: 'Dodaj ręcznie rekord CNAME w panelu Cloudflare (z proxy).', + // actions/importPublicHostnames.ts + 260: 'Importuj publiczne nazwy hostów', + 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.', + // actions/deletePublicHostname.ts 250: 'Usuń publiczną nazwę hosta', 251: 'Usuwa trasę publicznej nazwy hosta Cloudflare', @@ -188,8 +206,14 @@ export default { 242: 'Enregistrement DNS créé automatiquement.', 243: 'Ajoutez manuellement un enregistrement CNAME dans le tableau de bord Cloudflare (avec proxy).', + // actions/importPublicHostnames.ts + 260: "Importer les noms d'h\u00f4tes publics", + 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.", + // actions/deletePublicHostname.ts - 250: 'Supprimer le nom d\'hôte public', + 250: "Supprimer le nom d'h\u00f4te public", 251: 'Supprime une route de nom d\'hôte public Cloudflare', 252: 'Cela supprimera ce nom d\'hôte de votre tunnel Cloudflare et l\'enregistrement DNS de Cloudflare.', }, From 05d4f9fccded564f1413cae43d89e03cea7a5a5a Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 20:58:44 +0200 Subject: [PATCH 25/36] fix: tighten cloudflare tunnel account handling --- startos/actions/addPublicHostname.ts | 42 ++++++++++++++++++------- startos/actions/deletePublicHostname.ts | 34 ++++++++++---------- startos/actions/selectTunnel.ts | 4 ++- startos/cfRunner.ts | 5 ++- startos/fileModels/store.yaml.ts | 1 + 5 files changed, 57 insertions(+), 29 deletions(-) diff --git a/startos/actions/addPublicHostname.ts b/startos/actions/addPublicHostname.ts index 8871060..0f0c23b 100644 --- a/startos/actions/addPublicHostname.ts +++ b/startos/actions/addPublicHostname.ts @@ -100,26 +100,46 @@ export const addPublicHostname = sdk.Action.withInput( } const zone = conf.zones[zoneId] + const tunnelAccountId = conf.tunnel.accountId || zone.accountId + + if (conf.tunnel.accountId && zone.accountId !== conf.tunnel.accountId) { + return { + version: '1' as const, + title: 'Cloudflare Account Mismatch', + message: 'The selected domain belongs to a different Cloudflare account than the selected tunnel. Re-run the Cloudflare Tunnel action and choose a tunnel from this account, or pick a domain from the tunnel account.', + result: null, + } + } + 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, + internalPort, + service, + zoneId, + } + const nextIngress = { + ...(conf.ingress ?? {}), + [hostname]: nextEntry, + } + + // Push to Cloudflare first so local state only changes after the remote config is updated. + await pushIngressToApi(zone.accountId, tunnelId, zone.apiToken, nextIngress) - // Persist ingress entry and push to Cloudflare API await store.merge(effects, { + tunnel: { + ...conf.tunnel, + accountId: tunnelAccountId, + }, ingress: { - [hostname]: { - packageId: packageId === 'STARTOS' ? null : packageId, - hostId, - interfaceId, - internalPort, - service, - zoneId, - }, + [hostname]: nextEntry, }, }) - const updated = await store.read().once() - await pushIngressToApi(zone.accountId, tunnelId, zone.apiToken, updated?.ingress ?? {}) // Create DNS CNAME via cloudflared CLI using zone-specific cert const certSubpath = zoneCertSubpath(zoneId) diff --git a/startos/actions/deletePublicHostname.ts b/startos/actions/deletePublicHostname.ts index 985b164..2237807 100644 --- a/startos/actions/deletePublicHostname.ts +++ b/startos/actions/deletePublicHostname.ts @@ -43,29 +43,31 @@ export const deletePublicHostname = sdk.Action.withInput( async ({ effects, input }) => { const { hostname } = input.urlPluginMetadata - // Read before mutating so we can look up the zone for DNS deletion + // Read before mutating so we can look up the zone for the remote update and DNS deletion. const conf = await store.read().once() + const entry = conf?.ingress?.[hostname] + const zoneId = entry?.zoneId + const zone = zoneId ? conf?.zones?.[zoneId] : undefined + + if (conf?.tunnel) { + if (!zone) { + throw new Error( + `No Cloudflare zone credentials found for ${hostname}. Refusing to remove the local entry before the remote tunnel config is updated.`, + ) + } + + const nextIngress = { ...(conf.ingress ?? {}) } + delete nextIngress[hostname] + + // Push to Cloudflare first so local state only changes after the remote config is updated. + await pushIngressToApi(zone.accountId, conf.tunnel.id, zone.apiToken, nextIngress) + } - // Remove from store and push updated ingress to CF API await store.merge(effects, { ingress: { [hostname]: undefined } as any, }) - const updated = await store.read().once() - const accountId = Object.values(updated?.zones ?? {})[0]?.accountId ?? '' - if (updated?.tunnel && accountId) { - const anyZoneToken = Object.values(updated.zones ?? {})[0]?.apiToken ?? '' - await pushIngressToApi( - accountId, - updated.tunnel.id, - anyZoneToken, - updated.ingress ?? {}, - ) - } // Delete DNS record using the zone-specific token from the ingress entry - const entry = conf?.ingress?.[hostname] - const zoneId = entry?.zoneId - const zone = zoneId ? updated?.zones?.[zoneId] : undefined if (zone) { await deleteDnsRecord(zoneId!, hostname, zone.apiToken) } else { diff --git a/startos/actions/selectTunnel.ts b/startos/actions/selectTunnel.ts index 64f556b..67e9930 100644 --- a/startos/actions/selectTunnel.ts +++ b/startos/actions/selectTunnel.ts @@ -180,8 +180,10 @@ export const selectTunnel = sdk.Action.withInput( } catch { /* file didn't exist, that's fine */ } await runCf(effects, ['tunnel', 'token', `--cred-file=${credFile}`, tunnelId], 30_000, origincert) + const tunnelAccountId = firstZoneId ? conf?.zones?.[firstZoneId]?.accountId ?? '' : '' + await store.merge(effects, { - tunnel: { id: tunnelId, name: tunnelName }, + tunnel: { id: tunnelId, name: tunnelName, accountId: tunnelAccountId }, }) console.info(`Tunnel set to: ${tunnelName} (${tunnelId})`) diff --git a/startos/cfRunner.ts b/startos/cfRunner.ts index 3210108..d9a74cb 100644 --- a/startos/cfRunner.ts +++ b/startos/cfRunner.ts @@ -4,6 +4,9 @@ import { T } from '@start9labs/start-sdk' /** * Standard mounts for cloudflared CLI subcontainers. * Mounts the main volume at /root/data and .cloudflared at /root/.cloudflared. + * + * .cloudflared must be writable here because tunnel token commands write + * credentials into the mounted volume. */ export const cfMounts = sdk.Mounts.of() .mountVolume({ @@ -16,7 +19,7 @@ export const cfMounts = sdk.Mounts.of() volumeId: 'main', subpath: '.cloudflared', mountpoint: '/root/.cloudflared', - readonly: true, + readonly: false, }) /** diff --git a/startos/fileModels/store.yaml.ts b/startos/fileModels/store.yaml.ts index 03eaad2..cea9ae9 100644 --- a/startos/fileModels/store.yaml.ts +++ b/startos/fileModels/store.yaml.ts @@ -15,6 +15,7 @@ export type IngressEntry = z.infer export const tunnelInfoShape = z.object({ id: z.string(), name: z.string(), + accountId: z.string().catch(''), }) export type TunnelInfo = z.infer From 7730b2cf6ab2c536fcf028765c049a3736c0a87e Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 23:09:22 +0200 Subject: [PATCH 26/36] fix: harden Cloudflare API error handling --- startos/actions/addPublicHostname.ts | 52 +++++++- startos/actions/deletePublicHostname.ts | 37 +++++- startos/actions/importPublicHostnames.ts | 24 +++- startos/actions/selectTunnel.ts | 6 +- startos/cfApi.ts | 160 ++++++++++++++++++----- 5 files changed, 233 insertions(+), 46 deletions(-) diff --git a/startos/actions/addPublicHostname.ts b/startos/actions/addPublicHostname.ts index 0f0c23b..b0ad027 100644 --- a/startos/actions/addPublicHostname.ts +++ b/startos/actions/addPublicHostname.ts @@ -1,11 +1,30 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { pushIngressToApi } from '../cfApi' +import { pushIngressToApi, summarizeCloudflareError } from '../cfApi' import { zoneCertSubpath } from '../fileModels/certPem' import { i18n } from '../i18n' const { InputSpec, Value, Variants } = sdk +function summarizeDnsRouteFailure(output: string): string { + const trimmed = output.trim() + const lower = trimmed.toLowerCase() + + if (!trimmed) return 'Cloudflare did not return a detailed DNS error.' + if (lower.includes('already exists')) { + return 'Cloudflare reports that a DNS record for this hostname already exists.' + } + if (lower.includes('authentication') || lower.includes('unauthorized')) { + return 'Cloudflare rejected the DNS update because authentication failed.' + } + if (lower.includes('not found')) { + return 'Cloudflare could not find the requested tunnel or DNS zone.' + } + + const lastLine = trimmed.split('\n').filter(Boolean).at(-1) ?? trimmed + return lastLine.length > 180 ? `${lastLine.slice(0, 177)}...` : lastLine +} + const inputSpec = InputSpec.of({ urlPluginMetadata: Value.hidden<{ packageId: string @@ -129,7 +148,18 @@ export const addPublicHostname = sdk.Action.withInput( } // Push to Cloudflare first so local state only changes after the remote config is updated. - await pushIngressToApi(zone.accountId, tunnelId, zone.apiToken, nextIngress) + try { + await pushIngressToApi(zone.accountId, tunnelId, zone.apiToken, nextIngress) + } catch (error) { + const summary = summarizeCloudflareError(error) + console.error(`Failed to update Cloudflare tunnel config for ${hostname}: ${summary}`) + return { + version: '1' as const, + title: 'Cloudflare Update Failed', + message: `Could not update the Cloudflare tunnel configuration for ${hostname}. ${summary}`, + result: null, + } + } await store.merge(effects, { tunnel: { @@ -150,6 +180,7 @@ export const addPublicHostname = sdk.Action.withInput( } catch {} let dnsCreated = false + let dnsFailureDetail: string | null = null if (certExists) { await sdk.SubContainer.withTemp( effects, @@ -171,11 +202,20 @@ export const addPublicHostname = sdk.Action.withInput( ) if (result.stdout) console.info(result.stdout) if (result.stderr) console.info(result.stderr) - if (result.exitCode === 0) dnsCreated = true - else console.error(`DNS route creation failed. Add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`) + if (result.exitCode === 0) { + dnsCreated = true + } else { + dnsFailureDetail = summarizeDnsRouteFailure( + `${result.stderr || ''}\n${result.stdout || ''}`, + ) + console.error( + `DNS route creation failed for ${hostname}: ${dnsFailureDetail}. Add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`, + ) + } }, ) } else { + dnsFailureDetail = 'No zone certificate is available for this zone.' console.info(`No cert for zone ${zoneId} - add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`) } @@ -186,7 +226,9 @@ export const addPublicHostname = sdk.Action.withInput( title: i18n('Public Hostname Added'), message: dnsCreated ? `${hostname} is now routed to this service. ${i18n('DNS record created automatically.')}` - : `${hostname} is now routed to this service. ${i18n('Add a CNAME record manually in the Cloudflare dashboard (proxied).')} ${hostname} -> ${tunnelId}.cfargotunnel.com`, + : dnsFailureDetail + ? `${hostname} is now routed to this service, but automatic DNS creation failed: ${dnsFailureDetail} ${i18n('Add a CNAME record manually in the Cloudflare dashboard (proxied).')} ${hostname} -> ${tunnelId}.cfargotunnel.com` + : `${hostname} is now routed to this service. ${i18n('Add a CNAME record manually in the Cloudflare dashboard (proxied).')} ${hostname} -> ${tunnelId}.cfargotunnel.com`, result: { type: 'single' as const, value: `https://${hostname}`, diff --git a/startos/actions/deletePublicHostname.ts b/startos/actions/deletePublicHostname.ts index 2237807..4111786 100644 --- a/startos/actions/deletePublicHostname.ts +++ b/startos/actions/deletePublicHostname.ts @@ -1,6 +1,6 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { pushIngressToApi, deleteDnsRecord } from '../cfApi' +import { pushIngressToApi, deleteDnsRecord, summarizeCloudflareError } from '../cfApi' import { i18n } from '../i18n' const { InputSpec, Value } = sdk @@ -60,7 +60,18 @@ export const deletePublicHostname = sdk.Action.withInput( delete nextIngress[hostname] // Push to Cloudflare first so local state only changes after the remote config is updated. - await pushIngressToApi(zone.accountId, conf.tunnel.id, zone.apiToken, nextIngress) + try { + await pushIngressToApi(zone.accountId, conf.tunnel.id, zone.apiToken, nextIngress) + } catch (error) { + const summary = summarizeCloudflareError(error) + console.error(`Failed to update Cloudflare tunnel config while removing ${hostname}: ${summary}`) + return { + version: '1' as const, + title: 'Cloudflare Update Failed', + message: `Could not remove ${hostname} from the Cloudflare tunnel configuration. ${summary}`, + result: null, + } + } } await store.merge(effects, { @@ -68,15 +79,35 @@ export const deletePublicHostname = sdk.Action.withInput( }) // Delete DNS record using the zone-specific token from the ingress entry + let dnsWarning: string | null = null if (zone) { - await deleteDnsRecord(zoneId!, hostname, zone.apiToken) + try { + const dnsResult = await deleteDnsRecord(zoneId!, hostname, zone.apiToken) + if (dnsResult.errors.length > 0) { + dnsWarning = `The Cloudflare tunnel was updated, but deleting the DNS record failed: ${dnsResult.errors[0]}` + } + } catch (error) { + const summary = summarizeCloudflareError(error) + console.error(`Failed to delete DNS record for ${hostname}: ${summary}`) + dnsWarning = `The Cloudflare tunnel was updated, but deleting the DNS record failed: ${summary}` + } } else { console.info(`No zone info for ${hostname} - delete DNS record manually`) + dnsWarning = 'The Cloudflare tunnel was updated, but this package could not determine which zone to use for deleting the DNS record automatically.' } // Restart the daemon so cloudflared picks up the change await effects.restart() console.info(`Public hostname ${hostname} removed`) + + if (dnsWarning) { + return { + version: '1' as const, + title: 'Public Hostname Removed with DNS Warning', + message: dnsWarning, + result: null, + } + } }, ) diff --git a/startos/actions/importPublicHostnames.ts b/startos/actions/importPublicHostnames.ts index bf88548..97994bb 100644 --- a/startos/actions/importPublicHostnames.ts +++ b/startos/actions/importPublicHostnames.ts @@ -1,6 +1,6 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { fetchIngressFromApi } from '../cfApi' +import { fetchIngressFromApi, summarizeCloudflareError } from '../cfApi' import { i18n } from '../i18n' /** @@ -75,11 +75,23 @@ export const importPublicHostnames = sdk.Action.withoutInput( ) // Fetch all ingress rules from Cloudflare - const cfRules = await fetchIngressFromApi( - firstZone.accountId, - conf.tunnel.id, - firstZone.apiToken, - ) + let cfRules: Array<{ hostname: string; service: string }> + try { + cfRules = await fetchIngressFromApi( + firstZone.accountId, + conf.tunnel.id, + firstZone.apiToken, + ) + } catch (error) { + const summary = summarizeCloudflareError(error) + console.error(`Failed to import public hostnames from Cloudflare: ${summary}`) + return { + version: '1' as const, + title: 'Cloudflare Import Failed', + message: `Could not read the Cloudflare tunnel configuration. ${summary}`, + result: null, + } + } // Only consider rules not already tracked locally const newRules = cfRules.filter((r) => !existingHostnames.has(r.hostname)) diff --git a/startos/actions/selectTunnel.ts b/startos/actions/selectTunnel.ts index 67e9930..8f247c4 100644 --- a/startos/actions/selectTunnel.ts +++ b/startos/actions/selectTunnel.ts @@ -85,11 +85,14 @@ export const selectTunnel = sdk.Action.withInput( // Fetch list of available tunnels let tunnels: Array<{ id: string; name: string }> = [] + let tunnelLoadWarning: string | null = null try { const stdout = await runCf(effects!, ['tunnel', 'list', '--output', 'json'], 30_000, certForList) tunnels = parseTunnelList(stdout) } catch (e) { - console.error(`Failed to list tunnels: ${String(e)}`) + const summary = e instanceof Error ? e.message : String(e) + console.error(`Failed to list tunnels: ${summary}`) + tunnelLoadWarning = 'Could not load existing tunnels from Cloudflare. You can still create a new tunnel.' } // Infer server name from mDNS for new tunnel default @@ -127,6 +130,7 @@ export const selectTunnel = sdk.Action.withInput( return { name: 'Tunnel', + warning: tunnelLoadWarning, default: defaultId, disabled: false, variants: Variants.of(variants), diff --git a/startos/cfApi.ts b/startos/cfApi.ts index 5081fb8..f6b98e6 100644 --- a/startos/cfApi.ts +++ b/startos/cfApi.ts @@ -2,6 +2,27 @@ import { IngressEntry } from './fileModels/store.yaml' const CF_API = 'https://api.cloudflare.com/client/v4' +type CloudflareBody = { + success?: boolean + errors?: Array<{ code?: number; message?: string }> + messages?: Array<{ code?: number; message?: string }> + result?: any +} + +export class CloudflareApiError extends Error { + context: string + status: number + body: CloudflareBody | string | null + + constructor(context: string, status: number, body: CloudflareBody | string | null) { + super(`${context}: ${summarizeCloudflareBody(body)}`) + this.name = 'CloudflareApiError' + this.context = context + this.status = status + this.body = body + } +} + function authHeaders(apiToken: string) { return { Authorization: `Bearer ${apiToken}`, @@ -9,6 +30,75 @@ function authHeaders(apiToken: string) { } } +function summarizeCloudflareBody(body: CloudflareBody | string | null | undefined): string { + if (!body) return 'Unknown Cloudflare error' + + if (typeof body === 'string') { + return body.trim() || 'Unknown Cloudflare error' + } + + const parts = [...(body.errors ?? []), ...(body.messages ?? [])] + .map((entry) => { + const code = entry.code ? `#${entry.code} ` : '' + return `${code}${entry.message ?? 'Unknown error'}`.trim() + }) + .filter(Boolean) + + if (parts.length > 0) return parts.join('; ') + return 'Unknown Cloudflare error' +} + +export function summarizeCloudflareError(error: unknown): string { + if (error instanceof CloudflareApiError) { + const status = error.status ? ` (HTTP ${error.status})` : '' + return `${summarizeCloudflareBody(error.body)}${status}` + } + if (error instanceof Error) return error.message + return String(error) +} + +async function parseCloudflareResponse( + resp: Response, + context: string, +): Promise { + let body: CloudflareBody | string | null = null + + try { + body = (await resp.json()) as CloudflareBody + } catch { + try { + body = await resp.text() + } catch { + body = null + } + } + + const success = typeof body === 'object' && body !== null ? body.success : false + if (!resp.ok || !success) { + throw new CloudflareApiError(context, resp.status, body) + } + + return body as CloudflareBody +} + +async function fetchCloudflare( + input: string, + init: RequestInit, + context: string, +): Promise { + try { + const resp = await fetch(input, init) + return await parseCloudflareResponse(resp, context) + } catch (error) { + if (error instanceof CloudflareApiError) throw error + throw new CloudflareApiError( + context, + 0, + error instanceof Error ? error.message : String(error), + ) + } +} + /** * Push ingress rules to the Cloudflare API. * This is the single source of truth for tunnel ingress when source=cloudflare. @@ -30,7 +120,7 @@ export async function pushIngressToApi( // Required catch-all rules.push({ service: 'http_status:404' }) - const resp = await fetch( + await fetchCloudflare( `${CF_API}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, { method: 'PUT', @@ -42,12 +132,8 @@ export async function pushIngressToApi( }, }), }, + `Failed to update Cloudflare tunnel configuration for tunnel ${tunnelId}`, ) - - const data = (await resp.json()) as any - if (!data.success) { - throw new Error(`CF API error: ${JSON.stringify(data.errors)}`) - } } /** @@ -59,20 +145,21 @@ export async function fetchIngressFromApi( tunnelId: string, apiToken: string, ): Promise> { - try { - const resp = await fetch( - `${CF_API}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, - { headers: authHeaders(apiToken) }, - ) - const data = (await resp.json()) as any - if (!data.success) return [] - return ( - (data.result?.config?.ingress as Array<{ hostname?: string; service: string }>) ?? [] - ).filter((r): r is { hostname: string; service: string } => !!r.hostname) - } catch (e) { - console.error(`Failed to fetch ingress from CF API: ${String(e)}`) - return [] - } + 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; service: string }>) ?? [] + ).filter((r): r is { hostname: string; service: string } => !!r.hostname) +} + +export type DeleteDnsRecordResult = { + deletedCount: number + missing: boolean + errors: string[] } /** @@ -82,28 +169,39 @@ export async function deleteDnsRecord( zoneId: string, hostname: string, apiToken: string, -): Promise { - const listResp = await fetch( +): Promise { + const listData = await fetchCloudflare( `${CF_API}/zones/${zoneId}/dns_records?name=${hostname}&type=CNAME`, { headers: authHeaders(apiToken) }, + `Failed to list DNS records for ${hostname}`, ) - const listData = (await listResp.json()) as any const records: Array<{ id: string }> = listData.result ?? [] + const errors: string[] = [] + let deletedCount = 0 for (const record of records) { - const delResp = await fetch( - `${CF_API}/zones/${zoneId}/dns_records/${record.id}`, - { method: 'DELETE', headers: authHeaders(apiToken) }, - ) - const delData = (await delResp.json()) as any - if (delData.success) { + try { + await fetchCloudflare( + `${CF_API}/zones/${zoneId}/dns_records/${record.id}`, + { method: 'DELETE', headers: authHeaders(apiToken) }, + `Failed to delete DNS record ${record.id} for ${hostname}`, + ) + deletedCount += 1 console.info(`DNS record deleted for ${hostname}`) - } else { - console.error(`Failed to delete DNS record for ${hostname}: ${JSON.stringify(delData.errors)}`) + } catch (error) { + const summary = summarizeCloudflareError(error) + errors.push(summary) + console.error(`Failed to delete DNS record for ${hostname}: ${summary}`) } } if (records.length === 0) { console.info(`No DNS CNAME record found for ${hostname}`) } + + return { + deletedCount, + missing: records.length === 0, + errors, + } } From 22199042436525042f9b0b0095115bfa60eda980 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 23:09:36 +0200 Subject: [PATCH 27/36] chore: update remove zone action copy --- startos/actions/removeZone.ts | 2 +- startos/i18n/dictionaries/default.ts | 2 +- startos/i18n/dictionaries/translations.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/startos/actions/removeZone.ts b/startos/actions/removeZone.ts index 738b2fa..e60569d 100644 --- a/startos/actions/removeZone.ts +++ b/startos/actions/removeZone.ts @@ -15,7 +15,7 @@ export const removeZone = sdk.Action.withInput( const count = Object.keys(zones).length return { name: i18n('Remove DNS Zone'), - description: i18n('Remove a Cloudflare DNS zone from this package. Existing public hostnames for this zone will no longer have automatic DNS management.'), + description: i18n('Remove a Cloudflare DNS zone from this package. Existing hostnames in that zone may keep working, but this package will no longer manage their DNS or tunnel routes.'), warning: i18n('Existing DNS records and ingress rules in Cloudflare will NOT be deleted.'), allowedStatuses: 'any' as const, group: 'Configuration', diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index baa8cb6..65dcf83 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -23,7 +23,7 @@ const dict = { // actions/removeZone.ts 'Remove DNS Zone': 220, - 'Remove a Cloudflare DNS zone from this package. Existing public hostnames for this zone will no longer have automatic DNS management.': 221, + 'Remove a Cloudflare DNS zone from this package. Existing hostnames in that zone may keep working, but this package will no longer manage their DNS or tunnel routes.': 221, 'Existing DNS records and ingress rules in Cloudflare will NOT be deleted.': 222, 'No zones configured': 223, diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index 3702349..13b7b91 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -24,7 +24,7 @@ export default { // actions/removeZone.ts 220: 'Eliminar zona DNS', - 221: 'Elimina una zona DNS de Cloudflare de este paquete. Los nombres de host públicos existentes para esta zona ya no tendrán gestión DNS automática.', + 221: 'Elimina una zona DNS de Cloudflare de este paquete. Los nombres de host existentes en esa zona pueden seguir funcionando, pero este paquete ya no gestionará sus rutas DNS ni del túnel.', 222: 'Los registros DNS y las reglas de entrada existentes en Cloudflare NO serán eliminados.', 223: 'No hay zonas configuradas', @@ -78,7 +78,7 @@ export default { // actions/removeZone.ts 220: 'DNS-Zone entfernen', - 221: 'Entfernt eine Cloudflare-DNS-Zone aus diesem Paket. Für diese Zone werden öffentliche Hostnamen nicht mehr automatisch verwaltet.', + 221: 'Entfernt eine Cloudflare-DNS-Zone aus diesem Paket. Vorhandene Hostnamen in dieser Zone funktionieren möglicherweise weiterhin, aber dieses Paket verwaltet deren DNS- oder Tunnel-Routen nicht mehr.', 222: 'Vorhandene DNS-Einträge und Ingress-Regeln in Cloudflare werden NICHT gelöscht.', 223: 'Keine Zonen konfiguriert', @@ -132,7 +132,7 @@ export default { // actions/removeZone.ts 220: 'Usuń strefę DNS', - 221: 'Usuwa strefę DNS Cloudflare z tego pakietu. Istniejące publiczne nazwy hostów dla tej strefy nie będą już miały automatycznego zarządzania DNS.', + 221: 'Usuwa strefę DNS Cloudflare z tego pakietu. Istniejące nazwy hostów w tej strefie mogą nadal działać, ale ten pakiet nie będzie już zarządzał ich trasami DNS ani tunelu.', 222: 'Istniejące rekordy DNS i reguły przychodzące w Cloudflare NIE zostaną usunięte.', 223: 'Brak skonfigurowanych stref', @@ -186,7 +186,7 @@ export default { // actions/removeZone.ts 220: 'Supprimer la zone DNS', - 221: 'Supprime une zone DNS Cloudflare de ce paquet. Les noms d\'hôtes publics existants pour cette zone n\'auront plus de gestion DNS automatique.', + 221: 'Supprime une zone DNS Cloudflare de ce paquet. Les noms d\'hôte existants dans cette zone peuvent continuer à fonctionner, mais ce paquet ne gérera plus leurs routes DNS ni de tunnel.', 222: 'Les enregistrements DNS et les règles d\'entrée existants dans Cloudflare ne seront PAS supprimés.', 223: 'Aucune zone configurée', From 7fdbc88a9291a1284fbb59f87f899d0f25014a33 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 23:18:56 +0200 Subject: [PATCH 28/36] refactor: remove unnecessary action const assertions / run prettier --- Dockerfile | 4 +- startos/actions/addPublicHostname.ts | 79 +++++++++++----- startos/actions/cloudflareLogin.ts | 39 +++++--- startos/actions/deletePublicHostname.ts | 34 +++++-- startos/actions/importPublicHostnames.ts | 109 +++++++++++++++++----- startos/actions/removeZone.ts | 23 +++-- startos/actions/selectTunnel.ts | 91 ++++++++++++++---- startos/cfApi.ts | 18 +++- startos/i18n/dictionaries/default.ts | 6 +- startos/i18n/dictionaries/translations.ts | 22 ++--- startos/index.ts | 2 +- startos/plugin/url.ts | 3 +- startos/sdk.ts | 4 +- startos/versions/v2026.3.0.ts | 3 +- 14 files changed, 320 insertions(+), 117 deletions(-) diff --git a/Dockerfile b/Dockerfile index 623ef82..39ac9c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # cloudflared container version is defined in manifest.ts -ARG CLOUDFLARED_IMAGE +ARG CLOUDFLARED_IMAGE=cloudflare/cloudflared:latest # used to copy cloudflared binary -FROM $CLOUDFLARED_IMAGE as cloudflared +FROM $CLOUDFLARED_IMAGE AS cloudflared # run on debian bookworm slim FROM debian:12-slim diff --git a/startos/actions/addPublicHostname.ts b/startos/actions/addPublicHostname.ts index b0ad027..e6b90c7 100644 --- a/startos/actions/addPublicHostname.ts +++ b/startos/actions/addPublicHostname.ts @@ -50,7 +50,10 @@ const inputSpec = InputSpec.of({ domain: Value.dynamicUnion(async ({ effects }) => { const conf = await store.read().once() const zones = conf?.zones ?? {} - const variants: Record }> = {} + const variants: Record< + string, + { name: string; spec: ReturnType } + > = {} for (const [id, z] of Object.entries(zones)) { if (!z) continue @@ -58,7 +61,10 @@ const inputSpec = InputSpec.of({ } if (Object.keys(variants).length === 0) { - variants['none'] = { name: i18n('Login to Cloudflare to see your domains'), spec: InputSpec.of({}) } + variants['none'] = { + name: i18n('Login to Cloudflare to see your domains'), + spec: InputSpec.of({}), + } } return { @@ -94,7 +100,8 @@ export const addPublicHostname = sdk.Action.withInput( }, async ({ effects, input }) => { - const { packageId, internalPort, interfaceId, hostId } = input.urlPluginMetadata + const { packageId, internalPort, interfaceId, hostId } = + input.urlPluginMetadata const subdomain = input.subdomain.trim().toLowerCase() const zoneId = (input.domain as { selection: string }).selection @@ -102,18 +109,22 @@ export const addPublicHostname = sdk.Action.withInput( if (zoneId === 'none' || !conf?.zones?.[zoneId]) { return { - version: '1' as const, + version: '1', title: i18n('No Zone Configured'), - message: i18n('Login to Cloudflare first (run "Login to Cloudflare" action) to configure a DNS zone.'), + message: i18n( + 'Login to Cloudflare first (run "Login to Cloudflare" action) to configure a DNS zone.', + ), result: null, } } if (!conf.tunnel) { return { - version: '1' as const, + version: '1', title: i18n('No Tunnel Configured'), - message: i18n('Select a Cloudflare tunnel first (run "Cloudflare Tunnel" action).'), + message: i18n( + 'Select a Cloudflare tunnel first (run "Cloudflare Tunnel" action).', + ), result: null, } } @@ -123,9 +134,10 @@ export const addPublicHostname = sdk.Action.withInput( if (conf.tunnel.accountId && zone.accountId !== conf.tunnel.accountId) { return { - version: '1' as const, + version: '1', title: 'Cloudflare Account Mismatch', - message: 'The selected domain belongs to a different Cloudflare account than the selected tunnel. Re-run the Cloudflare Tunnel action and choose a tunnel from this account, or pick a domain from the tunnel account.', + message: + 'The selected domain belongs to a different Cloudflare account than the selected tunnel. Re-run the Cloudflare Tunnel action and choose a tunnel from this account, or pick a domain from the tunnel account.', result: null, } } @@ -149,12 +161,19 @@ export const addPublicHostname = sdk.Action.withInput( // Push to Cloudflare first so local state only changes after the remote config is updated. try { - await pushIngressToApi(zone.accountId, tunnelId, zone.apiToken, nextIngress) + await pushIngressToApi( + zone.accountId, + tunnelId, + zone.apiToken, + nextIngress, + ) } catch (error) { const summary = summarizeCloudflareError(error) - console.error(`Failed to update Cloudflare tunnel config for ${hostname}: ${summary}`) + console.error( + `Failed to update Cloudflare tunnel config for ${hostname}: ${summary}`, + ) return { - version: '1' as const, + version: '1', title: 'Cloudflare Update Failed', message: `Could not update the Cloudflare tunnel configuration for ${hostname}. ${summary}`, result: null, @@ -186,19 +205,35 @@ export const addPublicHostname = sdk.Action.withInput( effects, { imageId: 'main' }, sdk.Mounts.of() - .mountVolume({ volumeId: 'main', subpath: null, mountpoint: '/root/data', readonly: false }) - .mountVolume({ volumeId: 'main', subpath: '.cloudflared', mountpoint: '/root/.cloudflared', readonly: true }), + .mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/root/data', + readonly: false, + }) + .mountVolume({ + volumeId: 'main', + subpath: '.cloudflared', + mountpoint: '/root/.cloudflared', + readonly: true, + }), 'route-dns', async (sub) => { const certPath = `/root/.cloudflared/zone-${zoneId}.pem` const result = await sub.exec( [ - '/usr/local/bin/cloudflared', '--no-autoupdate', + '/usr/local/bin/cloudflared', + '--no-autoupdate', `--origincert=${certPath}`, - 'tunnel', 'route', 'dns', '--overwrite-dns', - tunnelId, hostname, + 'tunnel', + 'route', + 'dns', + '--overwrite-dns', + tunnelId, + hostname, ], - {}, 30_000, + {}, + 30_000, ) if (result.stdout) console.info(result.stdout) if (result.stderr) console.info(result.stderr) @@ -216,13 +251,15 @@ export const addPublicHostname = sdk.Action.withInput( ) } else { dnsFailureDetail = 'No zone certificate is available for this zone.' - console.info(`No cert for zone ${zoneId} - add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`) + console.info( + `No cert for zone ${zoneId} - add CNAME manually: ${hostname} -> ${tunnelId}.cfargotunnel.com`, + ) } await effects.restart() return { - version: '1' as const, + version: '1', title: i18n('Public Hostname Added'), message: dnsCreated ? `${hostname} is now routed to this service. ${i18n('DNS record created automatically.')}` @@ -230,7 +267,7 @@ export const addPublicHostname = sdk.Action.withInput( ? `${hostname} is now routed to this service, but automatic DNS creation failed: ${dnsFailureDetail} ${i18n('Add a CNAME record manually in the Cloudflare dashboard (proxied).')} ${hostname} -> ${tunnelId}.cfargotunnel.com` : `${hostname} is now routed to this service. ${i18n('Add a CNAME record manually in the Cloudflare dashboard (proxied).')} ${hostname} -> ${tunnelId}.cfargotunnel.com`, result: { - type: 'single' as const, + type: 'single', value: `https://${hostname}`, copyable: true, qr: false, diff --git a/startos/actions/cloudflareLogin.ts b/startos/actions/cloudflareLogin.ts index 6cfe050..18f5499 100644 --- a/startos/actions/cloudflareLogin.ts +++ b/startos/actions/cloudflareLogin.ts @@ -23,13 +23,18 @@ export const cloudflareLogin = sdk.Action.withoutInput( async ({ effects }) => { const conf = await store.read().const(effects) - const zoneNames = Object.values(conf?.zones ?? {}).filter(Boolean).map((z) => z!.zoneName) - const nameLabel = zoneNames.length === 0 - ? i18n('Login to Cloudflare') - : i18n('Add DNS Zone') + const zoneNames = Object.values(conf?.zones ?? {}) + .filter(Boolean) + .map((z) => z!.zoneName) + const nameLabel = + zoneNames.length === 0 + ? i18n('Login to Cloudflare') + : i18n('Add DNS Zone') return { name: nameLabel, - description: i18n('Authenticates with a Cloudflare DNS zone. Run this action again to add additional zones.'), + description: i18n( + 'Authenticates with a Cloudflare DNS zone. Run this action again to add additional zones.', + ), warning: null, allowedStatuses: 'any', group: 'Configuration', @@ -43,9 +48,17 @@ export const cloudflareLogin = sdk.Action.withoutInput( // Fire and forget - cf-login.sh starts cloudflared login, extracts the auth URL, // writes it to the volume, then waits up to 10 min for auth to complete - sdk.SubContainer.withTemp(effects, { imageId: 'main' }, mounts, 'cf-login', + sdk.SubContainer.withTemp( + effects, + { imageId: 'main' }, + mounts, + 'cf-login', async (sub) => { - const result = await sub.exec(['/usr/local/bin/cf-login.sh'], {}, 10 * 60 * 1000) + const result = await sub.exec( + ['/usr/local/bin/cf-login.sh'], + {}, + 10 * 60 * 1000, + ) if (result.stdout) console.info(result.stdout) if (result.stderr) console.info(result.stderr) if (result.exitCode !== 0) { @@ -59,15 +72,17 @@ export const cloudflareLogin = sdk.Action.withoutInput( while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 2000)) try { - const url = (await sdk.volumes.main.readFile(LOGIN_URL_PATH)).toString().trim() + const url = (await sdk.volumes.main.readFile(LOGIN_URL_PATH)) + .toString() + .trim() if (url.startsWith('https://dash.cloudflare.com')) { return { - version: '1' as const, + version: '1', title: 'Cloudflare Authorization', message: 'Visit the URL below to authorize. After authorizing, DNS routes will be created automatically when you add a public hostname.', result: { - type: 'single' as const, + type: 'single', value: url, copyable: true, qr: false, @@ -80,6 +95,8 @@ export const cloudflareLogin = sdk.Action.withoutInput( } } - throw new Error('Timed out waiting for Cloudflare auth URL. Check the service logs.') + throw new Error( + 'Timed out waiting for Cloudflare auth URL. Check the service logs.', + ) }, ) diff --git a/startos/actions/deletePublicHostname.ts b/startos/actions/deletePublicHostname.ts index 4111786..a61ee8c 100644 --- a/startos/actions/deletePublicHostname.ts +++ b/startos/actions/deletePublicHostname.ts @@ -1,6 +1,10 @@ import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' -import { pushIngressToApi, deleteDnsRecord, summarizeCloudflareError } from '../cfApi' +import { + pushIngressToApi, + deleteDnsRecord, + summarizeCloudflareError, +} from '../cfApi' import { i18n } from '../i18n' const { InputSpec, Value } = sdk @@ -27,7 +31,9 @@ export const deletePublicHostname = sdk.Action.withInput( async () => ({ name: i18n('Delete Public Hostname'), description: i18n('Remove a Cloudflare public hostname route'), - warning: i18n('This will remove this hostname from your Cloudflare tunnel and delete the DNS record from Cloudflare.'), + warning: i18n( + 'This will remove this hostname from your Cloudflare tunnel and delete the DNS record from Cloudflare.', + ), allowedStatuses: 'any', group: null, visibility: 'hidden', @@ -61,12 +67,19 @@ export const deletePublicHostname = sdk.Action.withInput( // Push to Cloudflare first so local state only changes after the remote config is updated. try { - await pushIngressToApi(zone.accountId, conf.tunnel.id, zone.apiToken, nextIngress) + await pushIngressToApi( + zone.accountId, + conf.tunnel.id, + zone.apiToken, + nextIngress, + ) } catch (error) { const summary = summarizeCloudflareError(error) - console.error(`Failed to update Cloudflare tunnel config while removing ${hostname}: ${summary}`) + console.error( + `Failed to update Cloudflare tunnel config while removing ${hostname}: ${summary}`, + ) return { - version: '1' as const, + version: '1', title: 'Cloudflare Update Failed', message: `Could not remove ${hostname} from the Cloudflare tunnel configuration. ${summary}`, result: null, @@ -82,7 +95,11 @@ export const deletePublicHostname = sdk.Action.withInput( let dnsWarning: string | null = null if (zone) { try { - const dnsResult = await deleteDnsRecord(zoneId!, hostname, zone.apiToken) + const dnsResult = await deleteDnsRecord( + zoneId!, + hostname, + zone.apiToken, + ) if (dnsResult.errors.length > 0) { dnsWarning = `The Cloudflare tunnel was updated, but deleting the DNS record failed: ${dnsResult.errors[0]}` } @@ -93,7 +110,8 @@ export const deletePublicHostname = sdk.Action.withInput( } } else { console.info(`No zone info for ${hostname} - delete DNS record manually`) - dnsWarning = 'The Cloudflare tunnel was updated, but this package could not determine which zone to use for deleting the DNS record automatically.' + dnsWarning = + 'The Cloudflare tunnel was updated, but this package could not determine which zone to use for deleting the DNS record automatically.' } // Restart the daemon so cloudflared picks up the change @@ -103,7 +121,7 @@ export const deletePublicHostname = sdk.Action.withInput( if (dnsWarning) { return { - version: '1' as const, + version: '1', title: 'Public Hostname Removed with DNS Warning', message: dnsWarning, result: null, diff --git a/startos/actions/importPublicHostnames.ts b/startos/actions/importPublicHostnames.ts index 97994bb..41e67c4 100644 --- a/startos/actions/importPublicHostnames.ts +++ b/startos/actions/importPublicHostnames.ts @@ -11,7 +11,9 @@ import { i18n } from '../i18n' * http://.startos: (regular services) * http://startos: (STARTOS itself) */ -function parseServiceUrl(service: string): { packageId: string | null; internalPort: number } | null { +function parseServiceUrl( + service: string, +): { packageId: string | null; internalPort: number } | null { try { const url = new URL(service) const port = Number(url.port) @@ -52,9 +54,11 @@ export const importPublicHostnames = sdk.Action.withoutInput( if (!conf?.tunnel) { return { - version: '1' as const, + version: '1', title: i18n('No Tunnel Configured'), - message: i18n('Select a Cloudflare tunnel first (run "Cloudflare Tunnel" action).'), + message: i18n( + 'Select a Cloudflare tunnel first (run "Cloudflare Tunnel" action).', + ), result: null, } } @@ -63,9 +67,11 @@ export const importPublicHostnames = sdk.Action.withoutInput( const firstZone = Object.values(conf.zones ?? {}).find(Boolean) if (!firstZone) { return { - version: '1' as const, + version: '1', title: i18n('No Zone Configured'), - message: i18n('Login to Cloudflare first (run "Login to Cloudflare" action) to configure a DNS zone.'), + message: i18n( + 'Login to Cloudflare first (run "Login to Cloudflare" action) to configure a DNS zone.', + ), result: null, } } @@ -84,9 +90,11 @@ export const importPublicHostnames = sdk.Action.withoutInput( ) } catch (error) { const summary = summarizeCloudflareError(error) - console.error(`Failed to import public hostnames from Cloudflare: ${summary}`) + console.error( + `Failed to import public hostnames from Cloudflare: ${summary}`, + ) return { - version: '1' as const, + version: '1', title: 'Cloudflare Import Failed', message: `Could not read the Cloudflare tunnel configuration. ${summary}`, result: null, @@ -98,25 +106,44 @@ export const importPublicHostnames = sdk.Action.withoutInput( if (newRules.length === 0) { return { - version: '1' as const, + version: '1', title: i18n('Import Public Hostnames'), - message: i18n('No new public hostnames found in Cloudflare that are not already tracked.'), + message: i18n( + 'No new public hostnames found in Cloudflare that are not already tracked.', + ), result: null, } } // Get all installed packages and their interfaces const packageIds = await effects.getInstalledPackages() - const interfaceMap: Map = new Map() + const interfaceMap: Map< + string, + { + packageId: string | null + interfaceId: string + hostId: string + internalPort: number + }[] + > = new Map() for (const pkgId of packageIds) { try { - const interfaces = await effects.listServiceInterfaces({ packageId: pkgId }) + const interfaces = await effects.listServiceInterfaces({ + packageId: pkgId, + }) for (const [ifaceId, iface] of Object.entries(interfaces)) { 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 @@ -124,16 +151,31 @@ export const importPublicHostnames = sdk.Action.withoutInput( } // Build a lookup of known zone names → zoneId so we can filter and tag hostnames - const knownZones = Object.entries(conf.zones ?? {}) - .filter((e): e is [string, NonNullable] => !!e[1]) + const knownZones = Object.entries(conf.zones ?? {}).filter( + (e): e is [string, NonNullable<(typeof e)[1]>] => !!e[1], + ) let imported = 0 const skipped: string[] = [] - const ingressUpdates: Record = {} + const ingressUpdates: Record< + string, + { + packageId: string | null + hostId: string + interfaceId: string + internalPort: number + service: string + zoneId: string + } + > = {} for (const rule of newRules) { // 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( + ([, z]) => + rule.hostname.endsWith(`.${z.zoneName}`) || + rule.hostname === z.zoneName, + ) if (!matchedZone) { skipped.push(`${rule.hostname} (not in any configured zone)`) continue @@ -147,9 +189,18 @@ export const importPublicHostnames = sdk.Action.withoutInput( } const { packageId, internalPort } = parsed - const key = packageId ? `${packageId}:${internalPort}` : `cloudflared:${internalPort}` // STARTOS itself unlikely but handled - - let match: { packageId: string | null; interfaceId: string; hostId: string; internalPort: number } | undefined + const key = packageId + ? `${packageId}:${internalPort}` + : `cloudflared:${internalPort}` // STARTOS itself unlikely but handled + + let match: + | { + packageId: string | null + interfaceId: string + hostId: string + internalPort: number + } + | undefined if (packageId) { const candidates = interfaceMap.get(key) @@ -157,7 +208,9 @@ export const importPublicHostnames = sdk.Action.withoutInput( } if (!match && packageId) { - skipped.push(`${rule.hostname} (no matching interface found for ${packageId}:${internalPort})`) + skipped.push( + `${rule.hostname} (no matching interface found for ${packageId}:${internalPort})`, + ) continue } @@ -178,13 +231,21 @@ export const importPublicHostnames = sdk.Action.withoutInput( } const lines: string[] = [] - if (imported > 0) lines.push(`Imported ${imported} hostname${imported === 1 ? '' : 's'}: ${Object.keys(ingressUpdates).join(', ')}`) - if (skipped.length > 0) lines.push(`Skipped ${skipped.length}: ${skipped.join('; ')}`) + if (imported > 0) + lines.push( + `Imported ${imported} hostname${imported === 1 ? '' : 's'}: ${Object.keys(ingressUpdates).join(', ')}`, + ) + if (skipped.length > 0) + lines.push(`Skipped ${skipped.length}: ${skipped.join('; ')}`) return { - version: '1' as const, + version: '1', title: i18n('Import Public Hostnames'), - message: lines.join('\n') || i18n('No new public hostnames found in Cloudflare that are not already tracked.'), + message: + lines.join('\n') || + i18n( + 'No new public hostnames found in Cloudflare that are not already tracked.', + ), result: null, } }, diff --git a/startos/actions/removeZone.ts b/startos/actions/removeZone.ts index e60569d..a6f5e7d 100644 --- a/startos/actions/removeZone.ts +++ b/startos/actions/removeZone.ts @@ -15,11 +15,16 @@ export const removeZone = sdk.Action.withInput( const count = Object.keys(zones).length return { name: i18n('Remove DNS Zone'), - description: i18n('Remove a Cloudflare DNS zone from this package. Existing hostnames in that zone may keep working, but this package will no longer manage their DNS or tunnel routes.'), - warning: i18n('Existing DNS records and ingress rules in Cloudflare will NOT be deleted.'), - allowedStatuses: 'any' as const, + description: i18n( + 'Remove a Cloudflare DNS zone from this package. Existing hostnames in that zone may keep working, but this package will no longer manage their DNS or tunnel routes.', + ), + warning: i18n( + 'Existing DNS records and ingress rules in Cloudflare will NOT be deleted.', + ), + allowedStatuses: 'any', group: 'Configuration', - visibility: count === 0 ? ({ disabled: i18n('No zones configured') } as const) : ('enabled' as const), + visibility: + count === 0 ? { disabled: i18n('No zones configured') } : 'enabled', } }, @@ -27,13 +32,19 @@ export const removeZone = sdk.Action.withInput( zoneId: Value.dynamicUnion(async ({ effects }) => { const conf = await store.read().once() const zones = conf?.zones ?? {} - const variants: Record }> = {} + const variants: Record< + string, + { name: string; spec: ReturnType } + > = {} for (const [id, z] of Object.entries(zones)) { if (!z) continue variants[id] = { name: z.zoneName, spec: InputSpec.of({}) } } if (Object.keys(variants).length === 0) { - variants['none'] = { name: 'No zones configured', spec: InputSpec.of({}) } + variants['none'] = { + name: 'No zones configured', + spec: InputSpec.of({}), + } } return { name: 'Zone', diff --git a/startos/actions/selectTunnel.ts b/startos/actions/selectTunnel.ts index 8f247c4..c526f83 100644 --- a/startos/actions/selectTunnel.ts +++ b/startos/actions/selectTunnel.ts @@ -14,7 +14,10 @@ function parseTunnelList(stdout: string): Array<{ id: string; name: string }> { const parsed = JSON.parse(stdout.trim()) if (Array.isArray(parsed)) { return parsed - .filter((t: any) => t.id && t.name && (t.deleted_at?.startsWith('0001') ?? true)) + .filter( + (t: any) => + t.id && t.name && (t.deleted_at?.startsWith('0001') ?? true), + ) .map((t: any) => ({ id: String(t.id), name: String(t.name) })) } } catch { @@ -57,9 +60,11 @@ export const selectTunnel = sdk.Action.withInput( name: 'Select Tunnel', description: i18n('Login to Cloudflare first to configure a zone'), warning: null, - allowedStatuses: 'any' as const, + allowedStatuses: 'any', group: 'Configuration', - visibility: { disabled: i18n('Login to Cloudflare first to configure a zone') } as const, + visibility: { + disabled: i18n('Login to Cloudflare first to configure a zone'), + }, } } const current = conf?.tunnel?.name @@ -68,7 +73,9 @@ export const selectTunnel = sdk.Action.withInput( : i18n('Cloudflare Tunnel: Not selected') return { name: nameLabel, - description: i18n('Choose which Cloudflare tunnel this server runs. You can select an existing tunnel or create a new one.'), + description: i18n( + 'Choose which Cloudflare tunnel this server runs. You can select an existing tunnel or create a new one.', + ), warning: null, allowedStatuses: 'any', group: 'Configuration', @@ -80,27 +87,42 @@ export const selectTunnel = sdk.Action.withInput( tunnel: Value.dynamicUnion(async ({ effects }) => { // Find first zone cert for origincert-required commands const confForList = await store.read().once() - const firstZoneIdForList = Object.keys(confForList?.zones ?? {}).find((id) => confForList!.zones[id]) - const certForList = firstZoneIdForList ? `/root/.cloudflared/zone-${firstZoneIdForList}.pem` : undefined + const firstZoneIdForList = Object.keys(confForList?.zones ?? {}).find( + (id) => confForList!.zones[id], + ) + const certForList = firstZoneIdForList + ? `/root/.cloudflared/zone-${firstZoneIdForList}.pem` + : undefined // Fetch list of available tunnels let tunnels: Array<{ id: string; name: string }> = [] let tunnelLoadWarning: string | null = null try { - const stdout = await runCf(effects!, ['tunnel', 'list', '--output', 'json'], 30_000, certForList) + const stdout = await runCf( + effects!, + ['tunnel', 'list', '--output', 'json'], + 30_000, + certForList, + ) tunnels = parseTunnelList(stdout) } catch (e) { const summary = e instanceof Error ? e.message : String(e) console.error(`Failed to list tunnels: ${summary}`) - tunnelLoadWarning = 'Could not load existing tunnels from Cloudflare. You can still create a new tunnel.' + tunnelLoadWarning = + 'Could not load existing tunnels from Cloudflare. You can still create a new tunnel.' } // Infer server name from mDNS for new tunnel default let serverName: string | null = null try { const mdnsUrl = await sdk.serviceInterface - .getOwn(effects!, 'metrics', (iface) => - iface?.addressInfo?.nonLocal.filter({ kind: 'mdns' })?.format()[0], + .getOwn( + effects!, + 'metrics', + (iface) => + iface?.addressInfo?.nonLocal + .filter({ kind: 'mdns' }) + ?.format()[0], ) .once() if (mdnsUrl) { @@ -113,7 +135,10 @@ export const selectTunnel = sdk.Action.withInput( const selectedId = conf?.tunnel?.id const defaultId = selectedId ?? tunnels[0]?.id ?? 'new' - const variants: Record }> = {} + const variants: Record< + string, + { name: string; spec: ReturnType } + > = {} for (const t of tunnels) { variants[t.id] = { @@ -149,27 +174,44 @@ export const selectTunnel = sdk.Action.withInput( }, async ({ effects, input }) => { - const selection = (input.tunnel as { selection: string; value: { name?: string } }) + const selection = input.tunnel as { + selection: string + value: { name?: string } + } let tunnelId: string let tunnelName: string // Find first zone cert for origincert-required commands const conf = await store.read().once() - const firstZoneId = Object.keys(conf?.zones ?? {}).find((id) => conf!.zones[id]) - const origincert = firstZoneId ? `/root/.cloudflared/zone-${firstZoneId}.pem` : undefined + const firstZoneId = Object.keys(conf?.zones ?? {}).find( + (id) => conf!.zones[id], + ) + const origincert = firstZoneId + ? `/root/.cloudflared/zone-${firstZoneId}.pem` + : undefined if (selection.selection === 'new') { const name = selection.value.name?.trim() if (!name) throw new Error('Tunnel name is required.') // Create the tunnel - response includes id and name - const stdout = await runCf(effects, ['tunnel', 'create', '--output', 'json', name], 30_000, origincert) + const stdout = await runCf( + effects, + ['tunnel', 'create', '--output', 'json', name], + 30_000, + origincert, + ) const created = JSON.parse(stdout.trim()) tunnelId = created.id tunnelName = created.name } else { tunnelId = selection.selection - const listOut = await runCf(effects, ['tunnel', 'list', '--output', 'json'], 30_000, origincert) + const listOut = await runCf( + effects, + ['tunnel', 'list', '--output', 'json'], + 30_000, + origincert, + ) const tunnels = parseTunnelList(listOut) const found = tunnels.find((t) => t.id === tunnelId) tunnelName = found?.name ?? tunnelId @@ -181,10 +223,19 @@ export const selectTunnel = sdk.Action.withInput( try { const { unlink } = await import('node:fs/promises') await unlink(sdk.volumes.main.subpath(credSubpath)) - } catch { /* file didn't exist, that's fine */ } - await runCf(effects, ['tunnel', 'token', `--cred-file=${credFile}`, tunnelId], 30_000, origincert) - - const tunnelAccountId = firstZoneId ? conf?.zones?.[firstZoneId]?.accountId ?? '' : '' + } catch { + /* file didn't exist, that's fine */ + } + await runCf( + effects, + ['tunnel', 'token', `--cred-file=${credFile}`, tunnelId], + 30_000, + origincert, + ) + + const tunnelAccountId = firstZoneId + ? (conf?.zones?.[firstZoneId]?.accountId ?? '') + : '' await store.merge(effects, { tunnel: { id: tunnelId, name: tunnelName, accountId: tunnelAccountId }, diff --git a/startos/cfApi.ts b/startos/cfApi.ts index f6b98e6..9de28bc 100644 --- a/startos/cfApi.ts +++ b/startos/cfApi.ts @@ -14,7 +14,11 @@ export class CloudflareApiError extends Error { status: number body: CloudflareBody | string | null - constructor(context: string, status: number, body: CloudflareBody | string | null) { + constructor( + context: string, + status: number, + body: CloudflareBody | string | null, + ) { super(`${context}: ${summarizeCloudflareBody(body)}`) this.name = 'CloudflareApiError' this.context = context @@ -30,7 +34,9 @@ function authHeaders(apiToken: string) { } } -function summarizeCloudflareBody(body: CloudflareBody | string | null | undefined): string { +function summarizeCloudflareBody( + body: CloudflareBody | string | null | undefined, +): string { if (!body) return 'Unknown Cloudflare error' if (typeof body === 'string') { @@ -73,7 +79,8 @@ async function parseCloudflareResponse( } } - const success = typeof body === 'object' && body !== null ? body.success : false + const success = + typeof body === 'object' && body !== null ? body.success : false if (!resp.ok || !success) { throw new CloudflareApiError(context, resp.status, body) } @@ -152,7 +159,10 @@ export async function fetchIngressFromApi( ) return ( - (data.result?.config?.ingress as Array<{ hostname?: string; service: string }>) ?? [] + (data.result?.config?.ingress as Array<{ + hostname?: string + service: string + }>) ?? [] ).filter((r): r is { hostname: string; service: string } => !!r.hostname) } diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index 65dcf83..8baa12b 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -7,7 +7,7 @@ const dict = { 'Cloudflare tunnel is not running': 3, // interfaces.ts - 'Metrics': 100, + Metrics: 100, 'Prometheus metrics endpoint': 101, // actions/cloudflareLogin.ts @@ -30,10 +30,10 @@ const dict = { // actions/addPublicHostname.ts 'Add Public Hostname': 230, 'Route a public Cloudflare hostname to this service': 231, - 'Subdomain': 232, + Subdomain: 232, 'The subdomain to route to this service (e.g. myapp).': 233, 'Subdomain only, no dots (e.g. myapp)': 234, - 'Domain': 235, + Domain: 235, 'Login to Cloudflare to see your domains': 236, 'No Zone Configured': 237, 'Login to Cloudflare first (run "Login to Cloudflare" action) to configure a DNS zone.': 238, diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index 13b7b91..4525f85 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -166,8 +166,8 @@ export default { fr_FR: { // main.ts 1: 'Tunnel Cloudflare', - 2: 'Le tunnel Cloudflare est en cours d\'exécution', - 3: 'Le tunnel Cloudflare n\'est pas en cours d\'exécution', + 2: "Le tunnel Cloudflare est en cours d'exécution", + 3: "Le tunnel Cloudflare n'est pas en cours d'exécution", // interfaces.ts 100: 'Métriques', @@ -176,23 +176,23 @@ export default { // actions/cloudflareLogin.ts 200: 'Se connecter à Cloudflare', 201: 'Ajouter une zone DNS', - 202: 'S\'authentifie avec une zone DNS Cloudflare. Relancez cette action pour ajouter des zones supplémentaires.', + 202: "S'authentifie avec une zone DNS Cloudflare. Relancez cette action pour ajouter des zones supplémentaires.", 209: 'Un nom pour votre nouveau tunnel Cloudflare.', // actions/selectTunnel.ts 210: 'Tunnel Cloudflare : Non sélectionné', 211: 'Choisissez quel tunnel Cloudflare ce serveur utilise. Vous pouvez sélectionner un tunnel existant ou en créer un nouveau.', - 212: 'Connectez-vous d\'abord à Cloudflare pour configurer une zone', + 212: "Connectez-vous d'abord à Cloudflare pour configurer une zone", // actions/removeZone.ts 220: 'Supprimer la zone DNS', - 221: 'Supprime une zone DNS Cloudflare de ce paquet. Les noms d\'hôte existants dans cette zone peuvent continuer à fonctionner, mais ce paquet ne gérera plus leurs routes DNS ni de tunnel.', - 222: 'Les enregistrements DNS et les règles d\'entrée existants dans Cloudflare ne seront PAS supprimés.', + 221: "Supprime une zone DNS Cloudflare de ce paquet. Les noms d'hôte existants dans cette zone peuvent continuer à fonctionner, mais ce paquet ne gérera plus leurs routes DNS ni de tunnel.", + 222: "Les enregistrements DNS et les règles d'entrée existants dans Cloudflare ne seront PAS supprimés.", 223: 'Aucune zone configurée', // actions/addPublicHostname.ts - 230: 'Ajouter un nom d\'hôte public', - 231: 'Achemine un nom d\'hôte public Cloudflare vers ce service', + 230: "Ajouter un nom d'hôte public", + 231: "Achemine un nom d'hôte public Cloudflare vers ce service", 232: 'Sous-domaine', 233: 'Le sous-domaine à acheminer vers ce service (ex. monapp).', 234: 'Sous-domaine uniquement, sans points (ex. monapp)', @@ -202,7 +202,7 @@ export default { 238: 'Connectez-vous d\'abord à Cloudflare (lancez l\'action "Se connecter à Cloudflare") pour configurer une zone DNS.', 239: 'Aucun tunnel configuré', 240: 'Sélectionnez d\'abord un tunnel Cloudflare (lancez l\'action "Tunnel Cloudflare").', - 241: 'Nom d\'hôte public ajouté', + 241: "Nom d'hôte public ajouté", 242: 'Enregistrement DNS créé automatiquement.', 243: 'Ajoutez manuellement un enregistrement CNAME dans le tableau de bord Cloudflare (avec proxy).', @@ -214,7 +214,7 @@ export default { // actions/deletePublicHostname.ts 250: "Supprimer le nom d'h\u00f4te public", - 251: 'Supprime une route de nom d\'hôte public Cloudflare', - 252: 'Cela supprimera ce nom d\'hôte de votre tunnel Cloudflare et l\'enregistrement DNS de Cloudflare.', + 251: "Supprime une route de nom d'hôte public Cloudflare", + 252: "Cela supprimera ce nom d'hôte de votre tunnel Cloudflare et l'enregistrement DNS de Cloudflare.", }, } satisfies Record diff --git a/startos/index.ts b/startos/index.ts index 11acb95..e74f69e 100644 --- a/startos/index.ts +++ b/startos/index.ts @@ -8,4 +8,4 @@ export { actions } from './actions' import { buildManifest } from '@start9labs/start-sdk' import { manifest as sdkManifest } from './manifest/index' import { versionGraph } from './versions' -export const manifest = buildManifest(versionGraph, sdkManifest) \ No newline at end of file +export const manifest = buildManifest(versionGraph, sdkManifest) diff --git a/startos/plugin/url.ts b/startos/plugin/url.ts index 86f6b1f..6b03426 100644 --- a/startos/plugin/url.ts +++ b/startos/plugin/url.ts @@ -9,8 +9,7 @@ export const registerUrlPlugin = sdk.setupOnInit(async (effects) => export const exportUrls = sdk.plugin.url.setupExportedUrls( async ({ effects }) => { - const ingress = - (await store.read((s) => s.ingress).const(effects)) ?? {} + const ingress = (await store.read((s) => s.ingress).const(effects)) ?? {} for (const [hostname, entry] of Object.entries(ingress)) { if (!entry) continue diff --git a/startos/sdk.ts b/startos/sdk.ts index a1c2496..04ae4b1 100644 --- a/startos/sdk.ts +++ b/startos/sdk.ts @@ -6,6 +6,4 @@ import { manifest } from './manifest' * * The exported "sdk" const is used throughout this package codebase. */ -export const sdk = StartSdk.of() - .withManifest(manifest) - .build(true) +export const sdk = StartSdk.of().withManifest(manifest).build(true) diff --git a/startos/versions/v2026.3.0.ts b/startos/versions/v2026.3.0.ts index a62e2a7..7a7c8ee 100644 --- a/startos/versions/v2026.3.0.ts +++ b/startos/versions/v2026.3.0.ts @@ -3,7 +3,8 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v2026_3_0 = VersionInfo.of({ version: '2026.3.0:1.0', releaseNotes: { - en_US: 'Revamped setup: login to Cloudflare, select/create tunnel, automatic DNS routes, and URL plugin for exposing services via public hostnames', + en_US: + 'Revamped setup: login to Cloudflare, select/create tunnel, automatic DNS routes, and URL plugin for exposing services via public hostnames', }, migrations: { up: async ({ effects }) => {}, From 7b79a7fe391591b279191299abc22b0bebfc9d24 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sun, 12 Apr 2026 23:31:19 +0200 Subject: [PATCH 29/36] feat: add managed Cloudflare overview action --- startos/actions/index.ts | 2 + startos/actions/managedOverview.ts | 136 ++++++++++++++++++++++ startos/i18n/dictionaries/default.ts | 25 ++++ startos/i18n/dictionaries/translations.ts | 100 ++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 startos/actions/managedOverview.ts diff --git a/startos/actions/index.ts b/startos/actions/index.ts index faf756d..cfa5150 100644 --- a/startos/actions/index.ts +++ b/startos/actions/index.ts @@ -5,6 +5,7 @@ import { removeZone } from './removeZone' import { addPublicHostname } from './addPublicHostname' import { deletePublicHostname } from './deletePublicHostname' import { importPublicHostnames } from './importPublicHostnames' +import { managedOverview } from './managedOverview' export const actions = sdk.Actions.of() .addAction(cloudflareLogin) @@ -13,3 +14,4 @@ export const actions = sdk.Actions.of() .addAction(addPublicHostname) .addAction(deletePublicHostname) .addAction(importPublicHostnames) + .addAction(managedOverview) diff --git a/startos/actions/managedOverview.ts b/startos/actions/managedOverview.ts new file mode 100644 index 0000000..c5ddf10 --- /dev/null +++ b/startos/actions/managedOverview.ts @@ -0,0 +1,136 @@ +import { T } from '@start9labs/start-sdk' +import { store, IngressEntry, ZoneInfo } from '../fileModels/store.yaml' +import { i18n } from '../i18n' +import { sdk } from '../sdk' + +function single( + name: string, + value: string, + description: string | null = null, + copyable = false, +): T.ActionResultMember { + return { + type: 'single', + name, + description, + value, + copyable, + masked: false, + qr: false, + } +} + +function group( + name: string, + value: T.ActionResultMember[], + description: string | null = null, +): T.ActionResultMember { + return { + type: 'group', + name, + description, + value, + } +} + +function getTunnelGroup(conf: { + tunnel: { id: string; name: string; accountId: string } | null +}): T.ActionResultMember { + const tunnel = conf.tunnel + + if (!tunnel) { + return group(i18n('Cloudflare Tunnel'), [ + single(i18n('Cloudflare Tunnel'), i18n('No tunnel selected')), + ]) + } + + const value: T.ActionResultMember[] = [ + single(i18n('Tunnel Name'), tunnel.name), + single(i18n('Tunnel ID'), tunnel.id, null, true), + ] + + if (tunnel.accountId) { + value.push(single(i18n('Account ID'), tunnel.accountId, null, true)) + } + + return group(i18n('Cloudflare Tunnel'), value) +} + +function getZoneGroups( + zones: Array<[string, ZoneInfo]>, + ingressEntries: Array<[string, IngressEntry]>, +): T.ActionResultMember[] { + if (zones.length === 0) { + return [ + group(i18n('Configured DNS Zones'), [ + single(i18n('Configured DNS Zones'), i18n('No DNS zones configured')), + ]), + ] + } + + return zones.map(([id, zone]) => { + const zoneRoutes = ingressEntries.filter(([, entry]) => entry.zoneId === id) + + const routeGroups: T.ActionResultMember[] = zoneRoutes.length + ? zoneRoutes.map(([hostname, entry]) => + group(hostname, [ + single(i18n('Public URL'), `https://${hostname}`, null, true), + single(i18n('Package'), entry.packageId ?? i18n('StartOS Server')), + single(i18n('Interface ID'), entry.interfaceId), + single(i18n('Internal Target'), entry.service, null, true), + ]), + ) + : [ + single( + i18n('Application Routes'), + i18n('No application routes are currently managed in this zone'), + ), + ] + + return group(`${i18n('DNS Zone')}: ${zone.zoneName}`, [ + single(i18n('Zone ID'), zone.zoneId, null, true), + group(i18n('Application Routes'), routeGroups), + ]) + }) +} + +export const managedOverview = sdk.Action.withoutInput( + 'managed-overview', + + async () => ({ + name: i18n('Managed Public Routes'), + description: i18n( + 'View the Cloudflare DNS zones, tunnel, and application routes currently managed by this package.', + ), + warning: null, + allowedStatuses: 'any', + group: 'Information', + visibility: 'enabled', + }), + + async ({ effects }): Promise => { + const conf = await store.read().const(effects) + const zones = Object.entries(conf?.zones ?? {}) + .filter((entry): entry is [string, ZoneInfo] => !!entry[1]) + .sort((a, b) => a[1].zoneName.localeCompare(b[1].zoneName)) + + const ingressEntries = Object.entries(conf?.ingress ?? {}) + .filter((entry): entry is [string, IngressEntry] => !!entry[1]) + .sort((a, b) => a[0].localeCompare(b[0])) + + return { + version: '1', + title: i18n('Managed Public Routes'), + message: i18n( + 'Show the DNS zones, tunnel, and public hostnames currently managed by this package.', + ), + result: { + type: 'group', + value: [ + getTunnelGroup({ tunnel: conf?.tunnel ?? null }), + ...getZoneGroups(zones, ingressEntries), + ], + }, + } + }, +) diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts index 8baa12b..7fe3077 100644 --- a/startos/i18n/dictionaries/default.ts +++ b/startos/i18n/dictionaries/default.ts @@ -53,6 +53,31 @@ const dict = { 'Delete Public Hostname': 250, 'Remove a Cloudflare public hostname route': 251, 'This will remove this hostname from your Cloudflare tunnel and delete the DNS record from Cloudflare.': 252, + + // actions/managedOverview.ts + 'Managed Public Routes': 270, + 'View the Cloudflare DNS zones, tunnel, and application routes currently managed by this package.': 271, + 'Show the DNS zones, tunnel, and public hostnames currently managed by this package.': 272, + 'Cloudflare Tunnel': 273, + 'No tunnel selected': 274, + 'Tunnel Name': 275, + 'Tunnel ID': 276, + 'Account ID': 277, + 'Configured DNS Zones': 278, + 'No DNS zones configured': 279, + 'Domain Name': 280, + 'Zone ID': 281, + 'Managed Hostnames': 282, + 'Managed Application Routes': 283, + 'No public hostnames are currently managed': 284, + 'Public URL': 285, + Package: 286, + 'StartOS Server': 287, + 'DNS Zone': 288, + 'Interface ID': 289, + 'Internal Target': 290, + 'Application Routes': 291, + 'No application routes are currently managed in this zone': 292, } as const /** diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts index 4525f85..10a140f 100644 --- a/startos/i18n/dictionaries/translations.ts +++ b/startos/i18n/dictionaries/translations.ts @@ -54,6 +54,31 @@ export default { 250: 'Eliminar nombre de host público', 251: 'Elimina una ruta de nombre de host público de Cloudflare', 252: 'Esto eliminará este nombre de host de tu túnel Cloudflare y el registro DNS de Cloudflare.', + + // actions/managedOverview.ts + 270: 'Rutas públicas administradas', + 271: 'Muestra las zonas DNS, el túnel y las rutas de aplicaciones de Cloudflare administradas actualmente por este paquete.', + 272: 'Muestra las zonas DNS, el túnel y los nombres de host públicos administrados actualmente por este paquete.', + 273: 'Túnel Cloudflare', + 274: 'Ningún túnel seleccionado', + 275: 'Nombre del túnel', + 276: 'ID del túnel', + 277: 'ID de la cuenta', + 278: 'Zonas DNS configuradas', + 279: 'No hay zonas DNS configuradas', + 280: 'Nombre de dominio', + 281: 'ID de zona', + 282: 'Nombres de host administrados', + 283: 'Rutas de aplicaciones administradas', + 284: 'Actualmente no hay nombres de host públicos administrados', + 285: 'URL pública', + 286: 'Paquete', + 287: 'Servidor StartOS', + 288: 'Zona DNS', + 289: 'ID de interfaz', + 290: 'Destino interno', + 291: 'Rutas de la aplicación', + 292: 'Actualmente no se gestionan rutas de aplicación en esta zona', }, de_DE: { // main.ts @@ -108,6 +133,31 @@ export default { 250: 'Öffentlichen Hostnamen löschen', 251: 'Entfernt eine öffentliche Cloudflare-Hostname-Route', 252: 'Dadurch wird dieser Hostname aus Ihrem Cloudflare-Tunnel und der DNS-Eintrag aus Cloudflare entfernt.', + + // actions/managedOverview.ts + 270: 'Verwaltete öffentliche Routen', + 271: 'Zeigt die DNS-Zonen, den Tunnel und die Anwendungsrouten von Cloudflare an, die aktuell von diesem Paket verwaltet werden.', + 272: 'Zeigt die DNS-Zonen, den Tunnel und die öffentlichen Hostnamen an, die aktuell von diesem Paket verwaltet werden.', + 273: 'Cloudflare-Tunnel', + 274: 'Kein Tunnel ausgewählt', + 275: 'Tunnelname', + 276: 'Tunnel-ID', + 277: 'Konto-ID', + 278: 'Konfigurierte DNS-Zonen', + 279: 'Keine DNS-Zonen konfiguriert', + 280: 'Domainname', + 281: 'Zonen-ID', + 282: 'Verwaltete Hostnamen', + 283: 'Verwaltete Anwendungsrouten', + 284: 'Derzeit werden keine öffentlichen Hostnamen verwaltet', + 285: 'Öffentliche URL', + 286: 'Paket', + 287: 'StartOS-Server', + 288: 'DNS-Zone', + 289: 'Schnittstellen-ID', + 290: 'Internes Ziel', + 291: 'Anwendungsrouten', + 292: 'In dieser Zone werden derzeit keine Anwendungsrouten verwaltet', }, pl_PL: { // main.ts @@ -162,6 +212,31 @@ export default { 250: 'Usuń publiczną nazwę hosta', 251: 'Usuwa trasę publicznej nazwy hosta Cloudflare', 252: 'Spowoduje to usunięcie tej nazwy hosta z tunelu Cloudflare i rekordu DNS z Cloudflare.', + + // actions/managedOverview.ts + 270: 'Zarządzane publiczne trasy', + 271: 'Pokazuje strefy DNS, tunel i trasy aplikacji Cloudflare, które są obecnie zarządzane przez ten pakiet.', + 272: 'Pokazuje strefy DNS, tunel i publiczne nazwy hostów, które są obecnie zarządzane przez ten pakiet.', + 273: 'Tunel Cloudflare', + 274: 'Nie wybrano tunelu', + 275: 'Nazwa tunelu', + 276: 'ID tunelu', + 277: 'ID konta', + 278: 'Skonfigurowane strefy DNS', + 279: 'Nie skonfigurowano stref DNS', + 280: 'Nazwa domeny', + 281: 'ID strefy', + 282: 'Zarządzane nazwy hostów', + 283: 'Zarządzane trasy aplikacji', + 284: 'Obecnie nie są zarządzane żadne publiczne nazwy hostów', + 285: 'Publiczny URL', + 286: 'Pakiet', + 287: 'Serwer StartOS', + 288: 'Strefa DNS', + 289: 'ID interfejsu', + 290: 'Cel wewnętrzny', + 291: 'Trasy aplikacji', + 292: 'W tej strefie nie są obecnie zarządzane żadne trasy aplikacji', }, fr_FR: { // main.ts @@ -216,5 +291,30 @@ export default { 250: "Supprimer le nom d'h\u00f4te public", 251: "Supprime une route de nom d'hôte public Cloudflare", 252: "Cela supprimera ce nom d'hôte de votre tunnel Cloudflare et l'enregistrement DNS de Cloudflare.", + + // 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.', + 273: 'Tunnel Cloudflare', + 274: 'Aucun tunnel sélectionné', + 275: 'Nom du tunnel', + 276: 'ID du tunnel', + 277: 'ID du compte', + 278: 'Zones DNS configurées', + 279: 'Aucune zone DNS configurée', + 280: 'Nom de domaine', + 281: 'ID de zone', + 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é', + 285: 'URL publique', + 286: 'Paquet', + 287: 'Serveur StartOS', + 288: 'Zone DNS', + 289: 'ID de l\'interface', + 290: 'Cible interne', + 291: 'Routes applicatives', + 292: 'Aucune route applicative n\'est actuellement gérée dans cette zone', }, } satisfies Record From 9e9ae1a953142ba4c76354e72906def74aaa0795 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Mon, 13 Apr 2026 00:10:26 +0200 Subject: [PATCH 30/36] docs: refresh beta release docs and notes --- README.md | 112 +++++++++++++++++++++++----------- instructions.md | 39 ++++++------ startos/versions/v2026.3.0.ts | 4 +- 3 files changed, 100 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 013091e..bc75024 100644 --- a/README.md +++ b/README.md @@ -26,89 +26,133 @@ Upstream repo: - [Backups and Restore](#backups-and-restore) - [Health Checks](#health-checks) - [Dependencies](#dependencies) +- [What Is Unchanged from Upstream](#what-is-unchanged-from-upstream) - [Limitations and Differences](#limitations-and-differences) +- [Quick Reference for AI Consumers](#quick-reference-for-ai-consumers) --- ## Image and Container Runtime -- Base image: `cloudflare/cloudflared:` copied into `debian:12-slim` +- Base image: `cloudflare/cloudflared:2026.3.0` copied into `debian:12-slim` - Architectures: `x86_64`, `aarch64` (aarch64 emulated if missing) -- Entrypoint: `cloudflared tunnel --config /root/data/start9/tunnel.yaml run` +- Entrypoint: `cloudflared tunnel --credentials-file /root/.cloudflared/.json run ` - Autoupdate disabled via `--no-autoupdate` ## Volume and Data Layout -All persistent data is stored in the `main` volume, mounted at `/root/data`: +All persistent data is stored in the `main` volume: | Path | Contents | |---|---| -| `/root/data/start9/config.yaml` | Package store (token, tunnel info, zone info, ingress map) | -| `/root/data/start9/tunnel.yaml` | Generated cloudflared ingress config (written on every start) | -| `/root/data/start9/login-url.txt` | Temporary: Cloudflare auth URL during login flow | -| `/root/data/.cloudflared/cert.pem` | Cloudflare origin certificate (written by login) | +| `/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 -1. **Login to Cloudflare** - Run the "Login to Cloudflare" action. A Cloudflare authorization URL is returned. Visit it in your browser and select the DNS zone you want to use. -2. **Select Tunnel** - Run the "Select Tunnel" action. Choose an existing tunnel or create a new one. The tunnel token is retrieved and stored automatically. -3. The service starts and begins proxying traffic through your tunnel. +1. **Login to Cloudflare** by running the **Login to Cloudflare** action. +2. Open the returned authorization URL and approve access for one DNS zone. +3. Repeat the login action if you want this package to manage additional DNS zones. +4. Run **Select Tunnel** to choose an existing tunnel or create a new one. +5. The package stores the tunnel credentials automatically and starts cloudflared. -No manual token management is required. +No manual token or credentials-file management is required. ## Configuration Management -- The tunnel token is retrieved automatically via `cloudflared tunnel token` after tunnel selection - no manual token input needed. -- Ingress rules are stored in `start9/config.yaml` and written to `start9/tunnel.yaml` before each start. -- Dashboard-configured public hostnames are fetched from the Cloudflare API on each start and merged with locally-configured ones. Local rules take precedence if a hostname appears in both. -- Cloudflare credentials (`cert.pem`) are stored in the `main` volume at `.cloudflared/cert.pem`. +- The package stores the selected tunnel, logged-in DNS zones, and managed routes in `start9/config.yaml`. +- 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. ## Network Access and Interfaces -- **Metrics** - Prometheus metrics endpoint at `http://cloudflared.startos:20241/metrics` (internal only, not exposed publicly). -- All public traffic is routed inbound through the Cloudflare edge. No inbound ports need to be opened on your router. +- **Metrics** - Prometheus metrics endpoint at `http://cloudflared.startos:20241/metrics` (internal only) +- 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 | Always | Start the Cloudflare login flow; returns an authorization URL to visit in your browser | -| Select Tunnel | When logged in | Choose an existing tunnel or create a new one | +| 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. This means any other installed service can add a public Cloudflare hostname directly from its URL list. +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 hostname (e.g. `myapp.example.com`) - pre-filled with `packageid.yourdomain.com` if a zone is configured -- If logged in, a DNS CNAME record is created automatically pointing to your tunnel -- If not logged in, create the CNAME manually: `hostname → .cfargotunnel.com` (proxied) +- Enter a subdomain and choose one of the logged-in DNS zones +- The package updates the Cloudflare tunnel ingress configuration automatically +- 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 ingress rule and DNS CNAME record are removed automatically +- 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 -**Dashboard hostnames:** -- Public hostnames configured in the Cloudflare Zero Trust dashboard are automatically merged into the tunnel config on each start. They do not appear in the StartOS URL list but work normally. +**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 ## Backups and Restore -The entire `main` volume is backed up, including `cert.pem`, the tunnel token, zone info, and all ingress entries. After restore, the service starts immediately with all previous configuration intact. +The entire `main` volume is backed up, including zone certificates, tunnel credentials, selected tunnel info, DNS zone info, and managed ingress entries. After restore, the service can resume using the same tunnel and managed routes. ## Health Checks -- **Cloudflare tunnel** - polls `http://cloudflared.startos:20241/metrics` every 30 seconds -- Service is considered healthy when the metrics endpoint responds successfully +- **Cloudflare tunnel** - polls `http://cloudflared.startos:20241/metrics` +- The service is considered healthy when the metrics endpoint responds successfully ## Dependencies None. +## What Is Unchanged from Upstream + +- Cloudflare Tunnel still uses your existing Cloudflare account, Zero Trust tunnel model, and DNS zones. +- Advanced tunnel settings that are not described here should still be managed exactly as documented in the upstream Cloudflare documentation. + ## Limitations and Differences -1. **Single DNS zone** - logging in authorizes one Cloudflare DNS zone. Hostnames on other zones can still be added but DNS records must be created manually for those zones. -2. **No tunnel management UI** - tunnels are managed via the StartOS actions interface, not a web UI. For advanced tunnel configuration, use the Cloudflare Zero Trust dashboard. -3. **Local config takes precedence over dashboard** - if a hostname is configured both locally and in the dashboard, the local entry wins. Dashboard-only entries are merged in automatically. -4. **Autoupdate disabled** - `--no-autoupdate` is set; updates are delivered via new package versions. -5. **Metrics endpoint is internal only** - the Prometheus metrics endpoint is not proxied through the tunnel. +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. +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. + +--- + +## Quick Reference for AI Consumers + +```yaml +package_id: cloudflared +upstream_version: 2026.3.0 +image: cloudflare/cloudflared:2026.3.0 +architectures: [x86_64, aarch64] +volumes: + main: + - /root/data/start9/config.yaml + - /root/data/start9/login-url.txt + - /root/data/.cloudflared/zone-.pem + - /root/data/.cloudflared/.json +ports: + metrics: 20241 +dependencies: none +startos_managed_env_vars: [] +actions: + - cloudflare-login + - select-tunnel + - remove-zone + - import-public-hostnames + - managed-overview +``` diff --git a/instructions.md b/instructions.md index 4307cfa..66b3cec 100644 --- a/instructions.md +++ b/instructions.md @@ -3,31 +3,32 @@ ``` ----- WARNING ----- -This is for advanced users who know what they are doing. - -Exposing your server on the internet brings a lot of responsibility and can expose your server/service to all kind of attacks. - -Don't be reckless! +Publishing services on the public internet requires care. +Use Cloudflare Tunnel only if you understand the security implications of exposing a service publicly. ----- WARNING ----- ``` -Here are some basic instructions. We won't go into detail because this should NOT be newb friendly. You have to REALLY KNOW WHAT YOU ARE DOING!! +## Quick setup -* You need a cloudflare account -* Setup a website, with domain, dns and flexible ssl -* Create a tunnel in cloudflare 'Zero Trust' -* Copy the generated token and paste it in StartOS Cloudflare Tunnel service configuration -* Start the Cloudflare Tunnel service in StartOS -* In cloudflare tunnel dashboard, add public hostnames and route them directly to a StartOS service. For example: +1. Install the Cloudflare Tunnel package on StartOS. +2. Run **Login to Cloudflare** and open the returned authorization URL. +3. Approve access for the DNS zone you want to manage. +4. Run **Select Tunnel** and either choose an existing tunnel or create a new one. +5. Open another service on StartOS, go to **URLs**, and add a Cloudflare Tunnel URL. +6. Pick a subdomain and one of the logged-in DNS zones. -``` -Subdomain: btcpay -Domain: mydomain.xyz -Path: (empty) +The package will: +- update the Cloudflare tunnel ingress configuration automatically +- try to create the DNS record automatically +- return a manual CNAME fallback if Cloudflare rejects the DNS change -Service Type: HTTP -URL: btcpayserver.embassy:80 +Manual fallback CNAME format: + +```text + -> .cfargotunnel.com ``` -If you have setup the website, domain (mydomain.xyz), DNS, a SSL certificate and tunnel correctly, the BTCPay server is now exposed through a cloudflare tunnel on 'https://btcpay.mydomain.xyz' +Use the **Import Public Hostnames** action if the tunnel already has hostnames configured in Cloudflare and you want StartOS to manage and display them too. + +Use **Managed Public Routes** to see the currently selected tunnel, DNS zones, and managed application routes in one place. diff --git a/startos/versions/v2026.3.0.ts b/startos/versions/v2026.3.0.ts index 7a7c8ee..60cfee1 100644 --- a/startos/versions/v2026.3.0.ts +++ b/startos/versions/v2026.3.0.ts @@ -1,10 +1,10 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v2026_3_0 = VersionInfo.of({ - version: '2026.3.0:1.0', + version: '2026.3.0:1-beta.1', releaseNotes: { en_US: - 'Revamped setup: login to Cloudflare, select/create tunnel, automatic DNS routes, and URL plugin for exposing services via public hostnames', + 'Cloudflare login and tunnel selection, managed public hostnames via the URL plugin, multi-zone DNS support, import of existing routes, and a Managed Public Routes overview action', }, migrations: { up: async ({ effects }) => {}, From b504370ee203d3ab7aa168066caaad79b5f0b659 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Fri, 17 Apr 2026 18:04:40 +0200 Subject: [PATCH 31/36] qr + i18n --- startos/actions/cloudflareLogin.ts | 2 +- startos/versions/v2026.3.0.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/startos/actions/cloudflareLogin.ts b/startos/actions/cloudflareLogin.ts index 18f5499..9cba4b6 100644 --- a/startos/actions/cloudflareLogin.ts +++ b/startos/actions/cloudflareLogin.ts @@ -85,7 +85,7 @@ export const cloudflareLogin = sdk.Action.withoutInput( type: 'single', value: url, copyable: true, - qr: false, + qr: true, masked: false, }, } diff --git a/startos/versions/v2026.3.0.ts b/startos/versions/v2026.3.0.ts index 60cfee1..a820805 100644 --- a/startos/versions/v2026.3.0.ts +++ b/startos/versions/v2026.3.0.ts @@ -1,10 +1,18 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v2026_3_0 = VersionInfo.of({ - version: '2026.3.0:1-beta.1', + version: '2026.3.0:1-beta.2', releaseNotes: { en_US: 'Cloudflare login and tunnel selection, managed public hostnames via the URL plugin, multi-zone DNS support, import of existing routes, and a Managed Public Routes overview action', + es_ES: + 'Inicio de sesión en Cloudflare y selección de túnel, hostnames públicos gestionados mediante el plugin de URL, soporte DNS para múltiples zonas, importación de rutas existentes y una acción de resumen de rutas públicas gestionadas', + de_DE: + 'Cloudflare-Anmeldung und Tunnelauswahl, verwaltete öffentliche Hostnamen über das URL-Plugin, DNS-Unterstützung für mehrere Zonen, Import bestehender Routen und eine Übersichtsaktion für verwaltete öffentliche Routen', + pl_PL: + 'Logowanie do Cloudflare i wybór tunelu, zarządzane publiczne nazwy hostów przez wtyczkę URL, obsługa DNS dla wielu stref, import istniejących tras oraz akcja przeglądu zarządzanych tras publicznych', + fr_FR: + 'Connexion à Cloudflare et sélection du tunnel, noms d’hôte publics gérés via le plugin d’URL, prise en charge DNS multi-zones, import des routes existantes et action de vue d’ensemble des routes publiques gérées', }, migrations: { up: async ({ effects }) => {}, From 5927b772d50a00bc29d88caefa53bcf3ff942b19 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sat, 18 Apr 2026 18:05:46 +0200 Subject: [PATCH 32/36] chore: use debian trixie --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 39ac9c8..56f7020 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,8 @@ ARG CLOUDFLARED_IMAGE=cloudflare/cloudflared:latest # used to copy cloudflared binary FROM $CLOUDFLARED_IMAGE AS cloudflared -# run on debian bookworm slim -FROM debian:12-slim +# run on debian troxie slim +FROM debian:13-slim ARG PLATFORM From 0e6ab78c489b761e98f7c8733e8f5fc1f3cb1664 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Sat, 18 Apr 2026 18:19:42 +0200 Subject: [PATCH 33/36] fix: make login flow more resilient --- scripts/cf-login.sh | 92 +++++++++++++++++++++++++----- startos/actions/cloudflareLogin.ts | 48 ++++++++++------ startos/versions/v2026.3.0.ts | 2 +- 3 files changed, 110 insertions(+), 32 deletions(-) diff --git a/scripts/cf-login.sh b/scripts/cf-login.sh index 7158221..ac00652 100644 --- a/scripts/cf-login.sh +++ b/scripts/cf-login.sh @@ -1,24 +1,69 @@ #!/bin/sh # Starts `cloudflared tunnel login`, extracts the auth URL and writes it to the -# volume so the StartOS action can return it to the user. Then waits up to 10 -# minutes for the user to authorize (cert.pem is written to /root/.cloudflared/). +# volume so the StartOS action can return it to the user. Each login runs in an +# isolated session directory; if a newer login flow starts, the older one exits +# cleanly without publishing a stale URL or cert.pem. -set -e +set -eu + +SESSION_ID="${LOGIN_SESSION_ID:-}" +if [ -z "$SESSION_ID" ]; then + echo 'ERROR: LOGIN_SESSION_ID is required' + exit 1 +fi URL_FILE="/root/data/start9/login-url.txt" -LOG_FILE="/tmp/cf-login.log" +SESSION_FILE="/root/data/start9/login-session-id.txt" +SESSION_ROOT="/root/data/start9/login-sessions" +SESSION_HOME="$SESSION_ROOT/$SESSION_ID" +SESSION_CERT_DIR="$SESSION_HOME/.cloudflared" +SESSION_CERT_FILE="$SESSION_CERT_DIR/cert.pem" +FINAL_CERT_FILE="/root/data/.cloudflared/cert.pem" +LOG_FILE="$SESSION_HOME/cf-login.log" +URL='' +CF_PID='' +mkdir -p "$SESSION_CERT_DIR" "/root/data/.cloudflared" "$(dirname "$URL_FILE")" rm -f "$URL_FILE" -# Start cloudflared login in the background; cert lands in /root/.cloudflared/ -cloudflared tunnel login >"$LOG_FILE" 2>&1 & +cleanup() { + rm -rf "$SESSION_HOME" +} + +cancel_login() { + if [ -n "$CF_PID" ]; then + kill "$CF_PID" 2>/dev/null || true + wait "$CF_PID" 2>/dev/null || true + fi +} + +is_current() { + [ -f "$SESSION_FILE" ] && [ "$(cat "$SESSION_FILE" 2>/dev/null)" = "$SESSION_ID" ] +} + +trap cleanup EXIT + +# Start cloudflared login in an isolated HOME so only the active session can +# publish the cert into the shared .cloudflared volume. +HOME="$SESSION_HOME" cloudflared tunnel login >"$LOG_FILE" 2>&1 & CF_PID=$! # Extract auth URL from the log as soon as it appears (timeout 30s) i=0 while [ $i -lt 30 ]; do + if ! is_current; then + echo 'Login flow superseded before URL was published' + cancel_login + exit 0 + fi + URL=$(grep -oE 'https://dash\.cloudflare\.com[^[:space:]"]+' "$LOG_FILE" 2>/dev/null | head -1) if [ -n "$URL" ]; then + if ! is_current; then + echo 'Login flow superseded before URL was published' + cancel_login + exit 0 + fi echo "$URL" >"$URL_FILE" break fi @@ -27,26 +72,45 @@ while [ $i -lt 30 ]; do done if [ -z "$URL" ]; then - echo "ERROR: could not extract auth URL after 30s" + echo 'ERROR: could not extract auth URL after 30s' cat "$LOG_FILE" - kill "$CF_PID" 2>/dev/null || true + cancel_login exit 1 fi -# Wait for auth to complete (user authorizes in browser) -wait "$CF_PID" -EXIT_CODE=$? +# Wait for auth to complete, but keep checking whether a newer login flow has +# taken over. +while kill -0 "$CF_PID" 2>/dev/null; do + if ! is_current; then + echo 'Login flow superseded by a newer request' + cancel_login + exit 0 + fi + sleep 1 +done + +if wait "$CF_PID"; then + EXIT_CODE=0 +else + EXIT_CODE=$? +fi cat "$LOG_FILE" +if ! is_current; then + echo 'Login flow superseded after cloudflared exited' + exit 0 +fi + if [ $EXIT_CODE -ne 0 ]; then echo "cloudflared login failed (exit $EXIT_CODE)" exit 1 fi -if [ ! -f "/root/.cloudflared/cert.pem" ]; then - echo "Login appeared to succeed but cert.pem was not found" +if [ ! -f "$SESSION_CERT_FILE" ]; then + echo 'Login appeared to succeed but cert.pem was not found' exit 1 fi -echo "Login successful" +cp "$SESSION_CERT_FILE" "$FINAL_CERT_FILE" +echo 'Login successful' diff --git a/startos/actions/cloudflareLogin.ts b/startos/actions/cloudflareLogin.ts index 9cba4b6..d9a054b 100644 --- a/startos/actions/cloudflareLogin.ts +++ b/startos/actions/cloudflareLogin.ts @@ -1,22 +1,17 @@ +import { randomUUID } from 'crypto' import { sdk } from '../sdk' import { store } from '../fileModels/store.yaml' import { i18n } from '../i18n' const LOGIN_URL_PATH = '/start9/login-url.txt' +const LOGIN_SESSION_PATH = '/start9/login-session-id.txt' -const mounts = sdk.Mounts.of() - .mountVolume({ - volumeId: 'main', - subpath: null, - mountpoint: '/root/data', - readonly: false, - }) - .mountVolume({ - volumeId: 'main', - subpath: '.cloudflared', - mountpoint: '/root/.cloudflared', - readonly: false, - }) +const mounts = sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/root/data', + readonly: false, +}) export const cloudflareLogin = sdk.Action.withoutInput( 'cloudflare-login', @@ -43,11 +38,16 @@ export const cloudflareLogin = sdk.Action.withoutInput( }, async ({ effects }) => { - // Clear any stale URL file before starting + const sessionId = randomUUID() + + // Mark this login flow as the only active one and clear any stale URL. await sdk.volumes.main.writeFile(LOGIN_URL_PATH, 'pending').catch(() => {}) + await sdk.volumes.main.writeFile(LOGIN_SESSION_PATH, sessionId) // Fire and forget - cf-login.sh starts cloudflared login, extracts the auth URL, - // writes it to the volume, then waits up to 10 min for auth to complete + // writes it to the volume, then waits up to 10 min for auth to complete. + // If the action is triggered again, the older flow notices that it has been + // superseded and shuts itself down cleanly. sdk.SubContainer.withTemp( effects, { imageId: 'main' }, @@ -56,8 +56,12 @@ export const cloudflareLogin = sdk.Action.withoutInput( async (sub) => { const result = await sub.exec( ['/usr/local/bin/cf-login.sh'], - {}, - 10 * 60 * 1000, + { + env: { + LOGIN_SESSION_ID: sessionId, + }, + }, + 10 * 60 * 1000 + 35_000, ) if (result.stdout) console.info(result.stdout) if (result.stderr) console.info(result.stderr) @@ -71,6 +75,16 @@ export const cloudflareLogin = sdk.Action.withoutInput( const deadline = Date.now() + 30_000 while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, 2000)) + + const activeSession = (await sdk.volumes.main.readFile(LOGIN_SESSION_PATH)) + .toString() + .trim() + if (activeSession !== sessionId) { + throw new Error( + 'Cloudflare login was restarted by a newer request. Use the newest login action result.', + ) + } + try { const url = (await sdk.volumes.main.readFile(LOGIN_URL_PATH)) .toString() diff --git a/startos/versions/v2026.3.0.ts b/startos/versions/v2026.3.0.ts index a820805..7af541d 100644 --- a/startos/versions/v2026.3.0.ts +++ b/startos/versions/v2026.3.0.ts @@ -1,7 +1,7 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v2026_3_0 = VersionInfo.of({ - version: '2026.3.0:1-beta.2', + version: '2026.3.0:2-beta.1', releaseNotes: { en_US: 'Cloudflare login and tunnel selection, managed public hostnames via the URL plugin, multi-zone DNS support, import of existing routes, and a Managed Public Routes overview action', From d6af6da8697c5e6cbc38b2715866d68c258feac5 Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Mon, 4 May 2026 17:22:11 +0200 Subject: [PATCH 34/36] chore: bump sdk to 1.4.1, version to 2026.3.0:2-beta.2 --- package-lock.json | 56 ++++++++++++++++++++++------------- package.json | 2 +- startos/versions/v2026.3.0.ts | 2 +- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d84e76..cbc82fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "1.0.0" + "@start9labs/start-sdk": "1.4.1" }, "devDependencies": { "@types/node": "^22.19.17", @@ -48,23 +48,35 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@nodable/entities": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-1.1.0.tgz", + "integrity": "sha512-bidpxmTBP0pOsxULw6XlxzQpTgrAGLDHGBK/JuWhPDL6ZV0GZ/PmN9CA9do6e+A9lYI6qx6ikJUtJYRxup141g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@start9labs/start-sdk": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.0.0.tgz", - "integrity": "sha512-rtAfumVbMy90iw2WRbWH7fGcuwAvvuFfR4YwgSsh5R2Bz9MXtcEfmznwhnrp+ntQ6BOUSQ0wLzePbfsS6kUagg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.4.1.tgz", + "integrity": "sha512-eyS519l4djF5z+vbxHayjRB1JT/6wH4bKK+xf4lAtDvuwZ9y1mGsTLaJzFKYyjo8AtAcite5A4p/psmQbJl79Q==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", - "@noble/curves": "^1.8.2", - "@noble/hashes": "^1.7.2", + "@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.5.6", + "fast-xml-parser": "~5.6.0", "ini": "^5.0.0", "isomorphic-fetch": "^3.0.0", - "mime": "^4.0.7", - "yaml": "^2.7.1", - "zod": "^4.3.6", + "mime": "^4.1.0", + "yaml": "^2.8.3", + "zod": "4.3.6", "zod-deep-partial": "^1.2.0" } }, @@ -104,9 +116,9 @@ } }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.7.tgz", + "integrity": "sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==", "funding": [ { "type": "github", @@ -119,9 +131,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.11", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.11.tgz", - "integrity": "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.6.0.tgz", + "integrity": "sha512-5G+uaEBbOm9M4dgMOV3K/rBzfUNGqGqoUTaYJM3hBwM8t71w07gxLQZoTsjkY8FtfjabqgQHEkeIySBDYeBmJw==", "funding": [ { "type": "github", @@ -130,8 +142,9 @@ ], "license": "MIT", "dependencies": { + "@nodable/entities": "^1.1.0", "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.4.0", + "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { @@ -294,15 +307,18 @@ } }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/zod": { diff --git a/package.json b/package.json index 6b024a1..135fc1e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "1.0.0" + "@start9labs/start-sdk": "1.4.1" }, "devDependencies": { "@types/node": "^22.19.17", diff --git a/startos/versions/v2026.3.0.ts b/startos/versions/v2026.3.0.ts index 7af541d..1d52627 100644 --- a/startos/versions/v2026.3.0.ts +++ b/startos/versions/v2026.3.0.ts @@ -1,7 +1,7 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v2026_3_0 = VersionInfo.of({ - version: '2026.3.0:2-beta.1', + version: '2026.3.0:2-beta.2', releaseNotes: { en_US: 'Cloudflare login and tunnel selection, managed public hostnames via the URL plugin, multi-zone DNS support, import of existing routes, and a Managed Public Routes overview action', From c13984f9f81b4f21796b18ab7df0071531199eff Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Thu, 14 May 2026 11:37:31 +0200 Subject: [PATCH 35/36] chore: bump sdk to 1.5.1, remove deprecated docsUrls, add instructions.md --- instructions.md | 50 +++++++++++++++++----------------- package-lock.json | 56 +++++++++++++++++++++++++-------------- package.json | 2 +- startos/manifest/index.ts | 3 --- 4 files changed, 63 insertions(+), 48 deletions(-) diff --git a/instructions.md b/instructions.md index 66b3cec..7171951 100644 --- a/instructions.md +++ b/instructions.md @@ -1,34 +1,36 @@ -# Cloudflare Tunnel for StartOS Instructions +# Cloudflare Tunnel Instructions -``` ------ WARNING ----- +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. -Publishing services on the public internet requires care. -Use Cloudflare Tunnel only if you understand the security implications of exposing a service publicly. +## Requirements ------ WARNING ----- -``` +- A Cloudflare account +- A domain managed by Cloudflare DNS -## Quick setup +## First-time setup -1. Install the Cloudflare Tunnel package on StartOS. -2. Run **Login to Cloudflare** and open the returned authorization URL. -3. Approve access for the DNS zone you want to manage. -4. Run **Select Tunnel** and either choose an existing tunnel or create a new one. -5. Open another service on StartOS, go to **URLs**, and add a Cloudflare Tunnel URL. -6. Pick a subdomain and one of the logged-in DNS zones. +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. +5. Once a tunnel is selected, the service will start automatically. -The package will: -- update the Cloudflare tunnel ingress configuration automatically -- try to create the DNS record automatically -- return a manual CNAME fallback if Cloudflare rejects the DNS change +## Assigning a public address to a service -Manual fallback CNAME format: +Once a tunnel is selected and the service is running, you can assign a public Cloudflare subdomain to any service interface directly from that service's addresses page. -```text - -> .cfargotunnel.com -``` +1. Navigate to the service you want to expose publicly. +2. Open the interface's addresses page. +3. In the **Cloudflare Tunnel** addresses table, click **Add** to assign a subdomain. +4. Enter a subdomain and select the DNS zone (domain) to use. +5. Cloudflare Tunnel will route traffic from `subdomain.yourdomain.com` to that interface. -Use the **Import Public Hostnames** action if the tunnel already has hostnames configured in Cloudflare and you want StartOS to manage and display them too. +To remove an address, click the overflow menu on that row and select **Delete**. -Use **Managed Public Routes** to see the currently selected tunnel, DNS zones, and managed application routes in one place. +If you have existing hostname routes already configured in Cloudflare, run **Import Public Hostnames** to load them into this service. + +## Actions + +- **Login to Cloudflare** - Authenticate with a Cloudflare DNS zone. +- **Select Tunnel** - Choose or create a Cloudflare tunnel. +- **Import Public Hostnames** - Import existing hostname routes from Cloudflare. diff --git a/package-lock.json b/package-lock.json index cbc82fe..175baa1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "cloudflared", "dependencies": { - "@start9labs/start-sdk": "1.4.1" + "@start9labs/start-sdk": "1.5.1" }, "devDependencies": { "@types/node": "^22.19.17", @@ -49,9 +49,9 @@ } }, "node_modules/@nodable/entities": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-1.1.0.tgz", - "integrity": "sha512-bidpxmTBP0pOsxULw6XlxzQpTgrAGLDHGBK/JuWhPDL6ZV0GZ/PmN9CA9do6e+A9lYI6qx6ikJUtJYRxup141g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", "funding": [ { "type": "github", @@ -61,9 +61,9 @@ "license": "MIT" }, "node_modules/@start9labs/start-sdk": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.4.1.tgz", - "integrity": "sha512-eyS519l4djF5z+vbxHayjRB1JT/6wH4bKK+xf4lAtDvuwZ9y1mGsTLaJzFKYyjo8AtAcite5A4p/psmQbJl79Q==", + "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==", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", @@ -71,7 +71,7 @@ "@noble/hashes": "^1.8.0", "@types/ini": "^4.1.1", "deep-equality-data-structures": "^2.0.0", - "fast-xml-parser": "~5.6.0", + "fast-xml-parser": "~5.7.0", "ini": "^5.0.0", "isomorphic-fetch": "^3.0.0", "mime": "^4.1.0", @@ -116,9 +116,9 @@ } }, "node_modules/fast-xml-builder": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.7.tgz", - "integrity": "sha512-Yh7/7rQuMXICNr0oMYDR2yHP6oUvmQsTToFeOWj/kIDhAwQ+c4Ol/lbcwOmEM5OHYQmh6S6EQSQ1sljCKP36bQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "funding": [ { "type": "github", @@ -127,13 +127,14 @@ ], "license": "MIT", "dependencies": { - "path-expression-matcher": "^1.1.3" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" } }, "node_modules/fast-xml-parser": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.6.0.tgz", - "integrity": "sha512-5G+uaEBbOm9M4dgMOV3K/rBzfUNGqGqoUTaYJM3hBwM8t71w07gxLQZoTsjkY8FtfjabqgQHEkeIySBDYeBmJw==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", "funding": [ { "type": "github", @@ -142,8 +143,8 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^1.1.0", - "fast-xml-builder": "^1.1.4", + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, @@ -246,9 +247,9 @@ } }, "node_modules/strnum": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", - "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", "funding": [ { "type": "github", @@ -306,6 +307,21 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/yaml": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", diff --git a/package.json b/package.json index 135fc1e..fc1c702 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "check": "tsc --noEmit" }, "dependencies": { - "@start9labs/start-sdk": "1.4.1" + "@start9labs/start-sdk": "1.5.1" }, "devDependencies": { "@types/node": "^22.19.17", diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts index 4e38b86..c5b1068 100644 --- a/startos/manifest/index.ts +++ b/startos/manifest/index.ts @@ -9,9 +9,6 @@ export const manifest = setupManifest({ upstreamRepo: 'https://github.com/cloudflare/cloudflared', marketingUrl: 'https://cloudflare.com/', donationUrl: null, - docsUrls: [ - 'https://github.com/remcoros/cloudflared-startos/blob/main/instructions.md', - ], description: { short: { en_US: 'Cloudflare Tunnel client', From c705bc40e59db2f3c94770659de051688db1281d Mon Sep 17 00:00:00 2001 From: Remco Ros Date: Thu, 14 May 2026 18:52:36 +0200 Subject: [PATCH 36/36] chore: release 2026.3.0:2 --- startos/versions/v2026.3.0.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/startos/versions/v2026.3.0.ts b/startos/versions/v2026.3.0.ts index 1d52627..dde2970 100644 --- a/startos/versions/v2026.3.0.ts +++ b/startos/versions/v2026.3.0.ts @@ -1,7 +1,7 @@ import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' export const v2026_3_0 = VersionInfo.of({ - version: '2026.3.0:2-beta.2', + version: '2026.3.0:2', releaseNotes: { en_US: 'Cloudflare login and tunnel selection, managed public hostnames via the URL plugin, multi-zone DNS support, import of existing routes, and a Managed Public Routes overview action',