diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e5ab77d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: Build + +on: + workflow_dispatch: + pull_request: + paths-ignore: ['*.md'] + branches: ['master', 'main', 'update/040'] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build: + if: github.event.pull_request.draft == false + uses: start9labs/shared-workflows/.github/workflows/build.yml@master + # with: + # FREE_DISK_SPACE: true + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} diff --git a/.github/workflows/buildService.yml b/.github/workflows/buildService.yml deleted file mode 100644 index 3fbc242..0000000 --- a/.github/workflows/buildService.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Build Service - -on: - workflow_dispatch: - pull_request: - paths-ignore: ['*.md'] - branches: ['main', 'master'] - push: - paths-ignore: ['*.md'] - branches: ['main', 'master'] - -jobs: - BuildPackage: - runs-on: ubuntu-latest - steps: - - name: Prepare StartOS SDK - uses: Start9Labs/sdk@v1 - - - name: Checkout services repository - uses: actions/checkout@v4 - - - 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 - 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 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 18a80aa..0000000 --- a/.github/workflows/releaseService.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Release Service - -on: - push: - tags: - - 'v*.*' - -jobs: - ReleasePackage: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Prepare StartOS SDK - uses: Start9Labs/sdk@v1 - - - name: Checkout services repository - uses: actions/checkout@v4 - - - name: Build the service package - run: | - git submodule update --init --recursive - start-sdk init - 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 - 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 "## SHA256 Hash" >> change-log.txt - echo '```' >> change-log.txt - sha256sum ${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: - S9USER: ${{ secrets.S9USER }} - S9PASS: ${{ secrets.S9PASS }} - S9REGISTRY: ${{ secrets.S9REGISTRY }} - run: | - if [[ -z "$S9USER" || -z "$S9PASS" || -z "$S9REGISTRY" ]]; then - 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 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/.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..56f7020 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -# cloudflared container version is defined in Makefile -ARG CLOUDFLARED_IMAGE +# cloudflared container version is defined in manifest.ts +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 +# run on debian troxie slim +FROM debian:13-slim ARG PLATFORM @@ -19,7 +19,8 @@ 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 + +# add login helper script +COPY --chmod=0755 scripts/cf-login.sh /usr/local/bin/cf-login.sh diff --git a/Makefile b/Makefile index 54f8088..c2cf7f8 100644 --- a/Makefile +++ b/Makefile @@ -1,80 +1,3 @@ -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 - -PKG_ID := $(shell yq e ".id" manifest.yaml) -PKG_VERSION := $(shell yq e ".version" manifest.yaml) -TS_FILES := $(shell find ./ -name \*.ts) - -.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 - @echo " Done!" - @echo " Filesize: $(shell du -h $(PKG_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 - -clean: - rm -rf docker-images - rm -f $(PKG_ID).s9pk - rm -f scripts/*.js - -scripts/embassy.js: $(TS_FILES) - deno run --allow-read --allow-write --allow-env --allow-net scripts/bundle.ts - -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 - -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 - -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 - -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 - -$(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 +# overrides to s9pk.mk must precede the include statement +ARCHES := x86 arm +include s9pk.mk \ No newline at end of file diff --git a/README.md b/README.md index 12832c1..bc75024 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,158 @@

- 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. -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). +Upstream repo: -- [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) +--- -## Cloning +## Table of Contents -Clone the repository locally. +- [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) +- [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) -``` -git clone git@github.com:remcoros/cloudflared-startos.git -cd cloudflared-startos -``` +--- -## Building +## Image and Container Runtime -To build the **Cloudflare Tunnel** service as a universal package, run the following command: +- Base image: `cloudflare/cloudflared:2026.3.0` copied into `debian:12-slim` +- Architectures: `x86_64`, `aarch64` (aarch64 emulated if missing) +- Entrypoint: `cloudflared tunnel --credentials-file /root/.cloudflared/.json run ` +- Autoupdate disabled via `--no-autoupdate` -``` -make -``` +## Volume and Data Layout -## Installing (on StartOS) +All persistent data is stored in the `main` volume: -Before installation, define `host: https://server-name.local` in your `~/.embassy/config.yaml` config file then run the following commands to determine successful install: +| Path | Contents | +|---|---| +| `/root/data/start9/config.yaml` | Package store with selected tunnel, logged-in DNS zones, and managed routes | +| `/root/data/start9/login-url.txt` | Temporary Cloudflare authorization URL during login flow | +| `/root/data/.cloudflared/zone-.pem` | Zone-specific Cloudflare origin certificate | +| `/root/data/.cloudflared/.json` | Tunnel credentials file used to run cloudflared | -> Change server-name.local to your Start9 server address +## Installation and First-Run Flow -``` -start-cli auth login -#Enter your StartOS password -make install -``` +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 or credentials-file management is required. + +## Configuration Management + +- 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) +- All public traffic is proxied through the Cloudflare edge. No inbound ports need to be opened on your router. -**Tip:** You can also install the cloudflared.s9pk by sideloading it under the **StartOS > System > Sideload a Service** section. +## Actions -## Verify Install +| Action | When available | Purpose | +|---|---|---| +| Login to Cloudflare / Add DNS Zone | Always | Start the Cloudflare login flow and authorize one DNS zone at a time | +| Select Tunnel | When at least one zone is configured | Choose an existing tunnel or create a new one | +| Remove DNS Zone | When zones exist | Remove a DNS zone from this package without deleting existing Cloudflare records | +| Import Public Hostnames | Always | Import existing Cloudflare tunnel hostnames into the StartOS-managed route list | +| Managed Public Routes | Always | Show the selected tunnel, managed DNS zones, and application routes | -Go to your StartOS Services page, select **Cloudflare Tunnel**, configure and start the service. +## URL Plugin -**Done!** +Cloudflare Tunnel registers as a `url-v0` URL plugin. Any other installed service can add a public Cloudflare hostname directly from its StartOS URL list. + +**Adding a hostname:** +- Open any service → URLs → Add URL → select Cloudflare Tunnel +- Enter a subdomain and choose one of the logged-in DNS zones +- The package updates the Cloudflare tunnel ingress configuration automatically +- It also tries to create the DNS CNAME automatically +- If the DNS step fails, the route is still added and the action returns the manual fallback: `hostname → .cfargotunnel.com` (proxied) + +**Removing a hostname:** +- Open the service → URLs → remove the Cloudflare URL +- The package removes the ingress rule from the tunnel configuration +- It also tries to delete the matching DNS record and returns a warning if manual cleanup is still needed + +**Importing existing dashboard routes:** +- If routes already exist on the tunnel in Cloudflare, run **Import Public Hostnames** +- Matching routes are added to the package store so StartOS can manage and display them + +## Backups and Restore + +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` +- 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. **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/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. 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/instructions.md b/instructions.md index 4307cfa..7171951 100644 --- a/instructions.md +++ b/instructions.md @@ -1,33 +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. -This is for advanced users who know what they are doing. +## Requirements -Exposing your server on the internet brings a lot of responsibility and can expose your server/service to all kind of attacks. +- A Cloudflare account +- A domain managed by Cloudflare DNS -Don't be reckless! +## First-time setup ------ WARNING ----- -``` +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. -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!! +## Assigning a public address to a service -* 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: +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. -``` -Subdomain: btcpay -Domain: mydomain.xyz -Path: (empty) +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. -Service Type: HTTP -URL: btcpayserver.embassy:80 -``` +To remove an address, click the overflow menu on that row and select **Delete**. -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' +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/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..175baa1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,359 @@ +{ + "name": "cloudflared", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cloudflared", + "dependencies": { + "@start9labs/start-sdk": "1.5.1" + }, + "devDependencies": { + "@types/node": "^22.19.17", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.8.2", + "typescript": "^5.9.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.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" + }, + "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/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.5.1.tgz", + "integrity": "sha512-iztLiOCtHuTfUCd2JOWio4OvBk5qFGa0NI+G+ZB/dQ1sWtunYEnzqMcF6N/Ss4L6+7bBOMAMU4VuhyxeZoHyIw==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "fast-xml-parser": "~5.7.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "yaml": "^2.8.3", + "zod": "4.3.6", + "zod-deep-partial": "^1.2.0" + } + }, + "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.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": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/ncc": { + "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": { + "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/fast-xml-builder": { + "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", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "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", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.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", + "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": { + "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" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "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/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.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "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/typescript": { + "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": { + "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/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", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "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 new file mode 100644 index 0000000..fc1c702 --- /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": "1.5.1" + }, + "devDependencies": { + "@types/node": "^22.19.17", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.8.2", + "typescript": "^5.9.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} 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/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/cf-login.sh b/scripts/cf-login.sh new file mode 100644 index 0000000..ac00652 --- /dev/null +++ b/scripts/cf-login.sh @@ -0,0 +1,116 @@ +#!/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. 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 -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" +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" + +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 + sleep 1 + i=$((i + 1)) +done + +if [ -z "$URL" ]; then + echo 'ERROR: could not extract auth URL after 30s' + cat "$LOG_FILE" + cancel_login + exit 1 +fi + +# 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 "$SESSION_CERT_FILE" ]; then + echo 'Login appeared to succeed but cert.pem was not found' + exit 1 +fi + +cp "$SESSION_CERT_FILE" "$FINAL_CERT_FILE" +echo 'Login successful' 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/addPublicHostname.ts b/startos/actions/addPublicHostname.ts new file mode 100644 index 0000000..e6b90c7 --- /dev/null +++ b/startos/actions/addPublicHostname.ts @@ -0,0 +1,278 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +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 + interfaceId: string + hostId: string + internalPort: number + }>(), + subdomain: Value.text({ + name: i18n('Subdomain'), + description: i18n('The subdomain to route to this service (e.g. myapp).'), + required: true, + default: null, + placeholder: 'myapp', + masked: false, + inputmode: 'text', + patterns: [ + { + regex: '^[a-zA-Z0-9][a-zA-Z0-9\\-]*$', + description: i18n('Subdomain only, no dots (e.g. myapp)'), + }, + ], + }), + domain: Value.dynamicUnion(async ({ effects }) => { + const conf = await store.read().once() + const zones = conf?.zones ?? {} + 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: i18n('Login to Cloudflare to see your domains'), + spec: InputSpec.of({}), + } + } + + return { + name: i18n('Domain'), + default: Object.keys(variants)[0], + disabled: false, + variants: Variants.of(variants), + } + }), +}) + +export const addPublicHostname = sdk.Action.withInput( + 'add-public-hostname', + + async () => ({ + name: i18n('Add Public Hostname'), + description: i18n('Route a public Cloudflare hostname to this service'), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'hidden', + }), + + inputSpec, + + // pre-fill subdomain from packageId + async ({ effects, prefill }) => { + const p = prefill as typeof inputSpec._PARTIAL + const suggestedHost = p?.urlPluginMetadata?.packageId + return suggestedHost && suggestedHost !== 'STARTOS' + ? { subdomain: suggestedHost } + : null + }, + + async ({ effects, input }) => { + const { packageId, internalPort, interfaceId, hostId } = + input.urlPluginMetadata + const subdomain = input.subdomain.trim().toLowerCase() + const zoneId = (input.domain as { selection: string }).selection + + const conf = await store.read().once() + + if (zoneId === 'none' || !conf?.zones?.[zoneId]) { + return { + version: '1', + title: i18n('No Zone Configured'), + message: i18n( + 'Login to Cloudflare first (run "Login to Cloudflare" action) to configure a DNS zone.', + ), + result: null, + } + } + + if (!conf.tunnel) { + return { + version: '1', + title: i18n('No Tunnel Configured'), + message: i18n( + 'Select a Cloudflare tunnel first (run "Cloudflare Tunnel" action).', + ), + result: null, + } + } + + const zone = conf.zones[zoneId] + const tunnelAccountId = conf.tunnel.accountId || zone.accountId + + if (conf.tunnel.accountId && zone.accountId !== conf.tunnel.accountId) { + return { + 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.', + 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. + 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', + title: 'Cloudflare Update Failed', + message: `Could not update the Cloudflare tunnel configuration for ${hostname}. ${summary}`, + result: null, + } + } + + await store.merge(effects, { + tunnel: { + ...conf.tunnel, + accountId: tunnelAccountId, + }, + ingress: { + [hostname]: nextEntry, + }, + }) + + // Create DNS CNAME via cloudflared CLI using zone-specific cert + const certSubpath = zoneCertSubpath(zoneId) + let certExists = false + try { + await sdk.volumes.main.readFile(certSubpath) + certExists = true + } catch {} + + let dnsCreated = false + let dnsFailureDetail: string | null = null + 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 certPath = `/root/.cloudflared/zone-${zoneId}.pem` + const result = await sub.exec( + [ + '/usr/local/bin/cloudflared', + '--no-autoupdate', + `--origincert=${certPath}`, + 'tunnel', + 'route', + 'dns', + '--overwrite-dns', + tunnelId, + hostname, + ], + {}, + 30_000, + ) + if (result.stdout) console.info(result.stdout) + if (result.stderr) console.info(result.stderr) + 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`, + ) + } + + await effects.restart() + + return { + version: '1', + title: i18n('Public Hostname Added'), + message: dnsCreated + ? `${hostname} is now routed to this service. ${i18n('DNS record created automatically.')}` + : 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', + value: `https://${hostname}`, + copyable: true, + qr: false, + masked: false, + }, + } + }, +) diff --git a/startos/actions/cloudflareLogin.ts b/startos/actions/cloudflareLogin.ts new file mode 100644 index 0000000..d9a054b --- /dev/null +++ b/startos/actions/cloudflareLogin.ts @@ -0,0 +1,116 @@ +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, +}) + +export const cloudflareLogin = sdk.Action.withoutInput( + 'cloudflare-login', + + 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') + return { + name: nameLabel, + description: i18n( + 'Authenticates with a Cloudflare DNS zone. Run this action again to add additional zones.', + ), + warning: null, + allowedStatuses: 'any', + group: 'Configuration', + visibility: 'enabled', + } + }, + + async ({ effects }) => { + 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. + // 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' }, + mounts, + 'cf-login', + async (sub) => { + const result = await sub.exec( + ['/usr/local/bin/cf-login.sh'], + { + env: { + LOGIN_SESSION_ID: sessionId, + }, + }, + 10 * 60 * 1000 + 35_000, + ) + 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)) + + 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() + .trim() + if (url.startsWith('https://dash.cloudflare.com')) { + return { + 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', + value: url, + copyable: true, + qr: true, + 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..a61ee8c --- /dev/null +++ b/startos/actions/deletePublicHostname.ts @@ -0,0 +1,131 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +import { + pushIngressToApi, + deleteDnsRecord, + summarizeCloudflareError, +} from '../cfApi' +import { i18n } from '../i18n' + +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: 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', + }), + + // input spec + inputSpec, + + // pre-fill + async () => null, + + // execution + async ({ effects, input }) => { + const { hostname } = input.urlPluginMetadata + + // 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. + 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', + title: 'Cloudflare Update Failed', + message: `Could not remove ${hostname} from the Cloudflare tunnel configuration. ${summary}`, + result: null, + } + } + } + + await store.merge(effects, { + ingress: { [hostname]: undefined } as any, + }) + + // Delete DNS record using the zone-specific token from the ingress entry + let dnsWarning: string | null = null + if (zone) { + 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', + title: 'Public Hostname Removed with DNS Warning', + message: dnsWarning, + result: null, + } + } + }, +) diff --git a/startos/actions/importPublicHostnames.ts b/startos/actions/importPublicHostnames.ts new file mode 100644 index 0000000..41e67c4 --- /dev/null +++ b/startos/actions/importPublicHostnames.ts @@ -0,0 +1,252 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +import { fetchIngressFromApi, summarizeCloudflareError } 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', + 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', + 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 + 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', + 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)) + + if (newRules.length === 0) { + return { + version: '1', + title: i18n('Import Public Hostnames'), + message: i18n( + 'No new public hostnames found in Cloudflare that are not already tracked.', + ), + result: null, + } + } + + // Get all installed packages and their interfaces + const packageIds = await effects.getInstalledPackages() + 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, + }) + 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<(typeof e)[1]>] => !!e[1], + ) + + let imported = 0 + const skipped: string[] = [] + 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, + ) + 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', + 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 new file mode 100644 index 0000000..cfa5150 --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,17 @@ +import { sdk } from '../sdk' +import { cloudflareLogin } from './cloudflareLogin' +import { selectTunnel } from './selectTunnel' +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) + .addAction(selectTunnel) + .addAction(removeZone) + .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/actions/removeZone.ts b/startos/actions/removeZone.ts new file mode 100644 index 0000000..a6f5e7d --- /dev/null +++ b/startos/actions/removeZone.ts @@ -0,0 +1,88 @@ +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 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') } : 'enabled', + } + }, + + InputSpec.of({ + zoneId: Value.dynamicUnion(async ({ effects }) => { + const conf = await store.read().once() + const zones = conf?.zones ?? {} + 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({}), + } + } + 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 new file mode 100644 index 0000000..c526f83 --- /dev/null +++ b/startos/actions/selectTunnel.ts @@ -0,0 +1,248 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +import { runCf } from '../cfRunner' +import { i18n } from '../i18n' + +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 = (serverName: string | null) => + InputSpec.of({ + name: Value.text({ + name: 'Tunnel Name', + description: i18n('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', + + async ({ effects }) => { + const conf = await store.read().const(effects) + const hasZone = Object.keys(conf?.zones ?? {}).length > 0 + if (!hasZone) { + return { + name: 'Select Tunnel', + description: i18n('Login to Cloudflare first to configure a zone'), + warning: null, + allowedStatuses: 'any', + group: 'Configuration', + visibility: { + disabled: i18n('Login to Cloudflare first to configure a zone'), + }, + } + } + const current = conf?.tunnel?.name + const nameLabel = current + ? `Cloudflare Tunnel: ${current}` + : 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.', + ), + warning: null, + allowedStatuses: 'any', + group: 'Configuration', + visibility: 'enabled', + } + }, + + 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 }> = [] + let tunnelLoadWarning: string | null = null + try { + 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.' + } + + // 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 {} + + // 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< + string, + { name: string; spec: ReturnType } + > = {} + + 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(serverName), + } + + return { + name: 'Tunnel', + warning: tunnelLoadWarning, + default: defaultId, + 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: {} }, + } + }, + + async ({ effects, input }) => { + 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 + + 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 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 tunnels = parseTunnelList(listOut) + const found = tunnels.find((t) => t.id === tunnelId) + tunnelName = found?.name ?? tunnelId + } + + // 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], + 30_000, + origincert, + ) + + const tunnelAccountId = firstZoneId + ? (conf?.zones?.[firstZoneId]?.accountId ?? '') + : '' + + await store.merge(effects, { + tunnel: { id: tunnelId, name: tunnelName, accountId: tunnelAccountId }, + }) + + console.info(`Tunnel set to: ${tunnelName} (${tunnelId})`) + + await effects.restart() + }, +) diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 0000000..0a90b1e --- /dev/null +++ b/startos/backups.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const { createBackup, restoreInit } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.ofVolumes('main'), +) diff --git a/startos/cfApi.ts b/startos/cfApi.ts new file mode 100644 index 0000000..9de28bc --- /dev/null +++ b/startos/cfApi.ts @@ -0,0 +1,217 @@ +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}`, + 'Content-Type': 'application/json', + } +} + +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. + * 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' }) + + await fetchCloudflare( + `${CF_API}/accounts/${accountId}/cfd_tunnel/${tunnelId}/configurations`, + { + method: 'PUT', + headers: authHeaders(apiToken), + body: JSON.stringify({ + config: { + ingress: rules, + 'warp-routing': { enabled: false }, + }, + }), + }, + `Failed to update Cloudflare tunnel configuration for tunnel ${tunnelId}`, + ) +} + +/** + * 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> { + 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[] +} + +/** + * Delete a DNS CNAME record for a hostname from Cloudflare DNS. + */ +export async function deleteDnsRecord( + zoneId: string, + hostname: string, + apiToken: string, +): 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 records: Array<{ id: string }> = listData.result ?? [] + const errors: string[] = [] + let deletedCount = 0 + + for (const record of records) { + 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}`) + } 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, + } +} diff --git a/startos/cfRunner.ts b/startos/cfRunner.ts new file mode 100644 index 0000000..d9a74cb --- /dev/null +++ b/startos/cfRunner.ts @@ -0,0 +1,56 @@ +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. + * + * .cloudflared must be writable here because tunnel token commands write + * credentials into the mounted volume. + */ +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: false, + }) + +/** + * 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, + origincert?: string, +): Promise { + const certArgs = origincert ? [`--origincert=${origincert}`] : [] + return sdk.SubContainer.withTemp( + effects, + { imageId: 'main' }, + cfMounts, + 'cf-cmd', + async (sub) => { + const result = await sub.exec( + ['/usr/local/bin/cloudflared', '--no-autoupdate', ...certArgs, ...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/certPem.ts b/startos/fileModels/certPem.ts new file mode 100644 index 0000000..5f59791 --- /dev/null +++ b/startos/fileModels/certPem.ts @@ -0,0 +1,26 @@ +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', +}) + +/** 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 new file mode 100644 index 0000000..cea9ae9 --- /dev/null +++ b/startos/fileModels/store.yaml.ts @@ -0,0 +1,57 @@ +import { z, FileHelper, T } from '@start9labs/start-sdk' +import { sdk } from '../sdk' + +export const ingressEntryShape = z.object({ + packageId: z.string().nullable(), + hostId: z.string().catch('main'), + interfaceId: z.string(), + internalPort: z.number(), + service: z.string(), + zoneId: z.string().catch(''), // which zone's DNS this hostname was created in +}) + +export type IngressEntry = z.infer + +export const tunnelInfoShape = z.object({ + id: z.string(), + name: z.string(), + accountId: z.string().catch(''), +}) + +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({ + tunnel: tunnelInfoShape.nullable().catch(null), + zones: z.record(z.string(), zoneInfoShape.nullish()).catch({}), + ingress: z.record(z.string(), ingressEntryShape.nullable()).catch({}), +}) + +export type StoreType = z.infer + +export const store = FileHelper.yaml( + { + base: sdk.volumes.main, + subpath: '/start9/config.yaml', + }, + shape, +) + +export const createDefaultStore = async (effects: T.Effects) => { + const conf = await store.read().once() + if (!conf) { + await store.write(effects, { + tunnel: null, + zones: {}, + ingress: {}, + }) + } +} diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..7fe3077 --- /dev/null +++ b/startos/i18n/dictionaries/default.ts @@ -0,0 +1,88 @@ +export const DEFAULT_LANG = 'en_US' + +const dict = { + // main.ts + 'Cloudflare tunnel': 1, + 'Cloudflare tunnel is running': 2, + 'Cloudflare tunnel is not running': 3, + + // 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 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, + + // 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/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, + '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 + +/** + * 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..10a140f --- /dev/null +++ b/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,320 @@ +import { LangDict } from './default' + +export default { + es_ES: { + // main.ts + 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', + 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 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', + + // 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/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', + 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 + 1: 'Cloudflare-Tunnel', + 2: 'Cloudflare-Tunnel läuft', + 3: 'Cloudflare-Tunnel läuft nicht', + + // 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. 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', + + // 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/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', + 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 + 1: 'Tunel Cloudflare', + 2: 'Tunel Cloudflare jest uruchomiony', + 3: 'Tunel Cloudflare nie jest uruchomiony', + + // 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 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', + + // 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/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', + 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 + 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", + + // 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ô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", + 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/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\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 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 new file mode 100644 index 0000000..e74f69e --- /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/index' +import { versionGraph } from './versions' +export const manifest = buildManifest(versionGraph, sdkManifest) diff --git a/startos/init/index.ts b/startos/init/index.ts new file mode 100644 index 0000000..c31a7ba --- /dev/null +++ b/startos/init/index.ts @@ -0,0 +1,23 @@ +import { sdk } from '../sdk' +import { setInterfaces } from '../interfaces' +import { versionGraph } from '../versions' +import { actions } from '../actions' +import { restoreInit } from '../backups' +import { seedStore } from './seedStore' +import { exportUrls, registerUrlPlugin } from '../plugin/url' +import { setupTasks } from './setupTasks' +import { setupZones } from './setupZones' + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + seedStore, + setupZones, + setInterfaces, + actions, + registerUrlPlugin, + setupTasks, + exportUrls, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/init/seedStore.ts b/startos/init/seedStore.ts new file mode 100644 index 0000000..b02ce66 --- /dev/null +++ b/startos/init/seedStore.ts @@ -0,0 +1,7 @@ +import { sdk } from '../sdk' +import { createDefaultStore } from '../fileModels/store.yaml' + +export const seedStore = sdk.setupOnInit(async (effects, kind) => { + if (kind !== 'install') return + await createDefaultStore(effects) +}) diff --git a/startos/init/setupTasks.ts b/startos/init/setupTasks.ts new file mode 100644 index 0000000..f3d2e0f --- /dev/null +++ b/startos/init/setupTasks.ts @@ -0,0 +1,28 @@ +import { sdk } from '../sdk' +import { store } from '../fileModels/store.yaml' +import { cloudflareLogin } from '../actions/cloudflareLogin' +import { selectTunnel } from '../actions/selectTunnel' + +/** + * Reactively manage required tasks. + * 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 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 configure a DNS zone', + }) + return + } + + 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/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})`) +}) diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..3dc7cfe --- /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, 'metrics') + 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 new file mode 100644 index 0000000..9c130ad --- /dev/null +++ b/startos/main.ts @@ -0,0 +1,67 @@ +import { store } from './fileModels/store.yaml' +import { sdk } from './sdk' +import { i18n } from './i18n' + +export const main = sdk.setupMain(async ({ effects }) => { + console.info('Starting cloudflared...') + + const conf = (await store.read().const(effects))! + + 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, + { + 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, + }), + 'main', + ), + exec: { + command: [ + '/usr/local/bin/cloudflared', + '--no-autoupdate', + '--management-diagnostics=false', + '--metrics', + '0.0.0.0:20241', + 'tunnel', + '--credentials-file', + credFile, + 'run', + conf.tunnel.id, + ], + env: {}, + }, + ready: { + display: i18n('Cloudflare tunnel'), + fn: () => + sdk.healthCheck.checkWebUrl( + effects, + 'http://cloudflared.startos:20241/metrics', + { + successMessage: i18n('Cloudflare tunnel is running'), + errorMessage: i18n('Cloudflare tunnel is not running'), + }, + ), + }, + requires: [], + }) +}) diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..c5b1068 --- /dev/null +++ b/startos/manifest/index.ts @@ -0,0 +1,50 @@ +import { setupManifest } from '@start9labs/start-sdk' +import { CLOUDFLARED_VERSION } from '../versions' + +export const manifest = setupManifest({ + id: 'cloudflared', + title: 'Cloudflare Tunnel', + license: 'Apache 2.0', + packageRepo: 'https://github.com/remcoros/cloudflared-startos', + upstreamRepo: 'https://github.com/cloudflare/cloudflared', + marketingUrl: 'https://cloudflare.com/', + donationUrl: null, + 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: {}, + plugins: ['url-v0'], +}) diff --git a/startos/plugin/url.ts b/startos/plugin/url.ts new file mode 100644 index 0000000..6b03426 --- /dev/null +++ b/startos/plugin/url.ts @@ -0,0 +1,37 @@ +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: true, + public: true, + hostname, + port: 443, + info: null, + }, + removeAction: deletePublicHostname, + overflowActions: [], + }) + .catch((e) => { + console.error(`Failed to export url for ${hostname}:`, e) + }) + } + }, +) diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 0000000..04ae4b1 --- /dev/null +++ b/startos/sdk.ts @@ -0,0 +1,9 @@ +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/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/versions/v2026.3.0.ts b/startos/versions/v2026.3.0.ts new file mode 100644 index 0000000..dde2970 --- /dev/null +++ b/startos/versions/v2026.3.0.ts @@ -0,0 +1,21 @@ +import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk' + +export const v2026_3_0 = VersionInfo.of({ + 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', + 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 }) => {}, + down: IMPOSSIBLE, + }, +}) 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 + } +}