diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml new file mode 100644 index 0000000..c4ca4bb --- /dev/null +++ b/.github/workflows/package.yml @@ -0,0 +1,199 @@ +name: Native install packages + +# Build the native OS install packages (#503) — the Windows .msi, macOS .pkg, and Ubuntu .deb — +# that ARE the install architecture (the dig-installer just fetches + runs them). Each package +# installs the binary, registers the OS service `net.dignetwork.dig-node`, the chia:// scheme +# handler (→ `dig-node open`), and the restrictive machine-wide state dir (#501). +# +# * pull_request → BUILD all three (validation only; no publish), so a broken package definition +# is caught on the PR (release.yml is tag/main-only and does not build packages). +# * push tag v* → build all three AND attach them to the GitHub Release. The Ubuntu .deb is a +# release ASSET (apt.dig.net's deploy ingests upstream GitHub release .debs to build the signed +# apt repo — no push needed; the .deb name + control metadata are apt-correct + stable). +on: + pull_request: + paths: + - "packaging/**" + - "src/**" + - "crates/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/package.yml" + push: + tags: + - "v*" + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_RELEASE_DEBUG: "0" + +jobs: + # ---- Ubuntu .deb ---------------------------------------------------------- + deb: + name: build .deb (linux-x64) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install Rust + run: | + rustup toolchain install stable + rustup default stable + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: ${{ runner.os }}-pkg-deb-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-pkg-deb- + - name: Resolve version + id: ver + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then V="${GITHUB_REF_NAME#v}"; else + V="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"; fi + echo "v=$V" >> "$GITHUB_OUTPUT" + - name: Build release binary + run: cargo build --release --locked --bin dig-node + - name: Build .deb + run: bash packaging/linux/build-deb.sh target/release/dig-node "${{ steps.ver.outputs.v }}" amd64 dist + - name: Validate .deb metadata + run: | + dpkg-deb --info dist/*.deb + dpkg-deb --contents dist/*.deb + - uses: actions/upload-artifact@v4 + with: + name: dig-node-deb + path: dist/*.deb + if-no-files-found: error + + # ---- macOS .pkg (universal) ---------------------------------------------- + pkg: + name: build .pkg (macos-universal) + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install Rust + targets + run: | + rustup toolchain install stable + rustup default stable + rustup target add aarch64-apple-darwin x86_64-apple-darwin + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: ${{ runner.os }}-pkg-mac-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-pkg-mac- + - name: Resolve version + id: ver + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then V="${GITHUB_REF_NAME#v}"; else + V="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"; fi + echo "v=$V" >> "$GITHUB_OUTPUT" + - name: Build both arches + lipo a universal binary + run: | + cargo build --release --locked --target aarch64-apple-darwin --bin dig-node + cargo build --release --locked --target x86_64-apple-darwin --bin dig-node + mkdir -p dist + lipo -create -output dist/dig-node \ + target/aarch64-apple-darwin/release/dig-node \ + target/x86_64-apple-darwin/release/dig-node + lipo -info dist/dig-node + - name: Build .pkg + run: bash packaging/macos/build-pkg.sh dist/dig-node "${{ steps.ver.outputs.v }}" dist + - uses: actions/upload-artifact@v4 + with: + name: dig-node-pkg + path: dist/*.pkg + if-no-files-found: error + + # ---- Windows .msi --------------------------------------------------------- + msi: + name: build .msi (windows-x64) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install Rust + shell: bash + run: | + rustup toolchain install stable + rustup default stable + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: ${{ runner.os }}-pkg-msi-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-pkg-msi- + - name: Resolve version + id: ver + shell: bash + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then V="${GITHUB_REF_NAME#v}"; else + V="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"; fi + echo "v=$V" >> "$GITHUB_OUTPUT" + - name: Build release binary + run: cargo build --release --locked --bin dig-node + - name: Install WiX + shell: bash + run: | + # Pin WiX v5.0.2 — the last release BEFORE the v6+ Open Source Maintenance Fee (OSMF) + # EULA gate (WIX7015), which cannot be accepted non-interactively in CI. The .wxs uses the + # v4 schema namespace, which v5 supports. Pin the extension to the SAME version so the + # WixToolset.Util.wixext major matches the toolset (a mismatched ext major fails to load). + dotnet tool install --global wix --version 5.0.2 + wix extension add -g WixToolset.Util.wixext/5.0.2 + - name: Build .msi + shell: bash + run: | + mkdir -p dist + wix build packaging/windows/dig-node.wxs \ + -ext WixToolset.Util.wixext \ + -arch x64 \ + -d Version="${{ steps.ver.outputs.v }}" \ + -d BinDir="target/release" \ + -o "dist/dig-node-${{ steps.ver.outputs.v }}-windows-x64.msi" + - uses: actions/upload-artifact@v4 + with: + name: dig-node-msi + path: dist/*.msi + if-no-files-found: error + + # ---- Attach to the GitHub Release (tags only) ----------------------------- + publish: + name: Attach packages to the release + needs: [deb, pkg, msi] + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Flatten + run: | + mkdir -p release + find artifacts -type f \( -name '*.deb' -o -name '*.pkg' -o -name '*.msi' \) -exec cp {} release/ \; + ls -la release + - name: Attach to release + uses: softprops/action-gh-release@v2 + with: + files: release/* + # Do NOT regenerate notes — release.yml (the binary release) owns the notes; this job + # only appends the native-package assets to the same tag's release. + generate_release_notes: false + fail_on_unmatched_files: true diff --git a/Cargo.lock b/Cargo.lock index 16cf62b..ef8e571 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1956,7 +1956,7 @@ dependencies = [ [[package]] name = "dig-node-service" -version = "0.27.0" +version = "0.28.0" dependencies = [ "axum", "base64", diff --git a/Cargo.toml b/Cargo.toml index 2b4bd0e..5f4c9e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ edition = "2021" # (`[workspace.package].version`), so it MUST be set here for a release to fire # (§3.6). The library crates (dig-node-core/dig-runtime/dig-wallet) keep their own # independent versions — only the released binary tracks the workspace version. -version = "0.27.0" +version = "0.28.0" # Release hardening, matching digstore: keep integer-overflow checks ON in release. # The node parses untrusted serialized input and does offset/length arithmetic over diff --git a/SPEC.md b/SPEC.md index d9a6bb4..51fffcf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1213,6 +1213,47 @@ out to both listeners (§4.1). 9.6. **Uninstall.** `uninstall` performs a best-effort `stop` first, then removes the registration. +### 9.7. Native install packages (#503) + +The canonical end-user install path is a NATIVE OS PACKAGE built by this repo's CI (`package.yml`), +published as GitHub Release assets on each `vX.Y.Z` tag. The `dig-installer` simply fetches + runs +the right package; it does not re-implement service registration. Each package installs the binary, +registers the OS service, registers the `chia://` scheme handler (→ `dig-node open`, §8.5), creates +the machine-wide state dir (§7.3a), and sets the `dig.local` → `127.0.0.2` hosts entry (via the +idempotent, no-shell `dig-node ensure-hosts`, §8.1). The `dig-node install`/`uninstall` CLI (§9.1) +remains for manual/dev use. + +- **Windows `.msi`** (WiX; `dig-node--windows-x64.msi`). Installs `dig-node.exe` under + `%ProgramFiles%\DIG Network\dig-node\`; `ServiceInstall`+`ServiceControl` register + `net.dignetwork.dig-node` (DisplayName **"DIG NETWORK: NODE"**) running `dig-node.exe run-service` + as LocalSystem, auto-start, STARTED on install, STOPPED+REMOVED on uninstall; creates + `C:\ProgramData\DigNode` with a **restrictive DACL — inheritance broken, only SYSTEM + + Administrators (never Users)** so the token is not world-readable (§7.3a; dig-node leaves a + pre-existing dir's ACL intact); registers `chia://` under `HKLM\Software\Classes\chia` + (`shell\open\command` = `"…\dig-node.exe" open "%1"`); appends the install dir to the system PATH; + runs `dig-node ensure-hosts` as a deferred (SYSTEM) custom action. A stable `UpgradeCode` + + `MajorUpgrade` give clean in-place upgrades. +- **macOS `.pkg`** (`dig-node--macos.pkg`, universal arm64+x86_64). Installs `dig-node` to + `/usr/local/bin`; a LaunchDaemon `/Library/LaunchDaemons/net.dignetwork.dig-node.plist` + (`RunAtLoad`+`KeepAlive`, `run` with `DIG_NODE_RUN_CONTEXT=service`); a tiny AppleScript app + (`/Applications/DIG Network.app`, `CFBundleURLTypes` for the `chia` scheme) forwards URL opens to + `dig-node open`; `postinstall` creates the restrictive state dir, `launchctl bootstrap`s the + daemon, and registers the handler with LaunchServices. +- **Ubuntu `.deb`** (`dig-node__amd64.deb`; `Package: dig-node`, `Depends: libc6`). Installs + `/usr/bin/dig-node`; a systemd system unit `net.dignetwork.dig-node.service` (auto-start, + `Restart=on-failure`, `DIG_NODE_RUN_CONTEXT=service`); a `.desktop` with + `MimeType=x-scheme-handler/chia` registered as the system default handler; `postinst` creates + `/var/lib/dig-node` (root-owned `0700`), the hosts entry, and enables+starts the unit; `prerm` + stops+disables it. The filename + control metadata are **apt-correct + stable** so apt.dig.net + ingests the Release asset to build its signed apt repo (the repo is GPG-signed by apt.dig.net; the + `.deb` itself needs no code-signing cert). +- **Scheme registration scope.** All three register the DIG-specific **`chia://`** scheme. The + `urn:dig:chia:` textual form is accepted by `dig-node open` (§8.5) but is NOT registered as a + global OS handler — doing so would hijack the entire `urn:` scheme (every URN on the machine). +- **Unix service identity.** The systemd/launchd services run as **root**, so `/var/lib/dig-node` + and `/Library/Application Support/DigNode` are root-owned `0700` and a non-root operator drives + control with `sudo dig-node pair` (the remedy the CLI prints, §7.3a). + --- ## 10. Error-code catalogue (JSON-RPC wire) diff --git a/crates/dig-node-service/src/hosts.rs b/crates/dig-node-service/src/hosts.rs new file mode 100644 index 0000000..0016d72 --- /dev/null +++ b/crates/dig-node-service/src/hosts.rs @@ -0,0 +1,160 @@ +//! `dig-node ensure-hosts` (#91/#503) — idempotently register the `dig.local` hosts entry. +//! +//! The bare-`http://dig.local` listener binds `127.0.0.2:80` (§4.1), which only works if the OS +//! resolves `dig.local` → `127.0.0.2`. The native install packages (#503) call this after placing +//! the binary so `dig.local` resolves without a separate installer step. It is the clean, +//! cross-platform, NO-SHELL way to do it (the MSI custom action, the .deb postinst, and the macOS +//! postinstall all converge here rather than hand-editing the hosts file with a shell). +//! +//! Idempotent + additive: an existing `127.0.0.2 dig.local` mapping is left untouched; only a +//! MISSING mapping is appended. Other hosts entries are never modified. + +use std::path::PathBuf; + +use crate::cli::Outcome; + +/// The loopback IP `dig.local` must resolve to (matches [`crate::config::DIG_LOCAL_IP`] / the #91 +/// bare-listener bind and the dig-installer's historical hosts entry). +pub const DIG_LOCAL_IP: &str = "127.0.0.2"; +/// The canonical hostname the bare-`http://dig.local` listener answers to (§4.1). +pub const DIG_LOCAL_HOST: &str = "dig.local"; + +/// The OS hosts-file path. Windows honors `%SystemRoot%`; Unix is the FHS `/etc/hosts`. +pub fn hosts_path() -> PathBuf { + #[cfg(windows)] + { + let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string()); + PathBuf::from(root).join(r"System32\drivers\etc\hosts") + } + #[cfg(not(windows))] + { + PathBuf::from("/etc/hosts") + } +} + +/// Whether `content` already maps `host` to `ip` on some non-comment line. PURE. Tolerant of +/// arbitrary leading/inner whitespace and trailing comments/aliases, and case-insensitive on the +/// hostname (DNS names are case-insensitive), so it does not append a duplicate for an existing +/// mapping written in any reasonable form. +pub fn has_entry(content: &str, ip: &str, host: &str) -> bool { + content.lines().any(|line| { + let line = line.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + return false; + } + let mut toks = line.split_whitespace(); + let Some(addr) = toks.next() else { + return false; + }; + addr == ip && toks.any(|h| h.eq_ignore_ascii_case(host)) + }) +} + +/// Return `content` with an `ip\thost` mapping appended (only meaningful when [`has_entry`] is +/// false). PURE. Guarantees the appended line starts on its own line (adds a separating newline +/// when `content` does not already end in one) and is itself newline-terminated. +pub fn with_entry(content: &str, ip: &str, host: &str) -> String { + let mut out = String::with_capacity(content.len() + host.len() + ip.len() + 2); + out.push_str(content); + if !content.is_empty() && !content.ends_with('\n') { + out.push('\n'); + } + out.push_str(ip); + out.push('\t'); + out.push_str(host); + out.push('\n'); + out +} + +/// Ensure the OS hosts file maps `dig.local` → `127.0.0.2`, appending the entry only if absent. +/// Requires write access to the hosts file (the installer/service runs elevated), so a permission +/// failure surfaces as an `io::Error`. Reports whether the entry was ADDED or already present. +pub fn run() -> std::io::Result { + let path = hosts_path(); + let content = std::fs::read_to_string(&path).unwrap_or_default(); + if has_entry(&content, DIG_LOCAL_IP, DIG_LOCAL_HOST) { + return Ok(Outcome::new( + format!( + "dig-node: {DIG_LOCAL_HOST} already maps to {DIG_LOCAL_IP} in {} — nothing to do.", + path.display() + ), + serde_json::json!({ "added": false, "hosts_path": path.display().to_string() }), + )); + } + let updated = with_entry(&content, DIG_LOCAL_IP, DIG_LOCAL_HOST); + std::fs::write(&path, updated)?; + Ok(Outcome::new( + format!( + "dig-node: added {DIG_LOCAL_IP} {DIG_LOCAL_HOST} to {} so http://{DIG_LOCAL_HOST} resolves.", + path.display() + ), + serde_json::json!({ "added": true, "hosts_path": path.display().to_string() }), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_an_existing_mapping_in_various_forms() { + assert!(has_entry( + "127.0.0.2\tdig.local\n", + "127.0.0.2", + "dig.local" + )); + assert!(has_entry("127.0.0.2 dig.local", "127.0.0.2", "dig.local")); + assert!(has_entry( + " 127.0.0.2 dig.local # DIG\n", + "127.0.0.2", + "dig.local" + )); + // Case-insensitive host, extra aliases on the line. + assert!(has_entry( + "127.0.0.2 foo DIG.LOCAL bar\n", + "127.0.0.2", + "dig.local" + )); + } + + #[test] + fn does_not_match_a_commented_or_different_mapping() { + assert!(!has_entry( + "# 127.0.0.2 dig.local\n", + "127.0.0.2", + "dig.local" + )); + assert!(!has_entry( + "127.0.0.1 dig.local\n", + "127.0.0.2", + "dig.local" + )); + assert!(!has_entry( + "127.0.0.2 other.host\n", + "127.0.0.2", + "dig.local" + )); + assert!(!has_entry("", "127.0.0.2", "dig.local")); + } + + #[test] + fn appends_on_its_own_newline_terminated_line() { + // No trailing newline in the source → a separator is inserted so the entry is its own line. + let out = with_entry("127.0.0.1 localhost", "127.0.0.2", "dig.local"); + assert_eq!(out, "127.0.0.1 localhost\n127.0.0.2\tdig.local\n"); + // Already newline-terminated → no blank line inserted. + let out2 = with_entry("127.0.0.1 localhost\n", "127.0.0.2", "dig.local"); + assert_eq!(out2, "127.0.0.1 localhost\n127.0.0.2\tdig.local\n"); + // Empty file → just the entry. + assert_eq!( + with_entry("", "127.0.0.2", "dig.local"), + "127.0.0.2\tdig.local\n" + ); + } + + #[test] + fn appended_content_is_then_detected_as_present() { + let out = with_entry("127.0.0.1 localhost\n", DIG_LOCAL_IP, DIG_LOCAL_HOST); + assert!(has_entry(&out, DIG_LOCAL_IP, DIG_LOCAL_HOST)); + } +} diff --git a/crates/dig-node-service/src/lib.rs b/crates/dig-node-service/src/lib.rs index c678f4c..27ee2b3 100644 --- a/crates/dig-node-service/src/lib.rs +++ b/crates/dig-node-service/src/lib.rs @@ -37,6 +37,9 @@ pub mod config; /// served-store CSP. The wiring lives in [`server`]. pub mod content; pub mod control; +/// `dig-node ensure-hosts` (#91/#503): idempotently register the `dig.local` → `127.0.0.2` OS +/// hosts entry so `http://dig.local` resolves to the node. Invoked by the native install packages. +pub mod hosts; pub mod meta; /// `dig-node open ` (#389): the OS scheme-handler target the /// installer registers for `chia://` + `urn:dig:chia:`. Strictly validates the untrusted diff --git a/crates/dig-node-service/src/main.rs b/crates/dig-node-service/src/main.rs index 997277f..6207c43 100644 --- a/crates/dig-node-service/src/main.rs +++ b/crates/dig-node-service/src/main.rs @@ -78,6 +78,11 @@ enum Command { /// The DIG link (`chia://[:]/` or `urn:dig:chia:<…>`). link: String, }, + /// Internal: idempotently register the `dig.local` → `127.0.0.2` OS hosts entry (#91/#503), + /// so `http://dig.local` resolves to the node. Invoked by the native install packages; + /// requires write access to the hosts file (run elevated). Not meant to be run by hand. + #[command(hide = true)] + EnsureHosts, } /// `dig-node pair` sub-actions. With none, lists pending requests + issued tokens. @@ -109,6 +114,7 @@ impl Command { Command::Status => "status", Command::Pair { .. } => "pair", Command::Open { .. } => "open", + Command::EnsureHosts => "ensure-hosts", } } } @@ -139,6 +145,7 @@ fn main() -> std::process::ExitCode { render(pair::run(&config, pair_action), action, json) } Command::Open { link } => render(open::run(&config, &link), action, json), + Command::EnsureHosts => render(dig_node_service::hosts::run(), action, json), }; std::process::ExitCode::from(exit.code()) } diff --git a/packaging/linux/build-deb.sh b/packaging/linux/build-deb.sh new file mode 100755 index 0000000..ebb0689 --- /dev/null +++ b/packaging/linux/build-deb.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Build the dig-node Ubuntu/Debian .deb from an already-built release binary. +# +# The .deb IS the install architecture on Ubuntu (#503): it installs the binary, registers +# the systemd system service `net.dignetwork.dig-node` (auto-start, started on install, +# stopped+disabled on remove), and registers the `chia://` OS scheme handler → `dig-node +# open` (#389). The dig-installer just fetches + `apt install`s this package. +# +# Usage: build-deb.sh [out-dir] +# = amd64 | arm64 (dpkg arch names) +# Emits: /dig-node__.deb +set -euo pipefail + +BIN="${1:?binary path required}" +VERSION="${2:?version required}" +ARCH="${3:?dpkg arch required (amd64|arm64)}" +OUT_DIR="${4:-dist}" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +# --- Layout ---------------------------------------------------------------- +install -d -m 0755 "$STAGE/DEBIAN" +install -d -m 0755 "$STAGE/usr/bin" +install -d -m 0755 "$STAGE/lib/systemd/system" +install -d -m 0755 "$STAGE/usr/share/applications" + +install -m 0755 "$BIN" "$STAGE/usr/bin/dig-node" +install -m 0644 "$HERE/systemd/net.dignetwork.dig-node.service" \ + "$STAGE/lib/systemd/system/net.dignetwork.dig-node.service" +install -m 0644 "$HERE/dig-node.desktop" \ + "$STAGE/usr/share/applications/dig-node.desktop" + +INSTALLED_SIZE="$(du -ks "$STAGE/usr" "$STAGE/lib" | awk '{s+=$1} END {print s}')" + +# --- control ---------------------------------------------------------------- +cat > "$STAGE/DEBIAN/control" < +Installed-Size: ${INSTALLED_SIZE} +Depends: libc6 +Homepage: https://dig.net +Description: DIG NETWORK: NODE — the local DIG node OS service + The canonical DIG node: serves chia:// (DIG) content locally over loopback and + resolves DIG links for the browser + extension. Installs as a systemd system + service (net.dignetwork.dig-node) and registers the chia:// OS scheme handler. +EOF + +# --- maintainer scripts ----------------------------------------------------- +# postinst: pre-create the restrictive machine-wide state dir (#501 — root-owned 0700 so +# the control token is not world-readable), enable+start the service, register the scheme +# handler as the system default. +cat > "$STAGE/DEBIAN/postinst" <<'EOF' +#!/bin/sh +set -e +case "$1" in + configure) + # #501: machine-wide auth-state dir, owner (root) only. The service inherits it. + install -d -m 0700 /var/lib/dig-node || true + # dig.local → 127.0.0.2 so `http://dig.local` reaches the node (best-effort, idempotent). + if ! grep -qE '^[[:space:]]*127\.0\.0\.2[[:space:]]+dig\.local([[:space:]]|$)' /etc/hosts 2>/dev/null; then + printf '127.0.0.2\tdig.local\n' >> /etc/hosts || true + fi + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl enable --now net.dignetwork.dig-node.service || true + fi + # Register the chia:// handler as the system default + refresh the desktop DB. + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database /usr/share/applications || true + fi + mkdir -p /etc/xdg + if [ ! -f /etc/xdg/mimeapps.list ] || ! grep -q 'x-scheme-handler/chia=' /etc/xdg/mimeapps.list 2>/dev/null; then + printf '[Default Applications]\nx-scheme-handler/chia=dig-node.desktop\n' >> /etc/xdg/mimeapps.list || true + fi + ;; +esac +exit 0 +EOF + +# prerm: stop + disable the service before removal. +cat > "$STAGE/DEBIAN/prerm" <<'EOF' +#!/bin/sh +set -e +case "$1" in + remove|deconfigure) + if command -v systemctl >/dev/null 2>&1; then + systemctl disable --now net.dignetwork.dig-node.service || true + fi + ;; +esac +exit 0 +EOF + +# postrm: reload systemd after files are gone (purge leaves /var/lib/dig-node for reinstall +# safety; a full purge removes it). +cat > "$STAGE/DEBIAN/postrm" <<'EOF' +#!/bin/sh +set -e +case "$1" in + remove) + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + fi + ;; + purge) + rm -rf /var/lib/dig-node || true + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + fi + ;; +esac +exit 0 +EOF + +chmod 0755 "$STAGE/DEBIAN/postinst" "$STAGE/DEBIAN/prerm" "$STAGE/DEBIAN/postrm" + +# --- build ------------------------------------------------------------------ +mkdir -p "$OUT_DIR" +OUT="$OUT_DIR/dig-node_${VERSION}_${ARCH}.deb" +dpkg-deb --root-owner-group --build "$STAGE" "$OUT" +echo "built: $OUT" diff --git a/packaging/linux/dig-node.desktop b/packaging/linux/dig-node.desktop new file mode 100644 index 0000000..dd81460 --- /dev/null +++ b/packaging/linux/dig-node.desktop @@ -0,0 +1,13 @@ +[Desktop Entry] +Type=Application +Name=DIG Network +Comment=Open DIG Network chia:// links +# The OS scheme-handler target (#389): xdg passes the clicked URL as %u; `dig-node open` +# strictly validates it (chia:// / urn:dig:chia: only) and opens the local node serve URL. +Exec=/usr/bin/dig-node open %u +Terminal=false +NoDisplay=true +# Register ONLY the DIG-specific `chia` scheme. The `urn:dig:chia:` textual form is also +# accepted by `dig-node open`, but registering the whole `urn` scheme at OS level would +# hijack every urn: link on the machine — so it is intentionally NOT a global handler here. +MimeType=x-scheme-handler/chia; diff --git a/packaging/linux/systemd/net.dignetwork.dig-node.service b/packaging/linux/systemd/net.dignetwork.dig-node.service new file mode 100644 index 0000000..b287198 --- /dev/null +++ b/packaging/linux/systemd/net.dignetwork.dig-node.service @@ -0,0 +1,29 @@ +[Unit] +Description=DIG NETWORK: NODE +Documentation=https://dig.net +# The node serves content + a P2P layer; come up after the network is online so the +# first bind + peer dial succeed. Never hard-depends on it (offline serve still works). +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# The .deb installs the binary here (see build-deb.sh). `run` is the unix-service +# entrypoint (SPEC §8.1) — it serves in the foreground until SIGTERM. +ExecStart=/usr/bin/dig-node run +# Self-identify as a SERVICE run (#501): the daemon may bootstrap the machine-wide +# state dir (/var/lib/dig-node) with a restrictive owner-only ACL. A bare CLI never does. +Environment=DIG_NODE_RUN_CONTEXT=service +# Restart-on-crash, matching the Windows SCM recovery actions + launchd KeepAlive. +Restart=on-failure +RestartSec=5 +# Runs as root (a system service): the state dir under /var/lib/dig-node is therefore +# root-owned 0700, so the control token is not readable by other local users (#501). A +# non-root operator drives control with `sudo dig-node pair` (the exact remedy the CLI prints). +# Hardening: the node needs no elevated capabilities beyond binding its loopback port. +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/packaging/macos/build-pkg.sh b/packaging/macos/build-pkg.sh new file mode 100755 index 0000000..8fadcf4 --- /dev/null +++ b/packaging/macos/build-pkg.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Build the dig-node macOS .pkg from an already-built release binary (runs on a macOS runner). +# +# The .pkg IS the install architecture on macOS (#503): it installs the binary to +# /usr/local/bin, a LaunchDaemon (net.dignetwork.dig-node, RunAtLoad+KeepAlive) to +# /Library/LaunchDaemons, and a tiny AppleScript app that registers the chia:// URL scheme → +# `dig-node open` (#389). postinstall creates the restrictive state dir (#501) + starts the daemon. +# +# Usage: build-pkg.sh [out-dir] +# Emits: /dig-node--macos.pkg (universal binary if is a fat binary) +# +# NOTE: the .pkg is NOT code-signed/notarized here (that needs a paid Apple Developer ID). Gatekeeper +# will warn on first open until signing is added; tracked as a follow-up (SPEC §9). The binary + all +# install behavior are correct + complete regardless. +set -euo pipefail + +BIN="${1:?binary path required}" +VERSION="${2:?version required}" +OUT_DIR="${3:-dist}" +IDENTIFIER="net.dignetwork.dig-node" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STAGE="$(mktemp -d)" +ROOT="$STAGE/root" +SCRIPTS="$STAGE/scripts" +trap 'rm -rf "$STAGE"' EXIT + +# --- Payload layout --------------------------------------------------------- +mkdir -p "$ROOT/usr/local/bin" "$ROOT/Library/LaunchDaemons" "$ROOT/Applications" +install -m 0755 "$BIN" "$ROOT/usr/local/bin/dig-node" +install -m 0644 "$HERE/net.dignetwork.dig-node.plist" \ + "$ROOT/Library/LaunchDaemons/net.dignetwork.dig-node.plist" + +# --- Build the chia:// URL-handler .app from the AppleScript --------------- +APP="$ROOT/Applications/DIG Network.app" +osacompile -o "$APP" "$HERE/dig-url-handler.applescript" +PLIST="$APP/Contents/Info.plist" +# Declare the `chia` URL scheme + a stable bundle id so LaunchServices resolves it. +# Set-or-Add: newer osacompile output may omit CFBundleIdentifier, so a bare Set fails +# ("Does Not Exist"); fall back to Add (mirrors the LSUIElement pattern below). +/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${IDENTIFIER}.urlhandler" "$PLIST" 2>/dev/null || \ + /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string ${IDENTIFIER}.urlhandler" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string net.dignetwork.chia" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST" +/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string chia" "$PLIST" +/usr/libexec/PlistBuddy -c "Set :LSUIElement true" "$PLIST" 2>/dev/null || \ + /usr/libexec/PlistBuddy -c "Add :LSUIElement bool true" "$PLIST" + +# --- Scripts ---------------------------------------------------------------- +mkdir -p "$SCRIPTS" +install -m 0755 "$HERE/scripts/preinstall" "$SCRIPTS/preinstall" +install -m 0755 "$HERE/scripts/postinstall" "$SCRIPTS/postinstall" + +# --- Build the component pkg, then wrap for distribution -------------------- +mkdir -p "$OUT_DIR" +COMPONENT="$STAGE/dig-node-component.pkg" +pkgbuild \ + --root "$ROOT" \ + --scripts "$SCRIPTS" \ + --identifier "$IDENTIFIER" \ + --version "$VERSION" \ + --install-location "/" \ + "$COMPONENT" + +OUT="$OUT_DIR/dig-node-${VERSION}-macos.pkg" +productbuild --package "$COMPONENT" "$OUT" +echo "built: $OUT" diff --git a/packaging/macos/dig-url-handler.applescript b/packaging/macos/dig-url-handler.applescript new file mode 100644 index 0000000..2161f9f --- /dev/null +++ b/packaging/macos/dig-url-handler.applescript @@ -0,0 +1,15 @@ +-- macOS URL-scheme handler for DIG links (#389/#503). +-- +-- macOS delivers a clicked chia:// URL to a registered handler via the GetURL Apple Event +-- (NOT argv), so a bare CLI binary cannot be the handler directly. This tiny AppleScript app +-- (compiled by build-pkg.sh, its Info.plist given CFBundleURLTypes for the `chia` scheme) +-- receives the event and forwards the URL to `dig-node open`, which strictly validates it and +-- opens the local node serve URL. +-- +-- `quoted form of` single-quote-escapes the URL, so even a crafted link cannot inject a shell +-- command; `dig-node open` re-validates + rejects metacharacters as a second layer. +on open location this_URL + try + do shell script "/usr/local/bin/dig-node open " & quoted form of this_URL + end try +end open location diff --git a/packaging/macos/net.dignetwork.dig-node.plist b/packaging/macos/net.dignetwork.dig-node.plist new file mode 100644 index 0000000..eeab3a7 --- /dev/null +++ b/packaging/macos/net.dignetwork.dig-node.plist @@ -0,0 +1,35 @@ + + + + + + Label + net.dignetwork.dig-node + ProgramArguments + + /usr/local/bin/dig-node + run + + EnvironmentVariables + + DIG_NODE_RUN_CONTEXT + service + + RunAtLoad + + KeepAlive + + ProcessType + Background + StandardOutPath + /var/log/dig-node.log + StandardErrorPath + /var/log/dig-node.log + + diff --git a/packaging/macos/scripts/postinstall b/packaging/macos/scripts/postinstall new file mode 100755 index 0000000..e745ec7 --- /dev/null +++ b/packaging/macos/scripts/postinstall @@ -0,0 +1,23 @@ +#!/bin/sh +# Post-install (runs as root): create the restrictive machine-wide state dir (#501), +# register + start the LaunchDaemon, and register the chia:// URL handler with LaunchServices. +set -e + +# #501: machine-wide auth-state dir, owner (root) only — the daemon inherits it and the +# control token is not readable by other local users. A non-root operator uses `sudo dig-node pair`. +mkdir -p "/Library/Application Support/DigNode" +chmod 700 "/Library/Application Support/DigNode" + +# Load + start the daemon (RunAtLoad brings it up; kickstart forces an immediate start). +launchctl bootout system/net.dignetwork.dig-node 2>/dev/null || true +launchctl bootstrap system /Library/LaunchDaemons/net.dignetwork.dig-node.plist || true +launchctl enable system/net.dignetwork.dig-node || true +launchctl kickstart -k system/net.dignetwork.dig-node || true + +# Register the chia:// handler app with LaunchServices so the scheme resolves to `dig-node open`. +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" +if [ -x "$LSREGISTER" ]; then + "$LSREGISTER" -f "/Applications/DIG Network.app" >/dev/null 2>&1 || true +fi + +exit 0 diff --git a/packaging/macos/scripts/preinstall b/packaging/macos/scripts/preinstall new file mode 100755 index 0000000..e8e7a52 --- /dev/null +++ b/packaging/macos/scripts/preinstall @@ -0,0 +1,6 @@ +#!/bin/sh +# Stop any running instance of the daemon before the payload is replaced, so the new binary +# is not overwritten while in use (idempotent — ignore "not loaded"). +set -e +launchctl bootout system/net.dignetwork.dig-node 2>/dev/null || true +exit 0 diff --git a/packaging/windows/dig-node.wxs b/packaging/windows/dig-node.wxs new file mode 100644 index 0000000..a3a4945 --- /dev/null +++ b/packaging/windows/dig-node.wxs @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +