diff --git a/exe-dev/README.md b/exe-dev/README.md new file mode 100644 index 0000000..ab4ce17 --- /dev/null +++ b/exe-dev/README.md @@ -0,0 +1,258 @@ +# exe.dev Integration for runZero + +Imports exe.dev virtual machines as assets into runZero, giving you visibility over your VM fleet alongside the rest of your infrastructure. + +Each VM appears as an asset with its hostname, region, status, HTTPS URL, and SSH destination as custom attributes, searchable via `custom_integration:exe.dev`. + +## Security considerations + +**Do not use a default API token for this integration.** + +Tokens generated with `ssh exe.dev ssh-key generate-api-key` carry the default `cmds` permission set, which includes commands this integration does not need: + +| Command | Risk if token is compromised | +|---|---| +| `new` | Attacker can spin up VMs at your cost | +| `ssh-key list` | Exposes your registered public keys | +| `share show` | Reveals what services you've made public (grant only if you want proxy visibility enrichment) | +| `team members` | Enumerates your organisation's users | + +At minimum this integration needs `ls`. Optional enrichment commands and what they add: + +| Command | Enrichment | +|---|---| +| `domain ls` | Custom domains added as additional hostnames | +| `share show` | Proxy visibility (`public`/`private`), port, email-enabled status; publicly exposed VMs get a `proxy:public` tag | + +Use a dedicated SSH key and a minimal-scope token as described below, so that a leaked credential cannot do anything beyond what the integration actually requires. + +Tokens are bearer tokens — possession grants access with no further proof of identity. Store them with the same care as a password. + +## Requirements + +### runZero +- Superuser access to the Custom Integrations configuration + +### exe.dev +- An exe.dev account with one or more VMs +- A minimal-scope API token (see below) + +## Generating a minimal-scope API token + +Two methods are available. The web dashboard is simpler but supports fewer +enrichment commands. Use the SSH key method if you need custom domain hostnames +or integration/CAASM metadata. + +| Method | `ls` | `share show` | `domain ls` | `integrations list` | +|---|:---:|:---:|:---:|:---:| +| Web dashboard | ✓ | ✓ | — | — | +| SSH key signing | ✓ | ✓ | ✓ | ✓ | + +### Method A: Web dashboard (simpler) + +1. Log in to [exe.dev](https://exe.dev) and go to **API Keys → Create API Key**. +2. Set a **Label** (e.g. `runzero-integration`) and choose an **Expiry** (30–90 days recommended). +3. Under **Allowed commands**, uncheck everything, then check only what you need: + - **Always required:** `ls` + - **Optional — proxy visibility enrichment:** `share show` +4. Click **Create** and copy the generated token. + +> The dashboard does not offer `domain ls` or `integrations list`. If you want +> custom domain hostnames or CAASM integration metadata, use Method B instead. + +### Method B: SSH key signing (full enrichment) + +> **All commands in this section run on your local machine** (laptop or +> workstation), not inside an exe.dev VM or the exe.dev lobby. You need an +> existing SSH key registered with exe.dev on that machine to run +> `ssh exe.dev ...` commands. + +#### 1. Create a dedicated SSH key for this integration + +Using a separate key means you can revoke RunZero's API access independently +of your regular exe.dev SSH access. + +```bash +ssh-keygen -t ed25519 -C runzero-integration -f ~/.ssh/exe_dev_runzero +cat ~/.ssh/exe_dev_runzero.pub | ssh exe.dev ssh-key add +``` + +#### 2. Set a 90-day expiry timestamp + +```bash +export EXPIRY=$(date -d '+90 days' +%s 2>/dev/null || date -v+90d +%s) +``` + +#### 3. Sign a minimal permissions payload + +Choose the permission tier that matches the enrichment you want (see the +enrichment table above), then run the signing block: + +```bash +b64url() { tr -d '\n=' | tr '+/' '-_'; } + +# VM list only (core CAASM — tags, comments, Shelley status): +export PERMISSIONS='{"exp":'"$EXPIRY"',"cmds":["ls"]}' + +# + custom domain hostnames: +# export PERMISSIONS='{"exp":'"$EXPIRY"',"cmds":["ls","domain ls"]}' + +# + proxy exposure visibility: +# export PERMISSIONS='{"exp":'"$EXPIRY"',"cmds":["ls","domain ls","share show"]}' + +# Full EASM/CAASM (recommended): +# export PERMISSIONS='{"exp":'"$EXPIRY"',"cmds":["ls","domain ls","share show","integrations list"]}' + +export PAYLOAD=$(printf '%s' "$PERMISSIONS" | base64 | b64url) +export SIG=$(printf '%s' "$PERMISSIONS" | ssh-keygen -Y sign -f ~/.ssh/exe_dev_runzero -n v0@exe.dev) +export SIGBLOB=$(echo "$SIG" | sed '1d;$d' | b64url) +export TOKEN="exe0.$PAYLOAD.$SIGBLOB" +``` + +#### 4. Convert to a short opaque token (recommended) + +The `exe0` token contains your permissions in plaintext. Converting it to an +`exe1` token makes it opaque and shorter: + +```bash +ssh exe.dev exe0-to-exe1 "$TOKEN" +``` + +Copy the resulting `exe1.` token — this is what you will store in runZero. + +#### 5. Verify + +```bash +curl -X POST https://exe.dev/exec \ + -H "Authorization: Bearer $TOKEN" \ + -d 'ls' +``` + +### Token rotation + +Tokens expire based on the expiry you set at creation. Set a calendar reminder +to rotate before expiry by repeating the relevant method above and updating the +credential in runZero. + +## runZero configuration + +1. In runZero, go to **Custom Integrations** and create a new integration. + +2. Paste the contents of `custom-integration-exe-dev.star` as the script. + +3. Create a credential of type **Custom Integration Script Secrets** with: + - **access_key**: your exe.dev token (the `exe1.` string) + - **access_secret**: enter any non-empty value (e.g. `unused`) — the field is required by the platform but is not used by this integration + +4. Attach the credential to the integration and save. + +5. Create an integration task, select your Explorer, and set a schedule. + +## Asset fields + +### Core (from `ls -l` — always populated) + +| runZero attribute | Value | +|---|---| +| `hostname` | `.exe.xyz` | +| `os` | Linux | +| `exe_dev_vm_name` | VM name | +| `exe_dev_status` | `running`, `stopped`, etc. | +| `exe_dev_region` | Region code (e.g. `lon`) | +| `exe_dev_region_display` | Human-readable region (e.g. `London, UK`) | +| `exe_dev_https_url` | Public HTTPS URL | +| `exe_dev_ssh_dest` | SSH hostname | +| `exe_dev_comment` | Free-text comment set on the VM | +| `exe_dev_tags` | Comma-separated exe.dev tags on the VM | +| `exe_dev_shelley` | `True` / `False` — Shelley AI agent installed (detected via `ls -l` or integration type) | + +### Exposure (requires `share show` token permission) + +| runZero attribute | Value | +|---|---| +| `exe_dev_proxy_public` | `True` / `False` — whether the HTTPS proxy is publicly accessible | +| `exe_dev_proxy_port` | Port the HTTPS proxy is bound to | +| `exe_dev_email_enabled` | `True` / `False` — whether inbound email is enabled | + +### Custom domains (requires `domain ls` token permission) + +| runZero attribute | Value | +|---|---| +| `exe_dev_custom_domains` | Comma-separated custom domains pointing to this VM (also added as hostnames) | + +### Integrations / CAASM (requires `integrations list` token permission) + +| runZero attribute | Value | +|---|---| +| `exe_dev_integrations` | Comma-separated integration names attached to this VM | +| `exe_dev_integration_types` | Comma-separated integration types (`github`, `http-proxy`, `reflection`, …) | +| `exe_dev_github_repos` | Comma-separated GitHub repositories this VM has access to | +| `exe_dev_http_proxy_targets` | Comma-separated external domains this VM can proxy to | + +## Tags applied by this integration + +| Tag | Meaning | +|---|---| +| `exe.dev` | All assets from this integration | +| `status:` | VM operational status | +| `region:` | Region (e.g. `region:lon`) | +| `tag:` | exe.dev tags on the VM (e.g. `tag:prod`) | +| `proxy:public` | HTTPS proxy is publicly accessible | +| `integration:github` | GitHub integration attached | +| `integration:http-proxy` | HTTP proxy integration attached | +| `agent:shelley` | Shelley AI agent is installed | + +## Searching in runZero + +``` +custom_integration:exe.dev +custom_integration:exe.dev AND attribute.exe_dev_status:running +custom_integration:exe.dev AND attribute.exe_dev_proxy_public:True +custom_integration:exe.dev AND attribute.exe_dev_shelley:True +custom_integration:exe.dev AND tag:agent:shelley AND tag:proxy:public +custom_integration:exe.dev AND attribute.exe_dev_github_repos:* +``` + +## Development and testing + +### Logic tests (no token required) + +A Python test harness in `test_integration.py` mocks the RunZero Starlark +runtime and exercises the integration logic locally: + +```bash +python3 test_integration.py +``` + +Covers `parse_hostname_from_url`, HTTP error handling (401/403/429/500/None), +VM→asset mapping, custom domain enrichment, share/proxy visibility enrichment, +graceful degradation when optional permissions are absent, hostname deduplication, +and edge cases (empty `vm_name`, missing token, partial VM records). + +### Live API verification (token required) + +Confirm the exe.dev API response schema matches what the script expects: + +```bash +# Verify VM list shape +curl -s -X POST https://exe.dev/exec \ + -H "Authorization: Bearer $TOKEN" \ + -d 'ls' | jq . + +# Verify custom domain shape (if using domain ls permission) +curl -s -X POST https://exe.dev/exec \ + -H "Authorization: Bearer $TOKEN" \ + -d 'domain ls -a' | jq . + +# Verify share shape for a specific VM (if using share show permission) +curl -s -X POST https://exe.dev/exec \ + -H "Authorization: Bearer $TOKEN" \ + -d 'share show ' | jq . +``` + +### End-to-end (RunZero sandbox) + +The Starlark runtime cannot be exercised outside RunZero. Before sharing +publicly, validate in a RunZero trial or sandbox instance by pasting the +script into a Custom Integration, attaching credentials, and running a task. +Check that assets appear and that `custom_integration:exe.dev` returns results. diff --git a/exe-dev/config.json b/exe-dev/config.json new file mode 100644 index 0000000..7f58d4e --- /dev/null +++ b/exe-dev/config.json @@ -0,0 +1,4 @@ +{ + "name": "exe.dev", + "type": "inbound" +} diff --git a/exe-dev/custom-integration-exe-dev.star b/exe-dev/custom-integration-exe-dev.star new file mode 100644 index 0000000..d91123e --- /dev/null +++ b/exe-dev/custom-integration-exe-dev.star @@ -0,0 +1,331 @@ +CONFIG = { + "id": "runzero-exe-dev", + "name": "exe.dev", + "type": "inbound", + "description": "Imports exe.dev virtual machines as runZero assets.", + "version": "26060600", + "params": [ + { + "key": "access_key", + "label": "API token", + "type": "secret", + "required": True, + "description": "Minimal-scope exe.dev token (exe1.…). See README for generation steps.", + }, + ], +} + +load("runzero.types", "ImportAsset") +load("json", json_decode="decode") +load("http", http_post="post") + +EXE_DEV_API = "https://exe.dev/exec" + + +def _log(msg): + print("[EXE.DEV] " + msg) + + +def parse_hostname_from_url(url_str): + if url_str == None or url_str == "": + return "" + host = url_str + if host.startswith("https://"): + host = host[8:] + elif host.startswith("http://"): + host = host[7:] + if "/" in host: + host = host.split("/")[0] + if ":" in host: + host = host.split(":")[0] + return host + + +def run_exe_command(token, command): + resp = http_post( + url=EXE_DEV_API, + headers={ + "Authorization": "Bearer " + token, + "Content-Type": "text/plain", + }, + body=bytes(command), + ) + + if resp == None: + _log("ERROR: No response from API for command: " + command) + return None + if resp.status_code == 401: + _log("ERROR: Unauthorized (401) - check your API token") + return None + if resp.status_code == 403: + _log("ERROR: Forbidden (403) - token missing permission for: " + command) + return None + if resp.status_code == 429: + _log("ERROR: Rate limited (429)") + return None + if resp.status_code != 200: + _log("ERROR: Unexpected status " + str(resp.status_code) + " for: " + command) + return None + if resp.body == None: + return None + + return json_decode(resp.body) + + +def fetch_custom_domains_map(token): + """Returns dict vm_name -> [hostname, ...] for all custom domains. + + Requires 'domain ls' in token cmds. Degrades gracefully on 403. + """ + domain_map = {} + data = run_exe_command(token, "domain ls -a") + if data == None: + _log("INFO: Custom domain enrichment unavailable (token may lack 'domain ls' permission)") + return domain_map + for d in data.get("domains", []): + vm_name = d.get("vm_name", "") + domain_name = d.get("domain", "") + if vm_name == "" or domain_name == "": + continue + host = parse_hostname_from_url(domain_name) + if host == "": + continue + if vm_name not in domain_map: + domain_map[vm_name] = [] + domain_map[vm_name].append(host) + _log("Retrieved " + str(len(domain_map)) + " VMs with custom domains") + return domain_map + + +def fetch_share_map(token, vm_names): + """Returns dict vm_name -> share info (public, email_enabled, port). + + Calls 'share show ' per VM. Requires 'share show' in token cmds. + Degrades gracefully on 403. + """ + share_map = {} + for vm_name in vm_names: + data = run_exe_command(token, "share show " + vm_name) + if data == None: + _log("INFO: Share enrichment unavailable (token may lack 'share show' permission)") + return share_map + share_map[vm_name] = { + "public": str(data.get("public", False)), + "email_enabled": str(data.get("email_enabled", False)), + "port": str(data.get("port", "")), + } + _log("Retrieved share config for " + str(len(share_map)) + " VMs") + return share_map + + +def fetch_integrations_map(token, vm_names, vm_tags_by_name): + """Returns dict vm_name -> [integration, ...] for every attached integration. + + Resolves all three attachment patterns: + vm: — attached to a specific VM + tag: — attached to all VMs carrying that tag + auto:all — attached to all VMs + + Requires 'integrations list' in token cmds. Degrades gracefully on 403. + """ + result = {} + for name in vm_names: + result[name] = [] + + data = run_exe_command(token, "integrations list") + if data == None: + _log("INFO: Integration enrichment unavailable (token may lack 'integrations list' permission)") + return result + + total = 0 + for integration in data.get("integrations", []): + for spec in integration.get("attached", []): + if spec == "auto:all": + for name in vm_names: + result[name].append(integration) + total += 1 + elif spec.startswith("vm:"): + vm_name = spec[3:] + if vm_name in result: + result[vm_name].append(integration) + total += 1 + elif spec.startswith("tag:"): + tag = spec[4:] + for name in vm_names: + if tag in vm_tags_by_name.get(name, []): + result[name].append(integration) + total += 1 + + _log("Mapped " + str(total) + " integration attachments across " + str(len(vm_names)) + " VMs") + return result + + +def _summarise_integrations(integrations): + """Derives attributes and tags from the list of integrations attached to one VM. + + Returns a dict with: + attrs — custom attribute key/value pairs + tags — additional RunZero tags to apply + """ + names = [] + types = [] + github_repos = [] + http_targets = [] + extra_tags = [] + has_shelley = False + + for i in integrations: + name = i.get("name", "") + itype = i.get("type", "") + + if name != "" and name not in names: + names.append(name) + if itype != "" and itype not in types: + types.append(itype) + + if itype == "github": + repo = i.get("repository", "") + if repo != "" and repo not in github_repos: + github_repos.append(repo) + if "integration:github" not in extra_tags: + extra_tags.append("integration:github") + + elif itype == "http-proxy": + target = parse_hostname_from_url(i.get("target", "")) + if target != "" and target not in http_targets: + http_targets.append(target) + if "integration:http-proxy" not in extra_tags: + extra_tags.append("integration:http-proxy") + + elif itype == "shelley": + has_shelley = True + if "agent:shelley" not in extra_tags: + extra_tags.append("agent:shelley") + + return { + "attrs": { + "exe_dev_integrations": ", ".join(names), + "exe_dev_integration_types": ", ".join(types), + "exe_dev_github_repos": ", ".join(github_repos), + "exe_dev_http_proxy_targets": ", ".join(http_targets), + "exe_dev_shelley": str(has_shelley), + }, + "tags": extra_tags, + } + + +def main(*args, **kwargs): + _log("=== EXE.DEV INTEGRATION ===") + + token = kwargs.get("access_key", "") + if token == "": + _log("ERROR: Missing access_key (exe.dev API token)") + return [] + + # Use -l for detailed listing: tags, comment, and shelley status + vms_data = run_exe_command(token, "ls -l") + if vms_data == None: + _log("WARN: No VM data retrieved") + return [] + + vms = vms_data.get("vms", []) + _log("Retrieved " + str(len(vms)) + " VMs") + + # Build lookup structures needed for integration resolution + vm_names = [vm.get("vm_name", "") for vm in vms if vm.get("vm_name", "") != ""] + vm_tags_by_name = {} + for vm in vms: + name = vm.get("vm_name", "") + if name != "": + vm_tags_by_name[name] = vm.get("tags", []) + + custom_domains_map = fetch_custom_domains_map(token) + share_map = fetch_share_map(token, vm_names) + integrations_map = fetch_integrations_map(token, vm_names, vm_tags_by_name) + + assets = [] + for vm in vms: + vm_name = vm.get("vm_name", "") + if vm_name == "": + continue + + ssh_dest = vm.get("ssh_dest", "") + https_url = vm.get("https_url", "") + status = vm.get("status", "") + region = vm.get("region", "") + region_display = vm.get("region_display", "") + comment = vm.get("comment", "") + vm_tags = vm.get("tags", []) + # ls -l may expose shelley status directly; fall back to False + shelley_direct = vm.get("shelley", False) + + # ── Hostnames ───────────────────────────────────────────────────────── + hostnames = [] + url_host = parse_hostname_from_url(https_url) + if url_host != "" and url_host not in hostnames: + hostnames.append(url_host) + if ssh_dest != "" and ssh_dest not in hostnames: + hostnames.append(ssh_dest) + for custom_domain in custom_domains_map.get(vm_name, []): + if custom_domain not in hostnames: + hostnames.append(custom_domain) + if vm_name not in hostnames: + hostnames.append(vm_name) + + # ── Share / proxy ───────────────────────────────────────────────────── + share = share_map.get(vm_name, {}) + + # ── Integration summary ─────────────────────────────────────────────── + int_summary = _summarise_integrations(integrations_map.get(vm_name, [])) + + # Shelley: detected via ls -l field OR integration type + shelley = str(shelley_direct or int_summary["attrs"]["exe_dev_shelley"] == "True") + + # ── Tags ────────────────────────────────────────────────────────────── + tags = ["exe.dev"] + if status != "": + tags.append("status:" + status) + if region != "": + tags.append("region:" + region) + if share.get("public") == "True": + tags.append("proxy:public") + for t in vm_tags: + tag_val = "tag:" + t + if tag_val not in tags: + tags.append(tag_val) + for t in int_summary["tags"]: + if t not in tags: + tags.append(t) + + # ── Attributes ──────────────────────────────────────────────────────── + attrs = { + "exe_dev_vm_name": vm_name, + "exe_dev_status": status, + "exe_dev_region": region, + "exe_dev_region_display": region_display, + "exe_dev_https_url": https_url, + "exe_dev_ssh_dest": ssh_dest, + "exe_dev_comment": comment, + "exe_dev_tags": ", ".join(vm_tags), + "exe_dev_custom_domains": ", ".join(custom_domains_map.get(vm_name, [])), + "exe_dev_proxy_public": share.get("public", ""), + "exe_dev_proxy_port": share.get("port", ""), + "exe_dev_email_enabled": share.get("email_enabled", ""), + "exe_dev_shelley": shelley, + } + for k, v in int_summary["attrs"].items(): + attrs[k] = v + # Reassert after merge: shelley=True if detected via ls -l OR integration type + attrs["exe_dev_shelley"] = shelley + + assets.append(ImportAsset( + id="exedev-" + vm_name, + hostnames=hostnames, + os="Linux", + tags=tags, + customAttributes=attrs, + )) + + _log("SUCCESS: Prepared " + str(len(assets)) + " assets") + _log("=== INTEGRATION COMPLETE ===") + return assets diff --git a/exe-dev/explorer/README.md b/exe-dev/explorer/README.md new file mode 100644 index 0000000..ecb0c29 --- /dev/null +++ b/exe-dev/explorer/README.md @@ -0,0 +1,159 @@ +# RunZero Explorer on exe.dev + +This directory contains tooling to deploy a RunZero Explorer onto an exe.dev +VM so it can scan your fleet's attack surface. + +## Network constraints + +**exe.dev VMs are network-isolated from each other** — there is no shared +private network. An Explorer on VM A cannot reach VM B's internal ports +directly. The exe.dev HTTP proxy integration is HTTP-only and cannot be used +for raw TCP port scanning. + +This means there are two distinct deployment models with different coverage: + +| | Architecture A: External scan | Architecture B: Tailscale | +|---|---|---| +| What's visible | Public-facing surface only | All listening ports on every VM | +| Setup effort | Low | Medium (Tailscale on every VM) | +| Best for | EASM — what attackers can reach | Internal audit and compliance | +| Scan targets | `.exe.xyz` hostnames | Tailscale IPs (`100.x.x.x`) | +| Port coverage | 80, 443, 3000–9999 (proxy range) | All ports | + +Both architectures use a single RunZero Explorer VM. Targets are generated +dynamically from the exe.dev API so the scan stays current as VMs are added +or removed. + +--- + +## Architecture A: External surface scan + +### Prerequisites + +- A RunZero account with an Explorer provisioning token (from the RunZero + console under **Explorers → Add Explorer**) +- An exe.dev API token with at least `ls` permission (see the root README) + +### 1. Create a dedicated Explorer VM + +From your local machine: + +```bash +ssh exe.dev new --name runzero-explorer +``` + +### 2. Deploy the Explorer + +SSH into the new VM and run `setup.sh`: + +```bash +ssh runzero-explorer.exe.xyz +``` + +Then from inside the VM: + +```bash +curl -fsSL https://raw.githubusercontent.com/runZeroInc/runzero-custom-integrations/main/exe-dev/explorer/setup.sh | \ + RUNZERO_TOKEN= bash +``` + +The script installs the Explorer binary and registers it with RunZero as a +persistent systemd service. + +### 3. Generate scan targets + +Run `generate-targets.sh` with your exe.dev API token. It queries the live +VM list and writes a target file consumable by the RunZero API or UI: + +```bash +EXE_DEV_TOKEN= ./generate-targets.sh +``` + +Output: `targets.txt` — one hostname per line, ready to paste into a RunZero +site's **Scan targets** field, or POST via the RunZero API. + +### 4. Configure the scan in RunZero + +In the RunZero console: +1. Go to **Sites → your site → Edit** +2. Under **Scan targets**, add the contents of `targets.txt` +3. Under **Scan ports**, add the exe.dev proxy range: `80,443,3000-9999` +4. Assign the Explorer VM as the scanner for this site +5. Schedule or trigger a scan + +### What the scan will find + +The Explorer scans each VM's public hostname over the internet through exe.dev's +HTTPS proxy. It will discover: + +- Port 80/443 — the primary HTTPS proxy (always present on running VMs) +- Ports 3000–9999 — any additional services the VM is transparently exposing + via the exe.dev proxy + +Each discovered service is correlated with the asset data imported by the +custom integration (`custom-integration-exe-dev.star`), giving you a unified +view of each VM's identity and exposed services. + +--- + +## Architecture B: Tailscale overlay + +Tailscale creates an encrypted mesh network between all your VMs, giving the +Explorer direct access to internal ports. + +### Prerequisites + +- Everything from Architecture A +- Tailscale account and auth key (`tskey-auth-...`) + +### 1. Install Tailscale on every VM + +The `tailscale-install.sh` script installs and connects Tailscale on a single +VM. Run it on each VM in your fleet: + +```bash +ssh .exe.xyz \ + "curl -fsSL https://raw.githubusercontent.com/runZeroInc/runzero-custom-integrations/main/exe-dev/explorer/tailscale-install.sh | \ + TAILSCALE_AUTH_KEY= bash" +``` + +Or loop over all VMs using the exe.dev API: + +```bash +EXE_DEV_TOKEN= TAILSCALE_AUTH_KEY= ./install-tailscale-fleet.sh +``` + +### 2. Deploy the Explorer (same as Architecture A) + +The Explorer VM also needs Tailscale installed. After setup, it will see all +other fleet VMs at their `100.x.x.x` Tailscale addresses. + +### 3. Generate Tailscale scan targets + +```bash +EXE_DEV_TOKEN= \ + SCAN_MODE=tailscale \ + TAILSCALE_API_KEY= \ + TAILSCALE_TAILNET= \ + ./generate-targets.sh +``` + +This queries the Tailscale API for the fleet's Tailscale IPs and matches them +against your exe.dev VM list, writing the results to `targets.txt`. + +### 4. Configure the scan in RunZero + +Same as Architecture A, but: +- **Scan targets**: Tailscale IPs from `targets.txt` (or the `100.64.0.0/10` CIDR) +- **Scan ports**: `1-65535` (full internal visibility) + +--- + +## Files + +| File | Purpose | +|---|---| +| `setup.sh` | Install and register RunZero Explorer on an exe.dev VM | +| `generate-targets.sh` | Query exe.dev API and output scan targets | +| `tailscale-install.sh` | Install Tailscale on a single VM | +| `install-tailscale-fleet.sh` | Install Tailscale across all fleet VMs | diff --git a/exe-dev/explorer/generate-targets.sh b/exe-dev/explorer/generate-targets.sh new file mode 100755 index 0000000..0a0e200 --- /dev/null +++ b/exe-dev/explorer/generate-targets.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Queries the exe.dev API for all VMs and writes scan targets to stdout +# (or targets.txt if OUTPUT_FILE is set). +# +# Architecture A (default) — external surface scan: +# Outputs public hostnames. RunZero scans these via the internet through +# exe.dev's HTTPS proxy. Discovers ports 80, 443, and 3000-9999. +# +# Architecture B — Tailscale (set SCAN_MODE=tailscale): +# Requires TAILSCALE_API_KEY. Outputs Tailscale IPs for each VM. +# RunZero scans these over the Tailscale overlay — full internal visibility. +# +# Usage: +# EXE_DEV_TOKEN= ./generate-targets.sh +# EXE_DEV_TOKEN= SCAN_MODE=tailscale TAILSCALE_API_KEY= TAILSCALE_TAILNET= ./generate-targets.sh +# EXE_DEV_TOKEN= OUTPUT_FILE=targets.txt ./generate-targets.sh + +set -euo pipefail + +EXE_DEV_TOKEN="${EXE_DEV_TOKEN:?EXE_DEV_TOKEN must be set}" +SCAN_MODE="${SCAN_MODE:-external}" +OUTPUT_FILE="${OUTPUT_FILE:-}" +EXE_DEV_API="https://exe.dev/exec" + +_log() { echo "[targets] $*" >&2; } + +# ── Fetch VM list from exe.dev ──────────────────────────────────────────────── + +_log "Fetching VM list from exe.dev..." + +response=$(curl -sf -X POST "$EXE_DEV_API" \ + -H "Authorization: Bearer ${EXE_DEV_TOKEN}" \ + -H "Content-Type: text/plain" \ + --data 'ls') || { _log "ERROR: exe.dev API call failed"; exit 1; } + +vm_count=$(echo "$response" | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('vms', [])))") +_log "Found ${vm_count} VMs" + +if [ "$vm_count" -eq 0 ]; then + _log "No VMs found — nothing to output" + exit 0 +fi + +# ── Generate targets ────────────────────────────────────────────────────────── + +if [ "$SCAN_MODE" = "external" ]; then + _log "Mode: external surface scan (public hostnames)" + _log "Port recommendation for RunZero site: 80,443,3000-9999" + _log "" + + targets=$(echo "$response" | python3 -c " +import json, sys + +data = json.load(sys.stdin) +vms = data.get('vms', []) + +for vm in vms: + ssh_dest = vm.get('ssh_dest', '') + status = vm.get('status', 'unknown') + region = vm.get('region', '') + name = vm.get('vm_name', '') + if ssh_dest: + print(f'# {name} [{status}] [{region}]') + print(ssh_dest) +") + +elif [ "$SCAN_MODE" = "tailscale" ]; then + TAILSCALE_API_KEY="${TAILSCALE_API_KEY:?TAILSCALE_API_KEY must be set for tailscale mode}" + TAILSCALE_TAILNET="${TAILSCALE_TAILNET:?TAILSCALE_TAILNET must be set for tailscale mode}" + + _log "Mode: Tailscale overlay (internal IPs)" + _log "Port recommendation for RunZero site: 1-65535" + _log "" + _log "Fetching Tailscale device list..." + + ts_response=$(curl -sf \ + -H "Authorization: Bearer ${TAILSCALE_API_KEY}" \ + "https://api.tailscale.com/api/v2/tailnet/${TAILSCALE_TAILNET}/devices") || { + _log "ERROR: Tailscale API call failed" + exit 1 + } + + # Build a map of hostname → Tailscale IP, then match against exe.dev VM names + targets=$(echo "$response $ts_response" | python3 -c " +import json, sys + +raw = sys.stdin.read() +# The two JSON blobs are space-separated — split on the boundary +# by finding the first '}' followed by whitespace and '{' +import re +parts = re.split(r'\}\s*\{', raw, maxsplit=1) +exe_data = json.loads(parts[0] + '}') +ts_data = json.loads('{' + parts[1]) + +exe_vms = {vm['vm_name']: vm for vm in exe_data.get('vms', [])} + +# Tailscale hostname is usually the short hostname without domain +ts_by_hostname = {} +for dev in ts_data.get('devices', []): + h = dev.get('hostname', '').split('.')[0].lower() + addrs = dev.get('addresses', []) + ipv4 = next((a.split('/')[0] for a in addrs if ':' not in a), None) + if h and ipv4: + ts_by_hostname[h] = ipv4 + +matched = 0 +unmatched = [] +output = [] +for vm_name, vm in exe_vms.items(): + ts_ip = ts_by_hostname.get(vm_name.lower()) + status = vm.get('status', 'unknown') + region = vm.get('region', '') + if ts_ip: + output.append(f'# {vm_name} [{status}] [{region}] -> {ts_ip}') + output.append(ts_ip) + matched += 1 + else: + unmatched.append(vm_name) + +for line in output: + print(line) + +if unmatched: + import sys + print(f'# WARNING: no Tailscale match for: {', '.join(unmatched)}', file=sys.stderr) +print(f'# Matched {matched}/{len(exe_vms)} VMs', file=sys.stderr) +") + +else + _log "ERROR: Unknown SCAN_MODE '${SCAN_MODE}'. Use 'external' or 'tailscale'." + exit 1 +fi + +# ── Output ──────────────────────────────────────────────────────────────────── + +if [ -n "$OUTPUT_FILE" ]; then + echo "$targets" > "$OUTPUT_FILE" + _log "Wrote targets to ${OUTPUT_FILE}" + _log "$(echo "$targets" | grep -c '^[^#]') scan targets" +else + echo "$targets" +fi diff --git a/exe-dev/explorer/install-tailscale-fleet.sh b/exe-dev/explorer/install-tailscale-fleet.sh new file mode 100755 index 0000000..0b0d7b3 --- /dev/null +++ b/exe-dev/explorer/install-tailscale-fleet.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Installs Tailscale on every running VM in your exe.dev fleet. +# +# Usage: +# EXE_DEV_TOKEN= TAILSCALE_AUTH_KEY= ./install-tailscale-fleet.sh +# +# Iterates the live VM list, SSHs into each running VM, and runs +# tailscale-install.sh. VMs that are stopped or already have Tailscale +# installed are skipped gracefully. +# +# Prerequisites: +# - SSH access to each VM (your SSH key registered with exe.dev) +# - tailscale-install.sh accessible at the URL below (update if forking) + +set -euo pipefail + +EXE_DEV_TOKEN="${EXE_DEV_TOKEN:?EXE_DEV_TOKEN must be set}" +TAILSCALE_AUTH_KEY="${TAILSCALE_AUTH_KEY:?TAILSCALE_AUTH_KEY must be set}" +EXE_DEV_API="https://exe.dev/exec" +INSTALL_SCRIPT_URL="https://raw.githubusercontent.com/runZeroInc/runzero-custom-integrations/main/exe-dev/explorer/tailscale-install.sh" + +_log() { echo "[fleet] $*"; } + +_log "Fetching VM list..." +response=$(curl -sf -X POST "$EXE_DEV_API" \ + -H "Authorization: Bearer ${EXE_DEV_TOKEN}" \ + -H "Content-Type: text/plain" \ + --data 'ls') || { _log "ERROR: exe.dev API call failed"; exit 1; } + +# Extract running VMs +mapfile -t vms < <(echo "$response" | python3 -c " +import json, sys +data = json.load(sys.stdin) +for vm in data.get('vms', []): + if vm.get('status') == 'running': + print(vm['ssh_dest']) +") + +_log "Found ${#vms[@]} running VM(s): ${vms[*]:-none}" + +success=0 +failed=() + +for ssh_dest in "${vms[@]}"; do + _log "Installing Tailscale on ${ssh_dest}..." + if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=15 "$ssh_dest" \ + "curl -fsSL '${INSTALL_SCRIPT_URL}' | TAILSCALE_AUTH_KEY='${TAILSCALE_AUTH_KEY}' bash" 2>&1 | \ + sed "s/^/ [${ssh_dest}] /"; then + _log "OK: ${ssh_dest}" + ((success++)) || true + else + _log "FAILED: ${ssh_dest}" + failed+=("$ssh_dest") + fi +done + +_log "" +_log "Done: ${success} succeeded, ${#failed[@]} failed" +if [ ${#failed[@]} -gt 0 ]; then + _log "Failed VMs: ${failed[*]}" + exit 1 +fi diff --git a/exe-dev/explorer/setup.sh b/exe-dev/explorer/setup.sh new file mode 100755 index 0000000..97a1c14 --- /dev/null +++ b/exe-dev/explorer/setup.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Installs the RunZero Explorer on an exe.dev VM and registers it as a +# systemd service so it persists across reboots. +# +# Usage: +# RUNZERO_TOKEN= bash setup.sh +# +# The provisioning token is a one-time token from the RunZero console: +# Explorers → Add Explorer → Copy token +# +# After running this script the Explorer will appear in the RunZero console +# within ~30 seconds. The token is consumed on first use and not stored. + +set -euo pipefail + +RUNZERO_TOKEN="${RUNZERO_TOKEN:?RUNZERO_TOKEN must be set}" +INSTALL_DIR="/opt/runzero" +SERVICE_NAME="runzero-explorer" +EXPLORER_URL="https://console.runzero.com/download/explorer/linux/amd64/runzero-explorer" + +echo "[setup] Detecting system..." +ARCH=$(uname -m) +case "$ARCH" in + x86_64) ARCH_SLUG="amd64" ;; + aarch64) ARCH_SLUG="arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +EXPLORER_URL="https://console.runzero.com/download/explorer/linux/${ARCH_SLUG}/runzero-explorer" + +echo "[setup] Installing dependencies..." +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y -qq curl ca-certificates + +echo "[setup] Downloading RunZero Explorer (${ARCH_SLUG})..." +mkdir -p "$INSTALL_DIR" +curl -fsSL "$EXPLORER_URL" -o "${INSTALL_DIR}/runzero-explorer" +chmod +x "${INSTALL_DIR}/runzero-explorer" + +echo "[setup] Creating systemd service..." +cat > "/etc/systemd/system/${SERVICE_NAME}.service" <.exe.xyz \ +# "curl -fsSL https://raw.githubusercontent.com/runZeroInc/runzero-custom-integrations/main/exe-dev/explorer/tailscale-install.sh | \ +# TAILSCALE_AUTH_KEY= bash" +# +# Or use install-tailscale-fleet.sh to run this across all VMs automatically. +# +# After connection the VM will be visible in your Tailscale admin console +# and reachable at its 100.x.x.x address from the RunZero Explorer VM. + +set -euo pipefail + +TAILSCALE_AUTH_KEY="${TAILSCALE_AUTH_KEY:?TAILSCALE_AUTH_KEY must be set}" + +echo "[tailscale] Installing Tailscale..." +curl -fsSL https://tailscale.com/install.sh | sh + +echo "[tailscale] Connecting to tailnet..." +tailscale up \ + --authkey="$TAILSCALE_AUTH_KEY" \ + --hostname="$(hostname)" \ + --accept-routes + +echo "[tailscale] Connected. Tailscale IP:" +tailscale ip -4