Files
adaptive-multi-protocol-gat…/ampg/install_plan.py
jwinterm 4767b7965a fix: emit real micron, and validate values interpolated into daemon configs
render_micron was a one-line alias to the Gemtext renderer, so every .mu file
AMPG produced was Gemtext. A Nomad Network browser renders those markers as
raw punctuation. Micron is a different markup: headings are > by depth, links
are inline as `[label`url] rather than standalone lines, emphasis is a toggle
pair, and literal blocks are fenced with `=.

Content is now escaped as well. Micron's control character is the backtick, and
>, <, #, and - are structural at column zero, so a vendor named ">Free Beer"
would have become a section heading on the published page. The line-start check
applies to the emitted line rather than each text chunk, since whether a
character is structural depends on it landing at column zero.

Separately, torrc and tunnels.conf are line-oriented with no quoting or escape
mechanism, so a newline in an interpolated value injects an arbitrary directive:
a tunnel_name of "web\nhost = attacker.example" silently repoints the tunnel.
tunnel_name and keys_file are now validated at the point of interpolation.
_validate_site_id covers the init path only, so a config loaded from disk never
passed through it. There is nothing to escape with here, so these reject rather
than sanitize.

Generated systemd units had no User= and no hardening, so an operator following
the install steps as root got a root-run daemon. Units now carry User=/Group=
when a service account is configured, a visible warning when not, and a baseline
of hardening directives chosen to not break a daemon whose state directory may
live anywhere.

Two tests asserted Gemtext markers inside a .mu file and so had encoded the bug.
2026-07-28 16:03:40 -04:00

1021 lines
31 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import shlex
from .approvals import (
ApprovalInput,
ApprovalRegistry,
approval_check,
load_approval_registry,
)
from .config import GatewayConfig, ProtocolConfig, SiteConfig
from .platforms import PlatformProvider, detect_platform
from .state_contract import daemon_config_path, protocol_state_dir
from .status import DaemonProbeFunc, TransportStatus, gateway_status
from .transports import fallback_transport_adapter, transport_adapter
@dataclass(frozen=True)
class InstallStep:
site_id: str
protocol: str
platform: str
stage: str
action: str
target: str
status: str
command: str
message: str
@dataclass(frozen=True)
class InstallArtifact:
site_id: str
protocol: str
platform: str
kind: str
path: Path
content: str
@dataclass(frozen=True)
class InstallStateCopy:
site_id: str
protocol: str
platform: str
kind: str
source: Path
target: Path
status: str
command: str
message: str
@dataclass(frozen=True)
class InstallSupervisorAction:
site_id: str
protocol: str
platform: str
kind: str
service: str
source: Path
status: str
command: str
message: str
@dataclass(frozen=True)
class InstallPackageAction:
site_id: str
protocol: str
platform: str
package: str
status: str
command: tuple[str, ...]
message: str
def install_plan(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[InstallStep]:
provider = platform_provider or detect_platform()
statuses = gateway_status(
config,
platform_provider=provider,
daemon_probe=daemon_probe,
)
status_by_protocol = {
(status.site_id, status.protocol): status for status in statuses
}
steps: list[InstallStep] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
status = status_by_protocol[(site.id, protocol.name)]
steps.extend(_protocol_install_steps(config, site, protocol, provider, status))
return steps
def blocked_install_steps(steps: list[InstallStep]) -> list[InstallStep]:
return [step for step in steps if step.status == "blocked"]
def install_artifacts(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[InstallArtifact]:
provider = platform_provider or detect_platform()
statuses = gateway_status(
config,
platform_provider=provider,
daemon_probe=daemon_probe,
)
status_by_protocol = {
(status.site_id, status.protocol): status for status in statuses
}
artifacts: list[InstallArtifact] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
status = status_by_protocol[(site.id, protocol.name)]
artifacts.extend(_protocol_install_artifacts(config, site, protocol, provider, status))
return artifacts
def install_package_actions(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[InstallPackageAction]:
provider = platform_provider or detect_platform()
steps = install_plan(
config,
platform_provider=provider,
daemon_probe=daemon_probe,
)
actions: list[InstallPackageAction] = []
for step in steps:
if step.stage == "package":
actions.append(
InstallPackageAction(
site_id=step.site_id,
protocol=step.protocol,
platform=step.platform,
package=step.target,
status=step.status,
command=package_install_command(provider, step.target),
message=step.message,
)
)
elif step.status == "blocked":
actions.append(
InstallPackageAction(
site_id=step.site_id,
protocol=step.protocol,
platform=step.platform,
package=step.target,
status="blocked",
command=(),
message=step.message,
)
)
return actions
def write_install_artifacts(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[InstallArtifact]:
artifacts = install_artifacts(
config,
platform_provider=platform_provider,
daemon_probe=daemon_probe,
)
for artifact in artifacts:
artifact.path.parent.mkdir(parents=True, exist_ok=True)
artifact.path.write_text(artifact.content, encoding="utf-8")
return artifacts
def install_state_copies(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[InstallStateCopy]:
provider = platform_provider or detect_platform()
statuses = gateway_status(
config,
platform_provider=provider,
daemon_probe=daemon_probe,
)
status_by_protocol = {
(status.site_id, status.protocol): status for status in statuses
}
approvals = load_approval_registry(config)
copies: list[InstallStateCopy] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
status = status_by_protocol[(site.id, protocol.name)]
copies.extend(
_protocol_state_copies(
config,
site,
protocol,
provider,
status,
approvals=approvals,
)
)
return copies
def install_supervisor_actions(
config: GatewayConfig,
*,
platform_provider: PlatformProvider | None = None,
daemon_probe: DaemonProbeFunc | None = None,
) -> list[InstallSupervisorAction]:
provider = platform_provider or detect_platform()
statuses = gateway_status(
config,
platform_provider=provider,
daemon_probe=daemon_probe,
)
status_by_protocol = {
(status.site_id, status.protocol): status for status in statuses
}
approvals = load_approval_registry(config)
actions: list[InstallSupervisorAction] = []
for site in config.sites:
for protocol in site.protocols.values():
if not protocol.enabled:
continue
status = status_by_protocol[(site.id, protocol.name)]
actions.extend(
_protocol_supervisor_actions(
config,
site,
protocol,
provider,
status,
approvals=approvals,
)
)
return actions
def _protocol_install_steps(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
status: TransportStatus,
) -> list[InstallStep]:
if status.action == "adopt-existing":
return [
_step(
site,
protocol,
provider,
stage="daemon",
action="adopt-existing",
target=protocol.daemon,
status="skipped",
command="-",
message="existing daemon is running; no AMPG-owned install is planned",
)
]
if status.action == "render-only":
return [
_step(
site,
protocol,
provider,
stage="daemon",
action="render-only",
target=protocol.daemon,
status="skipped",
command="-",
message="external policy renders outputs and review artifacts only",
)
]
if status.action != "manage-owned" or status.status == "error":
return [
_step(
site,
protocol,
provider,
stage="daemon",
action=status.action,
target=protocol.daemon,
status="blocked",
command="-",
message=status.message,
)
]
adapter = transport_adapter(protocol.name, protocol.daemon)
adapter = adapter or fallback_transport_adapter(protocol.name, protocol.daemon)
package_steps = [
_package_step(site, protocol, provider, protocol.daemon, installed=status.installed)
]
package_steps.extend(
_package_step(site, protocol, provider, support_daemon, installed=False)
for support_daemon in _support_daemons(protocol)
)
return [
*package_steps,
_step(
site,
protocol,
provider,
stage="state",
action="create-state",
target=str(_state_dir(config, provider, site, protocol)),
status="planned",
command=_mkdir_command(provider, _state_dir(config, provider, site, protocol)),
message="create AMPG-owned state directory for daemon config, keys, and logs",
),
_step(
site,
protocol,
provider,
stage="config",
action="write-managed-config",
target=str(daemon_config_path(config, site, protocol)),
status="planned",
command="-",
message=f"write managed {adapter.backend} config after plan review",
),
_step(
site,
protocol,
provider,
stage="supervisor",
action="configure-supervisor",
target=_service_name(site, protocol),
status="planned",
command=_supervisor_command(provider, site, protocol),
message=f"register AMPG-owned service with {provider.process_supervisor}",
),
_step(
site,
protocol,
provider,
stage="health",
action="check-transport",
target=protocol.name,
status="planned",
command="-",
message="verify generated site through the managed transport after start",
),
]
def _protocol_install_artifacts(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
status: TransportStatus,
) -> list[InstallArtifact]:
if status.action != "manage-owned" or status.status == "error":
return []
artifacts = [
_artifact(
site,
protocol,
provider,
kind="daemon-config",
filename=_daemon_config_filename(protocol),
content=_daemon_config_content(config, site, protocol, provider),
),
_artifact(
site,
protocol,
provider,
kind="daemon-supervisor",
filename=_supervisor_filename(provider, protocol.daemon),
content=_supervisor_content(
config,
site,
protocol,
provider,
service_suffix=protocol.daemon,
command=_daemon_start_command(config, site, protocol, provider),
),
),
]
if protocol.name in {"tor", "i2p"}:
artifacts.extend(
(
_artifact(
site,
protocol,
provider,
kind="loopback-config",
filename="nginx-loopback.conf",
content=_managed_nginx_loopback_content(config, site, protocol, provider),
),
_artifact(
site,
protocol,
provider,
kind="loopback-supervisor",
filename=_supervisor_filename(provider, "nginx-loopback"),
content=_supervisor_content(
config,
site,
protocol,
provider,
service_suffix="nginx-loopback",
command=_loopback_start_command(config, site, protocol, provider),
),
),
)
)
return artifacts
def _protocol_state_copies(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
status: TransportStatus,
approvals: ApprovalRegistry,
) -> list[InstallStateCopy]:
copies: list[InstallStateCopy] = []
for artifact in _protocol_install_artifacts(config, site, protocol, provider, status):
target = _state_copy_target(config, site, protocol, artifact)
if target is None:
continue
approval = approval_check(_approval_input(artifact), approvals)
copies.append(
InstallStateCopy(
site_id=site.id,
protocol=protocol.name,
platform=provider.name,
kind=artifact.kind,
source=artifact.path,
target=target,
status="planned" if approval.status == "approved" else "review",
command=_copy_command(provider, artifact.path, target),
message=(
"copy approved artifact into managed state before daemon start"
if approval.status == "approved"
else approval.message
),
)
)
return copies
def _protocol_supervisor_actions(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
status: TransportStatus,
approvals: ApprovalRegistry,
) -> list[InstallSupervisorAction]:
actions: list[InstallSupervisorAction] = []
for artifact in _protocol_install_artifacts(config, site, protocol, provider, status):
service_suffix = _supervisor_service_suffix(protocol, artifact)
if service_suffix is None:
continue
service = _artifact_service_name(site, protocol, service_suffix)
approval = approval_check(_approval_input(artifact), approvals)
actions.append(
InstallSupervisorAction(
site_id=site.id,
protocol=protocol.name,
platform=provider.name,
kind=artifact.kind,
service=service,
source=artifact.path,
status="planned" if approval.status == "approved" else "review",
command=_supervisor_start_command(provider, service),
message=(
"register and start approved supervisor after state config is copied"
if approval.status == "approved"
else approval.message
),
)
)
return actions
def _supervisor_service_suffix(
protocol: ProtocolConfig,
artifact: InstallArtifact,
) -> str | None:
if artifact.kind == "daemon-supervisor":
return protocol.daemon
if artifact.kind == "loopback-supervisor":
return "nginx-loopback"
return None
def _state_copy_target(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
artifact: InstallArtifact,
) -> Path | None:
if artifact.kind == "daemon-config":
return daemon_config_path(config, site, protocol)
if artifact.kind == "loopback-config":
return protocol_state_dir(config, site, protocol) / artifact.path.name
return None
def _package_step(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
daemon: str,
*,
installed: bool,
) -> InstallStep:
package = _package_name(provider, daemon)
return _step(
site,
protocol,
provider,
stage="package",
action="ensure-package",
target=package,
status="ready" if installed else "planned",
command="-" if installed else _package_command(provider, package),
message=(
f"{daemon} executable is already present"
if installed
else f"install package providing {daemon}"
),
)
def _support_daemons(protocol: ProtocolConfig) -> tuple[str, ...]:
if protocol.name in {"tor", "i2p"}:
return ("nginx",)
return ()
def _package_name(provider: PlatformProvider, daemon: str) -> str:
if provider.name == "macos-launchd" and daemon == "ipfs":
return "kubo"
return daemon
def package_install_command(provider: PlatformProvider, package: str) -> tuple[str, ...]:
if provider.name == "android-termux":
return ("pkg", "install", package)
if provider.name == "linux-systemd":
return ("sudo", "apt", "install", package)
if provider.name == "macos-launchd":
return ("brew", "install", package)
return ()
def _package_command(provider: PlatformProvider, package: str) -> str:
command = package_install_command(provider, package)
if command:
return shlex.join(command)
if provider.name == "linux-user":
return f"install {package} with the user package manager"
return "-"
def _mkdir_command(provider: PlatformProvider, path: Path) -> str:
prefix = "sudo " if provider.can_write_system_config else ""
return f"{prefix}mkdir -p {path}"
def _copy_command(provider: PlatformProvider, source: Path, target: Path) -> str:
prefix = "sudo " if provider.can_write_system_config else ""
parent = shlex.quote(str(target.parent))
source_text = shlex.quote(str(source))
target_text = shlex.quote(str(target))
return f"{prefix}mkdir -p {parent} && {prefix}cp {source_text} {target_text}"
def _supervisor_command(
provider: PlatformProvider,
site: SiteConfig,
protocol: ProtocolConfig,
) -> str:
service = _service_name(site, protocol)
if provider.name == "android-termux":
return f"sv-enable {service}"
if provider.name == "linux-systemd":
return f"sudo systemctl enable --now {service}.service"
if provider.name == "macos-launchd":
return f"launchctl bootstrap gui/$UID ~/Library/LaunchAgents/org.ampg.{service}.plist"
if provider.name == "linux-user":
return f"run {service} under the user supervisor"
return "-"
def _supervisor_start_command(provider: PlatformProvider, service: str) -> str:
if provider.name == "android-termux":
return f"sv-enable {service}"
if provider.name == "linux-systemd":
return f"sudo systemctl enable --now {service}.service"
if provider.name == "macos-launchd":
return f"launchctl bootstrap gui/$UID ~/Library/LaunchAgents/org.ampg.{service}.plist"
if provider.name == "linux-user":
return f"run {service} under the user supervisor"
return "-"
# Daemon config formats (torrc, i2pd tunnels.conf, systemd units) are
# line-oriented and have NO quoting or escape mechanism, so a single newline in
# an interpolated value injects an arbitrary directive: a `tunnel_name` of
# "web\nhost = attacker.example" silently repoints the tunnel. Because there is
# nothing to escape WITH, these validators reject rather than sanitize.
#
# `_validate_site_id` in onboarding.py covers the `init` path only. A config
# loaded from disk never passes through it, so validation also has to happen
# here, at the point of interpolation.
_CONFIG_TOKEN_ALLOWED = frozenset(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-."
)
def _validate_config_token(value: str, field: str) -> str:
"""Return `value` if it is safe to interpolate into a daemon config line."""
if not value:
raise ValueError(f"{field} must not be empty")
if len(value) > 128:
raise ValueError(f"{field} must be 128 characters or fewer")
rejected = sorted({char for char in value if char not in _CONFIG_TOKEN_ALLOWED})
if rejected:
raise ValueError(
f"{field} may only contain letters, numbers, and _-. characters; "
f"rejected {''.join(rejected)!r}"
)
return value
def _validate_config_path(path: Path, field: str) -> Path:
"""Return `path` if it cannot break out of its config line."""
text = str(path)
if "\n" in text or "\r" in text:
raise ValueError(f"{field} must not contain a line break")
return path
def _state_dir(
config: GatewayConfig,
provider: PlatformProvider,
site: SiteConfig,
protocol: ProtocolConfig,
) -> Path:
return protocol_state_dir(config, site, protocol)
def _artifact_dir(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> Path:
return site.outputs.plan_root / site.id / protocol.name / "managed" / provider.name
def _service_name(site: SiteConfig, protocol: ProtocolConfig) -> str:
return f"ampg-{site.id}-{protocol.name}"
def _artifact_service_name(
site: SiteConfig,
protocol: ProtocolConfig,
service_suffix: str,
) -> str:
return f"{_service_name(site, protocol)}-{service_suffix}"
def _daemon_config_filename(protocol: ProtocolConfig) -> str:
if protocol.name == "tor":
return "torrc"
if protocol.name == "i2p":
return "i2pd-tunnels.conf"
if protocol.name == "gemini":
return "agate-plan.txt"
if protocol.name == "clearnet":
return "nginx-server.conf"
return f"{protocol.daemon}-plan.txt"
def _daemon_config_content(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> str:
state_dir = _validate_config_path(
_state_dir(config, provider, site, protocol), "state directory"
)
if protocol.name == "tor":
port = int(protocol.options.get("local_port", 18080))
hidden_service_dir = state_dir / "hidden-service"
return f"""# Generated by AMPG. Review before installing.
DataDirectory {state_dir / "data"}
HiddenServiceDir {hidden_service_dir}/
HiddenServicePort 80 127.0.0.1:{port}
Log notice file {state_dir / "tor.log"}
"""
if protocol.name == "i2p":
port = int(protocol.options.get("local_port", 18081))
inport = int(protocol.options.get("inport", 80))
key_file = _validate_config_token(
protocol.options.get("keys_file", f"{site.id}-web.dat"), "keys_file"
)
tunnel_name = _validate_config_token(
protocol.options.get("tunnel_name", f"{site.id}-web"), "tunnel_name"
)
return f"""# Generated by AMPG. Review before installing.
[{tunnel_name}]
type = http
host = 127.0.0.1
port = {port}
inport = {inport}
keys = {key_file}
"""
if protocol.name == "gemini":
port = int(protocol.options.get("port", 1965))
return f"""# Generated by AMPG. Review before installing.
daemon = agate
hostname = {site.domain}
listen = [::]:{port}
content_root = {site.outputs.root / protocol.name}
certificate = {state_dir / f"{site.id}.crt"}
private_key = {state_dir / f"{site.id}.key"}
"""
if protocol.name == "clearnet":
return _managed_nginx_server_content(config, site, protocol, provider)
return f"""# Generated by AMPG. Review before installing.
daemon = {protocol.daemon}
protocol = {protocol.name}
content_root = {site.outputs.root / protocol.name}
state_dir = {state_dir}
"""
def _managed_nginx_server_content(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> str:
state_dir = _state_dir(config, provider, site, protocol)
return f"""# Generated by AMPG. Review before installing.
worker_processes 1;
error_log {state_dir / "nginx-error.log"} notice;
pid {state_dir / "nginx.pid"};
events {{
worker_connections 128;
}}
http {{
access_log {state_dir / "nginx-access.log"};
server {{
listen 80;
listen [::]:80;
server_name {site.domain} www.{site.domain};
root {site.outputs.root / protocol.name};
index index.html;
location / {{
try_files $uri $uri/ /index.html;
}}
}}
}}
"""
def _managed_nginx_loopback_content(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> str:
state_dir = _state_dir(config, provider, site, protocol)
port = int(protocol.options.get("local_port", 18080 if protocol.name == "tor" else 18081))
return f"""# Generated by AMPG. Review before installing.
worker_processes 1;
error_log {state_dir / "nginx-loopback-error.log"} notice;
pid {state_dir / "nginx-loopback.pid"};
events {{
worker_connections 128;
}}
http {{
access_log {state_dir / "nginx-loopback-access.log"};
server {{
listen 127.0.0.1:{port};
server_name _;
root {site.outputs.root / protocol.name};
index index.html;
location / {{
try_files $uri $uri/ /index.html;
}}
}}
}}
"""
def _supervisor_filename(provider: PlatformProvider, service_suffix: str) -> str:
if provider.name == "linux-systemd":
return f"{service_suffix}.service"
if provider.name == "macos-launchd":
return f"org.ampg.{service_suffix}.plist"
return f"{service_suffix}.run"
def _supervisor_content(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
*,
service_suffix: str,
command: str,
) -> str:
service_name = _artifact_service_name(site, protocol, service_suffix)
if provider.name == "linux-systemd":
# A unit with no User= runs as whoever installs it, which is normally
# root. Emit User=/Group= when the operator has configured a service
# account, and say so loudly in the generated file otherwise, since the
# whole point of the plan/approve flow is that a human reads this.
service_user = protocol.options.get("service_user")
if service_user:
service_user = _validate_config_token(service_user, "service_user")
service_group = _validate_config_token(
protocol.options.get("service_group", service_user), "service_group"
)
account_lines = f"User={service_user}\nGroup={service_group}\n"
else:
account_lines = (
"# WARNING: no service_user configured, so this unit runs as the\n"
"# user that installs it (normally root). Set `service_user` in the\n"
"# protocol options, or add User=/Group= below before installing.\n"
)
return f"""# Generated by AMPG. Review before installing.
[Unit]
Description=AMPG {protocol.name} {service_suffix} for {site.id}
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
{account_lines}WorkingDirectory={_state_dir(config, provider, site, protocol)}
ExecStart=/bin/sh -lc '{_shell_single_quote(command)}'
Restart=on-failure
RestartSec=5
# Baseline hardening. Kept to directives that cannot break a daemon whose
# state directory may live anywhere (so no ProtectSystem/ProtectHome here);
# tighten per deployment once the state path is known.
NoNewPrivileges=true
PrivateTmp=true
RestrictSUIDSGID=true
RestrictRealtime=true
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
"""
if provider.name == "macos-launchd":
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>org.ampg.{service_name}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-lc</string>
<string>{_xml_escape(command)}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
"""
shell = "/data/data/com.termux/files/usr/bin/sh" if provider.name == "android-termux" else "/bin/sh"
state_dir = _state_dir(config, provider, site, protocol)
data_dir_setup = f"\nmkdir -p {state_dir / 'data'}" if protocol.name == "i2p" else ""
return f"""#!{shell}
# Generated by AMPG. Review before installing.
set -eu
mkdir -p {state_dir}{data_dir_setup}
{command}
"""
def _daemon_start_command(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> str:
state_dir = _state_dir(config, provider, site, protocol)
if protocol.name == "tor":
return f"exec tor -f {state_dir / 'torrc'}"
if protocol.name == "i2p":
return f"exec i2pd --datadir={state_dir / 'data'} --tunconf={state_dir / 'i2pd-tunnels.conf'}"
if protocol.name == "gemini":
port = int(protocol.options.get("port", 1965))
return (
"exec agate "
f"--content {site.outputs.root / protocol.name} "
f"--addr [::]:{port} "
f"--hostname {site.domain} "
f"--certs {state_dir}"
)
if protocol.name == "clearnet":
return f"exec nginx -c {state_dir / 'nginx-server.conf'} -g 'daemon off;'"
return f"exec {protocol.daemon}"
def _loopback_start_command(
config: GatewayConfig,
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
) -> str:
state_dir = _state_dir(config, provider, site, protocol)
return f"exec nginx -c {state_dir / 'nginx-loopback.conf'} -g 'daemon off;'"
def _artifact(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
*,
kind: str,
filename: str,
content: str,
) -> InstallArtifact:
return InstallArtifact(
site_id=site.id,
protocol=protocol.name,
platform=provider.name,
kind=kind,
path=_artifact_dir(site, protocol, provider) / filename,
content=content,
)
def _approval_input(artifact: InstallArtifact) -> ApprovalInput:
return ApprovalInput(
site_id=artifact.site_id,
protocol=artifact.protocol,
platform=artifact.platform,
kind=artifact.kind,
path=artifact.path,
content=artifact.content,
)
def _xml_escape(value: str) -> str:
return (
value.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _shell_single_quote(value: str) -> str:
return value.replace("'", "'\\''")
def _step(
site: SiteConfig,
protocol: ProtocolConfig,
provider: PlatformProvider,
*,
stage: str,
action: str,
target: str,
status: str,
command: str,
message: str,
) -> InstallStep:
return InstallStep(
site_id=site.id,
protocol=protocol.name,
platform=provider.name,
stage=stage,
action=action,
target=target,
status=status,
command=command,
message=message,
)