Skip to content

Commit 86f3dcc

Browse files
sh1pt-actions-fleet[bot]ralyodioclaude
authored
dev2: Roundcube webmail at webmail.pit.world (any IMAP server) (#229)
- sites.d/webmail.profullstack.com: stock roundcube/roundcubemail:1.7.5-apache, SQLite, image-only; public at webmail.pit.world for now (Porkbun key revoked, so webmail.profullstack.com cannot be created yet). - webmail/anyhost: Roundcube plugin. Free-text Server on the login form (blank = Forward Email), bare host -> ssl://host:993, SMTP derived (imap.X -> smtp.X, else the host; 465 then 587), private/loopback hosts and non-IMAP ports refused. - dev2-site: never ask Railway for a site with no project_id (rw_token crashed on None); cert uses HTTP-01 when every name already resolves to dev2, so a dead Porkbun key cannot block it (sites.d cert_mode: dns-01 forces the old path). - edge.json: webmail.pit.world stays on nginx (keep_on_nginx). Co-authored-by: Anthony Ettinger <anthony@profullstack.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a3bfd75 commit 86f3dcc

4 files changed

Lines changed: 206 additions & 3 deletions

File tree

‎dev2/dev2-site‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,10 @@ def site_variables(s, sid=None, envfile="app.env"):
184184
dev2 (a retired service has no variables left anywhere else). Values from dev2 are already
185185
the rendered ones, which is what a re-render should start from."""
186186
vars_ = {}
187-
try: vars_ = rw_variables(s["project_id"], s["environment_id"], sid or s["service_id"])
188-
except RuntimeError as e: warn(f"railway variables: {str(e)[:120]}")
187+
# A site born on dev2 has no Railway project: never ask Railway (its token may be gone).
188+
if s.get("project_id"):
189+
try: vars_ = rw_variables(s["project_id"], s["environment_id"], sid or s["service_id"])
190+
except RuntimeError as e: warn(f"railway variables: {str(e)[:120]}")
189191
if vars_: return vars_
190192
txt = ssh("root", f"cat {WWW}/{shlex.quote(s['site'])}/{shlex.quote(envfile)} 2>/dev/null", check=False)
191193
if not txt.strip():
@@ -1485,6 +1487,13 @@ def cmd_cert(args):
14851487
if not zone_at_porkbun(s["site"]):
14861488
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), ' ')})")
14871489
return cmd_cert_http(s, domains)
1490+
# Every name already answers on dev2 (a *.pit.world wildcard, a site born here):
1491+
# HTTP-01 needs no DNS API at all, so a dead Porkbun key cannot block the cert.
1492+
# sites.d "cert_mode": "dns-01" forces the old path.
1493+
if s.get("cert_mode") != "dns-01" and not any(d.startswith("*.") for d in domains) and \
1494+
all(DEV2_IP in sh(["dig", "+short", d], check=False).split() for d in domains):
1495+
note(f"{', '.join(domains)} already resolve to dev2; using HTTP-01")
1496+
return cmd_cert_http(s, domains)
14881497
k, sk = porkbun_keys()
14891498
cert_dir = f"/etc/nginx/ssl/{s['site']}"
14901499
log(f"Certificate for {', '.join(domains)} (acme.sh, Porkbun DNS-01, issued before DNS moves)")

‎dev2/edge/edge.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
],
1313
"keep_on_nginx": {
1414
"//": "Names under a routed pattern that stay on nginx for now, with why.",
15-
"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"
15+
"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",
16+
"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"
1617
},
1718
"sites": [
1819
{ "hosts": ["pit.world"], "proxy": 8787, "max_body": "1MiB" },
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"site": "webmail.profullstack.com",
3+
"service": "webmail.profullstack.com",
4+
"repo": null,
5+
"branch": null,
6+
"image": "roundcube/roundcubemail:1.7.5-apache",
7+
"start_command": "apache2-foreground",
8+
"port_var": "80",
9+
"domains": [
10+
"webmail.pit.world"
11+
],
12+
"db": "none",
13+
"db_kinds": [],
14+
"redis": false,
15+
"volumes": [
16+
{
17+
"name": "db",
18+
"mountPath": "/var/roundcube/db"
19+
},
20+
{
21+
"name": "config",
22+
"mountPath": "/var/roundcube/config"
23+
},
24+
{
25+
"name": "plugin-anyhost",
26+
"mountPath": "/var/www/html/plugins/anyhost"
27+
}
28+
],
29+
"port_var_note": "Roundcube's Apache listens on 80 inside the container",
30+
"companions": [],
31+
"infra": [],
32+
"shared_repo": false,
33+
"project": null,
34+
"project_id": null,
35+
"environment_id": null,
36+
"service_id": null,
37+
"port": 3391,
38+
"health_path": "/",
39+
"max_body": "32m",
40+
"mem_limit": "384m",
41+
"mem_note": "Roundcube 1.7 on Apache + mod_php, SQLite; a handful of users",
42+
"vault": "",
43+
"env_overrides": {
44+
"ROUNDCUBEMAIL_DB_TYPE": "sqlite",
45+
"ROUNDCUBEMAIL_DB_DIR": "/var/roundcube/db",
46+
"ROUNDCUBEMAIL_DEFAULT_HOST": "ssl://imap.forwardemail.net",
47+
"ROUNDCUBEMAIL_DEFAULT_PORT": "993",
48+
"ROUNDCUBEMAIL_SMTP_SERVER": "ssl://smtp.forwardemail.net",
49+
"ROUNDCUBEMAIL_SMTP_PORT": "465",
50+
"ROUNDCUBEMAIL_PLUGINS": "anyhost,archive,zipdownload",
51+
"ROUNDCUBEMAIL_SKIN": "elastic",
52+
"ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE": "25M"
53+
},
54+
"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)."
55+
}

‎dev2/webmail/anyhost/anyhost.php‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?php
2+
3+
/**
4+
* anyhost: sign in to any IMAP server from one Roundcube.
5+
*
6+
* The login form shows a free-text Server field (imap_host is '' in
7+
* zz-profullstack.inc.php). This plugin
8+
* - defaults a blank server to Forward Email,
9+
* - turns "mail.example.com" or "mail.example.com:143" into a Roundcube host URL
10+
* (ssl://host:993, or tls://host:143 for STARTTLS),
11+
* - refuses hosts that resolve to private, loopback or link-local addresses and ports
12+
* other than 993/143, so the form cannot be used to reach the box's own services,
13+
* - picks the SMTP server for the session: smtp.<rest> when the IMAP host is
14+
* imap.<rest>, then the IMAP host itself, each tried on 465 (ssl) then 587 (tls).
15+
*
16+
* Source: profullstack/cli-tools dev2/webmail/anyhost (deployed to volumes/plugin-anyhost).
17+
*/
18+
class anyhost extends rcube_plugin
19+
{
20+
const DEFAULT_IMAP = 'ssl://imap.forwardemail.net:993';
21+
const DEFAULT_SMTP = 'ssl://smtp.forwardemail.net:465';
22+
23+
public function init()
24+
{
25+
$this->add_hook('authenticate', [$this, 'authenticate']);
26+
$this->add_hook('smtp_connect', [$this, 'smtp_connect']);
27+
$this->add_hook('template_object_loginform', [$this, 'loginform']);
28+
}
29+
30+
public function loginform($args)
31+
{
32+
// A hint in the empty Server field.
33+
$args['content'] = preg_replace(
34+
'/(<input[^>]*name="_host"[^>]*?)(\s*\/?>)/',
35+
'$1 placeholder="imap.forwardemail.net (default)"$2',
36+
$args['content']
37+
);
38+
return $args;
39+
}
40+
41+
public function authenticate($args)
42+
{
43+
$raw = trim((string) ($args['host'] ?? ''));
44+
if ($raw === '') {
45+
$args['host'] = self::DEFAULT_IMAP;
46+
return $args;
47+
}
48+
49+
$host = self::parse($raw, 993);
50+
if (!$host) {
51+
return self::abort($args);
52+
}
53+
[$name, $port] = $host;
54+
if (!in_array($port, [993, 143], true) || !self::public_host($name)) {
55+
return self::abort($args);
56+
}
57+
$args['host'] = ($port === 143 ? 'tls://' : 'ssl://') . $name . ':' . $port;
58+
return $args;
59+
}
60+
61+
public function smtp_connect($args)
62+
{
63+
$rcmail = rcmail::get_instance();
64+
$imap = (string) ($_SESSION['storage_host'] ?? '');
65+
if ($imap === '' || $imap === 'imap.forwardemail.net') {
66+
$args['smtp_host'] = self::DEFAULT_SMTP;
67+
return $args;
68+
}
69+
if (empty($_SESSION['anyhost_smtp'])) {
70+
$_SESSION['anyhost_smtp'] = self::find_smtp($imap) ?: ('ssl://' . $imap . ':465');
71+
}
72+
$args['smtp_host'] = $_SESSION['anyhost_smtp'];
73+
$args['smtp_user'] = $rcmail->get_user_name();
74+
$args['smtp_pass'] = $rcmail->get_user_password();
75+
return $args;
76+
}
77+
78+
private static function find_smtp($imap)
79+
{
80+
$candidates = [];
81+
if (stripos($imap, 'imap.') === 0) {
82+
$candidates[] = 'smtp.' . substr($imap, 5);
83+
}
84+
$candidates[] = $imap;
85+
foreach ($candidates as $h) {
86+
if (!self::public_host($h)) {
87+
continue;
88+
}
89+
foreach ([465 => 'ssl', 587 => 'tls'] as $port => $scheme) {
90+
$fp = @fsockopen($h, $port, $errno, $errstr, 4);
91+
if ($fp) {
92+
fclose($fp);
93+
return "$scheme://$h:$port";
94+
}
95+
}
96+
}
97+
return null;
98+
}
99+
100+
/** [hostname, port] from "host", "host:port" or "ssl://host:port"; null when malformed. */
101+
private static function parse($raw, $default_port)
102+
{
103+
$raw = preg_replace('#^[a-z]+://#i', '', $raw);
104+
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)) {
105+
return null;
106+
}
107+
return [strtolower($m[1]), isset($m[6]) ? (int) $m[6] : $default_port];
108+
}
109+
110+
/** Every address the name resolves to is public (no RFC 1918, loopback, link-local, ULA). */
111+
private static function public_host($name)
112+
{
113+
$ips = [];
114+
foreach ((array) @dns_get_record($name, DNS_A | DNS_AAAA) as $r) {
115+
if (!empty($r['ip'])) $ips[] = $r['ip'];
116+
if (!empty($r['ipv6'])) $ips[] = $r['ipv6'];
117+
}
118+
if (!$ips) {
119+
$ips = (array) @gethostbynamel($name);
120+
}
121+
if (!$ips) {
122+
return false;
123+
}
124+
foreach ($ips as $ip) {
125+
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
126+
return false;
127+
}
128+
}
129+
return true;
130+
}
131+
132+
private static function abort($args)
133+
{
134+
$args['abort'] = true;
135+
$args['error'] = 'That mail server is not allowed. Use its public hostname, port 993 (or 143 for STARTTLS).';
136+
return $args;
137+
}
138+
}

0 commit comments

Comments
 (0)