Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions dev2/dev2-site
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)")
Expand Down
3 changes: 2 additions & 1 deletion dev2/edge/edge.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
55 changes: 55 additions & 0 deletions dev2/sites.d/webmail.profullstack.com.json
Original file line number Diff line number Diff line change
@@ -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)."
}
138 changes: 138 additions & 0 deletions dev2/webmail/anyhost/anyhost.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

/**
* anyhost: sign in to any IMAP server from one Roundcube.
*
* The login form shows a free-text Server field (imap_host is '' in
* zz-profullstack.inc.php). This plugin
* - defaults a blank server to Forward Email,
* - turns "mail.example.com" or "mail.example.com:143" into a Roundcube host URL
* (ssl://host:993, or tls://host:143 for STARTTLS),
* - refuses hosts that resolve to private, loopback or link-local addresses and ports
* other than 993/143, so the form cannot be used to reach the box's own services,
* - picks the SMTP server for the session: smtp.<rest> when the IMAP host is
* imap.<rest>, 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(
'/(<input[^>]*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;
}
}
Loading