diff --git a/dev2/dev2-site b/dev2/dev2-site index 0809b7b..56170db 100755 --- a/dev2/dev2-site +++ b/dev2/dev2-site @@ -184,8 +184,10 @@ def site_variables(s, sid=None, envfile="app.env"): dev2 (a retired service has no variables left anywhere else). Values from dev2 are already the rendered ones, which is what a re-render should start from.""" vars_ = {} - try: vars_ = rw_variables(s["project_id"], s["environment_id"], sid or s["service_id"]) - except RuntimeError as e: warn(f"railway variables: {str(e)[:120]}") + # A site born on dev2 has no Railway project: never ask Railway (its token may be gone). + if s.get("project_id"): + try: vars_ = rw_variables(s["project_id"], s["environment_id"], sid or s["service_id"]) + except RuntimeError as e: warn(f"railway variables: {str(e)[:120]}") if vars_: return vars_ txt = ssh("root", f"cat {WWW}/{shlex.quote(s['site'])}/{shlex.quote(envfile)} 2>/dev/null", check=False) if not txt.strip(): @@ -1485,6 +1487,13 @@ def cmd_cert(args): if not zone_at_porkbun(s["site"]): note(f"{s['site']}'s zone is not at Porkbun (NS: {sh(['dig', '+short', 'NS', '.'.join(s['site'].split('.')[-2:])], check=False).strip().replace(chr(10), ' ')})") return cmd_cert_http(s, domains) + # Every name already answers on dev2 (a *.pit.world wildcard, a site born here): + # HTTP-01 needs no DNS API at all, so a dead Porkbun key cannot block the cert. + # sites.d "cert_mode": "dns-01" forces the old path. + if s.get("cert_mode") != "dns-01" and not any(d.startswith("*.") for d in domains) and \ + all(DEV2_IP in sh(["dig", "+short", d], check=False).split() for d in domains): + note(f"{', '.join(domains)} already resolve to dev2; using HTTP-01") + return cmd_cert_http(s, domains) k, sk = porkbun_keys() cert_dir = f"/etc/nginx/ssl/{s['site']}" log(f"Certificate for {', '.join(domains)} (acme.sh, Porkbun DNS-01, issued before DNS moves)") diff --git a/dev2/edge/edge.json b/dev2/edge/edge.json index f2d8aca..27f0f2c 100644 --- a/dev2/edge/edge.json +++ b/dev2/edge/edge.json @@ -12,7 +12,8 @@ ], "keep_on_nginx": { "//": "Names under a routed pattern that stay on nginx for now, with why.", - "live.streaming.pit.world": "per-path routing to two nixamp servers with a 404 fallback (error_page @server2); move after it has its own Caddy rehearsal" + "live.streaming.pit.world": "per-path routing to two nixamp servers with a 404 fallback (error_page @server2); move after it has its own Caddy rehearsal", + "webmail.pit.world": "Roundcube (dev2-site webmail.profullstack.com, :3391): an ordinary dev2-site vhost + HTTP-01 cert on nginx, not a pit-proxy name" }, "sites": [ { "hosts": ["pit.world"], "proxy": 8787, "max_body": "1MiB" }, diff --git a/dev2/sites.d/webmail.profullstack.com.json b/dev2/sites.d/webmail.profullstack.com.json new file mode 100644 index 0000000..2863b40 --- /dev/null +++ b/dev2/sites.d/webmail.profullstack.com.json @@ -0,0 +1,55 @@ +{ + "site": "webmail.profullstack.com", + "service": "webmail.profullstack.com", + "repo": null, + "branch": null, + "image": "roundcube/roundcubemail:1.7.5-apache", + "start_command": "apache2-foreground", + "port_var": "80", + "domains": [ + "webmail.pit.world" + ], + "db": "none", + "db_kinds": [], + "redis": false, + "volumes": [ + { + "name": "db", + "mountPath": "/var/roundcube/db" + }, + { + "name": "config", + "mountPath": "/var/roundcube/config" + }, + { + "name": "plugin-anyhost", + "mountPath": "/var/www/html/plugins/anyhost" + } + ], + "port_var_note": "Roundcube's Apache listens on 80 inside the container", + "companions": [], + "infra": [], + "shared_repo": false, + "project": null, + "project_id": null, + "environment_id": null, + "service_id": null, + "port": 3391, + "health_path": "/", + "max_body": "32m", + "mem_limit": "384m", + "mem_note": "Roundcube 1.7 on Apache + mod_php, SQLite; a handful of users", + "vault": "", + "env_overrides": { + "ROUNDCUBEMAIL_DB_TYPE": "sqlite", + "ROUNDCUBEMAIL_DB_DIR": "/var/roundcube/db", + "ROUNDCUBEMAIL_DEFAULT_HOST": "ssl://imap.forwardemail.net", + "ROUNDCUBEMAIL_DEFAULT_PORT": "993", + "ROUNDCUBEMAIL_SMTP_SERVER": "ssl://smtp.forwardemail.net", + "ROUNDCUBEMAIL_SMTP_PORT": "465", + "ROUNDCUBEMAIL_PLUGINS": "anyhost,archive,zipdownload", + "ROUNDCUBEMAIL_SKIN": "elastic", + "ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE": "25M" + }, + "notes": "Not a Railway site: born on dev2 2026-11-02. Public at webmail.pit.world (wildcard *.pit.world already on dev2, cert by HTTP-01); add webmail.profullstack.com to domains once the Porkbun API key is replaced (the vaulted pk1_264d pair is revoked: INVALID_API_KEYS 2026-11-02). Roundcube webmail, stock image, no repo. Login form has a Server field: users sign in to ANY IMAP server (blank = Forward Email, imap.forwardemail.net). The anyhost plugin (cli-tools dev2/webmail/anyhost, copied to volumes/plugin-anyhost) turns a bare hostname into ssl://host:993, derives SMTP (imap.X -> smtp.X, else the same host; 465 then 587) and refuses private/loopback addresses so the form cannot probe dev2. volumes/config/zz-profullstack.inc.php holds des_key (minted on the box, never committed) and the imap_host override. mail.profullstack.com is NOT this site: it is the bbs box's Mailu MTA (PTR, SPF, cert)." +} diff --git a/dev2/webmail/anyhost/anyhost.php b/dev2/webmail/anyhost/anyhost.php new file mode 100644 index 0000000..eeff6dc --- /dev/null +++ b/dev2/webmail/anyhost/anyhost.php @@ -0,0 +1,138 @@ + when the IMAP host is + * imap., then the IMAP host itself, each tried on 465 (ssl) then 587 (tls). + * + * Source: profullstack/cli-tools dev2/webmail/anyhost (deployed to volumes/plugin-anyhost). + */ +class anyhost extends rcube_plugin +{ + const DEFAULT_IMAP = 'ssl://imap.forwardemail.net:993'; + const DEFAULT_SMTP = 'ssl://smtp.forwardemail.net:465'; + + public function init() + { + $this->add_hook('authenticate', [$this, 'authenticate']); + $this->add_hook('smtp_connect', [$this, 'smtp_connect']); + $this->add_hook('template_object_loginform', [$this, 'loginform']); + } + + public function loginform($args) + { + // A hint in the empty Server field. + $args['content'] = preg_replace( + '/(]*name="_host"[^>]*?)(\s*\/?>)/', + '$1 placeholder="imap.forwardemail.net (default)"$2', + $args['content'] + ); + return $args; + } + + public function authenticate($args) + { + $raw = trim((string) ($args['host'] ?? '')); + if ($raw === '') { + $args['host'] = self::DEFAULT_IMAP; + return $args; + } + + $host = self::parse($raw, 993); + if (!$host) { + return self::abort($args); + } + [$name, $port] = $host; + if (!in_array($port, [993, 143], true) || !self::public_host($name)) { + return self::abort($args); + } + $args['host'] = ($port === 143 ? 'tls://' : 'ssl://') . $name . ':' . $port; + return $args; + } + + public function smtp_connect($args) + { + $rcmail = rcmail::get_instance(); + $imap = (string) ($_SESSION['storage_host'] ?? ''); + if ($imap === '' || $imap === 'imap.forwardemail.net') { + $args['smtp_host'] = self::DEFAULT_SMTP; + return $args; + } + if (empty($_SESSION['anyhost_smtp'])) { + $_SESSION['anyhost_smtp'] = self::find_smtp($imap) ?: ('ssl://' . $imap . ':465'); + } + $args['smtp_host'] = $_SESSION['anyhost_smtp']; + $args['smtp_user'] = $rcmail->get_user_name(); + $args['smtp_pass'] = $rcmail->get_user_password(); + return $args; + } + + private static function find_smtp($imap) + { + $candidates = []; + if (stripos($imap, 'imap.') === 0) { + $candidates[] = 'smtp.' . substr($imap, 5); + } + $candidates[] = $imap; + foreach ($candidates as $h) { + if (!self::public_host($h)) { + continue; + } + foreach ([465 => 'ssl', 587 => 'tls'] as $port => $scheme) { + $fp = @fsockopen($h, $port, $errno, $errstr, 4); + if ($fp) { + fclose($fp); + return "$scheme://$h:$port"; + } + } + } + return null; + } + + /** [hostname, port] from "host", "host:port" or "ssl://host:port"; null when malformed. */ + private static function parse($raw, $default_port) + { + $raw = preg_replace('#^[a-z]+://#i', '', $raw); + if (!preg_match('/^([a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+)(:(\d{1,5}))?$/i', $raw, $m)) { + return null; + } + return [strtolower($m[1]), isset($m[6]) ? (int) $m[6] : $default_port]; + } + + /** Every address the name resolves to is public (no RFC 1918, loopback, link-local, ULA). */ + private static function public_host($name) + { + $ips = []; + foreach ((array) @dns_get_record($name, DNS_A | DNS_AAAA) as $r) { + if (!empty($r['ip'])) $ips[] = $r['ip']; + if (!empty($r['ipv6'])) $ips[] = $r['ipv6']; + } + if (!$ips) { + $ips = (array) @gethostbynamel($name); + } + if (!$ips) { + return false; + } + foreach ($ips as $ip) { + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return false; + } + } + return true; + } + + private static function abort($args) + { + $args['abort'] = true; + $args['error'] = 'That mail server is not allowed. Use its public hostname, port 993 (or 143 for STARTTLS).'; + return $args; + } +}