mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
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.
1021 lines
31 KiB
Python
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("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """)
|
|
)
|
|
|
|
|
|
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,
|
|
)
|