Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
81c2f73
fix: require provisioned node key in shipped config (TC-1200, TC-1282)
samgbafa Oct 16, 2026
feb2019
feat: exercise personal node lifecycle on published images (TC-1282)
samgbafa Oct 16, 2026
a16b967
fix: check published node version response fields (TC-1282)
samgbafa Oct 16, 2026
5bde834
test: identify failing signed lifecycle operation (TC-1282)
samgbafa Oct 16, 2026
5d54c61
fix: await void SDK sign-in before lifecycle calls (TC-1282)
samgbafa Oct 16, 2026
48faa28
test: typecheck personal-node signing probe (TC-1282)
samgbafa Oct 16, 2026
8236a18
fix: implement guide restart operation (TC-1282)
samgbafa Oct 16, 2026
18fadbb
test: isolate dstack persistence gate (TC-1282)
samgbafa Oct 16, 2026
9a9bb36
fix: read node-owned data in offline backup (TC-1282)
samgbafa Oct 16, 2026
41de5cd
docs: clarify key continuity and restore cutover (TC-1282)
samgbafa Oct 16, 2026
39ac258
fix: pin dstack profile and persist local data at runtime path
samgbafa Oct 16, 2026
a3dd160
test: prove 1.20.6 legacy key override by node identity
samgbafa Oct 16, 2026
154e065
test: distinguish key-type selection from direct static env secret
samgbafa Oct 16, 2026
b25e4be
test: show redacted dstack startup errors on CI failure
samgbafa Oct 16, 2026
6674fb1
fix: use 1.20.6 TEE mode enum in dstack smoke
samgbafa Oct 16, 2026
20ee2ca
test: retain redacted dstack startup cause lines
samgbafa Oct 16, 2026
c59c67c
test: prove legacy key type selects KMS without a socket
samgbafa Oct 16, 2026
5254b0d
test: cover fresh 1.20.6 and reject unrelated key-check failure
samgbafa Oct 16, 2026
d8bc950
fix: require exact missing-secret startup diagnostic
samgbafa Oct 16, 2026
4f08122
fix: pin Caddy index digest and document updates
samgbafa Oct 16, 2026
ac8beca
test: prove legacy Dstack override with shipped keyless config
samgbafa Oct 16, 2026
1746224
ci: verify shipped Dstack env in source-built tests
samgbafa Oct 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/personal-node.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Personal node guide
on:
pull_request:
paths:
- 'deploy/personal-node/**'
- 'test/probe/personal-node-check.ts'
- '.github/workflows/personal-node.yml'
- 'tinycloud.toml'
- 'docker-compose.dstack.yaml'
- 'tinycloud-node-server/src/runtime.rs'
push:
branches: [main]
paths:
- 'deploy/personal-node/**'
- 'test/probe/personal-node-check.ts'
- '.github/workflows/personal-node.yml'
- 'docker-compose.dstack.yaml'
workflow_dispatch:

permissions:
contents: read

jobs:
published-images:
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: test/probe/package-lock.json
- name: Install pinned signing client
run: npm ci --prefix test/probe
- name: Typecheck signing probe
run: npm run typecheck --prefix test/probe
- name: Exercise the published personal-node guide
run: bash deploy/personal-node/ci.sh

dstack-profile:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: test/probe/package-lock.json
- name: Install pinned signing client
run: npm ci --prefix test/probe
- name: Check dstack-profile local-data persistence
run: bash deploy/personal-node/dstack-smoke.sh

dstack-config-source:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.97.1
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
with:
shared-key: rust-1.97.1-dstack-config
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check shipped keyless config with both Dstack environment forms
run: cargo test --locked -p tinycloud-node --features dstack shipped_config_allows_ --lib
44 changes: 22 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,35 +150,35 @@ With telemetry enabled, backend time-to-first-byte is recorded under the `server

## Quickstart

To run TinyCloud Protocol locally, install [Rustup](https://rustup.rs). The
repository selects the reviewed Rust `1.97.1` toolchain automatically through
`rust-toolchain.toml`; do not substitute a floating `stable` compiler.
For a self-hosted HTTPS deployment with persistent data, unique identity,
backup/restore, and a verified upgrade path, follow the
[personal-node guide](deploy/personal-node/README.md). The source-checkout
example below is for local development only. It deliberately requires a unique
key: the shipped configuration no longer contains a usable key.

Install [Rustup](https://rustup.rs); `rust-toolchain.toml` selects Rust 1.97.1.
Provision a private key file once and retain it across restarts:

You will need to create a directory for TinyCloud Protocol to store data in:
```bash
mkdir data
umask 077
mkdir -p data/blocks
openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n' > node.key
TINYCLOUD_KEYS__SECRET="$(cat node.key)" cargo run
```

Within this directory, create one more directories `blocks` and a database file `caps.db`:
```bash
mkdir data/blocks
touch data/caps.db
```
Keep `node.key` out of git, back it up separately from the data directory, and
do not replace it during an upgrade: derived node identities and sealed data
depend on it. `TINYCLOUD_KEYS__TYPE=Dstack` remains an explicit alternative
only for a properly configured dstack TEE.

To setup local data storage you can also run `./scripts/init-tinycloud-data.sh`
For an existing node, do **not** generate a new root during upgrade. Recover
the installation's original key from its private backup or its existing
operator configuration, provide that same root explicitly, and compare the
public node identity and encrypted/sealed data before accepting the upgrade.
If the original root is unavailable, stop: a replacement is a different node
identity and does not recover data sealed under the prior root. Changing the
identity also invalidates trust bindings that refer to its DID or public keys.

You will then need to set the environment variables to point to those directories:
```bash
export TINYCLOUD_STORAGE_BLOCKS_PATH="data/blocks"
export TINYCLOUD_STORAGE_DATABASE="data/caps.db"
```

Finally you can run TinyCloud Protocol using `cargo`:
```bash
cargo build
cargo run
```


## Configuration
Expand Down
3 changes: 3 additions & 0 deletions deploy/personal-node/Caddyfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{$NODE_DOMAIN} {
reverse_proxy node:8000
}
4 changes: 4 additions & 0 deletions deploy/personal-node/Caddyfile.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{$NODE_DOMAIN} {
tls internal
reverse_proxy node:8000
}
78 changes: 78 additions & 0 deletions deploy/personal-node/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Run a personal TinyCloud node

This ordinary-server profile runs a single node with local SQLite, local blocks, and Caddy HTTPS. It tracks the version TinyCloud itself runs in production: **1.20.6**, pinned by immutable image digest in [`compose.yaml`](compose.yaml). Caddy **2.10.2** is pinned by its multi-platform image digest there too. The prior published ordinary image 1.20.5 is used only to rehearse an upgrade and restore-based rollback. Do not use `latest` or 1.20.7: the latter was rolled back in production (TC-1176). New node-image changes remain unavailable to this published-image guide until a separately approved release ships.

This is **not** a TEE: the VPS operator and its host can access the filesystem and key. No hardware attestation, dstack KMS isolation, or cross-host account migration is claimed. This guide does not configure registry discovery or hosted-to-owned-node replication (TC-1280/TC-1283). TLS authenticates your domain but does not attest the machine.

## Preflight

- Own an ordinary Linux VPS with Docker Engine and Compose v2, `bash`, `curl`, `jq`, `openssl`, `tar`, `sudo`, Node.js 22 and npm. Clone this repository at the revision carrying the guide. Check that ports 80/443 are free, the domain A/AAAA record points to the VPS, and incoming 80/443 are allowed for Caddy certificate issuance and HTTPS. Do not expose the node's unauthenticated metrics port 8001, Docker socket, or its plain HTTP port 8000.
- Store node data on a persistent local filesystem with available disk for both the live data and a full backup. `node.sh init` sets only that dedicated data directory to UID/GID 1000, the non-root image user. Confirm host backups and access controls before admitting users.
- Choose a unique `NODE_HOME` path for this installation. Keep its `node.env` (node identity root) and your owner-held Ethereum private key separate, private and recoverable. Never put either in git, CI logs, or a public volume. Back up the node identity independently of the data. Preserve the original root through upgrades: changing it changes node identity and can make encrypted/sealed state unreadable.
- There is **no** hosted billing/quota URL in this profile. The ordinary node still depends on its chosen wallet/signing client and, for public HTTPS, DNS and the CA. Review the EGPL-1.5 license and host hardening for your deployment.

All commands below use the same [`node.sh`](node.sh) operations invoked by [the CI rehearsal](ci.sh). Run them from the repository root. No production credentials or hosted services are used by CI.

## Provision and launch

```bash
npm ci --prefix test/probe
export NODE_HOME="$HOME/personal-tinycloud"
bash deploy/personal-node/node.sh init node.example.com
bash deploy/personal-node/node.sh check-missing-key
bash deploy/personal-node/node.sh start
bash deploy/personal-node/node.sh check-version 1.20.6
```

Replace `node.example.com` with the domain you own. `init` writes a unique 48-byte random Static root to a `0600` private `node.env`; it is never echoed. The profile mounts a keyless TOML over the bundled image config, so even the older 1.20.6 image **fails** if the installation root is absent. `check-missing-key` must run before `start`; it requires the node's exact `Missing secret` diagnostic and rejects unrelated failures without printing their logs. Caddy obtains and renews a public certificate; do not bypass its trust verification.

The 1.20.6 config loader accepts both the legacy single-underscore nesting (`TINYCLOUD_KEYS_TYPE`, `TINYCLOUD_STORAGE_DATADIR`) and canonical double-underscore nesting (`TINYCLOUD_KEYS__TYPE`, `TINYCLOUD_STORAGE__DATADIR`). It merges canonical values last when both are set. This profile uses the canonical form; the existing dstack Compose retains its working single-underscore key-type setting.

To prove owner-held EOA operations, place your own wallet's 32-byte hexadecimal private key (without `0x`) in a separate `0600` file outside `NODE_HOME`, then run:

```bash
bash deploy/personal-node/node.sh seed /secure/path/owner.key
bash deploy/personal-node/node.sh restart
bash deploy/personal-node/node.sh verify /secure/path/owner.key
```

The pinned `@tinycloud/node-sdk` signs in, hosts an owned space, writes and reads a KV object and a SQL row. The `verify` operation checks their exact values and the node's public identity after restart. Do not put a key on the command line; only its file path is passed. Your wallet key is **not** included in the node backup and must be retained separately.

## Offline backup and fresh restore

Stop writes before backup. `backup` stops the node, archives the entire `/data` tree while SQLite and SQL actors are closed, encrypts `node.env` as a separate artifact, then restarts the node. The data archive itself is **not encrypted**; protect it as sensitive data or encrypt it using your backup system. The passphrase file is not included in either artifact. Retain both the backup and passphrase in independent secure locations. Test recovery regularly.

```bash
# Generate a strong private passphrase file outside the node and backup tree.
export BACKUP_PASSPHRASE_FILE=/secure/path/node-backup-passphrase
umask 077
openssl rand -base64 48 > "$BACKUP_PASSPHRASE_FILE"
bash deploy/personal-node/node.sh backup /secure/path/node-backup-2026-10-16 "$BACKUP_PASSPHRASE_FILE"

# On the destination, copy the encrypted backup, passphrase and owner wallet
# by independent secure channels. Stop the old node, repoint DNS to the new
# host, then start the restored node after Caddy can obtain its certificate.
export NODE_HOME="$HOME/personal-tinycloud-restored"
bash deploy/personal-node/node.sh restore /secure/path/node-backup-2026-10-16 "$BACKUP_PASSPHRASE_FILE"
bash deploy/personal-node/node.sh start
bash deploy/personal-node/node.sh check-version 1.20.6
bash deploy/personal-node/node.sh verify /secure/path/owner.key
```

Use a fresh host/directory, not two live nodes serving the same identity and domain at once. Stop the old node before DNS cutover; wait for the new host's Caddy certificate, then verify before admitting clients. If verification fails, stop the destination and revert DNS to the old host. `verify` compares the public node DID saved before backup and the signed KV/SQL data; a different root fails rather than silently rotating. The backup is a full offline snapshot, **not** continuous replication. TLS ACME state is not in the backup, and a restore needs a reachable domain and valid certificate again.

## Upgrade and rollback rehearsal

CI executes [`ci.sh`](ci.sh) on a clean runner: a fresh 1.20.6 installation with missing-key denial, an unrelated-failure negative control, signed data and restart; then 1.20.5 → 1.20.6, with data checks before and after restart, fresh-volume restores, and rollback by restoring the **pre-upgrade** snapshot into another fresh instance on 1.20.5. An in-place downgrade of a migrated database is not supported. A normal new installation starts directly on 1.20.6; do not downgrade it merely to rehearse.

```bash
# Run only on a disposable isolated runner with permission to create Docker
# containers. This is exactly the end-to-end job used by GitHub Actions.
bash deploy/personal-node/ci.sh
```

Before a future upgrade, take and verify an offline backup as above, stop the node, change the pinned image only to a reviewed released digest, start and verify identity and signed data. If verification fails, stop the candidate and restore that pre-upgrade backup to a fresh instance of the previous image; never blindly start the older binary against migrated data. The next-release upgrade step **requires the next approved production node release** and a new published-image CI run. TC-1176 prohibits promotion of that release until Sam separately clears the hold. Changes to the shipped default key in TC-1200 likewise require that next image; the mounted keyless profile is the safe 1.20.6 workaround, not proof that its bare image fails closed.

For a Caddy update, choose a reviewed release and resolve its published multi-platform index digest (for example, from the [Docker Hub tag metadata](https://hub.docker.com/v2/repositories/library/caddy/tags/2.10.2)). Replace only the `caddy@sha256:…` reference in `compose.yaml`, run the published-image CI job and verify HTTPS certificate issuance and renewal on a staging domain before upgrading a live proxy. Retain the Caddy `/data` and `/config` volumes; do not switch to a mutable tag.

The [personal-node workflow](../../.github/workflows/personal-node.yml) is the acceptance evidence only when its published-image job is green. Record the successful run URL and exact image versions/digests in TC-1282; no CI result here authorizes a production deployment or a stronger sovereignty claim.
79 changes: 79 additions & 0 deletions deploy/personal-node/ci.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Run only on an isolated GitHub Actions runner, not a shared Docker host.
set -euo pipefail
umask 077
here="$(cd "$(dirname "$0")" && pwd)"
work="$(mktemp -d)"
owner="$work/owner.key"
passphrase="$work/backup-passphrase"
openssl rand -hex 32 > "$owner"
openssl rand -base64 48 > "$passphrase"

cleanup() {
for name in tc1282-a tc1282-b tc1282-c tc1282-d tc1282-e; do
if [[ -f "$work/$name/tls-mode" ]]; then
NODE_HOME="$work/$name" "$here/node.sh" destroy-test || true
fi
done
}
trap cleanup EXIT

export NODE_HOME="$work/tc1282-e"
"$here/node.sh" init localhost test
"$here/node.sh" check-missing-key
saved_image="$(<"$NODE_HOME/image")"
printf '%s\n' 'not a valid image reference' > "$NODE_HOME/image"
unrelated_status=0
"$here/node.sh" compose-check >/dev/null 2>&1 || unrelated_status=$?
[[ "$unrelated_status" == 1 ]] ||
{ printf '%s\n' 'Negative control did not produce an unrelated exit 1' >&2; exit 1; }
if "$here/node.sh" check-missing-key; then
printf '%s\n' 'Missing-key guard accepted an unrelated exit 1' >&2
exit 1
fi
printf '%s\n' "$saved_image" > "$NODE_HOME/image"
"$here/node.sh" start
"$here/node.sh" check-version 1.20.6
"$here/node.sh" seed "$owner"
"$here/node.sh" restart
"$here/node.sh" verify "$owner"
"$here/node.sh" stop

export NODE_HOME="$work/tc1282-a"
"$here/node.sh" init localhost test 1.20.5
"$here/node.sh" check-missing-key
"$here/node.sh" start
"$here/node.sh" check-version 1.20.5
"$here/node.sh" seed "$owner"
"$here/node.sh" restart
"$here/node.sh" verify "$owner"
"$here/node.sh" backup "$work/pre-upgrade" "$passphrase"
"$here/node.sh" stop

export NODE_HOME="$work/tc1282-b"
"$here/node.sh" restore "$work/pre-upgrade" "$passphrase"
"$here/node.sh" start
"$here/node.sh" check-version 1.20.5
"$here/node.sh" verify "$owner"
"$here/node.sh" upgrade
"$here/node.sh" check-version 1.20.6
"$here/node.sh" verify "$owner"
"$here/node.sh" backup "$work/post-upgrade" "$passphrase"
"$here/node.sh" stop

export NODE_HOME="$work/tc1282-c"
"$here/node.sh" restore "$work/post-upgrade" "$passphrase"
"$here/node.sh" start
"$here/node.sh" check-version 1.20.6
"$here/node.sh" verify "$owner"
"$here/node.sh" stop

# Rollback is a restore from the pre-upgrade snapshot, not an in-place
# downgrade of a migrated database.
export NODE_HOME="$work/tc1282-d"
"$here/node.sh" restore "$work/pre-upgrade" "$passphrase"
"$here/node.sh" start
"$here/node.sh" check-version 1.20.5
"$here/node.sh" verify "$owner"
"$here/node.sh" stop
printf '%s\n' 'Personal-node lifecycle passed: TLS, signed KV/SQL, restart, fresh restore, upgrade, rollback restore'
36 changes: 36 additions & 0 deletions deploy/personal-node/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: personal-node
services:
node:
image: ${NODE_IMAGE:-ghcr.io/tinycloudlabs/tinycloud-node@sha256:41667d3d133aaa12290a2f3a744afacd838988327552d159c60f04466e66f86f}
restart: unless-stopped
env_file:
- ${NODE_KEY_ENV_FILE:?Set the private node key file}
environment:
TINYCLOUD_STORAGE__DATADIR: /data
ROCKET_ADDRESS: 0.0.0.0
volumes:
- ${NODE_DATA_DIR:?Set the node data directory}:/data
# 1.20.6 still embeds a sample key in its default TOML. This clean
# config masks the image copy, even when the operator key is absent.
- ./tinycloud.toml:/tinycloud.toml:ro
security_opt:
- no-new-privileges:true

https:
image: caddy@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb
restart: unless-stopped
depends_on:
- node
ports:
- "${NODE_HTTP_BIND:-80:80}"
- "${NODE_HTTPS_BIND:-443:443}"
environment:
NODE_DOMAIN: ${NODE_DOMAIN:?Set your domain}
volumes:
- ${NODE_CADDYFILE:-./Caddyfile}:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config

volumes:
caddy-data:
caddy-config:
Loading
Loading