From ec7aa3cc825fb8594d8f0d96c7f7733319fd80fc Mon Sep 17 00:00:00 2001 From: default Date: Fri, 10 Apr 2026 12:59:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20make=20domain/Cloudflare=20setup=20opti?= =?UTF-8?q?onal=20=E2=80=94=20IP-only=20deployments=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ip_only and use_cloudflare flags so RemoteVibeServer can be deployed without a domain name or Cloudflare account. - configurator/cli.py: upfront 'Do you have a domain?' question sets ip_only=True and skips domain/CF steps; Cloudflare step now first asks 'Use Cloudflare?' and shows manual DNS table when answered No; Done section shows http:// for IP-only configs - configurator/generator.py: add ip_only/use_cloudflare to default_config and _RVS_KEY_ORDER; compute coder_url dynamically in generate_cloud_init (empty for ip_only so setup.sh can fill it at boot); add IP_ONLY and USE_CLOUDFLARE to the env-file section of the cloud-init template - configurator/validators.py: _check_required_fields respects ip_only (only password required) and use_cloudflare=False (no CF fields required) - infra/dns.sh: graceful skip when IP_ONLY=true or CF creds absent; logs manual A-record instructions instead of aborting - infra/proxy.sh: plain HTTP Caddyfile (http://IP, no TLS) when IP_ONLY=true; EMAIL/FQDN only required for domain-based deployments - setup.sh: REQUIRED_VARS conditional on mode; FQDN overridden to PUBLIC_IP for ip_only; CODER_ACCESS_URL exported as http://IP or https://FQDN; Coder systemd unit and enable_coder_proxy updated to use CODER_ACCESS_URL - install.sh: CF key validation only warns when DOMAIN is set - docs/deployment.md: add IP-only Quickstart section; mark domain/CF as optional - tests: fix missing coder_admin_password in _full_config(); add 5 new preflight tests covering ip_only and use_cloudflare=False modes (250/250 passed) --- dev-server-provision/configurator/cli.py | 50 +++++++++- .../configurator/generator.py | 28 +++++- .../configurator/tests/test_validators.py | 97 +++++++++++++++++++ .../configurator/validators.py | 27 +++++- dev-server-provision/docs/deployment.md | 52 +++++++++- dev-server-provision/infra/dns.sh | 20 +++- dev-server-provision/infra/proxy.sh | 43 +++++++- dev-server-provision/setup.sh | 87 +++++++++++++---- install.sh | 10 +- 9 files changed, 378 insertions(+), 36 deletions(-) diff --git a/dev-server-provision/configurator/cli.py b/dev-server-provision/configurator/cli.py index d3e5a97..550c534 100644 --- a/dev-server-provision/configurator/cli.py +++ b/dev-server-provision/configurator/cli.py @@ -159,6 +159,23 @@ def _ask_import(config: dict[str, Any]) -> None: def _ask_domain_config(config: dict[str, Any]) -> None: _heading("Domain & DNS") + has_domain = inquirer.confirm( + message="Do you have a domain name for this server?", + default=True, + ).execute() + + if not has_domain: + config["ip_only"] = True + config["use_cloudflare"] = False + print( + f"\n {_YELLOW}IP-only mode selected.{_RESET}\n" + f" Coder will be accessible via plain HTTP on your server's public IP\n" + f" (e.g. http://). No TLS certificate will be provisioned.\n" + ) + return + + config["ip_only"] = False + config["domain"] = inquirer.text( message="Root domain (e.g. example.com):", default=config.get("domain") or "", @@ -188,6 +205,26 @@ def _ask_domain_config(config: dict[str, Any]) -> None: def _ask_cloudflare_config(config: dict[str, Any]) -> None: _heading("Cloudflare DNS") + use_cf = inquirer.confirm( + message="Use Cloudflare to manage DNS automatically?", + default=config.get("use_cloudflare", True), + ).execute() + + config["use_cloudflare"] = use_cf + + if not use_cf: + subdomain = config.get("subdomain", "") + domain = config.get("domain", "") + fqdn = f"{subdomain}.{domain}" + print( + f"\n {_YELLOW}Manual DNS mode.{_RESET} Please create the following DNS record\n" + f" at your DNS provider before or shortly after provisioning:\n\n" + f" {'Type':<8} {'Name':<30} {'Value':<20} {'TTL'}\n" + f" {'-'*8} {'-'*30} {'-'*20} {'-'*5}\n" + f" {'A':<8} {fqdn:<30} {'':<20} 3600\n" + ) + return + config["cloudflare_api_token"] = inquirer.secret( message="Cloudflare API Token (Zone:DNS:Edit):", default=config.get("cloudflare_api_token") or "", @@ -773,8 +810,9 @@ def run() -> None: # 2. Domain & DNS _ask_domain_config(config) - # 3. Cloudflare - _ask_cloudflare_config(config) + # 3. Cloudflare (skip entirely in ip_only mode) + if not config.get("ip_only"): + _ask_cloudflare_config(config) # 4. Coder admin password _ask_coder_admin_password(config) @@ -803,8 +841,12 @@ def run() -> None: _offer_deploy(provider, deploy_config, output_file) _heading("Done") - fqdn = f"{config['subdomain']}.{config['domain']}" - print(f" After provisioning (~5 min), access Coder at: {_CYAN}https://{fqdn}{_RESET}") + if config.get("ip_only"): + print(f" After provisioning (~5 min), access Coder at: {_CYAN}http://{_RESET}") + print(f" {_YELLOW}(Replace with the actual public IP of your server){_RESET}") + else: + fqdn = f"{config['subdomain']}.{config['domain']}" + print(f" After provisioning (~5 min), access Coder at: {_CYAN}https://{fqdn}{_RESET}") print() print(f" {_BOLD}Bare-server install:{_RESET}") print(f" Copy {_CYAN}RVSconfig.yml{_RESET} to your server, then run:") diff --git a/dev-server-provision/configurator/generator.py b/dev-server-provision/configurator/generator.py index 7aa9cbd..6753b2f 100644 --- a/dev-server-provision/configurator/generator.py +++ b/dev-server-provision/configurator/generator.py @@ -49,6 +49,13 @@ permissions: "0600" owner: root:root content: | + # ── Deployment mode ─────────────────────────────────────────────── + # IP_ONLY=true → no domain / TLS; Coder is served via plain HTTP on + # the server's public IP (set by setup.sh at boot). + # USE_CLOUDFLARE=false → domain is provided but DNS is managed manually. + IP_ONLY={ip_only} + USE_CLOUDFLARE={use_cloudflare} + # ── Domain & DNS ────────────────────────────────────────────────── DOMAIN={domain} SUBDOMAIN={subdomain} @@ -59,8 +66,10 @@ CLOUDFLARE_ZONE_ID={cloudflare_zone_id} # ── Coder ───────────────────────────────────────────────────────── - CODER_URL=https://{subdomain}.{domain} - CODER_ACCESS_URL=https://{subdomain}.{domain} + # CODER_URL / CODER_ACCESS_URL are set dynamically by setup.sh + # (ip_only: http:// | domain: https://.) + CODER_URL={coder_url} + CODER_ACCESS_URL={coder_url} # Admin login password (Coder requires ≥ 8 chars, no spaces). # Login email is the address entered above. Username: admin CODER_ADMIN_PASSWORD={coder_admin_password} @@ -161,6 +170,7 @@ def generate_cloud_init(config: dict[str, Any]) -> str: *config* must contain the keys used in ``_CLOUD_INIT_TEMPLATE``. Boolean agent flags are normalised to ``"true"`` / ``"false"``. + For ip_only mode the CODER_URL is left blank (setup.sh fills it at boot). """ # Normalise booleans → lowercase strings normalised: dict[str, str] = {} @@ -169,12 +179,24 @@ def generate_cloud_init(config: dict[str, Any]) -> str: normalised[key] = "true" if value else "false" else: normalised[key] = str(value) if value is not None else "" + + # Compute coder_url: empty for ip_only (setup.sh will fill it at boot), + # otherwise https://. + if config.get("ip_only"): + normalised["coder_url"] = "" + else: + subdomain = normalised.get("subdomain", "") + domain = normalised.get("domain", "") + normalised["coder_url"] = f"https://{subdomain}.{domain}" if subdomain and domain else "" + return _CLOUD_INIT_TEMPLATE.format(**normalised) def default_config() -> dict[str, Any]: """Return a config dict pre-filled with safe defaults.""" return { + "ip_only": False, + "use_cloudflare": True, "domain": "", "subdomain": "dev", "email": "", @@ -202,6 +224,8 @@ def default_config() -> dict[str, Any]: # The key order used when writing RVSconfig.yml — keeps the output tidy and # predictable. _RVS_KEY_ORDER: list[str] = [ + "ip_only", + "use_cloudflare", "domain", "subdomain", "email", diff --git a/dev-server-provision/configurator/tests/test_validators.py b/dev-server-provision/configurator/tests/test_validators.py index 3e8a132..32a7f69 100644 --- a/dev-server-provision/configurator/tests/test_validators.py +++ b/dev-server-provision/configurator/tests/test_validators.py @@ -155,6 +155,7 @@ def _full_config(self, **overrides): "email": "admin@example.com", "cloudflare_api_token": "a" * 40, "cloudflare_zone_id": "a" * 32, + "coder_admin_password": "securepass1", "enable_agent_copilot": False, "enable_agent_claude": False, "enable_agent_gemini": False, @@ -343,6 +344,102 @@ def test_opencode_unknown_provider_rejected(self): self.assertFalse(agent_check.passed) self.assertIn("not supported", agent_check.message) + # ---- ip_only mode -------------------------------------------------------- + + def test_ip_only_passes_with_only_password(self): + """ip_only=True: all domain/CF fields can be empty.""" + config = { + "ip_only": True, + "use_cloudflare": False, + "domain": "", + "subdomain": "", + "email": "", + "cloudflare_api_token": "", + "cloudflare_zone_id": "", + "coder_admin_password": "securepass1", + "enable_agent_copilot": False, + "enable_agent_claude": False, + "enable_agent_gemini": False, + "enable_agent_codex": False, + "enable_agent_opencode": False, + "openai_api_key": "", + "anthropic_api_key": "", + "google_api_key": "", + "github_token": "", + "codex_openai_auth_code": "", + "opencode_provider": "", + } + results = run_preflight_checks(config, provider="aws") + required_check = results[0] + self.assertTrue(required_check.passed, f"ip_only check failed: {required_check.message}") + + def test_ip_only_missing_password_fails(self): + """ip_only=True: coder_admin_password is still required.""" + config = { + "ip_only": True, + "use_cloudflare": False, + "domain": "", + "subdomain": "", + "email": "", + "cloudflare_api_token": "", + "cloudflare_zone_id": "", + "coder_admin_password": "", + "enable_agent_copilot": False, + "enable_agent_claude": False, + "enable_agent_gemini": False, + "enable_agent_codex": False, + "enable_agent_opencode": False, + "openai_api_key": "", + "anthropic_api_key": "", + "google_api_key": "", + "github_token": "", + "codex_openai_auth_code": "", + "opencode_provider": "", + } + results = run_preflight_checks(config, provider="aws") + required_check = results[0] + self.assertFalse(required_check.passed) + self.assertIn("coder_admin_password", required_check.message) + + # ---- use_cloudflare=False mode ------------------------------------------- + + def test_no_cloudflare_passes_without_cf_fields(self): + """use_cloudflare=False: CF token/zone not required, but domain/email are.""" + config = self._full_config( + use_cloudflare=False, + cloudflare_api_token="", + cloudflare_zone_id="", + ) + results = run_preflight_checks(config, provider="aws") + required_check = results[0] + self.assertTrue(required_check.passed, f"no-CF check failed: {required_check.message}") + + def test_no_cloudflare_missing_domain_fails(self): + """use_cloudflare=False: domain is still required.""" + config = self._full_config( + use_cloudflare=False, + domain="", + cloudflare_api_token="", + cloudflare_zone_id="", + ) + results = run_preflight_checks(config, provider="aws") + required_check = results[0] + self.assertFalse(required_check.passed) + self.assertIn("domain", required_check.message) + + def test_no_cloudflare_missing_email_fails(self): + """use_cloudflare=False: email is still required (for Let's Encrypt).""" + config = self._full_config( + use_cloudflare=False, + email="", + cloudflare_api_token="", + cloudflare_zone_id="", + ) + results = run_preflight_checks(config, provider="aws") + required_check = results[0] + self.assertFalse(required_check.passed) + self.assertIn("email", required_check.message) + class TestPreflightResult(unittest.TestCase): def test_repr_pass(self): diff --git a/dev-server-provision/configurator/validators.py b/dev-server-provision/configurator/validators.py index b7bf262..7f8426a 100644 --- a/dev-server-provision/configurator/validators.py +++ b/dev-server-provision/configurator/validators.py @@ -81,7 +81,7 @@ def validate_coder_password(value: str) -> str | bool: return True - +def validate_api_key_optional(value: str) -> str | bool: """Accept empty or any non-whitespace string.""" return True @@ -131,8 +131,29 @@ def __repr__(self) -> str: def _check_required_fields(config: dict[str, Any]) -> PreflightResult: - """All required fields must be non-empty.""" - required = ["domain", "subdomain", "email", "cloudflare_api_token", "cloudflare_zone_id", "coder_admin_password"] + """All required fields must be non-empty. + + Which fields are required depends on the deployment mode: + - ``ip_only=True``: only ``coder_admin_password`` is required — no domain, + no Cloudflare credentials needed. + - ``use_cloudflare=False`` (domain mode, manual DNS): domain / subdomain / + email are required but Cloudflare credentials are not. + - default (Cloudflare-managed DNS): all domain + CF fields are required. + """ + ip_only = config.get("ip_only", False) + use_cloudflare = config.get("use_cloudflare", True) + + if ip_only: + required: list[str] = ["coder_admin_password"] + elif not use_cloudflare: + required = ["domain", "subdomain", "email", "coder_admin_password"] + else: + required = [ + "domain", "subdomain", "email", + "cloudflare_api_token", "cloudflare_zone_id", + "coder_admin_password", + ] + missing = [f for f in required if not config.get(f)] if missing: return PreflightResult("Required fields", False, f"Missing: {', '.join(missing)}") diff --git a/dev-server-provision/docs/deployment.md b/dev-server-provision/docs/deployment.md index 9e7539e..6ed1702 100644 --- a/dev-server-provision/docs/deployment.md +++ b/dev-server-provision/docs/deployment.md @@ -6,15 +6,63 @@ This guide walks through deploying a fully automated remote development server f 1. **A cloud provider account** — Hetzner is recommended (affordable, EU-based, great API). Any provider supporting cloud-init works (AWS, GCP, Azure, DigitalOcean, etc.). -2. **A domain name** — with DNS managed by Cloudflare. +2. **A domain name** *(optional)* — required only for HTTPS with automatic TLS. See **IP-only Quickstart** below if you don't have one. -3. **A Cloudflare API token** — with `Zone → DNS → Edit` permission for your domain's zone. +3. **A Cloudflare API token** *(optional)* — with `Zone → DNS → Edit` permission. Required only when Cloudflare manages your DNS. For manual DNS or IP-only deployments this is not needed. 4. **Server requirements:** - Ubuntu 24.04 LTS - Minimum: 2 vCPUs, 4 GB RAM, 40 GB SSD (Hetzner CPX21 or larger) - Recommended: 4 vCPUs, 8 GB RAM, 80 GB SSD (Hetzner CPX31) +## IP-only Quickstart (no domain required) + +If you don't have a domain name, you can deploy RemoteVibeServer in **IP-only +mode** — Coder is served over plain HTTP on your server's public IP (port 80). + +No Cloudflare token, no domain, no TLS certificate needed. + +### 1. Generate your config + +Run the configurator: + +```bash +cd dev-server-provision +python -m configurator +``` + +When asked *"Do you have a domain name?"* — answer **No**. +The configurator will set `ip_only: true` in your config automatically. + +### 2. Deploy via cloud-init + +```bash +hcloud server create \ + --name dev-server \ + --type cpx21 \ + --image ubuntu-24.04 \ + --location nbg1 \ + --user-data-from-file cloud-init.yaml +``` + +### 3. Access Coder + +After ~5 minutes: + +``` +http:// +``` + +Find the IP with: + +```bash +hcloud server describe dev-server | grep "Public Net" +# or on the server: +cat /etc/dev-server/status +``` + +> **Note:** HTTP-only means traffic is unencrypted. Suitable for local/private networks or quick evaluation. For production use, add a domain and enable HTTPS. + ## Step 1: Prepare the Cloud-Init File 1. Copy the example file: diff --git a/dev-server-provision/infra/dns.sh b/dev-server-provision/infra/dns.sh index caa01c4..e0ee027 100755 --- a/dev-server-provision/infra/dns.sh +++ b/dev-server-provision/infra/dns.sh @@ -19,7 +19,25 @@ log() { echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] [dns] $*" | tee -a "$LOG_FILE"; die() { log "ERROR: $*" >&2; exit 1; } # --------------------------------------------------------------------------- -# Validate inputs +# IP-only / no-Cloudflare mode — skip DNS automation +# --------------------------------------------------------------------------- +if [[ "${IP_ONLY:-false}" == "true" || -z "${DOMAIN:-}" ]]; then + log "IP-only mode — no DNS record needed. Skipping DNS step." + exit 0 +fi + +if [[ -z "${CLOUDFLARE_API_TOKEN:-}" || -z "${CLOUDFLARE_ZONE_ID:-}" ]]; then + log "Cloudflare credentials not configured — skipping automated DNS." + log "Please create the following DNS record manually at your DNS provider:" + log " Type : A" + log " Name : ${SUBDOMAIN:-}.${DOMAIN:-}" + log " Value: ${PUBLIC_IP:-}" + log " TTL : 3600" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Validate remaining inputs # --------------------------------------------------------------------------- : "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is required}" : "${CLOUDFLARE_ZONE_ID:?CLOUDFLARE_ZONE_ID is required}" diff --git a/dev-server-provision/infra/proxy.sh b/dev-server-provision/infra/proxy.sh index 2e0e016..fa50f4d 100755 --- a/dev-server-provision/infra/proxy.sh +++ b/dev-server-provision/infra/proxy.sh @@ -32,8 +32,12 @@ die() { log "ERROR: $*" >&2; exit 1; } # --------------------------------------------------------------------------- # Validate # --------------------------------------------------------------------------- -: "${FQDN:?FQDN is required}" -: "${EMAIL:?EMAIL is required}" +# In IP-only mode FQDN equals the public IP and EMAIL may be empty. +# For a domain-based deployment both are required (Caddy needs them for ACME). +if [[ "${IP_ONLY:-false}" != "true" ]]; then + : "${FQDN:?FQDN is required}" + : "${EMAIL:?EMAIL is required}" +fi # --------------------------------------------------------------------------- # Install Caddy (idempotent) @@ -146,7 +150,33 @@ HTMLEOF # --------------------------------------------------------------------------- log "Writing maintenance Caddyfile → $CADDYFILE" -cat > "$CADDYFILE" < "$CADDYFILE" < "$CADDYFILE" </dev/null)" || resp_code="000" local first_resp; first_resp="$(cat "$resp_body")"; rm -f "$resp_body" @@ -288,7 +303,42 @@ enable_coder_proxy() { local caddyfile="/etc/caddy/Caddyfile" log "Enabling live Caddy reverse proxy → Coder …" - cat > "$caddyfile" < "$caddyfile" < "$caddyfile" < "$STATUS_FILE" </dev/null || echo "unknown") caddy_status=$(systemctl is-active caddy.service 2>/dev/null || echo "unknown") STATUSEOF @@ -348,7 +399,7 @@ STATUSEOF if [[ -n "${_CODER_ADMIN_PASS:-}" ]]; then { echo "coder_admin_user=admin" - echo "coder_admin_email=${EMAIL}" + echo "coder_admin_email=${EMAIL:-admin@localhost}" echo "coder_admin_password=${_CODER_ADMIN_PASS}" } >> "$STATUS_FILE" log "Admin credentials written to $STATUS_FILE" @@ -357,10 +408,10 @@ chmod 0600 "$STATUS_FILE" log "============================================" log " Provisioning complete!" -log " Coder URL : https://$FQDN" +log " Coder URL : $CODER_ACCESS_URL" log " Status : $STATUS_FILE" if [[ -n "${_CODER_ADMIN_PASS:-}" ]]; then - log " Admin : ${EMAIL} / password in status file" + log " Admin : ${EMAIL:-admin} / password in status file" fi log " Log : $LOG_FILE" log "============================================" diff --git a/install.sh b/install.sh index a80b881..cf0723e 100755 --- a/install.sh +++ b/install.sh @@ -111,6 +111,7 @@ done <<< "$RVS_CONTENT" # Add derived CODER variables DOMAIN="$(grep '^DOMAIN=' "$ENV_FILE" | cut -d= -f2-)" SUBDOMAIN="$(grep '^SUBDOMAIN=' "$ENV_FILE" | cut -d= -f2-)" +IP_ONLY_VAL="$(grep '^IP_ONLY=' "$ENV_FILE" | cut -d= -f2- || true)" if [[ -n "$DOMAIN" && -n "$SUBDOMAIN" ]]; then CODER_FQDN="https://${SUBDOMAIN}.${DOMAIN}" grep -q '^CODER_URL=' "$ENV_FILE" || echo "CODER_URL=${CODER_FQDN}" >> "$ENV_FILE" @@ -119,8 +120,13 @@ fi log "Environment file written to $ENV_FILE" -# Quick validation -REQUIRED_KEYS=(DOMAIN SUBDOMAIN EMAIL CLOUDFLARE_API_TOKEN CLOUDFLARE_ZONE_ID) +# Quick validation — CF keys only required when a domain is configured +# and ip_only mode is not active. +if [[ "$IP_ONLY_VAL" != "true" && -n "$DOMAIN" ]]; then + REQUIRED_KEYS=(DOMAIN SUBDOMAIN EMAIL CLOUDFLARE_API_TOKEN CLOUDFLARE_ZONE_ID) +else + REQUIRED_KEYS=(CODER_ADMIN_PASSWORD) +fi missing=() for key in "${REQUIRED_KEYS[@]}"; do val="$(grep "^${key}=" "$ENV_FILE" | cut -d= -f2- || true)"