Centralize Tensamin packages modules and release automation
Some checks failed
action.yml / Centralize Tensamin packages modules and release automation (push) Failing after 0s
Canary release / release (push) Failing after 21s

This commit is contained in:
Alois 2026-10-04 19:19:56 +02:00
commit 123205c97d
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
28 changed files with 32292 additions and 10 deletions

View file

@ -0,0 +1,85 @@
name: Canary release
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: tensamin-central-releases
cancel-in-progress: false
jobs:
release:
runs-on: nixos
env:
NIX_CONFIG: experimental-features = nix-command flakes
FORGEJO_SERVER_URL: ${{ forgejo.server_url }}
FORGEJO_REPOSITORY: ${{ forgejo.repository }}
FORGEJO_RUN_ID: ${{ forgejo.run_id }}
RELEASE_CHANNEL: canary
RELEASE_TOKEN: ${{ secrets.TENSAMIN_RELEASE_TOKEN }}
FORGEJO_REGISTRY_USER: ${{ vars.FORGEJO_REGISTRY_USER }}
IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }}
IOTA_RELEASE_PUBLIC_KEY: ${{ vars.IOTA_RELEASE_PUBLIC_KEY }}
IOTA_RELEASE_SIGNING_KEY_ID: ${{ vars.IOTA_RELEASE_SIGNING_KEY_ID || 'primary' }}
IOTA_BASE_VERSION: ${{ vars.IOTA_BASE_VERSION || '0.1.0' }}
TENSAMIN_SSH_KNOWN_HOSTS: ${{ vars.TENSAMIN_SSH_KNOWN_HOSTS }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Bootstrap pinned tools and source credentials
uses: ./.forgejo/workflows/source-access
with:
source-key: ${{ secrets.TENSAMIN_SOURCE_SSH_KEY }}
known-hosts: ${{ vars.TENSAMIN_SSH_KNOWN_HOSTS }}
- name: Set immutable release identity
run: |
set -euo pipefail
printf 'RELEASE_SEQUENCE=%s\nRELEASE_TAG=canary-%s-%s\n' "$(date +%s)" "$(git rev-parse HEAD)" "$FORGEJO_RUN_ID" >> "$GITHUB_ENV"
- name: Check central release scripts
run: |
set -euo pipefail
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command shellcheck scripts/release-build.sh scripts/release-client.sh
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command ruff check scripts/release.py scripts/deploy-release.py
- name: Validate and build all central packages and client assets
run: |
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command bash scripts/release-build.sh "$RELEASE_CHANNEL" release-out
- name: Attempt aarch64 build with available builders
run: |
set -euo pipefail
if ! nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command bash scripts/release-build.sh "$RELEASE_CHANNEL" release-arm aarch64-linux > arm-build.log 2>&1; then
printf 'aarch64 unavailable. See the attached arm-build.log.\n' > release-out/aarch64-unavailable.txt
cp arm-build.log release-out/
else
cp release-arm/*linux-aarch64* release-out/
cp release-arm/*arm64* release-out/
cp release-arm/electron-release-metadata-aarch64.json release-out/
fi
- name: Sign manifests and validate installer bundles
run: |
set -euo pipefail
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py sign --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py provenance --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
- name: Preserve combined build artifacts
uses: https://data.forgejo.org/actions/upload-artifact@v3
with:
name: combined-release
path: release-out/
- name: Stage and verify draft attachments
run: |
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py stage --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
- name: Publish canary and update channel pointers
run: |
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py publish --channel canary --tag "$RELEASE_TAG"
- name: Remove source credentials
if: always()
run: |
if [[ -n ${SSH_AGENT_PID:-} ]]; then kill "$SSH_AGENT_PID"; fi
rm -rf "$RUNNER_TEMP/tensamin-source"

View file

@ -0,0 +1,47 @@
name: Refresh signed metadata
on:
schedule:
- cron: '17 4 * * *'
concurrency:
group: tensamin-central-releases
cancel-in-progress: false
jobs:
refresh:
runs-on: nixos
env:
NIX_CONFIG: experimental-features = nix-command flakes
FORGEJO_SERVER_URL: ${{ forgejo.server_url }}
FORGEJO_REPOSITORY: ${{ forgejo.repository }}
FORGEJO_RUN_ID: ${{ forgejo.run_id }}
RELEASE_TOKEN: ${{ secrets.TENSAMIN_RELEASE_TOKEN }}
IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }}
IOTA_RELEASE_PUBLIC_KEY: ${{ vars.IOTA_RELEASE_PUBLIC_KEY }}
IOTA_RELEASE_SIGNING_KEY_ID: ${{ vars.IOTA_RELEASE_SIGNING_KEY_ID || 'primary' }}
IOTA_BASE_VERSION: ${{ vars.IOTA_BASE_VERSION || '0.1.0' }}
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Bootstrap pinned tools and source credentials
uses: ./.forgejo/workflows/source-access
with:
source-key: ${{ secrets.TENSAMIN_SOURCE_SSH_KEY }}
known-hosts: ${{ vars.TENSAMIN_SSH_KNOWN_HOSTS }}
- name: Refresh signed metadata
run: |
set -euo pipefail
root=$PWD
revision=$(git rev-parse HEAD)
for channel in stable canary; do
git checkout --detach "$revision"
nix develop --impure --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; }" --command python3 scripts/release.py refresh --channel "$channel" --directory "$RUNNER_TEMP/refresh-$FORGEJO_RUN_ID-$channel"
done
- name: Remove source credentials
if: always()
run: |
if [[ -n ${SSH_AGENT_PID:-} ]]; then kill "$SSH_AGENT_PID"; fi
rm -rf "$RUNNER_TEMP/tensamin-source"

View file

@ -0,0 +1,49 @@
name: Pinned source access
description: Bootstrap public pinned tools before preparing private Nix source access
inputs:
source-key:
description: SSH key with read access to locked sources
required: true
known-hosts:
description: Verified SSH host keys
required: true
runs:
using: composite
steps:
- name: Bootstrap tools from the public nixpkgs lock
shell: bash
run: |
set -euo pipefail
# Nix interpolation must remain literal for the evaluator.
# shellcheck disable=SC2016
tools=$(nix build --impure --no-link --print-out-paths --expr '
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
source = builtins.fetchTree lock.nodes.${lock.nodes.root.inputs.nixpkgs}.locked;
pkgs = import source { system = builtins.currentSystem; };
in pkgs.buildEnv {
name = "release-bootstrap";
paths = with pkgs; [ git openssh python3 bash coreutils ];
}')
printf '%s/bin\n' "$tools" >> "$GITHUB_PATH"
- name: Prepare SSH and checkout fetch credentials
shell: bash
env:
SOURCE_KEY: ${{ inputs.source-key }}
KNOWN_HOSTS: ${{ inputs.known-hosts }}
run: |
set -euo pipefail
umask 077
home="$RUNNER_TEMP/tensamin-source"
mkdir -p "$home/.ssh"
printf '%s\n' "$SOURCE_KEY" > "$home/.ssh/key"
printf '%s\n' "$KNOWN_HOSTS" > "$home/.ssh/known_hosts"
printf 'Host *\n IdentityFile "%s/.ssh/key"\n IdentitiesOnly yes\n StrictHostKeyChecking yes\n UserKnownHostsFile "%s/.ssh/known_hosts"\n BatchMode yes\n' "$home" "$home" > "$home/.ssh/config"
# An agent also supports Nix versions using libgit2 instead of Git's SSH command.
eval "$(ssh-agent -s)"
ssh-add "$home/.ssh/key"
# The askpass process reads the token when Git invokes it.
# shellcheck disable=SC2016
printf '#!/bin/sh\ncase "$1" in *Username*) printf "%%s\\n" token;; *) printf "%%s\\n" "$RELEASE_TOKEN";; esac\n' > "$home/askpass"
chmod 700 "$home/askpass"
printf 'HOME=%s\nGIT_SSH_COMMAND=ssh -F "%s/.ssh/config"\nGIT_ASKPASS=%s/askpass\nGIT_TERMINAL_PROMPT=0\nSSH_AUTH_SOCK=%s\nSSH_AGENT_PID=%s\n' "$home" "$home" "$home" "$SSH_AUTH_SOCK" "$SSH_AGENT_PID" >> "$GITHUB_ENV"

View file

@ -0,0 +1,100 @@
name: Stable release
on:
workflow_dispatch:
inputs:
canary_tag:
description: Successful immutable canary tag to promote
type: string
required: true
concurrency:
group: tensamin-central-releases
cancel-in-progress: false
jobs:
release:
runs-on: nixos
env:
NIX_CONFIG: experimental-features = nix-command flakes
FORGEJO_SERVER_URL: ${{ forgejo.server_url }}
FORGEJO_REPOSITORY: ${{ forgejo.repository }}
FORGEJO_RUN_ID: ${{ forgejo.run_id }}
RELEASE_CHANNEL: stable
RELEASE_TOKEN: ${{ secrets.TENSAMIN_RELEASE_TOKEN }}
FORGEJO_REGISTRY_USER: ${{ vars.FORGEJO_REGISTRY_USER }}
IOTA_RELEASE_SIGNING_KEY: ${{ secrets.IOTA_RELEASE_SIGNING_KEY }}
IOTA_RELEASE_PUBLIC_KEY: ${{ vars.IOTA_RELEASE_PUBLIC_KEY }}
IOTA_RELEASE_SIGNING_KEY_ID: ${{ vars.IOTA_RELEASE_SIGNING_KEY_ID || 'primary' }}
IOTA_BASE_VERSION: ${{ vars.IOTA_BASE_VERSION || '0.1.0' }}
NIXOS_FLAKE_REPOSITORY: ${{ vars.NIXOS_FLAKE_REPOSITORY }}
NIXOS_FLAKE_BRANCH: ${{ vars.NIXOS_FLAKE_BRANCH || 'main' }}
NIXOS_FLAKE_WRITE_TOKEN: ${{ secrets.NIXOS_FLAKE_WRITE_TOKEN }}
TENSAMIN_PROD_DEPLOY_SSH_KEY: ${{ secrets.TENSAMIN_PROD_DEPLOY_SSH_KEY }}
TENSAMIN_PROD_DEPLOY_HOST: ${{ vars.TENSAMIN_PROD_DEPLOY_HOST }}
TENSAMIN_PROD_DEPLOY_PORT: ${{ vars.TENSAMIN_PROD_DEPLOY_PORT || '22' }}
TENSAMIN_PROD_DEPLOY_JUMP_HOST: ${{ vars.TENSAMIN_PROD_DEPLOY_JUMP_HOST }}
TENSAMIN_PROD_DEPLOY_JUMP_PORT: ${{ vars.TENSAMIN_PROD_DEPLOY_JUMP_PORT || '7930' }}
TENSAMIN_SSH_KNOWN_HOSTS: ${{ vars.TENSAMIN_SSH_KNOWN_HOSTS }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Bootstrap pinned tools and source credentials
uses: ./.forgejo/workflows/source-access
with:
source-key: ${{ secrets.TENSAMIN_SOURCE_SSH_KEY }}
known-hosts: ${{ vars.TENSAMIN_SSH_KNOWN_HOSTS }}
- name: Select successful canary for stable
env:
CANARY_TAG: ${{ inputs.canary_tag }}
run: python3 scripts/release.py select --tag "$CANARY_TAG"
- name: Set immutable release identity
run: |
set -euo pipefail
printf 'RELEASE_SEQUENCE=%s\nRELEASE_TAG=stable-%s-%s\n' "$(date +%s)" "$(git rev-parse HEAD)" "$FORGEJO_RUN_ID" >> "$GITHUB_ENV"
- name: Check central release scripts
run: |
set -euo pipefail
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command shellcheck scripts/release-build.sh scripts/release-client.sh
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command ruff check scripts/release.py scripts/deploy-release.py
- name: Validate and build all central packages and client assets
run: |
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command bash scripts/release-build.sh "$RELEASE_CHANNEL" release-out
- name: Attempt aarch64 build with available builders
run: |
set -euo pipefail
if ! nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command bash scripts/release-build.sh "$RELEASE_CHANNEL" release-arm aarch64-linux > arm-build.log 2>&1; then
printf 'aarch64 unavailable. See the attached arm-build.log.\n' > release-out/aarch64-unavailable.txt
cp arm-build.log release-out/
else
cp release-arm/*linux-aarch64* release-out/
cp release-arm/*arm64* release-out/
cp release-arm/electron-release-metadata-aarch64.json release-out/
fi
- name: Sign manifests and validate installer bundles
run: |
set -euo pipefail
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py sign --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py provenance --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
- name: Preserve combined build artifacts
uses: https://data.forgejo.org/actions/upload-artifact@v3
with:
name: combined-release
path: release-out/
- name: Stage and verify draft attachments
run: |
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/release.py stage --channel "$RELEASE_CHANNEL" --tag "$RELEASE_TAG"
- name: Commit infrastructure lock, deploy, check health and publish stable
run: |
nix develop --impure --expr "import $PWD/scripts/release-env.nix { root = builtins.toPath \"$PWD\"; }" --command python3 scripts/deploy-release.py
- name: Remove source credentials
if: always()
run: |
if [[ -n ${SSH_AGENT_PID:-} ]]; then kill "$SSH_AGENT_PID"; fi
rm -rf "$RUNNER_TEMP/tensamin-source"

162
README.md
View file

@ -1,3 +1,161 @@
# Tensamin Production Pins
# Tensamin production pins
Pins for the Client, Iota, Omikron, Omega and MTP (incl. Type Maps) for a consistent production environment
Shared sources and builds for the client, Iota, Omikron, Omega, MTP, and type maps.
## Build usage
Linux x86_64 and aarch64 packages use one nixpkgs, Rust toolchain, MTP source,
and type-map source. The default package is the Electron client.
```sh
nix build .
nix run .#client
nix build .#client-web
nix build .#iota .#iota-daemon .#iota-ui .#omikron .#omega
nix develop .#iota
nix develop .#client
```
`client-web` contains the static site at its output root. `iota` contains
`iota`, `iota-daemon`, `iota-updater`, `iota-release`, and `iota-bundle`. `iota-ui` exposes
the terminal UI as `bin/iota-ui`. `iota-bundle` adds upstream systemd files,
release scripts, and artifact contracts under `share/iota`. Signed release
manifests and deployment-specific update URLs must be supplied to those scripts.
`iota-portable` builds musl-static binaries using the same sources and Cargo
vendor dependencies. Releases use these binaries for ordinary Linux, with no
Nix installation required. `share/iota/web` contains the pinned static assets.
`iota-container`, `omikron-container` and `omega-container` produce Docker-loadable images.
Mount runtime configuration, identity files, and certificates in their working
directories, `/var/lib/omikron` and `/var/lib/omega`.
`mtp-sdk` builds the SDK from the shared MTP source.
Iota's image runs the daemon as UID/GID 1000 and persists `/var/lib/iota`.
Bind mounts must be writable by that user. Supply writable configuration at
`/var/lib/iota/config/config.yaml`, with `web.mode: network`, `web.bind: 0.0.0.0`,
`web.port: 1984`, and `web.certificate`/`web.key` pointing to mounted TLS files.
Publish both `1984/tcp` and `1984/udp`. Review and accept terms interactively
with `docker exec -it CONTAINER /bin/iota terms accept`, then restart the
container. Use `--restart on-failure` to handle the daemon's restart exit 75.
Images update by pulling a new release and recreating the container.
## Updating builds
```sh
nix run .#update-all
# Recalculate dependency locks and hashes without moving source inputs:
nix run .#update-all -- --no-update
```
Run this from the checkout with SSH access to `git@methanium.net`. The command
supplies Cargo, pnpm, Python, Git, and Nix. It refreshes `flake.lock`, resolves
the transformed Cargo and pnpm dependency graphs into `packages/locks`, and
rebuilds every dependency fetcher to verify `packages/hashes.nix`. Review all
generated changes and build the affected packages before committing.
MTP git dependencies become local paths to the shared input before vendoring.
Omega's identity crate comes from the same Iota input as the Iota binaries.
Both YAML and Rust type-map includes come from `mtp-type-maps`. The SDK and
WASM compile from MTP source, with WASM generated for these same maps. The
packaged Vite plugin reuses that WASM and still generates JavaScript type maps.
Client dependency manifests and pnpm overrides cannot select a release SDK.
Project shells are `iota`, `omikron`, `omega`, `mtp`, and `client`.
The default shell supplies `update-all`. Android/Tauri shells are a followup.
## Source overrides for CI
Override raw source inputs when building current project sources:
```sh
nix build .#iota --override-input iota path:../iota
```
Packages expose `passthru.source`; Rust packages also expose
`passthru.transformedSource`, `passthru.mtp`, and `passthru.mtp-type-maps`.
`lib.mkPackages { system = "x86_64-linux"; sources = { iota = ../iota; }; }`
returns `packages` and `devShells`, and accepts a replacement `hashes` attrset.
Dependency-changing overrides require regenerated locks and vendor hashes.
Use `update-all` in a disposable checkout with the desired input overrides.
```sh
nix run .#update-all -- --no-update --override-input iota path:../iota
```
## NixOS modules
Add this flake as `inputs.tensamin`, pass `inputs` through `specialArgs`, and
import the combined module:
```nix
{ inputs, pkgs, ... }: {
imports = [ inputs.tensamin.nixosModules.default ];
environment.systemPackages = [
inputs.tensamin.packages.${pkgs.stdenv.hostPlatform.system}.client
];
tensamin.client.enable = true;
}
```
Individual imports are `nixosModules.iota`, `.omikron`, `.omega`, and `.client`.
All options live under `tensamin.*`, and services are disabled by default.
The client module serves `client-web` through nginx on `127.0.0.1:8080` by
default; installing Electron is separate. Public TLS and Anubis routing belong
to the infrastructure. Production routes host nginx through host Anubis and
a loopback origin to nginx in the internal production VM.
Iota requires TLS for enabled listeners. Omikron and Omega require runtime
certificates; Omikron also needs its ID and Omega trust bundle, and Omega
needs `DB_URL`. Use runtime path strings for secrets. See
[modules/README.md](modules/README.md) for options and state handling.
New module files must be present in the Git-backed flake source before consumers
can import them; local `path:` evaluation includes untracked files.
## Releases and updates
Combined publication belongs to this repository. Stable and canary releases
should contain artifacts built from the same pinned graph. Client dev builds
are local and verification-only, with no dev releases. Android IDs are
`net.tensamin.client`, `net.tensamin.client.canary`, and
`net.tensamin.client.dev`; canary uses yellow icons and dev uses blue outline
branding. Android signing identity must stay consistent and version codes
must increase per application ID.
Images publish to the Forgejo registry as
`SERVER/tensamin/SERVICE:IMMUTABLE_TAG-ARCH`, including `iota`, `omikron`, and
`omega`. Local image revision tags differ from the immutable publication tags.
See [scripts/releases.md](scripts/releases.md) for workflow configuration.
For unmanaged per-user Linux, extract `iota-portable-linux-ARCH.tar.gz` and run
`./bin/iota`. The CLI discovers its sibling daemon and packaged assets. User
IPC defaults to `$XDG_RUNTIME_DIR/iota/iota.sock`, or
`$XDG_STATE_HOME/iota/iota.sock`, with `~/.local/state` as the fallback.
Explicit absolute `IOTA_SOCKET` and `IOTA_DATA_ROOT` overrides still work.
TLS clients use the host CA trust store.
Unmanaged system-wide Linux Iota uses CLI bootstrap with a trusted signed bundle, then
explicit `iota update channel stable`, `iota update check`, and
`iota update apply` operations. Trust lives in `/etc/iota/update.env` and
cannot be replaced by process environment. The pinned upstream bootstrap
enables `iota-update.timer`; disable it with
`sudo systemctl disable --now iota-update.timer` for manual-only updates.
NixOS deployments update through their infrastructure flake.
The infrastructure and this flake have separate nixpkgs locks. Refresh both
when required for security fixes, then build and deploy the affected outputs.
`update-all` verifies dependency fetchers, not full application builds or live
deployment. Infrastructure `lunitely update` pulls published configuration
changes; `lunitely rebuild` activates the local checkout without pulling.
`lunitely boot` and `update --boot` stage the next boot without rebooting.
The production deploy wrapper pins an exact prod-pins SHA, rebuilds with
`--no-update-lock-file`, checks health, and restores the previous generation on
failure. It does not roll back mutable application data. Record successful
runtime pins in the infrastructure lock for later administrator updates.
See the documentation site's [deployment](https://docs.tensamin.net/deployment/),
[updates](https://docs.tensamin.net/updates/), and
[release guide](https://docs.tensamin.net/developers/releases/).
Its Obtainium configuration uses a real JSON import and explicit self-hosted
Forgejo source override. Sync it with release titles and APK layout after the
central publication pipeline is finalized.

156
flake.lock generated Normal file
View file

@ -0,0 +1,156 @@
{
"nodes": {
"client": {
"flake": false,
"locked": {
"lastModified": 1791114583,
"narHash": "sha256-J4j6LI+erWFp+maryBRN08Ra90BKLjv0NOhVC7subEY=",
"ref": "dev",
"rev": "d08a00a94acda0d1622de5960fa74750bf7d64db",
"revCount": 646,
"type": "git",
"url": "ssh://git@methanium.net/tensamin/client"
},
"original": {
"ref": "dev",
"type": "git",
"url": "ssh://git@methanium.net/tensamin/client"
}
},
"iota": {
"flake": false,
"locked": {
"lastModified": 1791114268,
"narHash": "sha256-iKNN+La/hAKXxJL6wYti2jlZ4uS2C5dRkQyuVnciPwU=",
"ref": "main",
"rev": "3d824fde58f1a9ff3c2df45311f7dc658e9700dd",
"revCount": 327,
"type": "git",
"url": "ssh://git@methanium.net/tensamin/iota"
},
"original": {
"ref": "main",
"type": "git",
"url": "ssh://git@methanium.net/tensamin/iota"
}
},
"mtp": {
"flake": false,
"locked": {
"lastModified": 1791113691,
"narHash": "sha256-r7LAe6KUuVCXLzyTNo1vqQeXxmfsXuzFV+qV6teZd+Y=",
"ref": "master",
"rev": "ad489265deacadd6de52ed2129d071803a0e7247",
"revCount": 215,
"type": "git",
"url": "ssh://git@methanium.net/methanium/mtp"
},
"original": {
"ref": "master",
"type": "git",
"url": "ssh://git@methanium.net/methanium/mtp"
}
},
"mtp-type-maps": {
"flake": false,
"locked": {
"lastModified": 1790572556,
"narHash": "sha256-ugNZR2Be9N9UjG0aVN8Yrk4hYOVC2edjtC9xBhbW35w=",
"ref": "main",
"rev": "4f18c7a0d9b04d38a77fbb011c4f0b21c25bf7bf",
"revCount": 28,
"type": "git",
"url": "ssh://git@methanium.net/tensamin/mtp-type-maps"
},
"original": {
"ref": "main",
"type": "git",
"url": "ssh://git@methanium.net/tensamin/mtp-type-maps"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1790981744,
"narHash": "sha256-sm6DclXJudZfP/pcDBQQsRqyMxBcQsuw+7yQr4rBBLE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "55ba7f49ef2962b42cbd126522b7df5f95037679",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"omega": {
"flake": false,
"locked": {
"lastModified": 1791114269,
"narHash": "sha256-56Nh5XnH7SK1P0kdtai/ZKcuQr8YlgDo5ndBf67YnFo=",
"ref": "main",
"rev": "993fa5ead0cfe5f5f0ab1ecd12ffe0f343380489",
"revCount": 157,
"type": "git",
"url": "ssh://git@methanium.net/tensamin/omega"
},
"original": {
"ref": "main",
"type": "git",
"url": "ssh://git@methanium.net/tensamin/omega"
}
},
"omikron": {
"flake": false,
"locked": {
"lastModified": 1791114268,
"narHash": "sha256-x6jc6l3LC3jTG/5YOuch32spGXfUzhO20M3g/Qmq2FY=",
"ref": "main",
"rev": "39371ad398d13aa1792c1ff58d2fb72f7be15787",
"revCount": 211,
"type": "git",
"url": "ssh://git@methanium.net/tensamin/omikron"
},
"original": {
"ref": "main",
"type": "git",
"url": "ssh://git@methanium.net/tensamin/omikron"
}
},
"root": {
"inputs": {
"client": "client",
"iota": "iota",
"mtp": "mtp",
"mtp-type-maps": "mtp-type-maps",
"nixpkgs": "nixpkgs",
"omega": "omega",
"omikron": "omikron",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1791101754,
"narHash": "sha256-y/GF+9B0t0TZ0nQiSRMqDXpr76yT7sdDbS/3GNLhzkE=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "dbc715a4b7c0ace63b9769a032d1dd34cd89e5bd",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,13 +1,51 @@
{
description = "Shared production sources and builds for Tensamin";
inputs = {
nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.zst";
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
iota = { url = "git+ssh://git@methanium.net/tensamin/iota?ref=main"; flake = false; };
omikron = { url = "git+ssh://git@methanium.net/tensamin/omikron?ref=main"; flake = false; };
omega = { url = "git+ssh://git@methanium.net/tensamin/omega?ref=main"; flake = false; };
client = { url = "git+ssh://git@methanium.net/tensamin/client?ref=dev"; flake = false; };
mtp = { url = "git+ssh://git@methanium.net/methanium/mtp?ref=master"; flake = false; };
mtp-type-maps = { url = "git+ssh://git@methanium.net/tensamin/mtp-type-maps?ref=main"; flake = false; };
};
outputs = inputs: {
packages = builtins.mapAttrs (system: pkgs: {
hello = pkgs.hello;
default = inputs.self.packages.${system}.hello;
}) inputs.nixpkgs.legacyPackages;
};
outputs = inputs@{ self, nixpkgs, ... }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forSystems = nixpkgs.lib.genAttrs systems;
project = system: import ./packages {
inherit inputs system;
pkgs = import nixpkgs {
inherit system;
overlays = [ inputs.rust-overlay.overlays.default ];
};
};
in {
packages = forSystems (system: (project system).packages);
devShells = forSystems (system: (project system).devShells);
apps = forSystems (system: {
update-all = {
type = "app";
program = "${self.packages.${system}.update-all}/bin/update-all";
};
});
lib.mkPackages = { system, sources ? {}, hashes ? import ./packages/hashes.nix }:
import ./packages {
inherit system hashes;
inputs = inputs // sources;
pkgs = import nixpkgs {
inherit system;
overlays = [ inputs.rust-overlay.overlays.default ];
};
};
nixosModules = if builtins.pathExists ./modules/default.nix
then import ./modules/default.nix { inherit self; }
else {};
};
}

133
lib/prepare-source.py Normal file
View file

@ -0,0 +1,133 @@
"""Make every Rust consumer use the same local MTP and type maps."""
import os
import pathlib
import re
import shutil
import sys
import tomllib
project, source, mtp, maps, iota, destination = sys.argv[1:]
root = pathlib.Path(destination)
shutil.copytree(source, root, symlinks=True)
root.chmod(root.stat().st_mode | 0o200)
if project != "mtp":
shutil.copytree(mtp, root / ".mtp", symlinks=True)
mtp_root = root if project == "mtp" else root / ".mtp"
crates = {}
for manifest in mtp_root.rglob("Cargo.toml"):
data = tomllib.loads(manifest.read_text())
if "name" in data.get("package", {}):
crates[data["package"]["name"]] = manifest.parent
if project == "omega":
shutil.copytree(pathlib.Path(iota) / "iota-identity", root / ".identity")
for path in [root, *root.rglob("*")]:
if not path.is_symlink():
path.chmod(path.stat().st_mode | 0o200)
if project == "omega":
manifest = root / "Cargo.toml"
manifest.write_text(manifest.read_text().replace('../iota/iota-identity', './.identity'))
for manifest in root.rglob("Cargo.toml"):
text = manifest.read_text()
# Preserve feature selections while replacing every independent git revision.
def local_dependency(match, manifest=manifest):
name, attributes = match.groups()
path = pathlib.Path(os.path.relpath(crates[name], manifest.parent))
attributes = re.sub(r'git\s*=\s*"[^"]+"\s*,?\s*', '', attributes)
attributes = re.sub(r'(rev|branch|tag)\s*=\s*"[^"]+"\s*,?\s*', '', attributes)
return f'{name} = {{ path = "{path}", {attributes}'
text = re.sub(r'(mtp(?:-[\w-]+)?)\s*=\s*\{([^}]*git\s*=\s*"[^"]*[Mm]ethanium/mtp[^}]*)(?=\})', local_dependency, text)
if manifest == root / "Cargo.toml" and project == "iota":
text = text.replace('exclude = [', 'exclude = [".mtp", ')
manifest.write_text(text)
maps_dir = root / "mtp-type-maps"
if maps_dir.is_symlink() or maps_dir.is_file():
maps_dir.unlink()
elif maps_dir.exists():
shutil.rmtree(maps_dir)
shutil.copytree(maps, maps_dir)
# Cargo reads only configuration from the invocation directory and its parents.
config = root / ".cargo" / "config.toml"
if config.exists():
config.unlink()
if project == "iota":
# Keep native executables usable before a system installation, without a
# shell launcher or store paths in the portable release.
paths = root / "iota-paths/src/lib.rs"
text = paths.read_text()
text = text.replace(
'return Err(PathError::MissingRequiredOverride("IOTA_SOCKET"));',
'''let home = absolute_env("HOME")?
.ok_or(PathError::MissingPlatformDirectory("home directory"))?;
let runtime = absolute_env("XDG_RUNTIME_DIR")?
.unwrap_or(xdg_or_home("XDG_STATE_HOME", &home, ".local/state")?);
return Ok(IpcEndpoint::UnixSocket(runtime.join("iota/iota.sock")));''',
)
for program in ["daemon", "updater"]:
text = text.replace(
f'.unwrap_or_else(|| install_root().join("current/bin/iota-{program}"))',
f'''.unwrap_or_else(|| {{
let sibling = env::current_exe().ok()
.and_then(|path| path.parent().map(|parent| parent.join("iota-{program}")));
sibling.filter(|path| path.is_file())
.unwrap_or_else(|| install_root().join("current/bin/iota-{program}"))
}})''',
)
text = text.replace(
'.unwrap_or(defaults.asset_dir);',
'''.unwrap_or_else(|| {
env::current_exe().ok()
.and_then(|path| path.parent()?.parent().map(|parent| parent.join("share/iota/web")))
.filter(|path| path.is_dir())
.unwrap_or(defaults.asset_dir)
});''',
)
paths.write_text(text)
daemon = root / "iota-daemon/src/main.rs"
daemon.write_text(daemon.read_text().replace(
' if !iota_terms::consent::load(&paths.state_dir).has_all_required() {',
''' if let Err(error) = paths.prepare_writable_directories() {
eprintln!("Cannot prepare Iota directories: {error}");
return ExitCode::FAILURE;
}
if !iota_terms::consent::load(&paths.state_dir).has_all_required() {''',
1,
))
# Ship and install every pinned static asset through the existing ZIP
# contract, so the validator and installer agree about the payload.
assets = sorted(path.relative_to(root).as_posix() for path in (root / "static/web").rglob("*") if path.is_file())
contract = root / "iota-installer/bundle-files.txt"
contract.write_text(contract.read_text().rstrip() + "\n" + "\n".join(assets) + "\n")
installer = root / "iota-installer/src/lib.rs"
text = installer.read_text().replace(
' for unit in [',
''' for name in REQUIRED.lines().filter(|name| name.starts_with("static/web/")) {
install(
&staging.path().join(name),
&format!("/usr/local/share/iota/web/{}", &name["static/web/".len()..]),
"0644",
)?;
}
for unit in [''',
1,
)
installer.write_text(text)
# Drop git identities for the crates now supplied through local paths. Cargo
# updates the dependency graph during update-all, never inside a sandboxed build.
lock = root / "Cargo.lock"
if lock.exists():
text = lock.read_text()
text = re.sub(r'(\[\[package\]\]\nname = "mtp[^\n]*\n.*?)(?=\n\[\[package\]\]|\Z)',
lambda m: re.sub(r'^source = .*\n|^checksum = .*\n', '', m[0], flags=re.MULTILINE), text, flags=re.DOTALL)
text = re.sub(r'(mtp[\w-]* [\d.]+) \(git\+[^)]+\)', r'\1', text)
lock.write_text(text)

106
modules/README.md Normal file
View file

@ -0,0 +1,106 @@
# NixOS modules
`import ./default.nix { inherit self; }` returns `default`, `iota`, `omikron`,
`omega`, and `client`. `default` imports all four component modules. The flake's
existing conditional import accepts this interface directly.
These new files must be included in the consuming Git checkout for Git-backed
flake evaluation. Verification used `path:` to include the untracked modules.
All options live under `tensamin`, with no `services.tensamin` wrapper or legacy
`services.iota`, `services.omikron`, or `services.omega` aliases.
## Interfaces
All components have `enable`, `package`, `bindAddress`, `port`, and
`openFirewall`. Services are disabled by default. Package defaults resolve via
`self.packages.${pkgs.stdenv.hostPlatform.system}`.
| Component | Package | Bind address | Port | Open firewall |
| --- | --- | --- | --- | --- |
| `tensamin.iota` | `iota-daemon` | `0.0.0.0` | 1984 | true |
| `tensamin.omikron` | `omikron` | `0.0.0.0` | 443 | true |
| `tensamin.omega` | `omega` | `0.0.0.0` | 443 | true |
| `tensamin.client` | `client-web` | `127.0.0.1` | 8080 | false |
Iota, Omikron, and Omega open TCP and UDP. Client opens only TCP when requested.
### Iota
- `stateDir`, `cacheDir`, `runtimeDir`, `logDir` default to `/var/lib/iota`,
`/var/cache/iota`, `/run/iota`, `/var/log/iota`.
- `assetDir` defaults to the central `iota` package's pinned source
`static/web` directory. This is upstream's shipped asset directory, currently
containing its 404 page, not the client application. Override it to serve
other assets.
- `webMode` is `network` by default, or `loopback` or `disabled`. Upstream
requires TLS for both enabled modes. Set `bindAddress` explicitly for loopback.
- `certFile` and `keyFile` are nullable runtime path strings, supplied together.
An enabled listener requires them unless `settingsFile` supplies complete TLS
configuration.
- `omegaApiUrl` defaults to `https://omega.tensamin.net`.
- `environmentFiles` is a list of runtime path strings, defaulting to `[]`.
- `settings` contains YAML-compatible operator configuration, defaulting to `{}`.
Module listener and asset options take precedence. Startup rewrites the mutable
`stateDir/config.yaml`, preserving omitted `iota_id`, `omikron_host`,
`omikron_port`, and `omikron_id`. Explicit values, including null, override them.
Other daemon/operator edits to this file are replaced at the next startup.
- `settingsFile` is a nullable runtime path string. It replaces generated
settings, with the same preservation of omitted discovery fields. Its listener
and TLS settings must agree with the module's firewall and capability options.
Relative paths resolve against `stateDir`, not the source file's directory.
The systemd service and socket are named `iota`. IPC uses
`${runtimeDir}/iota.sock`, mode `0660`, owned by `iota:iota`. Add authorized
operators to the `iota` group. Daemon identities remain under
`${stateDir}/identity`; the module does not reseed them. Exit code 75 forces a
restart. Config paths, deployment mode, supervisor, and all mutable directories
are passed through the upstream `IOTA_*` environment contract.
### Omikron and Omega
- `stateDir` defaults to `/var/lib/omikron` or `/var/lib/omega` and is the working
directory of the matching systemd service and service user.
- Set either `acmeCertDir`, containing `fullchain.pem` and `key.pem`, or both
`certFile` and `keyFile`. All are nullable runtime path strings. Startup copies
the certificate to `certs/cert.pem` and converts the key to unencrypted PKCS8
at `certs/key.pem`, owned by the service user with mode `0600`.
- `identityFile` and `publicIdentityFile` are nullable runtime path strings,
supplied together. Startup copies them to `omikron.mk` and `omikron.mpkb`, or
`omega.mk` and `omega.mpkb`. Null retains existing state or lets upstream
generate an identity. Supplied identities are reapplied on every startup.
- `environment` is an attribute set of non-secret string settings, default `{}`.
Module-generated listener and Omikron discovery variables take precedence.
- `environmentFiles` is a list of runtime environment paths, default `[]`.
Systemd loads these after the declared environment, so they can override it.
Keep listener variables consistent with firewall options. Omega requires
`DB_URL`; it uses file-based identities, not `PRIVATE_KEY`/`PUBLIC_KEY`.
Omikron also requires positive `id` and `omegaTrustFile`, the runtime Omega public
key bundle copied to `omega.mpkb`. `omegaHost` defaults to `tensamin.net` and
`omegaPort` to the upstream default 9187. Set it to the deployed Omega listener
port, whose module default is 443. These become `ID`, `OMEGA_HOST`, `OMEGA_PORT`,
`RHO_PORT`, and `BIND_ADDRESS`. Omega uses `PORT` and `BIND_ADDRESS`.
Trust and identity installation runs for both manual TLS and ACME. Configure
certificate issuance separately and restart the corresponding service after
renewal so it recopies certificates. Environment and secret path strings do not
copy secret contents into the Nix store.
### Client
`hostName` defaults to `localhost`. The module enables nginx and adds that
virtual host with an explicit HTTP listener at `bindAddress:port`, using
`client-web`'s output root and SPA fallback to `/index.html`. Configure public
TLS/proxy routing separately. It does not configure Anubis, guest accounts, or
install the Electron client.
## Verification
The combined module was evaluated with actual central package outputs in
minimal x86_64-linux and aarch64-linux NixOS container configurations. All
assertions passed and `system.build.toplevel.drvPath` evaluated. Checks included
both server TLS branches, all services disabled, Iota disabled-listener mode,
a custom Iota runtime directory, and a custom nginx listener. Missing Omikron
TLS produces the intended assertion. Evaluation does not build or start the
applications.

51
modules/client.nix Normal file
View file

@ -0,0 +1,51 @@
{ self }:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.tensamin.client;
inherit (lib) mkOption types;
in
{
options.tensamin.client = {
enable = lib.mkEnableOption "the static Tensamin web client";
package = mkOption {
type = types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.client-web;
description = "Static client package with index.html at its output root.";
};
hostName = mkOption {
type = types.str;
default = "localhost";
};
bindAddress = mkOption {
type = types.str;
default = "127.0.0.1";
};
port = mkOption {
type = types.port;
default = 8080;
};
openFirewall = mkOption {
type = types.bool;
default = false;
};
};
config = lib.mkIf cfg.enable {
services.nginx.enable = true;
services.nginx.virtualHosts.${cfg.hostName} = {
listen = [
{
addr = cfg.bindAddress;
inherit (cfg) port;
}
];
root = cfg.package;
locations."/".tryFiles = "$uri $uri/ /index.html";
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}

21
modules/default.nix Normal file
View file

@ -0,0 +1,21 @@
{ self }:
let
components = {
iota = import ./iota.nix { inherit self; };
omikron = import ./server.nix {
inherit self;
name = "omikron";
};
omega = import ./server.nix {
inherit self;
name = "omega";
};
client = import ./client.nix { inherit self; };
};
in
components
// {
default = {
imports = builtins.attrValues components;
};
}

251
modules/iota.nix Normal file
View file

@ -0,0 +1,251 @@
{ self }:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.tensamin.iota;
inherit (lib) mkOption types;
format = pkgs.formats.yaml { };
settings = lib.recursiveUpdate cfg.settings {
port = cfg.port;
web = {
mode = cfg.webMode;
bind = cfg.bindAddress;
port = cfg.port;
required = cfg.webMode != "disabled";
asset_dir = cfg.assetDir;
}
// lib.optionalAttrs (cfg.certFile != null) {
certificate = "${cfg.stateDir}/tls/cert.pem";
key = "${cfg.stateDir}/tls/key.pem";
};
};
sourceConfig =
if cfg.settingsFile != null then cfg.settingsFile else format.generate "iota-config.yaml" settings;
configFile = "${cfg.stateDir}/config.yaml";
python = pkgs.python3.withPackages (ps: [ ps.pyyaml ]);
mergeConfig = pkgs.writeText "iota-merge-config.py" ''
import os
import sys
import yaml
source, destination = sys.argv[1:]
with open(source) as stream:
settings = yaml.safe_load(stream) or {}
if os.path.exists(destination):
with open(destination) as stream:
previous = yaml.safe_load(stream) or {}
# Retain discovery state unless the operator explicitly supplies it.
for field in ["iota_id", "omikron_host", "omikron_port", "omikron_id"]:
if field not in settings and field in previous:
settings[field] = previous[field]
temporary = destination + ".new"
with open(temporary, "w") as stream:
yaml.safe_dump(settings, stream, sort_keys=False)
os.chmod(temporary, 0o640)
os.replace(temporary, destination)
'';
setup = pkgs.writeShellScript "iota-setup" ''
set -eu
umask 077
${python}/bin/python ${mergeConfig} ${lib.escapeShellArg sourceConfig} ${lib.escapeShellArg configFile}
chown iota:iota ${lib.escapeShellArg configFile}
${lib.optionalString (cfg.certFile != null) ''
install -d -m 0700 -o iota -g iota ${lib.escapeShellArg "${cfg.stateDir}/tls"}
install -m 0644 -o iota -g iota ${lib.escapeShellArg cfg.certFile} ${lib.escapeShellArg "${cfg.stateDir}/tls/cert.pem"}
install -m 0600 -o iota -g iota ${lib.escapeShellArg cfg.keyFile} ${lib.escapeShellArg "${cfg.stateDir}/tls/key.pem"}
''}
'';
in
{
options.tensamin.iota = {
enable = lib.mkEnableOption "the Tensamin Iota daemon";
package = mkOption {
type = types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.iota-daemon;
};
stateDir = mkOption {
type = types.str;
default = "/var/lib/iota";
};
cacheDir = mkOption {
type = types.str;
default = "/var/cache/iota";
};
runtimeDir = mkOption {
type = types.str;
default = "/run/iota";
};
logDir = mkOption {
type = types.str;
default = "/var/log/iota";
};
assetDir = mkOption {
type = types.str;
default = "${self.packages.${pkgs.stdenv.hostPlatform.system}.iota.passthru.source}/static/web";
description = "Static Iota assets. Defaults to the pinned source's shipped web directory.";
};
bindAddress = mkOption {
type = types.str;
default = "0.0.0.0";
};
port = mkOption {
type = types.port;
default = 1984;
};
webMode = mkOption {
type = types.enum [
"disabled"
"loopback"
"network"
];
default = "network";
description = "Iota listener mode. Both loopback and network modes require TLS upstream.";
};
certFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Runtime TLS certificate path.";
};
keyFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Runtime TLS private key path.";
};
omegaApiUrl = mkOption {
type = types.str;
default = "https://omega.tensamin.net";
};
openFirewall = mkOption {
type = types.bool;
default = true;
};
environmentFiles = mkOption {
type = types.listOf types.str;
default = [ ];
};
settings = mkOption {
type = format.type;
default = { };
description = "Operator settings, refreshed at startup. Listener options take precedence; omitted discovery fields retain daemon values.";
};
settingsFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Complete runtime YAML configuration, replacing generated settings. Its listener and TLS settings must agree with module options.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = (cfg.certFile == null) == (cfg.keyFile == null);
message = "tensamin.iota: certFile and keyFile must be supplied together.";
}
{
assertion = cfg.settingsFile != null || cfg.webMode == "disabled" || cfg.certFile != null;
message = "tensamin.iota: an enabled listener requires certFile and keyFile, or a complete settingsFile with TLS.";
}
{
assertion = lib.all (path: lib.hasPrefix "/" path) [
cfg.stateDir
cfg.cacheDir
cfg.runtimeDir
cfg.logDir
cfg.assetDir
];
message = "tensamin.iota: directory paths must be absolute.";
}
];
users.users.iota = {
isSystemUser = true;
group = "iota";
home = cfg.stateDir;
};
users.groups.iota = { };
systemd.tmpfiles.rules = map (path: "d ${path} 0750 iota iota -") [
cfg.stateDir
cfg.cacheDir
cfg.runtimeDir
cfg.logDir
];
systemd.sockets.iota = {
description = "Tensamin Iota IPC socket";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "${cfg.runtimeDir}/iota.sock";
SocketMode = "0660";
SocketUser = "iota";
SocketGroup = "iota";
DirectoryMode = "0750";
Backlog = 5;
RemoveOnStop = true;
};
};
systemd.services.iota = {
description = "Tensamin Iota daemon";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"iota.socket"
];
requires = [ "iota.socket" ];
environment = {
OMEGA_API_URL = cfg.omegaApiUrl;
IOTA_SOCKET = "${cfg.runtimeDir}/iota.sock";
IOTA_CONFIG_FILE = configFile;
IOTA_CONFIG_DIR = cfg.stateDir;
IOTA_STATE_DIR = cfg.stateDir;
IOTA_CACHE_DIR = cfg.cacheDir;
IOTA_RUNTIME_DIR = cfg.runtimeDir;
IOTA_LOG_DIR = cfg.logDir;
IOTA_ASSET_DIR = cfg.assetDir;
IOTA_DEPLOYMENT_MODE = "system_socket_activated";
IOTA_SUPERVISOR = "systemd";
};
serviceConfig = {
Type = "simple";
User = "iota";
Group = "iota";
WorkingDirectory = cfg.stateDir;
ExecStart = "${cfg.package}/bin/iota-daemon";
ExecStartPre = [ "+${setup}" ];
EnvironmentFile = cfg.environmentFiles;
Restart = "on-failure";
RestartSec = "5s";
RestartPreventExitStatus = "0";
RestartForceExitStatus = "75";
TimeoutStopSec = "10s";
KillMode = "mixed";
KillSignal = "SIGTERM";
UMask = "0077";
AmbientCapabilities = lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
CapabilityBoundingSet = lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
ReadWritePaths = [
cfg.stateDir
cfg.cacheDir
cfg.runtimeDir
cfg.logDir
];
ReadOnlyPaths = [ cfg.assetDir ];
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
};
};
networking.firewall = lib.mkIf (cfg.openFirewall && cfg.webMode != "disabled") {
allowedTCPPorts = [ cfg.port ];
allowedUDPPorts = [ cfg.port ];
};
};
}

199
modules/server.nix Normal file
View file

@ -0,0 +1,199 @@
{ self, name }:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.tensamin.${name};
isOmikron = name == "omikron";
inherit (lib) mkOption types;
cert = if cfg.acmeCertDir != null then "${cfg.acmeCertDir}/fullchain.pem" else cfg.certFile;
key = if cfg.acmeCertDir != null then "${cfg.acmeCertDir}/key.pem" else cfg.keyFile;
setup = pkgs.writeShellScript "${name}-setup" ''
set -eu
umask 077
install -d -m 0750 -o ${name} -g ${name} ${lib.escapeShellArg cfg.stateDir}
cd ${lib.escapeShellArg cfg.stateDir}
install -d -m 0700 -o ${name} -g ${name} certs
install -m 0644 -o ${name} -g ${name} ${
lib.escapeShellArg (if cert == null then "" else cert)
} certs/cert.pem
${pkgs.openssl}/bin/openssl pkcs8 -topk8 -nocrypt \
-in ${lib.escapeShellArg (if key == null then "" else key)} -out certs/key.pem
chown ${name}:${name} certs/key.pem
chmod 0600 certs/key.pem
${lib.optionalString isOmikron ''
install -m 0644 -o ${name} -g ${name} ${lib.escapeShellArg cfg.omegaTrustFile} omega.mpkb
''}
${lib.optionalString (cfg.identityFile != null) ''
install -m 0600 -o ${name} -g ${name} ${lib.escapeShellArg cfg.identityFile} ${name}.mk
''}
${lib.optionalString (cfg.publicIdentityFile != null) ''
install -m 0644 -o ${name} -g ${name} ${lib.escapeShellArg cfg.publicIdentityFile} ${name}.mpkb
''}
'';
in
{
options.tensamin.${name} = {
enable = lib.mkEnableOption "Tensamin ${name}";
package = mkOption {
type = types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.${name};
description = "Central ${name} package, or an explicit replacement.";
};
stateDir = mkOption {
type = types.str;
default = "/var/lib/${name}";
description = "Persistent working directory, including identities and certificates.";
};
bindAddress = mkOption {
type = types.str;
default = "0.0.0.0";
};
port = mkOption {
type = types.port;
default = 443;
};
openFirewall = mkOption {
type = types.bool;
default = true;
};
certFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Runtime TLS certificate path. Set together with keyFile.";
};
keyFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Runtime TLS private key path. Never copied into the Nix store.";
};
acmeCertDir = mkOption {
type = types.nullOr types.str;
default = null;
description = "Runtime directory containing fullchain.pem and key.pem. Restart the service after renewal.";
};
identityFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Existing private keyring to install at startup, or null to retain or generate state.";
};
publicIdentityFile = mkOption {
type = types.nullOr types.str;
default = null;
description = "Matching public key bundle to install at startup.";
};
environment = mkOption {
type = types.attrsOf types.str;
default = { };
description = "Additional non-secret runtime settings. Listener options take precedence.";
};
environmentFiles = mkOption {
type = types.listOf types.str;
default = [ ];
description =
if isOmikron then
"Runtime environment files, including optional LiveKit credentials."
else
"Runtime environment files. Supply DB_URL here; identities are file-based.";
};
}
// lib.optionalAttrs isOmikron {
id = mkOption {
type = types.ints.positive;
description = "Omikron ID assigned by Omega.";
};
omegaHost = mkOption {
type = types.str;
default = "tensamin.net";
};
omegaPort = mkOption {
type = types.port;
default = 9187;
};
omegaTrustFile = mkOption {
type = types.str;
description = "Runtime path to Omega's trusted public key bundle.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion =
(cfg.acmeCertDir != null && cfg.certFile == null && cfg.keyFile == null)
|| (cfg.acmeCertDir == null && cfg.certFile != null && cfg.keyFile != null);
message = "tensamin.${name}: set either acmeCertDir or both certFile and keyFile.";
}
{
assertion = (cfg.identityFile == null) == (cfg.publicIdentityFile == null);
message = "tensamin.${name}: identityFile and publicIdentityFile must be supplied together.";
}
{
assertion = lib.hasPrefix "/" cfg.stateDir;
message = "tensamin.${name}.stateDir must be absolute.";
}
];
users.users.${name} = {
isSystemUser = true;
group = name;
home = cfg.stateDir;
};
users.groups.${name} = { };
systemd.tmpfiles.rules = [ "d ${cfg.stateDir} 0750 ${name} ${name} -" ];
systemd.services.${name} = {
description = "Tensamin ${name}";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
environment =
cfg.environment
// {
BIND_ADDRESS = cfg.bindAddress;
}
// (
if isOmikron then
{
RHO_PORT = toString cfg.port;
OMEGA_HOST = cfg.omegaHost;
OMEGA_PORT = toString cfg.omegaPort;
ID = toString cfg.id;
}
else
{ PORT = toString cfg.port; }
);
serviceConfig = {
Type = "simple";
User = name;
Group = name;
WorkingDirectory = cfg.stateDir;
ExecStart = "${cfg.package}/bin/${name}";
ExecStartPre = [ "+${setup}" ];
EnvironmentFile = cfg.environmentFiles;
Restart = "always";
RestartSec = "5s";
UMask = "0077";
AmbientCapabilities = lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
CapabilityBoundingSet = lib.optional (cfg.port < 1024) "CAP_NET_BIND_SERVICE";
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
ReadWritePaths = [ cfg.stateDir ];
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
allowedUDPPorts = [ cfg.port ];
};
};
}

137
packages/client.nix Normal file
View file

@ -0,0 +1,137 @@
{ pkgs, rust, rustPlatform, inputs, hashes, mtpSource, mtpVendor }:
let
inherit (pkgs) lib;
pnpm = pkgs.pnpm;
sdk-deps = pkgs.fetchPnpmDeps {
pname = "mtp-sdk";
version = "0.4.0";
src = inputs.mtp;
inherit pnpm;
fetcherVersion = 4;
hash = hashes.sdk;
};
wasmVersion = (lib.findFirst (p: p.name == "wasm-bindgen") (throw "Missing wasm-bindgen")
(builtins.fromTOML (builtins.readFile (inputs.mtp + "/Cargo.lock"))).package).version;
wasmBindgen = pkgs.${"wasm-bindgen-cli_" + builtins.replaceStrings [ "." ] [ "_" ] wasmVersion};
mtp-sdk = pkgs.stdenv.mkDerivation {
pname = "mtp-sdk";
version = "0.4.0";
src = mtpSource;
pnpmDeps = sdk-deps;
cargoDeps = mtpVendor;
nativeBuildInputs = [ rust pkgs.nodejs pnpm pkgs.pnpmConfigHook rustPlatform.cargoSetupHook wasmBindgen pkgs.lld ];
MTP_TYPE_MAPS = "${inputs.mtp-type-maps}/type-maps.yaml";
RUSTFLAGS = "--cfg=web_sys_unstable_apis";
buildPhase = ''
runHook preBuild
cargo build --locked --offline --release -p mtp-wasm --target wasm32-unknown-unknown
mkdir -p sdk/bindings/wasm/pkg
wasm-bindgen target/wasm32-unknown-unknown/release/mtp_wasm.wasm \
--target web --out-dir sdk/bindings/wasm/pkg --out-name mtp_wasm
pnpm --dir sdk run build:ts
# The Nix SDK already contains WASM compiled with the authoritative maps.
# Keep the plugin's YAML generation and aliases, but reuse that build.
substituteInPlace sdk/dist/vite/index.js \
--replace-fail 'await runWasmPack(state);' \
'await fs.cp(path.join(packageRoot, "bindings/wasm/pkg"), state.outDir, { recursive: true });'
runHook postBuild
'';
installPhase = ''
mkdir -p "$out"
cp -r sdk/dist sdk/bindings sdk/type-map sdk/package.json "$out/"
mkdir -p "$out/node_modules"
cp -rL sdk/node_modules/yaml "$out/node_modules/"
'';
passthru.source = inputs.mtp;
};
client-source = pkgs.runCommand "client-source" { nativeBuildInputs = [ pkgs.python3 ]; } ''
cp -r ${inputs.client} "$out"
chmod -R u+w "$out"
rm -rf "$out/mtp-type-maps"
cp -r ${inputs.mtp-type-maps} "$out/mtp-type-maps"
mkdir -p "$out/.mtp-sdk"
cp ${inputs.mtp}/sdk/package.json "$out/.mtp-sdk/package.json"
python - "$out" <<'PY'
import json, sys, os, re
from pathlib import Path
root = Path(sys.argv[1])
workspace = root / 'pnpm-workspace.yaml'
workspace.write_text(re.sub(r'^ mtp:.*\n', "", workspace.read_text(), flags=re.M))
for path in root.rglob('package.json'):
if '.mtp-sdk' in path.parts:
continue
data = json.loads(path.read_text())
for section in ['dependencies', 'devDependencies']:
if 'mtp' in data.get(section, {}):
data[section]['mtp'] = 'link:' + os.path.relpath(root / '.mtp-sdk', path.parent)
path.write_text(json.dumps(data, indent=2) + '\n')
PY
${lib.optionalString (builtins.pathExists ./locks/client.yaml) ''cp ${./locks/client.yaml} "$out/pnpm-lock.yaml"''}
'';
client-deps = pkgs.fetchPnpmDeps {
pname = "tensamin";
version = "0.0.11";
src = client-source;
inherit pnpm;
fetcherVersion = 4;
hash = hashes.client;
};
common = {
version = "0.0.11";
src = client-source;
pnpmDeps = client-deps;
nativeBuildInputs = [ pkgs.nodejs pnpm pkgs.pnpmConfigHook pkgs.makeWrapper ];
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
postPatch = ''
rm -rf .mtp-sdk
cp -r ${mtp-sdk} .mtp-sdk
chmod -R u+w .mtp-sdk
'';
passthru = { source = inputs.client; inherit mtp-sdk; };
meta.platforms = [ "x86_64-linux" "aarch64-linux" ];
};
client-web = pkgs.stdenv.mkDerivation (common // {
pname = "tensamin-web";
buildPhase = ''
runHook preBuild
pnpm run build:web
runHook postBuild
'';
installPhase = ''mkdir -p "$out"; cp -r apps/web/dist/. "$out/"'';
});
client = pkgs.stdenv.mkDerivation (common // {
pname = "tensamin";
buildPhase = ''
runHook preBuild
TENSAMIN_WEB_BASE=./ pnpm run build:web
pnpm --dir apps/electron run build
runHook postBuild
'';
installPhase = ''
mkdir -p "$out/lib/tensamin" "$out/bin"
cp -r apps/electron/dist "$out/lib/tensamin/"
cp apps/electron/package.json "$out/lib/tensamin/"
mkdir -p "$out/lib/web/dist"
cp -r apps/web/dist/. "$out/lib/web/dist/"
makeWrapper ${pkgs.electron}/bin/electron "$out/bin/tensamin" \
--add-flags "$out/lib/tensamin" \
--prefix PATH : ${lib.makeBinPath [ pkgs.pulseaudio ]} \
--set ELECTRON_OZONE_PLATFORM_HINT auto
if [ -d apps/electron/build/icons ]; then
mkdir -p "$out/lib/tensamin/build"
cp -r apps/electron/build/icons "$out/lib/tensamin/build/icons"
install -Dm644 apps/electron/build/icons/icon.png "$out/share/icons/hicolor/512x512/apps/tensamin.png"
fi
mkdir -p "$out/share/applications"
cp ${pkgs.makeDesktopItem {
name = "tensamin";
desktopName = "Tensamin";
exec = "tensamin %U";
icon = "tensamin";
categories = [ "Network" ];
mimeTypes = [ "x-scheme-handler/tensamin" ];
}}/share/applications/* "$out/share/applications/"
'';
meta = common.meta // { mainProgram = "tensamin"; };
});
in { inherit client client-web mtp-sdk client-source client-deps sdk-deps; }

196
packages/default.nix Normal file
View file

@ -0,0 +1,196 @@
{ inputs, pkgs, system, hashes ? import ./hashes.nix }:
let
inherit (pkgs) lib;
rust = pkgs.rust-bin.stable.latest.default.override {
targets = [ "wasm32-unknown-unknown" "${pkgs.stdenv.hostPlatform.parsed.cpu.name}-unknown-linux-musl" ];
extensions = [ "rust-src" "rustfmt" "clippy" ];
};
rustPlatform = pkgs.makeRustPlatform { cargo = rust; rustc = rust; };
projects = [ "iota" "omikron" "omega" "mtp" ];
prepared = lib.genAttrs projects (name: pkgs.runCommand "${name}-source" {
nativeBuildInputs = [ pkgs.python3 ] ++ lib.optional (name == "iota") rust;
} ''
python ${../lib/prepare-source.py} ${name} ${inputs.${name}} ${inputs.mtp} \
${inputs.mtp-type-maps} ${inputs.iota} "$out"
${lib.optionalString (name == "iota") ''
rustfmt --edition 2024 "$out/iota-paths/src/lib.rs" "$out/iota-installer/src/lib.rs" "$out/iota-daemon/src/main.rs"
''}
'');
sources = lib.genAttrs projects (name:
let lock = ./locks + "/${name}.lock";
in if builtins.pathExists lock then pkgs.runCommand "${name}-locked-source" {} ''
cp -r ${prepared.${name}} "$out"
chmod -R u+w "$out"
cp ${lock} "$out/Cargo.lock"
'' else prepared.${name});
vendors = lib.genAttrs projects (name: rustPlatform.fetchCargoVendor {
pname = "${name}-vendor";
version = "0.1.0";
src = sources.${name};
hash = hashes.${name};
});
mkRust = name: flags: rustPlatform.buildRustPackage {
pname = name;
version = "0.1.0";
src = sources.${name};
cargoDeps = vendors.${name};
cargoBuildFlags = flags;
nativeBuildInputs = [ pkgs.cmake pkgs.perl pkgs.pkg-config ];
buildInputs = [ pkgs.openssl pkgs.sqlite ] ++ lib.optional (name == "omega") pkgs.libmysqlclient;
dontUseCmakeConfigure = true;
MTP_TYPE_MAPS = "${inputs.mtp-type-maps}/type-maps.yaml";
doCheck = false;
postInstall = lib.optionalString (name == "iota") ''
mkdir -p "$out/share/iota"
cp -r static/web "$out/share/iota/web"
'';
passthru = {
source = inputs.${name};
transformedSource = sources.${name};
inherit (inputs) mtp mtp-type-maps;
};
meta = { platforms = [ "x86_64-linux" "aarch64-linux" ]; mainProgram = name; };
};
iota = mkRust "iota" [ "-p" "iota" "-p" "iota-daemon" "-p" "iota-updater" "-p" "iota-installer" ];
staticPkgs = pkgs.pkgsStatic;
iotaPortable = rustPlatform.buildRustPackage {
pname = "iota-portable";
inherit (iota) version src cargoDeps cargoBuildFlags postInstall;
nativeBuildInputs = [ pkgs.cmake pkgs.perl pkgs.pkg-config pkgs.binutils pkgs.removeReferencesTo staticPkgs.stdenv.cc ];
buildInputs = [ staticPkgs.openssl staticPkgs.sqlite staticPkgs.zlib ];
OPENSSL_STATIC = "1";
SQLITE3_STATIC = "1";
env.PKG_CONFIG_ALLOW_CROSS = "1";
# Keep build scripts on the native host and cross-link only the payload.
buildPhase = ''
runHook preBuild
export CARGO_TARGET_${pkgs.stdenv.buildPlatform.rust.cargoEnvVarTarget}_LINKER=${pkgs.stdenv.cc}/bin/cc
export CARGO_TARGET_${staticPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget}_LINKER=${staticPkgs.stdenv.cc}/bin/${staticPkgs.stdenv.cc.targetPrefix}cc
export CC_${pkgs.stdenv.buildPlatform.rust.cargoEnvVarTarget}=${pkgs.stdenv.cc}/bin/cc
export CC_${staticPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget}=${staticPkgs.stdenv.cc}/bin/${staticPkgs.stdenv.cc.targetPrefix}cc
export CC_${builtins.replaceStrings [ "-" ] [ "_" ] staticPkgs.stdenv.hostPlatform.rust.rustcTarget}=${staticPkgs.stdenv.cc}/bin/${staticPkgs.stdenv.cc.targetPrefix}cc
export CXX_${builtins.replaceStrings [ "-" ] [ "_" ] staticPkgs.stdenv.hostPlatform.rust.rustcTarget}=${staticPkgs.stdenv.cc}/bin/${staticPkgs.stdenv.cc.targetPrefix}c++
unset NIX_CFLAGS_COMPILE NIX_LDFLAGS
export CARGO_TARGET_${staticPkgs.stdenv.hostPlatform.rust.cargoEnvVarTarget}_RUSTFLAGS="-L native=${lib.getLib staticPkgs.zlib}/lib --remap-path-prefix=/nix/store=/usr/src"
cargo build --locked --offline --release -j "$NIX_BUILD_CORES" \
--target ${staticPkgs.stdenv.hostPlatform.rust.rustcTarget} ${lib.escapeShellArgs iota.cargoBuildFlags}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p "$out/bin"
for binary in iota iota-daemon iota-updater iota-release iota-bundle; do
cp "target/${staticPkgs.stdenv.hostPlatform.rust.rustcTarget}/release/$binary" "$out/bin/"
done
runHook postInstall
'';
dontUseCmakeConfigure = true;
MTP_TYPE_MAPS = "${inputs.mtp-type-maps}/type-maps.yaml";
doCheck = false;
# Reject accidental dynamic linkage before these binaries reach a release.
postFixup = ''
for binary in "$out"/bin/*; do
# Prebuilt Rust std retains compiler source paths in panic messages.
remove-references-to -t ${rust} "$binary"
if readelf -l "$binary" | grep -q INTERP || readelf -d "$binary" | grep -q NEEDED; then
echo "Non-portable ELF: $binary" >&2
exit 1
fi
done
'';
allowedReferences = [ "out" ];
meta = iota.meta;
};
clientPackages = import ./client.nix {
inherit pkgs rust rustPlatform inputs hashes;
mtpSource = sources.mtp;
mtpVendor = vendors.mtp;
};
packages = {
inherit iota;
iota-portable = iotaPortable;
iota-bundle = pkgs.runCommand "iota-bundle" {} ''
mkdir -p "$out/bin" "$out/share/iota"
cp -r ${iota}/bin/. "$out/bin/"
cp -r ${inputs.iota}/systemd "$out/share/iota/"
cp -r ${sources.iota}/scripts "$out/share/iota/"
cp ${inputs.iota}/iota-updater/artifacts.tsv "$out/share/iota/"
cp -r ${iota}/share/iota/web "$out/share/iota/static-web"
cp ${sources.iota}/iota-installer/bundle-files.txt "$out/share/iota/"
'';
iota-daemon = iota.overrideAttrs {
pname = "iota-daemon";
cargoBuildFlags = [ "-p" "iota-daemon" ];
meta.mainProgram = "iota-daemon";
};
iota-ui = iota.overrideAttrs {
pname = "iota-ui";
cargoBuildFlags = [ "-p" "iota" ];
postInstall = iota.postInstall + ''mv "$out/bin/iota" "$out/bin/iota-ui"'';
meta.mainProgram = "iota-ui";
};
omikron = mkRust "omikron" [];
omega = mkRust "omega" [];
iota-container = pkgs.dockerTools.buildLayeredImage {
name = "tensamin/iota";
tag = "${inputs.iota.shortRev or "local"}";
contents = [ iotaPortable pkgs.cacert pkgs.dockerTools.binSh ];
fakeRootCommands = ''
mkdir -p var/lib/iota/config var/lib/iota/runtime tmp
chmod 1777 tmp
chown -R 1000:1000 var/lib/iota
'';
enableFakechroot = true;
config = {
Entrypoint = [ "${iotaPortable}/bin/iota-daemon" ];
User = "1000:1000";
WorkingDir = "/var/lib/iota";
Volumes = { "/var/lib/iota" = {}; };
ExposedPorts = { "1984/tcp" = {}; "1984/udp" = {}; };
Env = [
"HOME=/var/lib/iota"
"IOTA_DATA_ROOT=/var/lib/iota"
"IOTA_DEPLOYMENT_MODE=user_local"
"IOTA_ASSET_DIR=${iotaPortable}/share/iota/web"
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
];
};
};
omikron-container = pkgs.dockerTools.buildLayeredImage {
name = "tensamin/omikron";
tag = "${inputs.omikron.shortRev or "local"}";
contents = [ packages.omikron pkgs.cacert pkgs.dockerTools.binSh ];
config = {
Entrypoint = [ "${packages.omikron}/bin/omikron" ];
WorkingDir = "/var/lib/omikron";
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
};
};
omega-container = pkgs.dockerTools.buildLayeredImage {
name = "tensamin/omega";
tag = "${inputs.omega.shortRev or "local"}";
contents = [ packages.omega pkgs.cacert pkgs.dockerTools.binSh ];
config = {
Entrypoint = [ "${packages.omega}/bin/omega" ];
WorkingDir = "/var/lib/omega";
Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
};
};
inherit (clientPackages) client client-web mtp-sdk;
default = packages.client;
update-all = pkgs.writeShellApplication {
name = "update-all";
runtimeInputs = [ pkgs.nix pkgs.git pkgs.python3 rust pkgs.nodejs pkgs.pnpm pkgs.coreutils ];
text = ''exec python ${../scripts/update-all.py} "$@"'';
};
} // lib.mapAttrs' (name: value: lib.nameValuePair "${name}-source" value) prepared
// lib.mapAttrs' (name: value: lib.nameValuePair "${name}-vendor" value) vendors
// { inherit (clientPackages) client-source client-deps sdk-deps; };
in {
inherit packages;
devShells = lib.genAttrs [ "iota" "omikron" "omega" "mtp" "client" ] (name: pkgs.mkShell {
packages = [ rust pkgs.git pkgs.cmake pkgs.perl pkgs.pkg-config pkgs.nodejs pkgs.pnpm pkgs.wasm-pack ];
buildInputs = [ pkgs.openssl pkgs.sqlite ] ++ lib.optional (name == "omega") pkgs.libmysqlclient;
MTP_TYPE_MAPS = "${inputs.mtp-type-maps}/type-maps.yaml";
}) // { default = pkgs.mkShell { packages = [ packages.update-all ]; }; };
}

8
packages/hashes.nix Normal file
View file

@ -0,0 +1,8 @@
{
iota = "sha256-LYr2OqOSUX3AXwoJr15xtswpHRVz50evrrT7ep5FZog=";
omikron = "sha256-HFSZ6fEzMzHWFrjPBY08XaLC+OoNNIURflvO/CVaKZM=";
omega = "sha256-rvz6UoZlKPHvTsURLTBqSjuPvdwlKpmKBQ1+27fzC8I=";
mtp = "sha256-zfucKSWEIx3l1L9lTj1lidD/qE/HwueLBVJoND51MMU=";
client = "sha256-w6onBznxx/7bsIjodQz98tcsudcDNEtTPJgf2bmrXSU=";
sdk = "sha256-24mJCREdij/ha95AdmxOIsE/cHKkiBNoQCPANau1GcU=";
}

10385
packages/locks/client.yaml Normal file

File diff suppressed because it is too large Load diff

6163
packages/locks/iota.lock Normal file

File diff suppressed because it is too large Load diff

5677
packages/locks/mtp.lock Normal file

File diff suppressed because it is too large Load diff

3359
packages/locks/omega.lock Normal file

File diff suppressed because it is too large Load diff

4180
packages/locks/omikron.lock Normal file

File diff suppressed because it is too large Load diff

100
scripts/deploy-release.py Normal file
View file

@ -0,0 +1,100 @@
"""Commit only the infrastructure pin, deploy that commit, revert on failure."""
import json
import os
import re
import subprocess
import tempfile
from pathlib import Path
def run(*args, **kwargs):
return subprocess.check_output(args, text=True, **kwargs).strip()
def main():
revision = run("git", "rev-parse", "HEAD")
repository = os.environ["NIXOS_FLAKE_REPOSITORY"]
branch = os.environ.get("NIXOS_FLAKE_BRANCH", "main")
server = os.environ["FORGEJO_SERVER_URL"].rstrip("/")
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repository):
raise ValueError("Invalid infrastructure repository")
with tempfile.TemporaryDirectory(prefix="tensamin-deploy-") as temporary:
root = Path(temporary)
askpass = root / "askpass"
askpass.write_text('#!/bin/sh\ncase "$1" in *Username*) printf "%s\\n" token;; *) printf "%s\\n" "$NIXOS_FLAKE_WRITE_TOKEN";; esac\n')
askpass.chmod(0o700)
env = os.environ | {"GIT_ASKPASS": str(askpass), "GIT_TERMINAL_PROMPT": "0"}
checkout = root / "infrastructure"
run("git", "clone", "--branch", branch, "--single-branch", f"{server}/{repository}.git", str(checkout), env=env)
original = run("git", "rev-parse", "HEAD", cwd=checkout)
# Fail before pushing if the forced command has not adopted the new contract.
wrapper = (checkout / "garten/tensamin-prod/services/deploy.nix").read_text()
if "<nixos-flake commit SHA>" not in wrapper:
raise RuntimeError("Deployment wrapper must accept <nixos-flake commit SHA> before promotion")
url = f"git+ssh://git@methanium.net/{os.environ['FORGEJO_REPOSITORY']}?ref=main&rev={revision}"
run("nix", "flake", "lock", "--override-input", "prod-pins", url, cwd=checkout)
lock = json.loads((checkout / "flake.lock").read_text())
if lock["nodes"][lock["nodes"]["root"]["inputs"]["prod-pins"]]["locked"]["rev"] != revision:
raise RuntimeError("Infrastructure pin mismatch")
run("git", "add", "flake.lock", cwd=checkout)
identity = ["git", "-c", "user.name=Tensamin releases", "-c", "user.email=releases@tensamin.net"]
changed = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=checkout, check=False).returncode
if changed not in {0, 1}:
raise RuntimeError("Unable to inspect infrastructure pin changes")
if changed:
run(*identity, "commit", "-m", f"tensamin-prod: pin {revision}", cwd=checkout)
commit = run("git", "rev-parse", "HEAD", cwd=checkout)
key = root / "deploy-key"
key.write_text(os.environ["TENSAMIN_PROD_DEPLOY_SSH_KEY"] + "\n")
key.chmod(0o600)
known = root / "known_hosts"
known.write_text(os.environ["TENSAMIN_SSH_KNOWN_HOSTS"] + "\n")
config = root / "ssh_config"
host = os.environ["TENSAMIN_PROD_DEPLOY_HOST"]
port = os.environ.get("TENSAMIN_PROD_DEPLOY_PORT", "22")
jump_host = os.environ.get("TENSAMIN_PROD_DEPLOY_JUMP_HOST", "")
jump_port = os.environ.get("TENSAMIN_PROD_DEPLOY_JUMP_PORT", "7930")
for hostname in [host, jump_host]:
if hostname and not re.fullmatch(r"[A-Za-z0-9_.:-]+", hostname):
raise ValueError("Invalid deployment SSH hostname")
for value in [port, jump_port]:
if not value.isdecimal() or not 1 <= int(value) <= 65535:
raise ValueError("Invalid deployment SSH port")
config.write_text(
f"Host tensamin-deploy\n HostName {host}\n Port {port}\n User deploy\n"
+ (" ProxyJump tensamin-deploy-jump\n" if jump_host else "")
+ (f"Host tensamin-deploy-jump\n HostName {jump_host}\n Port {jump_port}\n"
" User deploy-jump\n" if jump_host else "")
+ f'Host *\n IdentityFile "{key}"\n IdentitiesOnly yes\n'
f' StrictHostKeyChecking yes\n UserKnownHostsFile "{known}"\n'
" BatchMode yes\n ConnectTimeout 15\n"
)
ssh = ["ssh", "-F", str(config), "-T", "tensamin-deploy"]
# Prepare and validate SSH before publishing an infrastructure change.
run("ssh", "-G", "-F", str(config), "tensamin-deploy")
if changed:
run("git", "push", "origin", f"HEAD:refs/heads/{branch}", cwd=checkout, env=env)
try:
subprocess.run([*ssh, commit], check=True)
# Publication is inside the transaction, so failed publication restores the pin.
subprocess.run(["python3", "scripts/release.py", "publish", "--channel", "stable",
"--tag", os.environ["RELEASE_TAG"]], check=True)
except BaseException:
if not changed:
raise
# A normal revert preserves unrelated concurrent infrastructure commits.
run("git", "fetch", "origin", branch, cwd=checkout, env=env)
run("git", "reset", "--hard", f"origin/{branch}", cwd=checkout)
run(*identity, "revert", "--no-edit", commit, cwd=checkout)
run("git", "push", "origin", f"HEAD:refs/heads/{branch}", cwd=checkout, env=env)
rollback = run("git", "rev-parse", "HEAD", cwd=checkout)
subprocess.run([*ssh, rollback], check=True)
raise
Path("release-out/deployment.json").write_text(json.dumps({
"previous_infrastructure": original, "infrastructure": commit, "prod_pins": revision,
}, indent=2) + "\n")
if __name__ == "__main__":
main()

58
scripts/release-build.sh Normal file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
root=$(pwd)
channel=${1:?Usage: release-build.sh CHANNEL OUTPUT_DIRECTORY}
out=$(realpath -m "${2:?Output directory required}")
[[ $channel == stable || $channel == canary ]]
mkdir -p "$out"
export TENSAMIN_CHANNEL=$channel ELECTRON_SKIP_BINARY_DOWNLOAD=1
export TENSAMIN_ANDROID_VERSION_CODE=${RELEASE_SEQUENCE:?Release sequence required}
system=${3:-$(nix eval --impure --raw --expr 'builtins.currentSystem')}
expr="import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"packages\"; channel = \"$channel\"; system = \"$system\"; }"
packages=$(nix build --impure --no-link --print-out-paths --expr "$expr")
nix build --impure --no-link --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"checks\"; system = \"$system\"; }"
arch=$system
arch=${arch%-linux}
printf '%s\n' "$packages" > "$out/packages-$arch.txt"
for name in iota iota-daemon iota-ui omikron omega client client-web mtp-sdk; do
# Nix closures, unlike a plain copy of a Nix binary, retain runtime libraries.
mapfile -t closure < <(nix-store --query --requisites "$packages/$name")
nix-store --export "${closure[@]}" | gzip -n > "$out/$name-linux-$arch.nar.gz"
done
for binary in iota iota-daemon iota-updater; do
cp "$packages/iota-portable/bin/$binary" "$out/$binary-linux-$arch"
done
tar -czf "$out/iota-portable-linux-$arch.tar.gz" -C "$packages/iota-portable" bin share
cp -L "$packages/iota-container" "$out/iota-image-linux-$arch.tar.gz"
cp -L "$packages/omikron-container" "$out/omikron-image-linux-$arch.tar.gz"
cp -L "$packages/omega-container" "$out/omega-image-linux-$arch.tar.gz"
cp "$packages/iota-portable/bin/iota-release" "$out/iota-release-linux-$arch"
cp "$packages/iota-portable/bin/iota-bundle" "$out/iota-bundle-linux-$arch"
cp -rL "$packages/iota-bundle/share/iota" "$out/iota-contract"
# The helpers' relative script paths are part of the Iota contract.
mkdir -p "$out/iota-contract/iota-updater" "$out/iota-contract/iota-installer"
mv "$out/iota-contract/artifacts.tsv" "$out/iota-contract/iota-updater/"
mv "$out/iota-contract/bundle-files.txt" "$out/iota-contract/iota-installer/"
mkdir -p "$out/iota-contract/static"
mv "$out/iota-contract/static-web" "$out/iota-contract/static/web"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
source=$(nix build --no-link --print-out-paths ".#packages.$system.client-source")
cp -r "$source/." "$work/"
chmod -R u+w "$work"
rm -rf "$work/.mtp-sdk"
cp -rL "$packages/mtp-sdk" "$work/.mtp-sdk"
chmod -R u+w "$work/.mtp-sdk"
export RELEASE_ROOT=$root RELEASE_OUT=$out RELEASE_WORK=$work RELEASE_ARCH=$arch
export TENSAMIN_RELEASE_TAG=${RELEASE_TAG:?} TENSAMIN_RELEASE_VERSION=$RELEASE_TAG
export FORGEJO_RELEASE_ASSET_BASE_URL="${FORGEJO_SERVER_URL:?}/${FORGEJO_REPOSITORY:?}/releases/download/$RELEASE_TAG"
pushd "$work" >/dev/null
nix develop --impure --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"electron\"; system = \"$system\"; }" \
--command bash "$root/scripts/release-client.sh" desktop
if [[ $arch == x86_64 ]]; then
nix develop --impure --expr "import $root/scripts/release-env.nix { root = builtins.toPath \"$root\"; kind = \"tauri\"; }" \
--command bash "$root/scripts/release-client.sh" android
fi
popd >/dev/null

35
scripts/release-client.sh Normal file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
cd "${RELEASE_WORK:?}"
pnpm install --frozen-lockfile
case ${1:?} in
desktop)
pnpm run ci
electron_arch=x64
[[ $RELEASE_ARCH != aarch64 ]] || electron_arch=arm64
pnpm --dir apps/electron exec electron-builder --config electron-builder.config.cjs \
--linux --"$electron_arch" --publish never
pnpm run copy-releases
for asset in releases/*; do
[[ $(basename "$asset") == SHA256SUMS ]] && continue
name=$(basename "$asset")
[[ $name != electron-release-metadata.json ]] || name="electron-release-metadata-$RELEASE_ARCH.json"
cp "$asset" "$RELEASE_OUT/$name"
done
;;
android)
: "${ANDROID_KEYSTORE_BASE64:?}" "${ANDROID_KEY_ALIAS:?}" "${ANDROID_KEY_PASSWORD:?}" "${ANDROID_STORE_PASSWORD:?}"
umask 077
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > release.keystore
# Gradle resolves the keystore properties relative to the client root.
printf 'storeFile=release.keystore\nkeyAlias=%s\nkeyPassword=%s\nstorePassword=%s\n' \
"$ANDROID_KEY_ALIAS" "$ANDROID_KEY_PASSWORD" "$ANDROID_STORE_PASSWORD" > keystore.properties
trap 'rm -f release.keystore keystore.properties' EXIT
cargo test --locked --manifest-path apps/tauri/src-tauri/Cargo.toml
pnpm run build:mobile
apk=apps/tauri/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk
"$ANDROID_HOME/build-tools/35.0.0/apksigner" verify --verbose "$apk"
cp "$apk" "$RELEASE_OUT/Tensamin-$TENSAMIN_RELEASE_TAG.apk"
;;
*) exit 2 ;;
esac

69
scripts/release-env.nix Normal file
View file

@ -0,0 +1,69 @@
{ root, kind ? "tools", channel ? "canary", system ? builtins.currentSystem }:
let
central = builtins.getFlake (toString root);
pkgs = import central.inputs.nixpkgs {
inherit system;
overlays = [ central.inputs.rust-overlay.overlays.default ];
};
project = central.lib.mkPackages { inherit system; };
# Reuse only the client's tool shells, with central nixpkgs and Rust inputs.
# Client builds below always consume client-source and mtp-sdk from prod-pins.
clientTools = (import (central.inputs.client + "/flake.nix")).outputs {
self = central.inputs.client;
nixpkgs = central.inputs.nixpkgs // {
# The client's old SDK platform-tools pin is absent in central nixpkgs.
outPath = pkgs.runCommand "release-client-nixpkgs" {} ''
mkdir -p "$out"
cat > "$out/default.nix" <<'EOF'
args: let
pkgs = import ${central.inputs.nixpkgs} args;
in pkgs // { androidenv = pkgs.androidenv // {
composeAndroidPackages = options: pkgs.androidenv.composeAndroidPackages
(options // { platformToolsVersion = "37.0.1"; });
}; }
EOF
'';
};
rust-overlay = central.inputs.rust-overlay;
};
branded = name: project.packages.${name}.overrideAttrs {
TENSAMIN_CHANNEL = channel;
};
checked = name: project.packages.${name}.overrideAttrs (old: {
doCheck = true;
installPhase = ''mkdir -p "$out"'';
nativeBuildInputs = old.nativeBuildInputs ++ project.devShells.${name}.nativeBuildInputs;
preBuild = (old.preBuild or "") + ''
cargo fmt --all --check
cargo clippy --locked --offline --workspace --all-targets -- -D warnings
'';
cargoTestFlags = [ "--workspace" ];
});
mtpCheck = project.packages.iota.overrideAttrs (old: {
pname = "mtp-checks";
src = project.packages.mtp-sdk.src;
cargoDeps = project.packages.mtp-vendor;
cargoBuildFlags = [ "--workspace" ];
cargoTestFlags = [ "--workspace" ];
doCheck = true;
installPhase = ''mkdir -p "$out"'';
nativeBuildInputs = old.nativeBuildInputs ++ project.devShells.mtp.nativeBuildInputs;
preBuild = ''
cargo fmt --all --check
cargo clippy --locked --offline --workspace --all-targets -- -D warnings
'';
});
in
if kind == "tools" then pkgs.mkShell {
packages = with pkgs; [ nix git (python3.withPackages (p: [ p.pyyaml ])) bash coreutils jq zip gnutar gzip openssh skopeo shellcheck ruff ];
} else if kind == "electron" || kind == "tauri" then clientTools.devShells.${system}.${kind}
else if kind == "checks" then pkgs.linkFarm "release-checks" (map (name: {
inherit name;
path = checked name;
}) [ "iota" "omikron" "omega" ] ++ [ { name = "mtp"; path = mtpCheck; } ])
else if kind == "packages" then pkgs.linkFarm "release-packages" (map (name: {
inherit name;
path = if builtins.elem name [ "client" "client-web" ] then branded name else project.packages.${name};
}) [ "iota" "iota-portable" "iota-bundle" "iota-daemon" "iota-ui" "omikron" "omega"
"iota-container" "omikron-container" "omega-container" "client" "client-web" "mtp-sdk" ])
else throw "Unknown release environment ${kind}"

335
scripts/release.py Normal file
View file

@ -0,0 +1,335 @@
"""Forgejo release staging, provenance selection, signing and channel refresh."""
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def run(*args, **kwargs):
return subprocess.check_output(args, text=True, **kwargs).strip()
def write(path, data):
path.write_text(json.dumps(data, indent=2) + "\n")
class Forgejo:
def __init__(self):
self.server = os.environ["FORGEJO_SERVER_URL"].rstrip("/")
self.repo = os.environ["FORGEJO_REPOSITORY"]
self.base = f"{self.server}/api/v1/repos/{self.repo}"
self.token = os.environ["RELEASE_TOKEN"]
def request(self, path, method="GET", data=None, content_type="application/json", raw=False):
url = path if path.startswith("https://") else self.base + path
# Do not forward the API token to an asset redirect on another host.
if urllib.parse.urlparse(url).netloc != urllib.parse.urlparse(self.server).netloc:
raise ValueError("Unexpected asset host")
if data is not None and not isinstance(data, bytes):
data = json.dumps(data).encode()
request = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": f"token {self.token}", "Content-Type": content_type,
})
class SameHostRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
if urllib.parse.urlparse(newurl).netloc != urllib.parse.urlparse(req.full_url).netloc:
raise ValueError("Refusing authenticated cross-host redirect")
return super().redirect_request(req, fp, code, msg, headers, newurl)
with urllib.request.build_opener(SameHostRedirect()).open(request, timeout=120) as response:
body = response.read()
return json.loads(body) if body and not raw and "json" in response.headers.get("Content-Type", "") else body
def release(self, tag, missing=False):
try:
return self.request("/releases/tags/" + urllib.parse.quote(tag, safe=""))
except urllib.error.HTTPError as error:
if missing and error.code == 404:
return None
raise
def assets(self, release):
result = []
for page in range(1, 100):
batch = self.request(f"/releases/{release['id']}/assets?limit=50&page={page}")
result.extend(batch)
if len(batch) < 50:
return result
raise RuntimeError("Too many release assets")
def download(self, release, name):
asset = next(asset for asset in self.assets(release) if asset["name"] == name)
return self.request(asset["browser_download_url"], raw=True)
def upload(self, release, path):
# Forgejo's attachment API takes multipart/form-data, not raw bytes.
boundary = "tensamin-" + os.urandom(16).hex()
body = (f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; '
f'filename="{path.name}"\r\nContent-Type: application/octet-stream\r\n\r\n').encode()
body += path.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
return self.request(f"/releases/{release['id']}/assets?name=" + urllib.parse.quote(path.name),
"POST", body, "multipart/form-data; boundary=" + boundary)
def provenance(directory, channel, tag):
lock = json.loads(Path("flake.lock").read_text())
sources = {name: lock["nodes"][node]["locked"]
for name, node in lock["nodes"]["root"]["inputs"].items() if isinstance(node, str)}
files = {}
for path in sorted(directory.iterdir()):
if path.is_file() and path.name not in {"release.json", "SHA256SUMS"}:
files[path.name] = {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"size": path.stat().st_size}
write(directory / "release.json", {
"schema": 1, "channel": channel, "tag": tag, "revision": run("git", "rev-parse", "HEAD"),
"run_id": os.environ["FORGEJO_RUN_ID"], "sources": sources,
"lock_sha256": hashlib.sha256(Path("flake.lock").read_bytes()).hexdigest(),
"architectures": [arch for arch in ["x86_64", "aarch64"]
if (directory / f"iota-linux-{arch}").exists()],
"artifacts": files,
})
(directory / "SHA256SUMS").write_text("".join(
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}\n"
for path in sorted(directory.iterdir()) if path.is_file() and path.name != "SHA256SUMS"))
def sign(directory, channel, tag, sequence):
api = Forgejo()
old = api.release(channel, missing=True)
if old:
for asset in api.assets(old):
if re.fullmatch(r"iota-update-linux-(x86_64|aarch64)\.json", asset["name"]):
manifest = json.loads(api.download(old, asset["name"]))
if sequence <= manifest["release_sequence"]:
raise RuntimeError("Channel sequence must strictly increase")
source_sha = json.loads(Path("flake.lock").read_text())
source_sha = source_sha["nodes"][source_sha["nodes"]["root"]["inputs"]["iota"]]["locked"]["rev"]
os.environ["IOTA_RELEASE_SOURCE_SHA"] = source_sha
now = dt.datetime.now(dt.timezone.utc)
published = now.isoformat(timespec="seconds").replace("+00:00", "Z")
expires = (now + dt.timedelta(days=14)).isoformat(timespec="seconds").replace("+00:00", "Z")
host = run("nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem").removesuffix("-linux")
signer = directory / f"iota-release-linux-{host}"
verifier = directory / f"iota-bundle-linux-{host}"
signer.chmod(0o755)
verifier.chmod(0o755)
base = f"{api.server}/{api.repo}/releases/download/{tag}"
contract = directory / "iota-contract/scripts"
for arch in ["x86_64", "aarch64"]:
if not (directory / f"iota-linux-{arch}").exists():
continue
binaries = directory / f"bin-{arch}"
binaries.mkdir(exist_ok=True)
for binary in ["iota", "iota-daemon", "iota-updater"]:
target = binaries / binary
target.write_bytes((directory / f"{binary}-linux-{arch}").read_bytes())
target.chmod(0o755)
manifest = directory / f"iota-update-linux-{arch}.json"
run("bash", str(contract / "build-update-manifest.sh"), str(binaries),
os.environ["IOTA_BASE_VERSION"], channel, str(sequence), published, expires,
"linux", arch, base, str(manifest))
public = run(str(signer), "sign", str(manifest), str(manifest) + ".sig")
if public != os.environ["IOTA_RELEASE_PUBLIC_KEY"]:
raise RuntimeError("Release signing key does not match the pinned public key")
url = f"{api.server}/{api.repo}/releases/download/{channel}/{manifest.name}"
bundle = directory / f"iota-linux-{arch}.zip"
bundle.unlink(missing_ok=True)
run("bash", str(contract / "build-release-bundle.sh"), str(binaries),
json.loads(manifest.read_text())["product_version"], str(manifest), url, public,
url + ".sig", channel, os.environ.get("IOTA_RELEASE_SIGNING_KEY_ID", "primary"), str(bundle))
run(str(verifier), str(bundle))
def stage(directory, channel, tag):
api = Forgejo()
if api.release(tag, missing=True):
raise RuntimeError("Immutable release tag already exists")
release = api.request("/releases", "POST", {
"tag_name": tag, "target_commitish": run("git", "rev-parse", "HEAD"),
"name": tag, "body": "Verified central source build. See release.json for provenance.",
"draft": True, "prerelease": channel == "canary",
})
for path in sorted(directory.iterdir()):
if path.is_file():
api.upload(release, path)
# Validate staged attachment bytes before any deployment or visibility change.
for path in sorted(directory.iterdir()):
if path.is_file() and hashlib.sha256(api.download(release, path.name)).digest() != hashlib.sha256(path.read_bytes()).digest():
raise RuntimeError(f"Staged asset mismatch: {path.name}")
write(directory / "stage.json", {"id": release["id"], "tag": tag})
def publish(directory, channel, tag):
api = Forgejo()
immutable = api.release(tag)
if not immutable["draft"]:
raise RuntimeError("Expected a staged draft")
registry = urllib.parse.urlparse(api.server).netloc
auth = directory / ".registry-auth.json"
try:
subprocess.run(["skopeo", "login", "--authfile", str(auth), "--username",
os.environ["FORGEJO_REGISTRY_USER"], "--password-stdin", registry],
input=api.token, text=True, check=True)
for image in directory.glob("*-image-linux-*.tar.gz"):
service, arch = image.name.split("-image-linux-")
arch = arch.removesuffix(".tar.gz")
run("skopeo", "copy", "--authfile", str(auth), "docker-archive:" + str(image),
f"docker://{registry}/{api.repo.split('/')[0]}/{service}:{tag}-{arch}")
api.request(f"/releases/{immutable['id']}", "PATCH", {"draft": False})
try:
update_pointer(api, directory, channel, tag)
except Exception:
api.request(f"/releases/{immutable['id']}", "PATCH", {"draft": True})
raise
finally:
auth.unlink(missing_ok=True)
def update_pointer(api, directory, channel, tag):
pointer = api.release(channel, missing=True)
new = pointer is None
if new:
pointer = api.request("/releases", "POST", {
"tag_name": channel, "target_commitish": run("git", "rev-parse", "HEAD"),
"name": channel, "draft": True, "prerelease": channel == "canary",
})
backup = []
names = {path.name for path in directory.glob("iota-update-linux-*.json*")}
names.update({"channel.json", "electron-release-metadata.json"})
# A refresh never moves binaries or changes the immutable release identity.
write(directory / "channel.json", {"channel": channel, "tag": tag,
"url": f"{api.server}/{api.repo}/releases/tag/{tag}"})
electron = [json.loads(path.read_text()) for path in directory.glob("electron-release-metadata-*.json")]
if electron:
combined = electron[0]
combined["artifacts"] = [artifact for entry in electron for artifact in entry["artifacts"]]
write(directory / "electron-release-metadata.json", combined)
else:
names.discard("electron-release-metadata.json")
# Remove stale architecture manifests too, so they cannot advertise another build.
old_assets = api.assets(pointer)
names.update(asset["name"] for asset in old_assets if asset["name"].startswith("iota-update-linux-"))
try:
for asset in old_assets:
if asset["name"] in names:
backup.append((asset["name"], api.download(pointer, asset["name"])))
api.request(f"/releases/{pointer['id']}/assets/{asset['id']}", "DELETE")
for name in sorted(names):
path = directory / name
if path.exists():
api.upload(pointer, path)
api.request(f"/releases/{pointer['id']}", "PATCH", {"draft": False,
"body": f"Current {channel} build: {tag}. Signed metadata refreshed automatically."})
except Exception:
for asset in api.assets(pointer):
if asset["name"] in names:
api.request(f"/releases/{pointer['id']}/assets/{asset['id']}", "DELETE")
for name, raw in backup:
path = directory / name
path.write_bytes(raw)
api.upload(pointer, path)
if new:
api.request(f"/releases/{pointer['id']}", "DELETE")
raise
def select(tag):
if not re.fullmatch(r"canary-[0-9a-f]{40}-[0-9]+", tag):
raise ValueError("Select an immutable canary tag")
api = Forgejo()
release = api.release(tag)
data = json.loads(api.download(release, "release.json"))
if release["draft"] or not release["prerelease"] or data["channel"] != "canary" or data["tag"] != tag:
raise RuntimeError("Not a published canary")
result = api.request(f"/actions/runs/{data['run_id']}")
result = result.get("workflow_run", result)
if result["conclusion"] != "success" or result["head_sha"] != data["revision"]:
raise RuntimeError("Canary workflow has not completed successfully")
revision = data["revision"]
if not re.fullmatch(r"[0-9a-f]{40}", revision):
raise ValueError("Invalid source revision")
run("git", "fetch", "origin", revision)
run("git", "checkout", "--detach", revision)
if hashlib.sha256(Path("flake.lock").read_bytes()).hexdigest() != data["lock_sha256"]:
raise RuntimeError("Canary lock provenance mismatch")
Path(".release-selection.json").write_text(json.dumps(data))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=["select", "sign", "stage", "publish", "refresh", "provenance"])
parser.add_argument("--directory", type=Path, default=Path("release-out"))
parser.add_argument("--channel", choices=["stable", "canary"], default="canary")
parser.add_argument("--tag")
args = parser.parse_args()
directory = args.directory.resolve()
if args.command == "select":
select(args.tag)
return
sequence = int(os.environ.get("RELEASE_SEQUENCE", str(int(time.time()))))
if not 0 < sequence <= 2100000000:
raise ValueError("Sequence exceeds Android version-code range")
if args.command == "refresh":
api = Forgejo()
pointer = api.release(args.channel, missing=True)
if pointer is None:
return
tag = json.loads(api.download(pointer, "channel.json"))["tag"]
release = api.release(tag)
directory.mkdir(parents=True, exist_ok=True)
data = json.loads(api.download(release, "release.json"))
run("git", "fetch", "origin", data["revision"])
run("git", "checkout", "--detach", data["revision"])
if hashlib.sha256(Path("flake.lock").read_bytes()).hexdigest() != data["lock_sha256"]:
raise RuntimeError("Refresh lock provenance mismatch")
for asset in api.assets(release):
path = directory / asset["name"]
if path.name != asset["name"]:
raise ValueError("Unsafe asset name")
path.write_bytes(api.download(release, path.name))
for name, expected in data["artifacts"].items():
path = directory / name
if hashlib.sha256(path.read_bytes()).hexdigest() != expected["sha256"]:
raise RuntimeError(f"Immutable asset mismatch: {name}")
host = run("nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem").removesuffix("-linux")
# Helpers are Nix-linked binaries. Restore their exact runtime closure.
with (directory / f"iota-linux-{host}.nar.gz").open("rb") as archive:
unzip = subprocess.Popen(["gzip", "-dc"], stdin=archive, stdout=subprocess.PIPE)
subprocess.run(["nix-store", "--import"], stdin=unzip.stdout, check=True)
unzip.stdout.close()
if unzip.wait() != 0:
raise RuntimeError("Unable to restore release helper closure")
# Recover the exact contract from the immutable source revision.
bundle = run("nix", "build", "--no-link", "--print-out-paths", ".#iota-bundle")
run("cp", "-rL", bundle + "/share/iota", str(directory / "iota-contract"))
(directory / "iota-contract/static").mkdir(exist_ok=True)
(directory / "iota-contract/static-web").rename(directory / "iota-contract/static/web")
for name, destination in [("artifacts.tsv", "iota-updater"), ("bundle-files.txt", "iota-installer")]:
(directory / "iota-contract" / destination).mkdir(exist_ok=True)
(directory / "iota-contract" / name).rename(directory / "iota-contract" / destination / name)
sign(directory, args.channel, tag, sequence)
update_pointer(api, directory, args.channel, tag)
return
if not args.tag:
parser.error("--tag is required")
if args.command == "sign":
sign(directory, args.channel, args.tag, sequence)
elif args.command == "provenance":
provenance(directory, args.channel, args.tag)
elif args.command == "stage":
stage(directory, args.channel, args.tag)
elif args.command == "publish":
publish(directory, args.channel, args.tag)
if __name__ == "__main__":
main()

114
scripts/releases.md Normal file
View file

@ -0,0 +1,114 @@
# Central release setup
Only prod-pins publishes releases. Pushes to main and manual canary runs validate
the locked, prepared Rust and client sources, then build combined immutable
releases. Stable dispatch requires an immutable `canary-FULL_PROD_SHA-RUN_ID` tag
whose central workflow completed successfully. Promotion checks out that exact
prod-pins revision, including dependency locks and hashes. It rebuilds with stable
client branding. No input update command runs during promotion.
## Runner and credentials
Use a trusted `nixos` runner with Nix, Git and Bash available for bootstrap.
The workflow's tools use the central nixpkgs and Rust overlay. Source inputs
require SSH read access to every locked repository. Configure these secrets:
- `TENSAMIN_SOURCE_SSH_KEY`, read access to the pinned repositories.
- `TENSAMIN_RELEASE_TOKEN`, prod-pins release and package registry write access,
and Actions run read access.
- `IOTA_RELEASE_SIGNING_KEY`, 64 hex characters encoding the Ed25519 seed.
- `TENSAMIN_PROD_DEPLOY_SSH_KEY`, key authorized for the server's forced command.
- `NIXOS_FLAKE_WRITE_TOKEN`, infrastructure repository read and write access.
- `ANDROID_KEYSTORE_BASE64`, the existing Android keystore encoded as base64.
- `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`, `ANDROID_STORE_PASSWORD`, matching
the existing Android signing identity. Map existing secret names to these
workflow environment entries if their names differ.
Configure repository variables:
- `IOTA_RELEASE_PUBLIC_KEY`, pinned public key, 64 hex characters.
- `IOTA_RELEASE_SIGNING_KEY_ID`, default `primary`.
- `IOTA_BASE_VERSION`, default `0.1.0`.
- `FORGEJO_REGISTRY_USER`, token owner's Forgejo username.
- `NIXOS_FLAKE_REPOSITORY`, infrastructure repository as `owner/repository`.
- `NIXOS_FLAKE_BRANCH`, default `main`.
- `TENSAMIN_PROD_DEPLOY_HOST`, deployment SSH hostname.
- `TENSAMIN_PROD_DEPLOY_PORT`, default `22`.
- `TENSAMIN_PROD_DEPLOY_JUMP_HOST`, optional SSH jump hostname, `methanium.net`
for production. The jump user is `deploy-jump`; both hops use the deploy key.
- `TENSAMIN_PROD_DEPLOY_JUMP_PORT`, default `7930`.
- `TENSAMIN_SSH_KNOWN_HOSTS`, verified host keys for source Git and deployment.
For the production VM set `TENSAMIN_PROD_DEPLOY_HOST=10.201.0.10` and
`TENSAMIN_PROD_DEPLOY_PORT=22`. `TENSAMIN_SSH_KNOWN_HOSTS` must contain verified
entries for `methanium.net` on source Git port 22, `[methanium.net]:7930` for the
jump host, and `10.201.0.10` for the guest on port 22. These are separate host
identities and may have different keys. Include any other locked source SSH
hostnames too. The generated SSH config applies the key and known-hosts file to
both hops, including rollback deployment.
The infrastructure forced command must accept `<nixos-flake commit SHA>`, fetch
and deploy precisely that commit, validate its prod-pins lock, and return success
only after activation and application health checks. It must restore the prior
system and checkout on failure. `deploy-release.py` deliberately rejects the old
wrapper that accepts `<prod-pins commit SHA>`. It commits only infrastructure
`flake.lock`, pushes without force, passes that infrastructure commit through SSH,
then publishes stable. If deployment or publication fails, it reverts the pin
with a normal Git commit and deploys the rollback commit. A revert conflict or
unreachable server fails visibly and needs operator recovery.
## Artifacts and channels
Each immutable release contains:
- `release.json`, exact prod-pins revision, source lock identities, workflow run,
architectures, artifact sizes and SHA-256 hashes, plus `SHA256SUMS`.
- `iota-linux-ARCH`, `iota-daemon-linux-ARCH`, `iota-updater-linux-ARCH` and signed
`iota-update-linux-ARCH.json` with `.sig`, using Iota's typed Rust signer.
These executables are musl-static and run on ordinary Linux without Nix.
- `iota-portable-linux-ARCH.tar.gz`, per-user binaries under `bin` and static
assets under `share/iota/web`. Extract and run `./bin/iota`. The daemon and
updater are discovered next to the CLI. Host CA certificates supply TLS trust.
- `iota-linux-ARCH.zip`, checked by `iota-bundle`, with channel trust settings
and static assets, for system-wide installation through CLI bootstrap.
- `PACKAGE-linux-ARCH.nar.gz`, gzip-compressed Nix closure exports for Iota,
services, client, web and SDK. Restore with `gzip -dc FILE | nix-store --import`.
These are separate Nix artifacts, not the unmanaged Linux installation path.
- Docker-loadable Iota, Omikron and Omega images. Registry names are
`SERVER/tensamin/SERVICE:IMMUTABLE_TAG-ARCH`.
- Signed universal `Tensamin-IMMUTABLE_TAG.apk`, Linux Electron AppImage, deb and
rpm assets, and per-architecture Electron release metadata.
`stable` and `canary` are the only mutable metadata releases. Each has
`channel.json`, combined `electron-release-metadata.json`, and Iota update
manifests with signatures. Binary URLs always reference an immutable release.
Daily refresh re-signs manifests for the same binary identity with a higher
sequence and 14-day expiry. Publication and refresh share one concurrency group.
Sequences use Unix seconds, must strictly increase, and remain within Android's
version-code bound. Do not publish to these channels outside the serialized flow.
Forgejo attachment replacement is not transactional. On API failure the script
restores prior channel attachments and returns failure. Readers can encounter a
brief missing or mismatched manifest/signature pair and should retry. Old immutable
binary assets remain available. A failed stable promotion retains its draft for
inspection. Registry uploads use immutable tags and can leave unused images if a
later publication step fails.
The workflow tries aarch64 using configured Nix builders and execution support.
If that attempt fails, the x86_64 release includes `aarch64-unavailable.txt` and
`arm-build.log`. Partial aarch64 artifacts are not advertised. Full aarch64
Electron packaging requires execution support as well as a builder. Android is
built on x86_64 using prepared `client-source` and `mtp-sdk`, never the client's
original release SDK dependency. The client's tool shells use central inputs;
platform-tools is adjusted to the version available in central nixpkgs.
## Local validation
```sh
nix develop --impure --expr 'import ./scripts/release-env.nix { root = builtins.toPath (builtins.getEnv "PWD"); }' --command shellcheck scripts/release-build.sh scripts/release-client.sh
nix develop --impure --expr 'import ./scripts/release-env.nix { root = builtins.toPath (builtins.getEnv "PWD"); }' --command ruff check scripts/release.py scripts/deploy-release.py
```
Live Forgejo draft uploads, registry writes and deployment are performed only by
the release workflow after builds and checks. Enabling stable requires the revised
infrastructure wrapper and all signing and deployment credentials above.

72
scripts/update-all.py Normal file
View file

@ -0,0 +1,72 @@
"""Refresh the shared sources, transformed locks, and fixed-output hashes."""
import argparse
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
def run(*args, **kwargs):
result = subprocess.run(args, text=True, **kwargs)
if result.returncode:
raise SystemExit(result.stderr if kwargs.get("capture_output") else result.returncode)
return result
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--no-update", action="store_true", help="Keep the current flake inputs")
parser.add_argument("--override-input", nargs=2, action="append", default=[], metavar=("NAME", "SOURCE"))
args = parser.parse_args()
root = Path.cwd()
if not (root / "packages/hashes.nix").exists():
raise SystemExit("Run update-all from the prod-pins checkout")
if not args.no_update:
run("nix", "flake", "update", *(item for pair in args.override_input for item in ["--override-input", *pair]))
overrides = [item for pair in args.override_input for item in ["--override-input", *pair]]
system = run("nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem", capture_output=True).stdout
def build(name):
return run("nix", "build", f"path:{root}#packages.{system}.{name}",
"--no-link", "--print-out-paths", *overrides, capture_output=True).stdout.strip()
locks = root / "packages/locks"
locks.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory(prefix="prod-pins-update-") as temporary:
for name in ["mtp", "iota", "omikron", "omega", "client"]:
source = build(f"{name}-source")
dest = Path(temporary) / name
shutil.copytree(source, dest, symlinks=True)
for path in [dest, *dest.rglob("*")]:
if not path.is_symlink():
path.chmod(path.stat().st_mode | 0o200)
if name == "client":
run("pnpm", "install", "--lockfile-only", "--ignore-scripts", "--no-frozen-lockfile", cwd=dest)
shutil.copyfile(dest / "pnpm-lock.yaml", locks / "client.yaml")
else:
run("cargo", "update", "--workspace", cwd=dest)
shutil.copyfile(dest / "Cargo.lock", locks / f"{name}.lock")
hash_file = root / "packages/hashes.nix"
for key, output in [(n, f"{n}-vendor") for n in ["mtp", "iota", "omikron", "omega"]] + [("sdk", "sdk-deps"), ("client", "client-deps")]:
# Force a fetch even when the previous hash points at a cached result.
text = hash_file.read_text()
original = text
text = re.sub(rf'({key} = ")[^"]+', rf'\g<1>sha256-{"A" * 43}=', text)
hash_file.write_text(text)
result = subprocess.run(["nix", "build", f"path:{root}#packages.{system}.{output}",
"--no-link", "--print-out-paths", *overrides], text=True, capture_output=True)
match = re.search(r"got:\s+(sha256-[A-Za-z0-9+/=]+)", result.stderr)
if not match:
hash_file.write_text(original)
raise SystemExit(result.stderr or f"Could not determine {key} hash")
hash_file.write_text(re.sub(rf'({key} = ")[^"]+', rf'\g<1>{match[1]}', text))
build(output)
print(f"Updated {key}: {match[1]}", flush=True)
print("Updated sources, dependency locks, and verified vendor hashes.")