diff --git a/README.md b/README.md index 418dc32..563d17c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Dev Boxer -Set up an Ubuntu 24.04 VPS as a remote Claude Code development environment — Matron chat stack, desktop GUI, security hardening, and your choice of Cloudflare Tunnel or plain IP + self-signed TLS — in one command. +Set up your first Matron on a fresh Ubuntu 24.04 VPS: browser app, journal, bridge, file viewer and your choice of Claude Code or Codex. The guided starter path uses trusted HTTPS for a public IPv4 address, without a domain. + +**Start with the [four-step guide](https://matron.chat/getting-started/).** Development desktops, Cloudflare and other advanced settings remain available. ## Part of the Matron ecosystem @@ -16,25 +18,18 @@ Set up an Ubuntu 24.04 VPS as a remote Claude Code development environment — M ## What you get -- **Claude Code** on a persistent remote server — run long-lived agents without keeping your laptop open -- **Matron chat** — talk to Claude Code from the Matron apps (iOS/desktop/web); this box runs [matron-bridge](https://github.com/Matronhq/matron-bridge) and, in bundled mode, its own [matron-journal](https://github.com/Matronhq/matron-journal) sync server -- **File viewer** — `viewer.` (or `https://:8444` in IP mode) serves files and artifacts the agent shares, via `matron-viewer.service` -- **Two exposure modes** — your own domain via Cloudflare Tunnel, or no domain at all via IP + self-signed TLS (see [docs/exposure-modes.md](docs/exposure-modes.md)) -- **Desktop GUI (optional)** — XFCE4 + XRDP + VS Code, installed on demand with `~/setup-desktop`; reachable only through an SSH tunnel, no open ports -- **Security hardening** — SSH key-only auth, custom port, UFW, fail2ban, unattended-upgrades -- **Cloudflare Tunnel** — zero-trust HTTPS access with optional SSO -- **Dev tools** — Docker, Node.js 22, Git, Python, uv, GitHub CLI, lazydocker, Chrome, Firefox +The starter profile installs one AI CLI, the browser app, journal, bridge and file viewer. It sets up SSH keys on port 22, a firewall, automatic OS updates and automatic HTTPS renewal. It omits desktops, Docker, browsers, coding plugins and local voice transcription. Email and other service integrations are not included. + +Advanced developer setup retains those optional tools and the Cloudflare/self-signed exposure modes. See [exposure modes](docs/exposure-modes.md). ## Prerequisites -- A fresh **Ubuntu 24.04** machine with sudo access. Anywhere works: - - **Cheap VPS** (Hetzner / DigitalOcean / etc., ~$5/mo) — fastest path - - **Spare desktop or laptop** at home (no monthly fee — see [No VPS?](#no-vps-use-spare-hardware)) - - **VM** (multipass, OrbStack, Hyper-V) on top of macOS or Windows -- **amd64 or arm64** (on arm64, Chrome is skipped — no arm64 build) -- **Cloudflare mode only:** a Cloudflare account with a domain, a zone DNS API token, and optionally a one-time account setup token -- **IP mode:** nothing extra — no domain needed -- SSH key pair +- A **fresh Ubuntu 24.04 x86-64 VPS** with root or sudo access, initially 2 CPUs / 4 GB RAM / 40 GB disk. This size has been exercised in a resource-limited container; a public VPS acceptance run is still required before release. +- A stable public **IPv4** address and inbound TCP ports **22, 80, 443, 8444**. Port 80 must remain reachable for certificate renewal. +- Your SSH **public** key, with its private key kept on your computer. +- A Claude Code or Codex account with CLI access. AI usage and the VPS are separate costs. The [buying guide](https://matron.chat/getting-started/#vps) compares budget providers, contract terms and setup fees. + +For spare hardware, ARM64 or Cloudflare, choose Advanced and read the relevant guide below. ## Quick start @@ -48,16 +43,27 @@ curl -fsSL https://raw.githubusercontent.com/matronhq/dev-boxer/main/install.sh ### What happens next -The installer clones Dev Boxer to `/opt/dev-boxer`, prompts for the minimum required settings, writes `config.yml` and gitignored `secrets.yml`, then runs the full setup. Later `sudo ./setup.rb ...` reruns from that checkout, which will automatically fast-forward before continuing; set `DEV_BOXER_SKIP_AUTO_UPDATE=1` to disable that. +The installer clones Dev Boxer to `/opt/dev-boxer`, saves your settings and private `secrets.yml`, then provisions the server. Choose: + +1. **Set up my first Matron** — choose a sign-in name, an AI provider, the public IP and an SSH public key. Accept the certificate terms, then follow the AI sign-in link. Open the printed Matron URL and use the password or phone QR shown only in your terminal. +2. **Add a box to my Matron** — enter the existing HTTPS address, approve the pairing code in Matron, and sign into the AI provider on this box. +3. **Advanced developer setup** — the original wizard, including journal, domain/Cloudflare, desktop and developer configuration. It is also available with `sudo ./setup.rb --advanced` on a new checkout. + +Start a chat in the prepared `work` folder and give it a small design task: + +> Create three visual directions for a simple fitness tracker: calm, bold and playful. Show colors, typography and a sample screen for each in one self-contained HTML page. Save it as design-directions.html, share a preview link, and put a notice in For you linking to the finished page. + +Open the result from its **For you** notice and check all three directions. This checks the agent's work, file preview and result notification together. Setup's service checks do not substitute for this test. + +Later SSH connections use `ssh dev@YOUR_VPS_IP` on the starter path. Test that login before closing the initial terminal. The agent's `dev` account has passwordless sudo and can administer the server. + +Repeating the install command uses the saved settings and installed component revisions. Use `sudo matron status`, `sudo matron login`, or `sudo matron link` for recovery. Make a backup before the explicit `sudo matron update --backup PATH` action. See [backup, restore and recovery](docs/starter-recovery.md). -It asks for, in order: +The starter's component revisions and AI CLI versions are pinned in `components.json`. Ubuntu security updates remain automatic. The original advanced profile keeps its existing component update behavior; installer auto-pull is now opt-in with `DEV_BOXER_AUTO_UPDATE=1`. -1. Linux username, SSH public key, and SSH port -2. Journal location: bundled on this box (plus a journal username), or external (plus its `wss://` URL and an optional agent-token file) -3. Exposure mode: `cloudflare` or `ip`. Cloudflare mode asks for the base domain, whether Dev Boxer manages DNS (plus a zone DNS token), and whether it creates the tunnel and Access app (plus allowed emails/domains and a one-time account setup token). IP mode asks for the public IP — leave blank to auto-detect. -4. Claude experience level: beginner, intermediate, or advanced — tunes the generated CLAUDE.md +### Advanced developer settings -It derives `dev.`, `chat.`, `viewer.`, and `hello.` automatically in Cloudflare mode (`chat.` only in bundled-journal mode). +The advanced wizard asks for a Linux username and SSH port, journal mode, exposure mode and Claude experience level. In Cloudflare mode it derives `dev`, `chat`, `viewer` and `hello` hostnames. The rest of this README describes those advanced options. **Private repos:** the wizard doesn't ask for a GitHub token. To let the dev user clone private repos without an interactive login, add a fine-grained PAT to `secrets.yml` before running setup: @@ -129,7 +135,7 @@ See [docs/cloudflare-access.md](docs/cloudflare-access.md) for a manual walkthro ## Modules -Setup runs 10 modules by default, in order; the optional desktop module (03) brings the total to 11. +The starter runs only security, users, prerequisites, the chosen AI CLI, Matron, exposure and onboarding. Advanced setup uses the developer modules below. | # | Module | What it does | |---|--------|--------------| @@ -141,7 +147,7 @@ Setup runs 10 modules by default, in order; the optional desktop module (03) bri | 06 | `browsers` | Chrome, Firefox, Xvfb | | 07 | `claude` | Claude Code CLI + plugins + Chrome DevTools MCP + partial-clone git guard hook | | 08 | `matron` | matron-journal (bundled mode) + matron-bridge, agent enrollment, optional local voice-note transcription (whisper.cpp; set `bridge.voice_notes.enabled: false` when your journal transcribes voice notes, and setup removes it; see below) | -| 09 | `exposure` | Cloudflare Tunnel or IP + self-signed TLS, per `exposure.mode` | +| 09 | `exposure` | Trusted IP HTTPS for the starter; Cloudflare Tunnel or self-signed IP TLS in Advanced | | 10 | `desktop-apps` | lazydocker, CLAUDE.md, MOTD, optional desktop helper | | 11 | `hello-world` | `localhost:9820` tunnel smoke-test service | @@ -167,7 +173,7 @@ To add more MCP servers to the box's Claude Code, see [docs/adding-mcp-servers.m ## Multiple boxes, one journal -Point more than one Dev Boxer install at the same matron-journal by setting `journal.mode: external` and `journal.url` (its `wss://` address) on every box after the first. Provide a pre-minted `journal.token_file` (mint one on the journal host: `matron-admin agent add `), or leave it unset to pair from the Matron app during setup instead. See [docs/exposure-modes.md](docs/exposure-modes.md) for how to reach that journal from the outside. +For the starter path, choose **Add a box to my Matron** on the next VPS. In advanced configuration, point more than one install at the same matron-journal by setting `journal.mode: external` and `journal.url` (its `wss://` address) on every box after the first. Provide a pre-minted `journal.token_file` (mint one on the journal host: `matron-admin agent add `), or leave it unset to pair from the Matron app during setup instead. See [docs/exposure-modes.md](docs/exposure-modes.md) for how to reach that journal from the outside. ## Re-enrolling an agent diff --git a/bin/matron b/bin/matron new file mode 100755 index 0000000..a7a98ed --- /dev/null +++ b/bin/matron @@ -0,0 +1,163 @@ +#!/usr/bin/env ruby +# Administrative recovery commands. Credentials are only ever shown on the +# operator's controlling terminal by the explicit `link` action. +require "optparse" +require "shellwords" +require "json" +require "fileutils" +require "tmpdir" +require "time" +require "open3" + +ROOT = File.expand_path("..", __dir__) +$LOAD_PATH.unshift(File.join(ROOT, "lib")) +require "dev_boxer" +require "dev_boxer/ai_reply" + +config_path = File.join(ROOT, "config.yml") +OptionParser.new { |o| o.on("--config PATH") { |v| config_path = File.expand_path(v) } }.order! +action = ARGV.shift || "status" +abort "Run this command with sudo." unless Process.uid.zero? +config = DevBoxer::Config.load(config_path) +username = config.user.name +provider = config.agent&.provider || "claude" +abort "Invalid AI provider in config." unless %w[claude codex].include?(provider) +shell = DevBoxer::Shell.new +log = DevBoxer::Log.new +exposure = DevBoxer::Exposure.for(config: config, shell: shell, log: log) +bundled = (config.journal&.mode || "bundled") == "bundled" +origin = DevBoxer::JournalEnrollment.https_base(exposure.journal_public_url) +services = %w[matron-bridge matron-viewer nginx] +services << "matron-journal" if bundled +secrets_path = DevBoxer::Config.secrets_path_for(config_path) +state_paths = [config_path, secrets_path, "/etc/matron", "/home/#{username}/matron-bridge/.env", "/home/#{username}/matron-bridge/.journal-token", "/home/#{username}/matron-bridge/mcp-config.local.json"] +state_paths << "/opt/matron-journal/data" if bundled +restore_args = [username, bundled ? "bundled" : "external", *state_paths] +restore_tool = File.join(ROOT, "bin/restore-state.py") + +begin + case action + when "status" + failed = [] + services.each do |service| + ready = shell.service_active?(service) + puts "#{ready ? 'OK' : 'CHECK'} #{service}" + failed << service unless ready + end + if bundled + ready = shell.sh("curl --fail --silent --show-error --max-time 15 #{Shellwords.escape(origin + '/app/')} >/dev/null") + puts "#{ready ? 'OK' : 'CHECK'} browser: #{origin}/app/" + failed << "HTTPS browser" unless ready + end + viewer_ready = shell.sh("curl --fail --silent --show-error --max-time 15 #{Shellwords.escape(exposure.viewer_base_url + '/health')} >/dev/null") + puts "#{viewer_ready ? 'OK' : 'CHECK'} HTTPS file viewer" + failed << "HTTPS file viewer" unless viewer_ready + if config.exposure.mode == "public-ip" + ready = shell.sh("openssl x509 -in /etc/letsencrypt/live/matron-ip/cert.pem -noout -checkend 172800 >/dev/null 2>&1") + timer = shell.service_active?("matron-certificate.timer") + renewed = shell.sh("systemctl is-failed --quiet matron-certificate.service") + puts "#{ready && timer && !renewed ? 'OK' : 'CHECK'} certificate and automatic renewal" + failed << "certificate renewal (sudo journalctl -u matron-certificate.service)" unless ready && timer && !renewed + end + abort "Needs attention: #{failed.join(', ')}. Re-run setup or see https://matron.chat/getting-started/#help" unless failed.empty? + puts "Services respond. Finish sign-in and check your first chat in Matron." + when "login" + status_cmd = provider == "codex" ? "codex login status" : "claude auth status" + unless shell.sh("su - #{Shellwords.escape(username)} -c #{Shellwords.escape(status_cmd)} >/dev/null 2>&1") + puts "Open the provider's sign-in link on your own computer. Enter any code only in its sign-in flow." + login_cmd = provider == "codex" ? "codex login --device-auth" : "claude auth login" + shell.run_as_user_interactive(username, login_cmd) + end + puts "Checking a short AI reply (uses a small amount of your account allowance)..." + prompt = "Reply with exactly: Matron is ready" + cmd = provider == "codex" ? "codex exec --skip-git-repo-check --sandbox read-only --json #{Shellwords.escape(prompt)}" : "claude -p --tools '' --output-format json #{Shellwords.escape(prompt)}" + result = shell.run_as_user(username, "cd ~/work && #{cmd}") + abort "Sign-in completed, but no successful reply arrived. Try sudo matron login again." unless DevBoxer::AiReply.ready?(provider, result) + puts "AI reply received. Open Matron and try your first chat." + when "link" + unless bundled + puts "Sign into your existing Matron at #{origin}/app/; this box uses that account." + exit + end + # Do not send passwords or sign-in QR codes into piped logs. + File.open("/dev/tty", "w") do |tty| + saved = YAML.safe_load_file(secrets_path) + journal_user = config.journal&.username || username + tty.puts "Open #{origin}/app/" + tty.puts "Username: #{journal_user}" + tty.puts "Password: #{saved.dig('journal', 'user_password')}" + tty.puts "For a phone, scan this short-lived sign-in QR in Matron:" + cmd = DevBoxer::JournalEnrollment.matron_admin_command("link-code #{Shellwords.escape(journal_user)} --server-url #{Shellwords.escape(origin)}") + tty.puts shell.sh!(cmd) + tty.puts "Save the password in your password manager. Never paste it into chat." + end + when "enroll" + exec "ruby", File.join(ROOT, "bin/enroll"), "--config", config_path + when "resume" + exec "ruby", File.join(ROOT, "setup.rb"), "--config", config_path, *ARGV + when "backup" + destination = File.expand_path(ARGV.shift || "/var/backups/matron-#{Time.now.utc.strftime('%Y%m%d-%H%M%S')}.tar.gz") + abort "Destination already exists" if File.exist?(destination) + FileUtils.mkdir_p(File.dirname(destination), mode: 0o700) + old_umask = File.umask(0o077) + stopped = [] + begin + %w[matron-bridge matron-viewer matron-journal].each do |unit| + next if unit == "matron-journal" && !bundled + next unless shell.service_active?(unit) + shell.systemctl(:stop, unit) + stopped << unit + end + Dir.mktmpdir("matron-backup") do |dir| + files = state_paths.select { |p| File.exist?(p) } + revision = shell.sh!("git -C #{Shellwords.escape(ROOT)} rev-parse HEAD").strip + manifest = {"format" => 1, "user" => username, "bundled" => bundled, "files" => files, "installer_revision" => revision, "components" => JSON.parse(File.read(File.join(ROOT, "components.json")))} + File.write(File.join(dir, "manifest.json"), JSON.pretty_generate(manifest)) + shell.sh!("tar -czf #{Shellwords.escape(destination)} -C #{Shellwords.escape(dir)} manifest.json -C / #{files.map { |p| Shellwords.escape(p.delete_prefix('/')) }.join(' ')}") + end + puts "Backup saved to #{destination} (owner-only). It contains credentials: store it privately off this VPS." + puts "Work folders and AI provider sessions are excluded. Back up your work separately; sign in again after restoring." + ensure + stopped.reverse_each { |unit| shell.systemctl(:start, unit) } + File.umask(old_umask) + end + when "restore" + archive = File.expand_path(ARGV.shift.to_s) + abort "Use sudo matron restore BACKUP --replace-state. This replaces this installation's Matron data." unless ARGV.shift == "--replace-state" && File.file?(archive) + abort "Backup validation failed; nothing restored." unless system("python3", restore_tool, "--check", archive, *restore_args) + # On any failure below, leave services stopped until a good restore. + %w[matron-bridge matron-viewer matron-journal].each do |unit| + next if unit == "matron-journal" && !bundled + next unless shell.service_active?(unit) + shell.systemctl(:stop, unit) + end + args = ["python3", restore_tool, archive, *restore_args] + abort "Restore refused; state may be incomplete. Keep services stopped and restore a known-good backup." unless system(*args) + shell.sh!("chown -R matron:matron /opt/matron-journal/data") if bundled + %w[.env .journal-token mcp-config.local.json].each do |name| + path = "/home/#{username}/matron-bridge/#{name}" + shell.sh!("chown #{Shellwords.escape(username)}:#{Shellwords.escape(username)} #{Shellwords.escape(path)}") if File.exist?(path) + end + shell.sh!("chown #{username}:#{username} /etc/matron/agent-token") if File.exist?("/etc/matron/agent-token") + shell.sh!("chown root:matron /etc/matron/journal.env") if bundled && File.exist?("/etc/matron/journal.env") + # Keep the new server's reachability settings if restoring onto a replacement. + DevBoxer::Config.merge_into_file(config_path, {"exposure" => config.to_h.fetch("exposure"), "ssh" => config.to_h.fetch("ssh"), "user" => {"ssh_public_key" => config.user.ssh_public_key}}) + puts "State restored. Re-applying this installation's HTTPS settings and services." + exec "ruby", File.join(ROOT, "setup.rb"), "--config", config_path + when "update" + abort "Run sudo matron backup first. Then use: sudo matron update --backup /path/to/backup.tar.gz" unless ARGV.shift == "--backup" + backup = File.expand_path(ARGV.shift.to_s) + abort "Backup file not found" unless File.file?(backup) && File.size(backup).positive? + abort "Backup validation failed; update cancelled." unless system("python3", restore_tool, "--check", backup, *restore_args) + shell.sh!("git -C #{Shellwords.escape(ROOT)} diff --quiet && git -C #{Shellwords.escape(ROOT)} diff --cached --quiet") + shell.sh!("git -C #{Shellwords.escape(ROOT)} pull --ff-only") + exec "ruby", File.join(ROOT, "setup.rb"), "--config", config_path + else + abort "Commands: status, login, link, enroll, resume, backup [path], restore PATH --replace-state, update --backup PATH." + end +rescue DevBoxer::Shell::Error => e + warn e.message + exit 1 +rescue Errno::ENXIO + abort "Open an interactive SSH terminal to show the sign-in details." +end diff --git a/bin/restore-state.py b/bin/restore-state.py new file mode 100755 index 0000000..c15f77f --- /dev/null +++ b/bin/restore-state.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Restore only Matron state, never executables or files outside its roots.""" +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import sys +import tarfile +import tempfile + + +def validate(members, roots): + seen = set() + for member in members: + path = PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or path == PurePosixPath("."): + raise ValueError("Backup contains an unsafe path") + if path in seen: + raise ValueError("Backup contains a duplicate path") + seen.add(path) + if not (member.isfile() or member.isdir()): + raise ValueError("Backup contains a link or special file") + if member.name == "manifest.json": + continue + if not any(path == root or root in path.parents for root in roots): + raise ValueError("Backup contains a file outside Matron state") + + +def checked_manifest(source, roots, username, bundled): + validate(source.getmembers(), roots) + manifest = json.load(source.extractfile("manifest.json")) + if manifest.get("format") != 1 or manifest.get("user") != username or manifest.get("bundled") != bundled: + raise ValueError("Restore needs the same account and first-box/add-box mode") + return manifest + + +def restore(archive, roots, username, bundled, destination=Path("/")): + with tarfile.open(archive, "r:gz") as source: + members = source.getmembers() + checked_manifest(source, roots, username, bundled) + with tempfile.TemporaryDirectory(prefix="matron-restore-") as scratch: + source.extractall(scratch, filter="data") + # All destination ancestors must be real directories. A worker + # cannot redirect a privileged restore through its symlinks. + for member in members: + if member.name == "manifest.json": + continue + target = destination / member.name + if any(p.is_symlink() for p in [target, *target.parents]): + raise ValueError("Restore destination contains a symlink") + # A WAL from the replaced database must never be replayed into + # the restored database. The archive's own WAL, if any, is copied + # back together with its database below. Services are stopped. + if any(m.name == "opt/matron-journal/data/matron.db" for m in members): + for suffix in ("-wal", "-shm"): + (destination / ("opt/matron-journal/data/matron.db" + suffix)).unlink(missing_ok=True) + for member in members: + if member.name == "manifest.json": + continue + target = destination / member.name + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(Path(scratch) / member.name, target) + os.chmod(target, member.mode & 0o777) + + +if __name__ == "__main__": + try: + check = sys.argv[1] == "--check" + archive, username, mode, *allowed = sys.argv[2:] if check else sys.argv[1:] + roots = [PurePosixPath(p.lstrip("/")) for p in allowed] + if check: + with tarfile.open(archive, "r:gz") as source: + checked_manifest(source, roots, username, mode == "bundled") + else: + restore(archive, roots, username, mode == "bundled") + except (ValueError, KeyError, OSError, TypeError, tarfile.TarError) as exc: + sys.exit(str(exc)) diff --git a/components.json b/components.json new file mode 100644 index 0000000..d270f17 --- /dev/null +++ b/components.json @@ -0,0 +1,6 @@ +{ + "journal": {"repo": "https://github.com/Matronhq/matron-journal.git", "revision": "d591f8c24278a896aeca611cc8e1183b0e3e12a9"}, + "bridge": {"repo": "https://github.com/Matronhq/matron-bridge.git", "revision": "88dc8cd0f25b5a1e153949e685b64510cd263938"}, + "web": {"repo": "https://github.com/Matronhq/matron-web.git", "revision": "cd4769dfb1157d7fed15a73e09d815634bc9bcb0"}, + "tools": {"codex": "0.169.0", "claude": "2.1.325"} +} diff --git a/docs/adding-mcp-servers.md b/docs/adding-mcp-servers.md index 93e9e52..a4cda57 100644 --- a/docs/adding-mcp-servers.md +++ b/docs/adding-mcp-servers.md @@ -25,33 +25,24 @@ claude plugin install greptile@claude-plugins-official Plugins install globally and are available in all Claude Code sessions. List installed plugins with `claude plugin list`. -## 2. Global MCP Config +## 2. Matron bridge connections -For servers not available as plugins, add them to Claude Code's global MCP config at `~/.claude/settings.json`. Create or edit the file: +For tools used by Matron sessions, edit `~/matron-bridge/mcp-config.local.json` as the dev user. Do not edit the generated `mcp-config.json`: setup replaces that file. The local overlay is preserved by setup and shared by the bridge's Claude and Codex adapters. ```json { "mcpServers": { - "my-server": { - "command": "npx", - "args": ["-y", "@my-org/my-mcp-server"], - "env": { - "MY_API_KEY": "your-key-here" - } - } - } + "example": {"command": "/usr/local/bin/example-connector", "args": []} + }, + "mcpExtras": {} } ``` -Each entry under `mcpServers` is a server name mapped to a process definition (`command`, `args`, optional `env`). Claude Code starts these processes on launch and keeps them running for the session. +Keep credentials in the tool's own protected storage, not in examples or a repository. Run `sudo systemctl restart matron-bridge` after changing the overlay, then restart the affected chat. The starter dev account has administrator access; consider that when granting tools access to credentials. -You can also manage servers via the CLI: +## Standalone Claude sessions -```bash -claude mcp add my-server -- npx -y @my-org/my-mcp-server -claude mcp list -claude mcp remove my-server -``` +For Claude sessions started directly in a terminal, use `claude mcp add` as described by the [Claude MCP documentation](https://code.claude.com/docs/en/mcp). Matron sessions use the bridge overlay above. ## 3. Popular MCP Servers diff --git a/docs/exposure-modes.md b/docs/exposure-modes.md index 2e859df..12526cb 100644 --- a/docs/exposure-modes.md +++ b/docs/exposure-modes.md @@ -2,6 +2,14 @@ How your box is reached from the internet is one config choice: `exposure.mode`. +The beginner wizard uses **`public-ip`**: a trusted Let's Encrypt IP +certificate, automatic renewal, journal/browser on 443 and viewer on 8444. +It requires a stable public IPv4 address and inbound port 80 for HTTP-01 +issuance and renewal. SSH stays on 22. No domain is needed for this setup. +See [starter recovery](starter-recovery.md). + +The following modes remain available through Advanced: + | | `cloudflare` | `ip` | |---|---|---| | Needs a domain | Yes (Cloudflare-managed; ~$5-6/yr) | No | diff --git a/docs/starter-recovery.md b/docs/starter-recovery.md new file mode 100644 index 0000000..d13f19e --- /dev/null +++ b/docs/starter-recovery.md @@ -0,0 +1,80 @@ +# Starter setup: recovery and maintenance + +These commands apply to the **first Matron / add a box** starter profile. Run them in your private SSH terminal on the VPS. Connect with `ssh dev@YOUR_VPS_IP`; the starter keeps port 22 and installs the public key supplied during setup. + +## Interrupted installation + +Run the original installer command again. It keeps the installed checkout, saved settings, account password, encryption key, agent token and local connector overlay. Components come from the revisions in `components.json`; a normal rerun does not pull newer versions. + +If the `matron` command has already been installed, `sudo matron resume` is equivalent. Otherwise use `sudo /opt/dev-boxer/setup.rb`. The error tells you which step failed and how to start there with `--from NAME`. + +Before closing the original connection, open a second terminal and test SSH as `dev`. If setup stopped before that user was created, use the provider's initial login or recovery console. + +## Status and sign-in + +| Command | Result | +| --- | --- | +| `sudo matron status` | Checks services, browser HTTPS and certificate expiry/renewal. | +| `sudo matron login` | Signs into the selected AI as the correct Linux user and checks a short reply. This uses a small amount of AI allowance. | +| `sudo matron link` | Shows the saved password and a fresh phone sign-in QR only on the controlling terminal. | +| `sudo matron enroll` | Replaces a revoked connection token; external boxes require another pairing approval. | + +After sign-in, start a chat in `work` and use the [first design task](../README.md#what-happens-next). Open the finished HTML page from its **For you** notice and check the three directions. A service being active is not proof that the provider account, work output or file preview works. + +## HTTPS renewal + +The `public-ip` strategy obtains a trusted, short-lived Let's Encrypt certificate using Certbot's webroot IP support. It never substitutes a self-signed certificate after a failure. The journal uses port 443; the file viewer uses a separate origin on 8444. Keep port 80 reachable through the VPS and provider firewalls. + +`matron-certificate.timer` checks twice daily and reloads nginx after renewal. `sudo matron status` reports expiry within two days or a failed renewal service. Inspect and retry with: + +```sh +sudo journalctl -u matron-certificate.service +sudo systemctl start matron-certificate.service +sudo matron status +``` + +If the public IP changes, edit `exposure.ip.address` in `/opt/dev-boxer/config.yml` and resume setup. Do not dismiss a certificate warning. + +## Back up + +```sh +sudo matron backup +``` + +This briefly stops the bridge, viewer and local journal, archives their state consistently, then restarts the services that were running. The default destination is `/var/backups/matron-TIMESTAMP.tar.gz`, readable only by root. It includes: + +- Installer settings and secrets, journal encryption key and connection tokens. +- Local journal database and media, when this is the first box. +- Bridge environment and `mcp-config.local.json`. +- A manifest recording the installer revision, component revisions and installation mode. + +It **does not include** your work folders, AI-provider login files, provider session transcripts, arbitrary connector databases/credentials, or a remotely hosted journal. Back those up separately as needed. After restoring, sign into the AI again and start fresh sessions; existing journal history remains readable. + +Copy the archive off the VPS to private storage. It contains credentials and private conversations; do not upload it to a public repository or attach it to a support issue. Keep the original permissions and encrypt it using your backup system. A disk on the same VPS is not an off-server backup. + +## Restore + +Install the same starter version on the replacement VPS first. Use the same sign-in name and first-box/add-box choice, but the replacement's public IP and your current SSH key. Keep the old box stopped if moving its identity. + +Copy your archive to the new server, then run: + +```sh +sudo matron restore /path/to/backup.tar.gz --replace-state +``` + +This replaces the installation's Matron data. Restore refuses links, special files and paths outside the expected Matron state locations. If it fails after stopping services, it leaves them stopped so incomplete state is not served. Restore a known-good archive before restarting. + +The command reapplies the replacement's IP and SSH settings, obtains its HTTPS certificate, and starts services using the restored credentials. It does not restore OS packages or roll back database migrations. For recovery after an update, retain the previous installer revision and use its compatible component set; do not restore an older database into an arbitrary newer release without checking compatibility. + +Then run `sudo matron login`, `sudo matron status`, sign into Matron and check a new chat and **For you** item. Restore your work and connector-specific backups separately. + +## Deliberate updates + +```sh +sudo matron backup +sudo matron update --backup /path/to/the/backup.tar.gz +``` + +Update fast-forwards the installer checkout, then applies that revision's component set. Local tracked modifications stop the update. Read release notes first, preserve the backup privately, and check a chat afterwards. Ubuntu security updates still run automatically. Starter AI CLI versions are pinned; Claude's automatic updater is disabled for this profile. + +The original advanced profile is different: it retains its existing component pull behavior and exposure choices. Use its documentation for that installation. diff --git a/install.sh b/install.sh index 475bc82..e5168df 100755 --- a/install.sh +++ b/install.sh @@ -39,10 +39,9 @@ apt-get update -qq apt-get install -y -qq ca-certificates curl git ruby if [ -d "$INSTALL_DIR/.git" ]; then - log "Updating Dev Boxer in $INSTALL_DIR" - git -C "$INSTALL_DIR" fetch --quiet origin "$BRANCH" - git -C "$INSTALL_DIR" checkout --quiet "$BRANCH" - git -C "$INSTALL_DIR" pull --ff-only --quiet origin "$BRANCH" + log "Resuming the installed Dev Boxer version in $INSTALL_DIR" + # Repeating setup must not silently change the software being installed. + # The explicit `matron update` command requires a backup before updating. else log "Cloning Dev Boxer into $INSTALL_DIR" mkdir -p "$(dirname "$INSTALL_DIR")" diff --git a/lib/dev_boxer.rb b/lib/dev_boxer.rb index 0ff71ab..1277d59 100644 --- a/lib/dev_boxer.rb +++ b/lib/dev_boxer.rb @@ -19,6 +19,7 @@ class SetupError < StandardError; end require_relative "dev_boxer/template" require_relative "dev_boxer/exposure" require_relative "dev_boxer/wizard" +require_relative "dev_boxer/starter_wizard" require_relative "dev_boxer/module_base" require_relative "dev_boxer/modules" require_relative "dev_boxer/preflight" diff --git a/lib/dev_boxer/ai_reply.rb b/lib/dev_boxer/ai_reply.rb new file mode 100644 index 0000000..a0e68a0 --- /dev/null +++ b/lib/dev_boxer/ai_reply.rb @@ -0,0 +1,19 @@ +require "json" + +module DevBoxer + module AiReply + def self.ready?(provider, output) + if provider == "claude" + message = JSON.parse(output) + message["type"] == "result" && !message["is_error"] && message["result"].to_s.strip == "Matron is ready" + else + output.lines.any? do |line| + event = JSON.parse(line) + event["type"] == "item.completed" && event.dig("item", "type") == "agent_message" && event.dig("item", "text").to_s.strip == "Matron is ready" + end + end + rescue JSON::ParserError, TypeError + false + end + end +end diff --git a/lib/dev_boxer/components.rb b/lib/dev_boxer/components.rb new file mode 100644 index 0000000..d96b808 --- /dev/null +++ b/lib/dev_boxer/components.rb @@ -0,0 +1,28 @@ +require "json" +require "shellwords" + +module DevBoxer + module Components + def self.tool_version(name) + version = JSON.parse(File.read(File.expand_path("../../components.json", __dir__))).fetch("tools").fetch(name) + raise SetupError, "Invalid tool version" unless version.match?(/\A\d+\.\d+\.\d+\z/) + version + end + + def self.checkout(name, path, owner:, shell:) + entry = JSON.parse(File.read(File.expand_path("../../components.json", __dir__))).fetch(name) + revision = entry.fetch("revision") + raise SetupError, "Invalid component revision" unless revision.match?(/\A[0-9a-f]{40}\z/) + dir = Shellwords.escape(path) + # The journal is a system account with /usr/sbin/nologin, so `su -` + # cannot run its install commands. + run = ->(cmd) { shell.sh!("runuser -u #{Shellwords.escape(owner)} -- sh -c #{Shellwords.escape(cmd)}") } + unless Dir.exist?(File.join(path, ".git")) + run.call("git clone --depth 1 #{Shellwords.escape(entry.fetch('repo'))} #{dir}") + end + run.call("git -C #{dir} diff --quiet && git -C #{dir} diff --cached --quiet") + run.call("git -C #{dir} fetch --depth 1 origin #{revision}") + run.call("git -C #{dir} checkout --detach #{revision}") + end + end +end diff --git a/lib/dev_boxer/config.rb b/lib/dev_boxer/config.rb index e2ef756..a934e36 100644 --- a/lib/dev_boxer/config.rb +++ b/lib/dev_boxer/config.rb @@ -59,6 +59,9 @@ def self.validation_errors(config) errors = [] errors << MATRIX_RETIRED if hash.key?("matrix") wizard_sections.each { |section| section.validate(hash, errors) } + if hash.dig("setup", "profile") == "starter" + errors << "agent.provider must be claude or codex" unless %w[claude codex].include?(hash.dig("agent", "provider")) + end errors end diff --git a/lib/dev_boxer/exposure.rb b/lib/dev_boxer/exposure.rb index 0567d20..83fc113 100644 --- a/lib/dev_boxer/exposure.rb +++ b/lib/dev_boxer/exposure.rb @@ -6,6 +6,7 @@ module Exposure def self.for(config:, shell:, log:, templates_dir: nil, config_path: nil, secrets_path: nil, interactive: true) klass = case config.exposure&.mode + when "public-ip" then PublicIp when "ip" then SelfSigned when "cloudflare", nil then Cloudflare else raise "Unknown exposure.mode: #{config.exposure.mode.inspect} (expected cloudflare or ip)" @@ -59,3 +60,4 @@ def warn(msg) = log.warn(msg) require_relative "exposure/cloudflare" require_relative "exposure/self_signed" +require_relative "exposure/public_ip" diff --git a/lib/dev_boxer/exposure/public_ip.rb b/lib/dev_boxer/exposure/public_ip.rb new file mode 100644 index 0000000..7d1c0dd --- /dev/null +++ b/lib/dev_boxer/exposure/public_ip.rb @@ -0,0 +1,116 @@ +require "shellwords" + +module DevBoxer + module Exposure + # A real ACME IP certificate, never a self-signed fallback. The journal + # and viewer use different origins so a shared artifact cannot act as + # the signed-in journal app. + class PublicIp < Base + CERTBOT = "/opt/matron-certbot/bin/certbot".freeze + ACME_ROOT = "/var/lib/matron-acme".freeze + CERT_DIR = "/etc/letsencrypt/live/matron-ip".freeze + SITE = "/etc/nginx/sites-available/matron-starter".freeze + LOCK = "/run/lock/matron-certificate.lock".freeze + + def address = config.exposure.ip.address + def journal_public_url = journal_bundled? ? "wss://#{address}/ws" : config.journal.url + def viewer_base_url = "https://#{address}:8444" + def hello_url = "https://#{address}/app/" + def summary_lines = ["Open Matron: #{JournalEnrollment.https_base(journal_public_url)}/app/", "Viewer: #{viewer_base_url}"] + + def setup! + problem = StarterWizard.address_problem(address.to_s) + raise SetupError, problem if problem + raise SetupError, "Certificate terms must be accepted during setup." unless config.exposure.accept_terms == true + shell.apt_install("nginx", "python3-venv") + shell.sh!("python3 -m venv /opt/matron-certbot") unless shell.command_exists?(CERTBOT) + shell.sh!("#{CERTBOT.sub(/certbot$/, 'pip')} install 'certbot==5.10.0'") + shell.sh!("install -d -m 755 #{ACME_ROOT}") + shell.write_file(SITE, nginx_config(tls: File.exist?("#{CERT_DIR}/fullchain.pem"))) + shell.sh!("ln -sfn #{SITE} /etc/nginx/sites-enabled/matron-starter") + # This mode is for a fresh VPS. Existing sites are not removed. + shell.sh!("nginx -t") + shell.systemctl(:enable, "nginx") + shell.systemctl(:restart, "nginx") + [80, 443, 8444].each { |port| shell.sh!("ufw allow #{port}/tcp") } + info "Obtaining the HTTPS certificate (waiting for any renewal already running)" + shell.sh!("flock -w 600 #{LOCK} #{issue_command}") + shell.write_file(SITE, nginx_config(tls: true)) + shell.sh!("nginx -t") + shell.systemctl(:reload, "nginx") + install_renewal + ok "Trusted HTTPS ready; certificates renew automatically" + end + + def issue_command + "#{CERTBOT} certonly --non-interactive --agree-tos --register-unsafely-without-email " \ + "--preferred-profile shortlived --webroot --webroot-path #{ACME_ROOT} " \ + "--cert-name matron-ip --ip-address #{Shellwords.escape(address)} --keep-until-expiring" + end + + def nginx_config(tls:) + text = <<~NGINX + # Managed by Matron setup. + server { + listen 80; + server_name #{address}; + location /.well-known/acme-challenge/ { root #{ACME_ROOT}; } + location / { return 404; } + } + NGINX + return text unless tls + text += server(443, 9810) if journal_bundled? + text + server(8444, 9803) + end + + private + + def server(port, upstream) + <<~NGINX + server { + listen #{port} ssl; + server_name #{address}; + ssl_certificate #{CERT_DIR}/fullchain.pem; + ssl_certificate_key #{CERT_DIR}/privkey.pem; + client_max_body_size 50m; + location / { + proxy_pass http://127.0.0.1:#{upstream}; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header CF-Connecting-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_read_timeout 1d; + proxy_buffering off; + } + } + NGINX + end + + def install_renewal + shell.write_file("/etc/systemd/system/matron-certificate.service", <<~UNIT) + [Unit] + Description=Renew Matron HTTPS certificate + After=network-online.target nginx.service + [Service] + Type=oneshot + ExecStart=/usr/bin/flock -w 600 #{LOCK} #{CERTBOT} renew --cert-name matron-ip --quiet --no-random-sleep-on-renew --deploy-hook "nginx -t && systemctl reload nginx" + UNIT + shell.write_file("/etc/systemd/system/matron-certificate.timer", <<~UNIT) + [Unit] + Description=Check Matron certificate renewal twice daily + [Timer] + OnCalendar=*-*-* 00,12:00:00 + RandomizedDelaySec=1800 + Persistent=true + [Install] + WantedBy=timers.target + UNIT + shell.sh!("systemctl daemon-reload") + shell.sh!("systemctl enable --now matron-certificate.timer") + end + end + end +end diff --git a/lib/dev_boxer/modules/05_dev_tools.rb b/lib/dev_boxer/modules/05_dev_tools.rb index 9eb763a..221a738 100644 --- a/lib/dev_boxer/modules/05_dev_tools.rb +++ b/lib/dev_boxer/modules/05_dev_tools.rb @@ -16,6 +16,7 @@ def run install_base_packages install_node install_python + return ok "Matron prerequisites installed" if config.setup&.profile == "starter" install_uv install_github_cli configure_github_auth_for_user diff --git a/lib/dev_boxer/modules/07_claude.rb b/lib/dev_boxer/modules/07_claude.rb index 527ea3b..d58b7f5 100644 --- a/lib/dev_boxer/modules/07_claude.rb +++ b/lib/dev_boxer/modules/07_claude.rb @@ -1,4 +1,5 @@ require "json" +require_relative "../components" module DevBoxer module Modules @@ -37,6 +38,7 @@ def run ensure_local_bin_on_path install_guard_hook write_settings + return ok "Claude Code installed" if config.setup&.profile == "starter" register_marketplace install_plugins install_chrome_devtools_mcp @@ -53,6 +55,14 @@ def claude_installed? end def install_cli + if config.setup&.profile == "starter" + version = Components.tool_version("claude") + if claude_installed? && shell.run_as_user(username, "#{home_dir}/.local/bin/claude --version").split.first == version + return skip "Claude Code #{version} already installed" + end + shell.run_as_user(username, "curl -fsSL https://claude.ai/install.sh | bash -s -- #{version}") + return ok "Claude Code #{version} installed" + end if claude_installed? skip "Claude Code CLI already installed" return @@ -98,6 +108,10 @@ def write_settings changed = true end changed = true if add_guard_hook(existing) + if config.setup&.profile == "starter" && existing.dig("env", "DISABLE_AUTOUPDATER") != "1" + (existing["env"] ||= {})["DISABLE_AUTOUPDATER"] = "1" + changed = true + end if changed File.write(path, JSON.pretty_generate(existing) + "\n") diff --git a/lib/dev_boxer/modules/08_matron.rb b/lib/dev_boxer/modules/08_matron.rb index 08b74c7..f61425c 100644 --- a/lib/dev_boxer/modules/08_matron.rb +++ b/lib/dev_boxer/modules/08_matron.rb @@ -1,6 +1,8 @@ require "securerandom" require "shellwords" require_relative "../journal_enrollment" +require_relative "../components" +require "json" module DevBoxer module Modules @@ -33,6 +35,7 @@ def run when "bundled" install_journal ensure_journal_user + shell.sh!(matron_admin("user admin #{Shellwords.escape(journal_username)} on")) if starter? when "external" probe_external_journal! else @@ -53,6 +56,7 @@ def run private def journal_mode = config.journal&.mode || "bundled" + def starter? = config.setup&.profile == "starter" def username = config.user.name def home_dir = "/home/#{username}" def bridge_dir = "#{home_dir}/matron-bridge" @@ -73,7 +77,10 @@ def install_journal ok "System user matron created" end - if Dir.exist?(JOURNAL_DIR) + if starter? + shell.sh!("install -d -o matron -g matron #{JOURNAL_DIR}") + Components.checkout("journal", JOURNAL_DIR, owner: "matron", shell: shell) + elsif Dir.exist?(JOURNAL_DIR) skip "matron-journal already cloned" shell.sh!(as_matron("git -C #{JOURNAL_DIR} pull --ff-only")) else @@ -83,10 +90,11 @@ def install_journal end info "Installing matron-journal dependencies" - shell.sh!(as_matron("cd #{JOURNAL_DIR} && npm ci --omit=dev")) + shell.sh!(as_matron("cd #{JOURNAL_DIR} && ONNXRUNTIME_NODE_INSTALL_CUDA=skip npm ci --omit=dev")) shell.sh!(as_matron("mkdir -p #{JOURNAL_DIR}/data")) FileUtils.cp("#{JOURNAL_DIR}/deploy/matron-journal.service", "#{unit_dir}/matron-journal.service") + configure_starter_journal if starter? shell.sh!("systemctl daemon-reload") shell.systemctl(:enable, "matron-journal") shell.systemctl(:restart, "matron-journal") @@ -120,10 +128,10 @@ def ensure_journal_user # Partial earlier run: user exists but the password was never # recorded. matron-admin user passwd converges us. info "Journal user #{journal_username} exists but secrets.yml has no password — resetting it" - shell.sh!(matron_admin("user passwd #{Shellwords.escape(journal_username)} --password #{Shellwords.escape(password)}")) + shell.sh!(matron_admin("user passwd #{Shellwords.escape(journal_username)} --password-stdin"), stdin: password) else info "Creating journal user #{journal_username}" - shell.sh!(matron_admin("user add #{Shellwords.escape(journal_username)} --password #{Shellwords.escape(password)}")) + shell.sh!(matron_admin("user add #{Shellwords.escape(journal_username)} --password-stdin"), stdin: password) end persist_journal_secrets(password) ok "Journal user #{journal_username} ready — the password in secrets.yml is your Matron app login" @@ -158,7 +166,9 @@ def probe_external_journal! # ----- bridge ----- def install_bridge - if Dir.exist?(bridge_dir) + if starter? + Components.checkout("bridge", bridge_dir, owner: username, shell: shell) + elsif Dir.exist?(bridge_dir) skip "matron-bridge already cloned" unless shell.sh("su - #{username} -c 'cd #{bridge_dir} && git pull --ff-only'") warn "Could not update matron-bridge (git pull --ff-only failed); continuing with the current checkout" @@ -172,7 +182,7 @@ def install_bridge # Always run: npm install is quick when nothing changed, and it repairs # an interrupted install or picks up dependencies after the pull above. info "Installing npm dependencies" - shell.run_as_user(username, "cd #{bridge_dir} && npm install") + shell.run_as_user(username, "cd #{bridge_dir} && #{starter? ? 'npm ci --omit=dev' : 'npm install'}") ok "matron-bridge installed" end @@ -284,6 +294,13 @@ def write_bridge_env(token_file) info "Generating bridge .env" render_private_template("matron-bridge.env", "#{bridge_dir}/.env", bridge_env_vars(token_file)) shell.sh!("chown #{username}:#{username} #{bridge_dir}/.env") + if starter? + File.open("#{bridge_dir}/.env", "a") do |f| + f.puts "MATRON_DEFAULT_AGENT=#{config.agent.provider}" + f.puts "DEFAULT_WORKDIR=#{home_dir}/work" + end + shell.sh!("install -d -o #{username} -g #{username} #{home_dir}/work") + end ok "Bridge .env generated" end @@ -391,6 +408,13 @@ def default_model_line end def write_mcp_config + if starter? + # Both AI backends read this persistent overlay. Never regenerate + # it: connector installers own their entries. + path = "#{bridge_dir}/mcp-config.local.json" + shell.write_file(path, JSON.pretty_generate({"mcpServers" => {}, "mcpExtras" => {}}), mode: 0o600, owner: "#{username}:#{username}") unless File.exist?(path) + return + end info "Generating bridge MCP config" # World-readable, so the secrets in bridge_env_vars must not even be # in its render input. @@ -412,6 +436,29 @@ def install_systemd_units shell.systemctl(:restart, "matron-viewer") ok "Bridge services installed and started" end + + def configure_starter_journal + web_dir = "/opt/matron-web" + shell.sh!("install -d -o matron -g matron #{web_dir}") + Components.checkout("web", web_dir, owner: "matron", shell: shell) + shell.sh!(as_matron("cd #{web_dir} && npx --yes pnpm@10.29.3 install --frozen-lockfile && npx --yes pnpm@10.29.3 build")) + origin = JournalEnrollment.https_base(exposure.journal_public_url) + shell.write_file("#{web_dir}/webapp/config.json", JSON.generate({"brand" => "Matron", "journal_server_url" => origin})) + saved = File.exist?(secrets_path) ? YAML.safe_load_file(secrets_path) : {} + key = saved.dig("journal", "token_key") || SecureRandom.hex(32) + raise SetupError, "Invalid saved journal encryption key" unless key.match?(/\A[0-9a-f]{64}\z/) + Config.merge_into_file(secrets_path, {"journal" => {"token_key" => key}}) + shell.write_file("/etc/matron/journal.env", <<~ENV, mode: 0o640, owner: "root:matron") + MATRON_PUBLIC_URL=#{origin} + MATRON_WEB_DIR=#{web_dir}/webapp + MATRON_TOKEN_KEY=#{key} + MATRON_PUSH_GATEWAY_URL=https://push.matron.chat + ENV + shell.write_file("#{unit_dir}/matron-journal.service.d/onboarding.conf", <<~UNIT) + [Service] + EnvironmentFile=/etc/matron/journal.env + UNIT + end end end end diff --git a/lib/dev_boxer/modules/11_hello_world.rb b/lib/dev_boxer/modules/11_hello_world.rb index 4a26d21..f81a994 100644 --- a/lib/dev_boxer/modules/11_hello_world.rb +++ b/lib/dev_boxer/modules/11_hello_world.rb @@ -110,17 +110,16 @@ def print_first_phone_qr(journal_user) # from JournalEnrollment.matron_admin_command so these lines can never # drift from what the live mint actually runs. def print_link_later_hints(journal_user) - mint = JournalEnrollment.matron_admin_command(link_code_args(journal_user)) - png = JournalEnrollment.matron_admin_command("#{link_code_args(journal_user)} --expires 24h --png /tmp/matron-link.png") + mint = "sudo -u matron env MATRON_DB=/opt/matron-journal/data/matron.db node /opt/matron-journal/bin/matron-admin.js #{link_code_args(journal_user)}" host = link_later_host + destination = Shellwords.escape("#{username}@#{host}") + port = Integer(config.ssh&.port || 2222) info "" info "Link a phone later:" + info " Connect to this box: ssh -p #{port} #{destination}" info " Mint a fresh sign-in QR in your terminal (valid 10 minutes):" - info " ssh root@#{host} #{Shellwords.escape(mint)}" - info " Or mint a 24-hour QR image to send to someone — it signs them" - info " straight in, so treat the file like a password:" - info " ssh root@#{host} #{Shellwords.escape(png)}" - info " scp root@#{host}:/tmp/matron-link.png . && ssh root@#{host} rm /tmp/matron-link.png" + info " #{mint}" + info " Keep the QR private: it signs directly into your account." end # The one place the link-code invocation is assembled — the live QR diff --git a/lib/dev_boxer/modules/12_codex.rb b/lib/dev_boxer/modules/12_codex.rb index 2bdad82..67a81ef 100644 --- a/lib/dev_boxer/modules/12_codex.rb +++ b/lib/dev_boxer/modules/12_codex.rb @@ -1,3 +1,5 @@ +require_relative "../components" + module DevBoxer module Modules # OpenAI's Codex CLI, alongside Claude Code, so a box arrives with both @@ -26,6 +28,12 @@ def run # every account on the box gets it rather than only the first user to # run setup. Node 22 arrives in 05_dev_tools, well before this module. def install_cli + if config.setup&.profile == "starter" + version = Components.tool_version("codex") + return skip "Codex CLI #{version} already installed" if shell.command_exists?("codex") && shell.sh!("codex --version").split.last == version + shell.sh!("npm install -g #{PACKAGE}@#{version}") + return ok "Codex CLI #{version} installed" + end if shell.command_exists?("codex") skip "Codex CLI already installed" return diff --git a/lib/dev_boxer/modules/13_onboarding.rb b/lib/dev_boxer/modules/13_onboarding.rb new file mode 100644 index 0000000..dde31d1 --- /dev/null +++ b/lib/dev_boxer/modules/13_onboarding.rb @@ -0,0 +1,40 @@ +require "shellwords" + +module DevBoxer + module Modules + class Onboarding < ModuleBase + module_name "onboarding" + module_order 13 + + def run + section "Open your Matron" + install_commands + shell.sh!("/usr/local/bin/matron status") + if interactive? + shell.run_as_user_interactive("root", "/usr/local/bin/matron login") + shell.run_as_user_interactive("root", "/usr/local/bin/matron link") + else + info "Services installed. Finish AI sign-in with: sudo matron login" + info "Then show your sign-in QR with: sudo matron link" + end + info "Open #{JournalEnrollment.https_base(exposure.journal_public_url)}/app/" + info "Click the pencil (New session), choose this box if asked, leave Folder path blank, then Start." + info 'Send: "Create three visual directions for a simple fitness tracker: calm, bold and playful. Show colors, typography and a sample screen for each in one self-contained HTML page. Save it as design-directions.html, share a preview link, and put a notice in For you linking to the finished page."' + info "Open the finished design page from its For you notice and check all three directions." + info "Help and recovery: https://matron.chat/getting-started/#help" + end + + private + + def install_commands + root = File.expand_path("../../..", __dir__) + # The installed launcher retains the location of the checkout and + # config without requiring the user to know either one. + shell.write_file("/usr/local/bin/matron", <<~SH, mode: 0o755) + #!/bin/sh + exec ruby #{Shellwords.escape(File.join(root, "bin/matron"))} --config #{Shellwords.escape(config_path)} "$@" + SH + end + end + end +end diff --git a/lib/dev_boxer/port_check.rb b/lib/dev_boxer/port_check.rb index 23991ad..705a3ab 100644 --- a/lib/dev_boxer/port_check.rb +++ b/lib/dev_boxer/port_check.rb @@ -33,6 +33,12 @@ def self.planned(config) ports << Port.new(number: port(ip&.hello_port, 8445), service: "the hello-world page over HTTPS", config_key: "exposure.ip.hello_port", modules: %w[exposure], owners: %w[nginx]) end + if config.exposure&.mode == "public-ip" + [80, 443, 8444].each do |number| + ports << Port.new(number: number, service: "Matron HTTPS and certificate renewal", + modules: %w[exposure], owners: %w[nginx]) + end + end if config.desktop&.enabled == true ports << Port.new(number: 3389, service: "remote desktop (XRDP)", modules: %w[desktop], owners: %w[xrdp xrdp-sesman]) diff --git a/lib/dev_boxer/runner.rb b/lib/dev_boxer/runner.rb index a1fc4c9..f0dfb45 100644 --- a/lib/dev_boxer/runner.rb +++ b/lib/dev_boxer/runner.rb @@ -76,6 +76,13 @@ def validate_known!(module_name) end def optional_module_disabled?(mod) + if @config.setup&.profile == "starter" + return true if %w[desktop docker browsers desktop-apps hello-world].include?(mod.module_name) + return true if mod.module_name == "claude" && @config.agent&.provider == "codex" + return true if mod.module_name == "codex" && @config.agent&.provider != "codex" + elsif mod.module_name == "onboarding" + return true + end mod.module_name == "desktop" && @config.desktop&.enabled != true end end diff --git a/lib/dev_boxer/starter_wizard.rb b/lib/dev_boxer/starter_wizard.rb new file mode 100644 index 0000000..3cd1cd0 --- /dev/null +++ b/lib/dev_boxer/starter_wizard.rb @@ -0,0 +1,100 @@ +require "ipaddr" +require "uri" +require "securerandom" + +module DevBoxer + # The first-run path. Existing installations keep the advanced wizard and + # their settings; this profile is only chosen for a new installation. + class StarterWizard + def self.run(config_path:, input: $stdin, output: $stdout, **) + new(config_path, Wizard::Prompter.new(input: input, output: output)).run + end + + def initialize(config_path, prompter) + @path, @p = config_path, prompter + end + + def run + @p.say "Set up Matron" + @p.say "1 — Set up my first Matron" + @p.say "2 — Add a box to my Matron" + @p.say "3 — Advanced developer setup" + choice = @p.ask_choice("Choose", choices: %w[1 2 3], default: "1") + return Wizard.run(config_path: @path, input: @p.input, output: @p.output) if choice == "3" + + name = ask_valid("Your sign-in name", default: "alice") do |s| + s.match?(/\A[a-z][a-z0-9_-]{0,30}\z/) && s != "root" ? nil : "Use a short name: lowercase letters, digits, - or _." + end + agent = @p.ask_choice("Which AI account will you sign into?", choices: %w[claude codex], default: "claude") + @p.say "Use the stable public IPv4 address shown in your VPS dashboard." + address = ask_valid("This VPS's public IPv4 address") { |s| self.class.address_problem(s) } + @p.say "Allow inbound TCP ports 22, 80, 443 and 8444 in your provider's firewall." + @p.say "HTTPS certificates renew automatically. Port 80 must stay reachable for renewal." + @p.say "Certificate issuance uses Let's Encrypt: https://letsencrypt.org/repository/" + raise SetupError, "Setup cancelled before making changes." unless @p.confirm("Accept the certificate authority's terms and continue?", default: false) + + @p.say "Paste your SSH PUBLIC key (the .pub file). Keep the private key on your computer." + key = ask_valid("SSH public key", default: existing_public_key) { |s| SshKeyCheck.problem(s) } + journal = { "mode" => choice == "1" ? "bundled" : "external", "username" => name } + if choice == "2" + origin = ask_valid("Your existing Matron HTTPS address") { |s| self.class.origin_problem(s) } + journal["url"] = origin.sub(/\/$/, "").sub(/\Ahttps:/, "wss:") + "/ws" + end + data = { + "setup" => { "profile" => "starter" }, + "user" => { "name" => "dev", "ssh_public_key" => key }, + "ssh" => { "port" => 22 }, + "journal" => journal, + "exposure" => { "mode" => "public-ip", "ip" => { "address" => address }, "accept_terms" => true }, + "agent" => { "provider" => agent }, + "bridge" => { "voice_notes" => { "enabled" => false } }, + "claude" => { "experience_level" => "beginner", "plugins" => [] }, + "desktop" => { "enabled" => false }, + "monitoring" => { "node_exporter" => false }, + "hello_world" => { "port" => 9820 }, + } + Config.merge_into_file(@path, data) + Config.merge_into_file(Config.secrets_path_for(@path), { "user" => { "rdp_password" => SecureRandom.urlsafe_base64(24) } }) + @p.say "Settings saved. Setup can be safely resumed with the same command." + :created + end + + def self.address_problem(value) + return "Enter a public IPv4 address, without a port or network range." if value.include?("/") + ip = IPAddr.new(value) + return "Enter the public IPv4 address from your VPS dashboard." unless ip.ipv4? && !ip.private? && !ip.loopback? && !ip.link_local? + return "Enter a unicast public IPv4 address." if IPAddr.new("0.0.0.0/8").include?(ip) || IPAddr.new("224.0.0.0/3").include?(ip) || IPAddr.new("100.64.0.0/10").include?(ip) + nil + rescue IPAddr::InvalidAddressError + "Enter an IPv4 address, for example 203.0.113.7." + end + + def self.origin_problem(value) + u = URI(value) + return nil if u.scheme == "https" && u.host && !u.userinfo && !u.query && !u.fragment && ["", "/"].include?(u.path) + "Use just the HTTPS address, without /app, /ws, a password or a query." + rescue URI::InvalidURIError + "Enter a valid HTTPS address." + end + + private + + def ask_valid(label, **opts) + loop do + answer = @p.ask(label, **opts) + problem = yield(answer) + return answer unless problem + @p.say problem + opts.delete(:default) + end + end + + def existing_public_key + return unless File.file?("/root/.ssh/authorized_keys") + File.readlines("/root/.ssh/authorized_keys").filter_map do |line| + key = line.strip[/((?:ssh-ed25519|ssh-rsa|ecdsa-sha2-\S+) AAAA\S*(?: .*)?)/, 1] + key if key && !SshKeyCheck.problem(key) + end.first + end + end +end diff --git a/lib/dev_boxer/template.rb b/lib/dev_boxer/template.rb index e67d818..806ae5f 100644 --- a/lib/dev_boxer/template.rb +++ b/lib/dev_boxer/template.rb @@ -7,7 +7,7 @@ module Template def self.render(path, vars) raise NotFound, "Template not found: #{path}" unless File.exist?(path) - content = File.read(path) + content = File.read(path, encoding: Encoding::UTF_8) content.gsub(PLACEHOLDER) { vars[$1].to_s } end diff --git a/lib/dev_boxer/wizard/exposure_section.rb b/lib/dev_boxer/wizard/exposure_section.rb index c2243e9..f3f648f 100644 --- a/lib/dev_boxer/wizard/exposure_section.rb +++ b/lib/dev_boxer/wizard/exposure_section.rb @@ -13,11 +13,17 @@ def self.validate(hash, errors) mode = hash.dig("exposure", "mode") if Config.blank?(mode) errors << "exposure.mode is required" - elsif !%w[cloudflare ip].include?(mode) - errors << "exposure.mode must be cloudflare or ip" + elsif !%w[cloudflare ip public-ip].include?(mode) + errors << "exposure.mode must be cloudflare, ip or public-ip" end validate_cloudflare(hash, errors) if mode == "cloudflare" validate_ip(hash, errors) if mode == "ip" + if mode == "public-ip" + address = hash.dig("exposure", "ip", "address").to_s + problem = StarterWizard.address_problem(address) + errors << "exposure.ip.address: #{problem}" if problem + errors << "exposure.accept_terms must be true for certificate issuance" unless hash.dig("exposure", "accept_terms") == true + end end def self.validate_cloudflare(hash, errors) diff --git a/setup.rb b/setup.rb index f5a8454..3585888 100755 --- a/setup.rb +++ b/setup.rb @@ -17,6 +17,7 @@ def git_success?(*args) def auto_update_checkout return unless Process.uid.zero? + return unless ENV["DEV_BOXER_AUTO_UPDATE"] == "1" return if ENV["DEV_BOXER_SKIP_AUTO_UPDATE"] == "1" return unless Dir.exist?(File.join(ROOT, ".git")) @@ -74,6 +75,7 @@ def auto_update_checkout opts.on("--templates-dir DIR","Path to templates directory") { |v| options[:templates_dir] = v } opts.on("--wizard-only", "Run first-run setup wizard, then exit") { options[:wizard_only] = true } opts.on("--reconfigure", "Force the setup wizard before running") { options[:reconfigure] = true } + opts.on("--advanced", "Use the developer setup wizard") { options[:advanced] = true } opts.on("--non-interactive","Fail instead of prompting for config") { options[:non_interactive] = true } opts.on("-h", "--help", "Show this help") { puts opts; exit 0 } end @@ -96,8 +98,9 @@ def auto_update_checkout warn "Run setup interactively first, or create config.yml and secrets.yml manually." exit 2 end - DevBoxer::Wizard.run(config_path: options[:config], force: force) -rescue DevBoxer::Wizard::Prompter::InputClosed => e + wizard = options[:advanced] || File.exist?(options[:config]) ? DevBoxer::Wizard : DevBoxer::StarterWizard + wizard.run(config_path: options[:config], force: force) +rescue DevBoxer::Wizard::Prompter::InputClosed, DevBoxer::SetupError => e warn e.message exit 2 end diff --git a/test/ai_reply_test.rb b/test/ai_reply_test.rb new file mode 100644 index 0000000..db56f99 --- /dev/null +++ b/test/ai_reply_test.rb @@ -0,0 +1,13 @@ +require_relative "test_helper" +require "dev_boxer/ai_reply" + +class AiReplyTest < Minitest::Test + def test_only_successful_assistant_results_count_as_a_reply + assert DevBoxer::AiReply.ready?("claude", JSON.generate(type: "result", is_error: false, result: "Matron is ready")) + refute DevBoxer::AiReply.ready?("claude", JSON.generate(type: "result", is_error: true, result: "Matron is ready")) + assert DevBoxer::AiReply.ready?("codex", JSON.generate(type: "item.completed", item: {type: "agent_message", text: "Matron is ready"})) + refute DevBoxer::AiReply.ready?("codex", JSON.generate(type: "error", message: "Failed prompt: Matron is ready")) + refute DevBoxer::AiReply.ready?("codex", JSON.generate(type: "item.completed", item: {type: "command_execution", text: "Matron is ready"})) + refute DevBoxer::AiReply.ready?("claude", "not JSON") + end +end diff --git a/test/config_test.rb b/test/config_test.rb index b310c0e..ef2e2b0 100644 --- a/test/config_test.rb +++ b/test/config_test.rb @@ -327,7 +327,7 @@ def test_validation_rejects_unknown_exposure_mode errors = DevBoxer::Config.validation_errors(DevBoxer::Config.from_hash(hash)) - assert_includes errors, "exposure.mode must be cloudflare or ip" + assert_includes errors, "exposure.mode must be cloudflare, ip or public-ip" end def test_validation_ip_mode_accepts_minimal_config diff --git a/test/hello_world_test.rb b/test/hello_world_test.rb index cf4234c..f0d08ae 100644 --- a/test/hello_world_test.rb +++ b/test/hello_world_test.rb @@ -204,17 +204,12 @@ def test_bundled_summary_prints_link_later_hints summary = output.string assert_includes summary, "Link a phone later" - # Both hint lines wrap the real matron-admin command in ssh to this box. - ssh_lines = summary.lines.select { |l| l.include?("ssh root@box1.example.com") } - assert_equal 3, ssh_lines.count, summary # re-mint, png mint, and the rm in the scp line - # The remote command is the same runuser/MATRON_DB wrapper the live QR - # mint uses (Shellwords-escaped for the ssh hop, hence \-escapes). - assert_includes summary, "runuser" - assert_includes summary, "link-code" - assert_includes summary, "--expires" - assert_includes summary, "24h" - assert_includes summary, "/tmp/matron-link.png" - assert_includes summary, "scp root@box1.example.com:/tmp/matron-link.png ." + assert_includes summary, "ssh -p 2222 dev@box1.example.com" + assert_includes summary, "sudo -u matron env MATRON_DB=" + assert_includes summary, "link-code alice --server-url https://203.0.113.7:8443" + refute_includes summary, "root@" + refute_includes summary, "24h" + end end @@ -238,7 +233,8 @@ def test_link_later_hints_fall_back_when_hostname_fails mod.send(:print_matron_login_instructions) - assert_includes output.string, "ssh root@" + assert_includes output.string, "ssh -p 2222 dev@" + refute_includes output.string, "root@" end private diff --git a/test/modules_shape_test.rb b/test/modules_shape_test.rb index 11c3bc5..1c40b6a 100644 --- a/test/modules_shape_test.rb +++ b/test/modules_shape_test.rb @@ -52,9 +52,9 @@ def test_modules_implement_run end end - def test_all_twelve_modules_present + def test_all_modules_present names = @modules.map(&:module_name).sort - assert_equal %w[browsers claude codex desktop desktop-apps dev-tools docker exposure hello-world matron security users], names + assert_equal %w[browsers claude codex desktop desktop-apps dev-tools docker exposure hello-world matron onboarding security users], names end def test_template_vars_are_passed_as_explicit_hashes diff --git a/test/restore_test.py b/test/restore_test.py new file mode 100644 index 0000000..a2284db --- /dev/null +++ b/test/restore_test.py @@ -0,0 +1,68 @@ +import io +import json +from pathlib import Path, PurePosixPath +import runpy +import tarfile +import tempfile +import unittest + +restore_module = runpy.run_path(str(Path(__file__).resolve().parents[1] / "bin/restore-state.py")) + + +class RestoreTest(unittest.TestCase): + def archive(self, directory, extras, **manifest_changes): + path = Path(directory) / "backup.tar.gz" + manifest = {"format": 1, "user": "dev", "bundled": True} + manifest.update(manifest_changes) + with tarfile.open(path, "w:gz") as output: + for name, data in [("manifest.json", json.dumps(manifest).encode()), *extras]: + member = tarfile.TarInfo(name) + member.mode = 0o600 + member.size = len(data) + output.addfile(member, io.BytesIO(data)) + return path + + def test_roundtrip_removes_stale_wal_and_keeps_private_modes(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "target" + data = root / "opt/matron-journal/data" + data.mkdir(parents=True) + (data / "matron.db-wal").write_text("old WAL") + (data / "matron.db-shm").write_text("old SHM") + archive = self.archive(directory, [("opt/matron-journal/data/matron.db", b"restored data")]) + restore_module["restore"](archive, [PurePosixPath("opt/matron-journal/data")], "dev", True, root) + self.assertEqual(b"restored data", (data / "matron.db").read_bytes()) + self.assertEqual(0o600, (data / "matron.db").stat().st_mode & 0o777) + self.assertFalse((data / "matron.db-wal").exists()) + self.assertFalse((data / "matron.db-shm").exists()) + + def test_rejects_traversal_outside_paths_links_and_duplicate_members(self): + for name in ["../etc/passwd", "/etc/passwd", "etc/passwd", "."]: + with self.subTest(name=name): + with self.assertRaises(ValueError): + restore_module["validate"]([tarfile.TarInfo(name)], [PurePosixPath("state")]) + for kind in [tarfile.SYMTYPE, tarfile.LNKTYPE, tarfile.FIFOTYPE, tarfile.CHRTYPE]: + member = tarfile.TarInfo("state/token") + member.type = kind + with self.assertRaises(ValueError): + restore_module["validate"]([member], [PurePosixPath("state")]) + with self.assertRaises(ValueError): + restore_module["validate"]([tarfile.TarInfo("state/token")] * 2, [PurePosixPath("state")]) + + def test_wrong_mode_and_symlink_destination_leave_data_untouched(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) / "target" + root.mkdir() + outside = Path(directory) / "outside" + outside.mkdir() + (outside / "token").write_text("unchanged") + (root / "state").symlink_to(outside) + archive = self.archive(directory, [("state/token", b"replacement")]) + for bundled in [False, True]: + with self.assertRaises(ValueError): + restore_module["restore"](archive, [PurePosixPath("state")], "dev", bundled, root) + self.assertEqual("unchanged", (outside / "token").read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/starter_test.rb b/test/starter_test.rb new file mode 100644 index 0000000..73568a3 --- /dev/null +++ b/test/starter_test.rb @@ -0,0 +1,91 @@ +require_relative "test_helper" +require "tmpdir" +require_relative "../lib/dev_boxer/modules/08_matron" + +class StarterTest < Minitest::Test + def test_first_run_and_add_box_produce_valid_profiles + public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGP4T5GJsgHLV5B6gGfmWsWxm1uKZ2bJH9UKZcf3T3fP fixture" + ["1", "2"].each do |choice| + Dir.mktmpdir do |dir| + input = StringIO.new([choice, "alice", "codex", "203.0.113.7", "y", public_key, "https://chat.example.com"].join("\n") + "\n") + path = File.join(dir, "config.yml") + DevBoxer::StarterWizard.run(config_path: path, input: input, output: StringIO.new) + config = DevBoxer::Config.load(path) + assert_empty DevBoxer::Config.validation_errors(config) + assert_equal 22, config.ssh.port + assert_equal "starter", config.setup.profile + assert_equal "codex", config.agent.provider + assert_equal 0o600, File.stat(File.join(dir, "secrets.yml")).mode & 0o777 + if choice == "2" + assert_equal "wss://chat.example.com/ws", config.journal.url + else + assert_equal "bundled", config.journal.mode + end + end + end + end + + def test_declining_terms_does_not_write_configuration + Dir.mktmpdir do |dir| + path = File.join(dir, "config.yml") + assert_raises(DevBoxer::SetupError) do + DevBoxer::StarterWizard.run(config_path: path, input: StringIO.new("1\nalice\nclaude\n203.0.113.7\nn\n"), output: StringIO.new) + end + refute File.exist?(path) + end + end + + def test_bad_addresses_and_urls_cannot_enter_proxy_configuration + %w[127.0.0.1 10.0.0.1 0.0.0.0 224.0.0.1 100.64.0.1 192.168.1.1 ::1 example.com 203.0.113.7/24].each do |address| + assert DevBoxer::StarterWizard.address_problem(address), address + end + assert DevBoxer::StarterWizard.address_problem("203.0.113.7;id") + %w[http://example.com https://example.com/app https://alice:password@example.com https://example.com/?token=x].each do |origin| + assert DevBoxer::StarterWizard.origin_problem(origin), origin + end + assert_nil DevBoxer::StarterWizard.origin_problem("https://chat.example.com:8443/") + end + + def test_starter_only_installs_selected_provider_and_essential_modules + modules = DevBoxer::Modules.discover(File.expand_path("../lib/dev_boxer/modules", __dir__)) + %w[claude codex].each do |provider| + io = StringIO.new + config = DevBoxer::Config.from_hash({"setup" => {"profile" => "starter"}, "agent" => {"provider" => provider}}) + DevBoxer::Runner.new(modules: modules, config: config, log: DevBoxer::Log.new(io: io)).run(dry_run: true) + assert_includes io.string, provider + assert_includes io.string, "onboarding" + refute_match(/docker|browsers|desktop|hello-world/, io.string) + refute_includes io.string, provider == "codex" ? "claude" : "codex" + end + end + + def test_password_provisioning_uses_stdin_and_never_logs_it + Dir.mktmpdir do |dir| + calls = [] + io = StringIO.new + shell = DevBoxer::Shell.new(runner: ->(cmd, opts) { calls << [cmd, opts]; [true, "", ""] }) + mod = DevBoxer::Modules::Matron.new(config: DevBoxer::Config.from_hash({"user" => {"name" => "alice"}}), + shell: shell, log: DevBoxer::Log.new(io: io), secrets_path: File.join(dir, "secrets.yml")) + mod.stub(:journal_user_exists?, false) { mod.send(:ensure_journal_user) } + password = YAML.safe_load_file(File.join(dir, "secrets.yml")).dig("journal", "user_password") + assert_equal password, calls.first.last[:stdin] + assert_includes calls.first.first, "--password-stdin" + refute_includes calls.first.first, password + refute_includes io.string, password + end + end + + def test_public_https_does_not_trust_client_supplied_forwarding_headers + config = DevBoxer::Config.from_hash({"journal" => {"mode" => "bundled"}, "exposure" => {"mode" => "public-ip", "ip" => {"address" => "203.0.113.7"}}}) + ex = DevBoxer::Exposure.for(config: config, shell: DevBoxer::Shell.new, log: DevBoxer::Log.new(io: StringIO.new)) + proxy = ex.nginx_config(tls: true) + assert_includes proxy, "proxy_set_header CF-Connecting-IP $remote_addr;" + assert_includes proxy, "proxy_set_header X-Forwarded-For $remote_addr;" + refute_includes proxy, "$proxy_add_x_forwarded_for" + assert_includes proxy, "listen 443 ssl;" + assert_includes proxy, "listen 8444 ssl;" + refute_includes ex.nginx_config(tls: false), "ssl_certificate" + assert_includes ex.issue_command, "--preferred-profile shortlived" + refute_match(/--staging|--manual|--standalone/, ex.issue_command) + end +end diff --git a/test/template_test.rb b/test/template_test.rb index 48ba4fb..94644aa 100644 --- a/test/template_test.rb +++ b/test/template_test.rb @@ -2,6 +2,20 @@ require "tmpdir" class TemplateTest < Minitest::Test + def test_utf8_templates_work_in_a_minimal_server_locale + Dir.mktmpdir do |dir| + path = File.join(dir, "utf8.txt") + File.write(path, "Setup — {{NAME}}\n") + previous = Encoding.default_external + begin + Encoding.default_external = Encoding::US_ASCII + assert_equal "Setup — alice\n", DevBoxer::Template.render(path, "NAME" => "alice") + ensure + Encoding.default_external = previous + end + end + end + def test_render_substitutes_double_brace_placeholders Dir.mktmpdir do |dir| tpl = "#{dir}/in.txt"