fix(fips): app launch ports allowed through the mesh firewall

The companion opens catalog apps by direct port over the mesh; the
fips0 default-deny baseline blocked every one of them (apps 'stuck'
from the phone, 2026-07-26). Core now writes a second fips.d drop-in
from the generated catalog launch-port list on every install/upgrade.
Service/RPC ports (bitcoind 8332, LND 10009, Tor) stay closed.
generate-app-catalog.py emits the Rust port list alongside the TS one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-26 22:23:46 +01:00
parent 325b9ea9c9
commit 02917cc22c
4 changed files with 113 additions and 0 deletions

View File

@ -0,0 +1,44 @@
//! Generated by scripts/generate-app-catalog.py. Do not edit manually.
//!
//! Catalog app launch ports (the web UIs the companion opens by direct
//! port). Used to write the fips0 firewall allowance drop-in so app UIs
//! are reachable over the mesh; ports of apps that aren't installed have
//! no listener, so allowing them is inert.
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283,
2342,
3000,
3001,
3002,
4080,
5180,
7778,
8080,
8081,
8082,
8083,
8084,
8085,
8087,
8088,
8089,
8090,
8096,
8123,
8175,
8176,
8240,
8334,
8888,
8999,
9000,
9100,
10380,
11434,
18081,
18083,
23000,
32838,
50002,
];

View File

@ -252,6 +252,29 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
let nft_install = sudo_install_file(&nft_stage, "/etc/fips/fips.d/80-web-ui.nft", "0644").await; let nft_install = sudo_install_file(&nft_stage, "/etc/fips/fips.d/80-web-ui.nft", "0644").await;
let _ = tokio::fs::remove_file(&nft_stage).await; let _ = tokio::fs::remove_file(&nft_stage).await;
nft_install?; nft_install?;
// App launch ports: the companion opens catalog apps by direct port
// over the mesh. Ports of apps that aren't installed have no listener,
// so the allowance is inert until an app exists to answer.
let port_list = super::app_ports::APP_LAUNCH_PORTS
.iter()
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join(", ");
let app_dropin = format!(
"# Written by archipelago on every daemon config install.\n\
# Catalog app launch ports (web UIs) allowed through the fips0\n\
# default-deny inbound baseline. Service/RPC ports stay closed.\n\
tcp dport {{ {port_list} }} accept\n"
);
let app_stage = std::env::temp_dir().join(format!("fips-appports-{}.nft", std::process::id()));
tokio::fs::write(&app_stage, app_dropin)
.await
.context("Failed to stage app-ports nft drop-in")?;
let app_install =
sudo_install_file(&app_stage, "/etc/fips/fips.d/85-app-ports.nft", "0644").await;
let _ = tokio::fs::remove_file(&app_stage).await;
app_install?;
// Make the allowance live immediately; a no-op error when the // Make the allowance live immediately; a no-op error when the
// hardening baseline isn't installed on this node yet. // hardening baseline isn't installed on this node yet.
if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) { if tokio::fs::try_exists("/etc/fips/fips.nft").await.unwrap_or(false) {

View File

@ -26,6 +26,7 @@
#![allow(dead_code)] #![allow(dead_code)]
pub mod anchors; pub mod anchors;
pub mod app_ports;
pub mod config; pub mod config;
pub mod dial; pub mod dial;
pub mod iface; pub mod iface;

View File

@ -141,6 +141,32 @@ def render_app_session_config(manifests: dict[str, dict[str, Any]]) -> str:
return "\n".join(lines) return "\n".join(lines)
def render_rust_ports(ports: dict[str, int], extra_ports: list[int]) -> str:
"""Rust constant of catalog launch ports for the fips0 firewall drop-in
(core/archipelago/src/fips/app_ports.rs). Extra ports cover the frontend's
APP_PORTS overrides (companions/aliases) that have no manifest of their own.
"""
distinct = sorted(set(list(ports.values()) + extra_ports))
lines = [
"//! Generated by scripts/generate-app-catalog.py. Do not edit manually.",
"//!",
"//! Catalog app launch ports (the web UIs the companion opens by direct",
"//! port). Used to write the fips0 firewall allowance drop-in so app UIs",
"//! are reachable over the mesh; ports of apps that aren\'t installed have",
"//! no listener, so allowing them is inert.",
"",
"pub const APP_LAUNCH_PORTS: &[u16] = &[",
]
lines.extend(f" {port}," for port in distinct)
lines.extend(["];", ""])
return "\n".join(lines)
# Keep in lockstep with APP_PORTS overrides in
# neode-ui/src/views/appSession/appSessionConfig.ts.
RUST_EXTRA_PORTS = [8334, 50002, 18083, 11434, 8081, 8240, 8175, 8176, 8080]
def sync_catalog(path: Path, manifests: dict[str, dict[str, Any]]) -> int: def sync_catalog(path: Path, manifests: dict[str, dict[str, Any]]) -> int:
with path.open("r", encoding="utf-8") as fh: with path.open("r", encoding="utf-8") as fh:
catalog = json.load(fh) catalog = json.load(fh)
@ -178,6 +204,11 @@ def main() -> int:
default=[], default=[],
help="Catalog JSON path to update. May be passed multiple times.", help="Catalog JSON path to update. May be passed multiple times.",
) )
parser.add_argument(
"--rust-app-ports",
default="core/archipelago/src/fips/app_ports.rs",
help="Generated Rust launch-port list for the fips0 firewall drop-in. Empty string to skip.",
)
parser.add_argument( parser.add_argument(
"--app-session-config", "--app-session-config",
default="neode-ui/src/views/appSession/generatedAppSessionConfig.ts", default="neode-ui/src/views/appSession/generatedAppSessionConfig.ts",
@ -201,6 +232,20 @@ def main() -> int:
print(f"{path}: updated") print(f"{path}: updated")
else: else:
print(f"{path}: updated 0 fields") print(f"{path}: updated 0 fields")
if args.rust_app_ports:
ports = {
app_id: port
for app_id, app in manifests.items()
if (port := manifest_launch_port(app))
}
rust_path = Path(args.rust_app_ports)
rust_content = render_rust_ports(ports, RUST_EXTRA_PORTS)
rust_old = rust_path.read_text(encoding="utf-8") if rust_path.exists() else ""
if rust_old != rust_content:
rust_path.write_text(rust_content, encoding="utf-8")
print(f"{rust_path}: updated")
else:
print(f"{rust_path}: updated 0 fields")
print(f"total_updated={total}") print(f"total_updated={total}")
return 0 return 0