From ff0dbcb4363c2fad29eef60fbbe258cedc21f66e Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 08:36:14 +0000 Subject: [PATCH 1/2] Publish the managed gateway's ports 80 and 443 only for HTTPS The gateway publishes ports.web always, and 80 and 443 once public_url is set or while domain setup verifies a candidate address (ingress_config.published). configuration.listeners follows, so a new installation checks 80 and 443 only with --public-url and oac apply checks them when public_url turns HTTPS on. Domain setup (Web and oac domain) checks, before it reserves a job, that the hostname resolves (hostname_unresolved, 409) and that no other program holds 80 or 443 (https_ports_unavailable, 409). The installation's own listeners include those of the written gateway, so a domain change with HTTPS on, or a retry after an interrupted attempt, never fails on the gateway's own ports. The attempt renders the candidate through the common apply, which recreates the gateway with 80 and 443 before Caddy verifies it; failure restores the gateway without them. The verification failure message now names DNS, firewall or NAT, and the certificate request as its causes. The fake host's listening table includes the ports running containers publish. --- deploy/install/README.md | 4 +- deploy/install/configuration.py | 22 +++++----- deploy/install/ingress.py | 41 ++++++++++++++----- deploy/install/ingress_config.py | 25 +++++++++--- deploy/install/install_output.py | 2 +- deploy/install/installer_fakes.py | 21 ++++++---- deploy/install/oac_cli.py | 54 ++++++++++++++++--------- deploy/install/test_ingress.py | 39 ++++++++++++++++-- deploy/install/test_install.py | 11 ++++- docs/configuration.md | 4 +- docs/getting-started/install-options.md | 10 ++--- docs/getting-started/install.md | 6 +-- docs/getting-started/operations.md | 7 ++-- 13 files changed, 175 insertions(+), 71 deletions(-) diff --git a/deploy/install/README.md b/deploy/install/README.md index 9e2f4d392..abf3c8f18 100644 --- a/deploy/install/README.md +++ b/deploy/install/README.md @@ -65,8 +65,8 @@ This directory holds the Core/Web installer, the `oac` command and the node inst A default combined Docker installation adds two Compose services from one pinned image: `gateway` runs Caddy, and `installation` runs the packaged `oac domain-server`. The latter runs with the installing account's UID and its Docker socket access and calls the same locked apply implementation. Its only request surface is the private `ingress/api/api.sock`, with Core-key authentication and one typed domain action. Core and Web get no Docker socket, host process authority or writable installation configuration. Web gets only the private API socket directory, never Caddy's admin socket. -- The gateway owns ports 80 and 443 and the initial Web port. Caddy issues and renews certificates and keeps its private data in `ingress/data`. `generated/Caddyfile` is derived from `config.json`, and apply reloads it through the private Caddy socket even when container inputs already match. -- A domain change keeps the old entry point while it verifies a trusted certificate and an installation-specific response over HTTPS. Only then does it set `public_url` and call the common apply path. Failure restores the previous configuration and reports incomplete recovery; failed retries restore through the common apply even after a partial change. +- The gateway publishes the initial Web port, and ports 80 and 443 only for HTTPS (`ingress_config.published`). Caddy issues and renews certificates and keeps its private data in `ingress/data`. `generated/Caddyfile` is derived from `config.json`, and apply reloads it through the private Caddy socket even when container inputs already match. +- A domain change first checks that the hostname resolves and that no other program holds port 80 or 443; ports the written gateway already publishes are its own. It keeps the old entry point while the common apply renders the candidate address: the gateway publishes 80 and 443 and serves the candidate, and the verification needs a trusted certificate and an installation-specific response over HTTPS. Only then does it set `public_url` and apply again. Failure restores the previous configuration, including the gateway without 80 and 443 when HTTPS was off, and reports incomplete recovery; failed retries restore through the common apply even after a partial change. - The operation record keeps the last successfully applied public address. Apply and start update it after gateway verification and service health checks; generated files alone never prove that a new address is active. A successful apply reconciles the domain operation status after verifying the running services. - The domain operation refuses unrelated pending `config.json` edits and shares `.oac.lock` with the CLI. Its status file is bookkeeping and the recovery receipt; `config.json` stays the source of desired settings. An interrupted operation keeps its desired files and a visible failure and retry state; it never creates another service project or deletes execution data. - A Web restart ends console sessions, so the UI gives the new HTTPS sign-in address instead of treating a dropped request as success. diff --git a/deploy/install/configuration.py b/deploy/install/configuration.py index a5582c5c9..fb73ada62 100644 --- a/deploy/install/configuration.py +++ b/deploy/install/configuration.py @@ -146,16 +146,18 @@ def service_address(config, service, connect=False): Listener = collections.namedtuple("Listener", "purpose host port setting") -def listeners(config): +def listeners(config, candidate=None): """Every host listener the services bind, from the addresses they are rendered with. - setting is the config.json key that owns the port. + setting is the config.json key that owns the port. candidate is the address domain + setup verifies, as in render. """ mode, ports, result = config["mode"], config["ports"], [] if ingress_config.enabled(config): - # The gateway publishes Web's port and the HTTPS ports; Web itself publishes none. + # The gateway publishes Web's port and, for HTTPS, 80 and 443; Web itself publishes none. result += [Listener("Web", config["host"], port, "ports.web") if port == ports["web"] else - Listener("HTTPS", config["host"], port, "ingress") for port, _ in ingress_config.published(config)] + Listener("HTTPS", config["host"], port, "public_url") + for port, _ in ingress_config.published(config, candidate)] elif mode != "core-only": result.append(Listener("Web", service_host(config, "web"), ports["web"], "ports.web")) if mode != "web-only": @@ -229,7 +231,7 @@ def settings_document(root, config, applied_at): "applied_at": applied_at, "settings": config_model.settings(config)} -def compose_config(root, config, state): +def compose_config(root, config, state, candidate=None): root = Path(root) mode, native = config["mode"], config.get("native_core", False) identity = f'{state["uid"]}:{state["gid"]}' @@ -297,7 +299,7 @@ def compose_config(root, config, state): web["volumes"].append(bind(root / "ingress/api", "/installation")) services["web"] = web if ingress_config.enabled(config): - services.update(ingress_config.services(root, config, state, bind)) + services.update(ingress_config.services(root, config, state, bind, candidate)) return doc @@ -327,7 +329,9 @@ def native_installer_inputs(root): return [sha256(catalog.read_bytes()), sorted(p.name for p in directory.glob("*.tar.gz") if p.is_file())] -def render(root, config, state, applied_at): +def render(root, config, state, applied_at, candidate=None): + """candidate is an HTTPS address domain setup verifies before public_url changes: + the gateway publishes 80 and 443 and serves it too.""" root = Path(root) mode, native = config["mode"], config.get("native_core", False) secrets = secret_digests(root, mode) @@ -354,7 +358,7 @@ def render(root, config, state, applied_at): }, sort_keys=True) if mode != "core-only": external["web"] = json.dumps({"core.key": secrets["core.key"]}) - compose = compose_config(root, config, state) + compose = compose_config(root, config, state, candidate) services = {} for name, service in compose["services"].items(): # Compose resolves env_file into the service configuration, so its content counts. @@ -362,7 +366,7 @@ def render(root, config, state, applied_at): services[name] = sha256(text + external.get("core" if name == "migrate" else name, "")) service["labels"] = {LABEL: services[name]} if ingress_config.enabled(config): - files["Caddyfile"] = ingress_config.caddyfile(config, state) + files["Caddyfile"] = ingress_config.caddyfile(config, state, candidate) files["compose.json"] = json.dumps(compose, indent=2) + "\n" if native: unit = native_service.unit_name(state) diff --git a/deploy/install/ingress.py b/deploy/install/ingress.py index d5c6caa54..109a1ad77 100644 --- a/deploy/install/ingress.py +++ b/deploy/install/ingress.py @@ -6,6 +6,7 @@ from http.server import BaseHTTPRequestHandler import json from pathlib import Path +import socket import socketserver import ssl import threading @@ -84,6 +85,14 @@ def prepare(root, name, confirmation): rendered, disk, old_settings = oac_cli.render_now(root, config, state) if oac_cli.edited_files(state, disk, rendered): raise DomainError("generated_files_edited", "Resolve hand-edited generated files with oac apply before configuring the domain", 409) + host = urlsplit(target).hostname + if not resolves(host): + raise DomainError("hostname_unresolved", f"{host} does not resolve. Add A/AAAA records for it that point at this server, then retry when DNS returns them.", 409) + # The gateway publishes 80 and 443 only for HTTPS; the ports it already publishes are its own. + busy = [str(listener.port) for listener in oac_cli.taken_listeners(candidate, oac_cli.own_listeners(config, old_settings, disk))] + if busy: + named = f"Port {busy[0]} is" if len(busy) == 1 else "Ports " + " and ".join(busy) + " are" + raise DomainError("https_ports_unavailable", f"{named} already in use on this server. Automatic HTTPS cannot run beside another program on ports 80 and 443: free both and retry. Find the program with: sudo ss -ltnp '( sport = :80 or sport = :443 )'", 409) old, base, answered = oac_cli.old_public_url(root, config, old_settings, disk, actual) args = argparse.Namespace(dry_run=False, yes=False, confirm_public_url_change=confirmation) try: @@ -95,6 +104,14 @@ def prepare(root, name, confirmation): return config, state, candidate, args, job +def resolves(host): + """Whether DNS answers with an A or AAAA record.""" + try: + return bool(socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)) + except (OSError, UnicodeError): + return False + + def verify(target, installation_id, timeout=180): """A trusted certificate and this gateway's identity must both answer at the public address.""" host = urlsplit(target).hostname @@ -111,14 +128,18 @@ def verify(target, installation_id, timeout=180): except (OSError, http.client.HTTPException): pass if time.monotonic() >= deadline: - raise DomainError("https_not_ready", "HTTPS verification failed. Point this hostname's A/AAAA records at the server, allow inbound ports 80 and 443, and check gateway logs. The previous address is kept; retry after correcting DNS or connectivity.") + raise DomainError("https_not_ready", f"HTTPS verification failed: {host} did not reach this installation with a trusted certificate. Check that its A/AAAA records point at this server, that no firewall or NAT blocks inbound ports 80 and 443, and the gateway logs for certificate errors. The previous address is kept; retry after the fix.") time.sleep(2) def execute(root, prepared): config, state, candidate, args, job = prepared + # Core and Web keep the applied address until the candidate is verified. + keep = argparse.Namespace(dry_run=False, yes=False, confirm_public_url_change=configuration.local_public_url(config)) try: - gateway.reload(root, gateway.caddyfile(config, state, candidate=candidate["public_url"])) + # Through common apply, the gateway publishes 80 and 443 and serves the candidate. + oac_cli.write_private(Path(root) / "config.json", json.dumps(config, indent=2) + "\n") + oac_cli._apply(Path(root), keep, False, False, False, lambda _: None, candidate=candidate["public_url"]) verify(candidate["public_url"], state["installation_id"]) job["state"] = "applying" save(root, job) @@ -131,12 +152,10 @@ def execute(root, prepared): except Exception as error: recovery_failed = False try: - # A retry may inherit partially applied services from an interrupted - # attempt. Restore through common apply even if this attempt only probed TLS. + # Restore through common apply: the gateway without the candidate, and without + # 80 and 443 unless HTTPS was already on, and any partially applied services. oac_cli.write_private(Path(root) / "config.json", json.dumps(config, indent=2) + "\n") - restore = argparse.Namespace(dry_run=False, yes=False, - confirm_public_url_change=configuration.local_public_url(config)) - oac_cli._apply(Path(root), restore, False, False, False, lambda _: None) + oac_cli._apply(Path(root), keep, False, False, False, lambda _: None) gateway.reload(root, gateway.caddyfile(config, state)) except Exception: recovery_failed = True @@ -232,14 +251,14 @@ def setup(self): def serve(root): root = Path(root) - socket = Path("/control/api.sock") - socket.unlink(missing_ok=True) + path = Path("/control/api.sock") + path.unlink(missing_ok=True) import os previous = os.umask(0o077) try: - with Server(str(socket), Handler) as server: + with Server(str(path), Handler) as server: server.root = root - os.chmod(socket, 0o600) + os.chmod(path, 0o600) server.serve_forever() finally: os.umask(previous) diff --git a/deploy/install/ingress_config.py b/deploy/install/ingress_config.py index 766b66d24..f3d90715d 100644 --- a/deploy/install/ingress_config.py +++ b/deploy/install/ingress_config.py @@ -107,12 +107,27 @@ def reload(root, document): raise RuntimeError("HTTPS gateway is unavailable; inspect gateway logs and retry") from None -def published(config): - """(host port, gateway port) of each port the gateway publishes on config["host"].""" - return [(config["ports"]["web"], 8080), (80, 80), (443, 443)] +def published(config, candidate=None): + """(host port, gateway port) of each port the gateway publishes on config["host"]. + Automatic HTTPS needs ports 80 and 443, so the gateway publishes them only once + public_url is set, or while domain setup verifies a candidate address. + """ + https = [(80, 80), (443, 443)] if config["public_url"] or candidate else [] + return [(config["ports"]["web"], 8080)] + https -def services(root, config, state, bind): + +def written_listeners(compose): + """(address, port) of each host port the gateway publishes in a written compose.json.""" + try: + ports = json.loads(compose)["services"]["gateway"]["ports"] + return {(ipaddress.ip_address(host.strip("[]")), int(port)) + for host, port, _ in (item.rsplit(":", 2) for item in ports)} + except (TypeError, ValueError, KeyError, AttributeError): + return set() + + +def services(root, config, state, bind, candidate=None): root = Path(root) identity = f'{state["uid"]}:{state["gid"]}' common = {"image": state["images"]["ingress"], "user": identity, "restart": "unless-stopped", @@ -121,7 +136,7 @@ def services(root, config, state, bind): if ":" in host: host = "[" + host + "]" gateway = dict(common, command=["caddy", "run", "--config", "/generated/Caddyfile", "--adapter", "caddyfile"], - ports=[f"{host}:{port}:{target}" for port, target in published(config)], + ports=[f"{host}:{port}:{target}" for port, target in published(config, candidate)], environment={"XDG_DATA_HOME": "/data", "XDG_CONFIG_HOME": "/data/config", "NO_PROXY": "core,web,localhost,127.0.0.1,::1", "no_proxy": "core,web,localhost,127.0.0.1,::1"}, diff --git a/deploy/install/install_output.py b/deploy/install/install_output.py index cf9adcaef..9c96a1ff3 100644 --- a/deploy/install/install_output.py +++ b/deploy/install/install_output.py @@ -56,7 +56,7 @@ def summary(root, config, addresses, fresh, selection, deployment, reachable, in paragraph("Use this key for the Core management API. Keep it private.") heading("Next") if ingress_config.enabled(config) and not config["public_url"]: - paragraph("Open Web at the server IP and sign in. In System → Domain and HTTPS, enter your DNS hostname; the installation requests and renews its certificate. DNS must point to this server and ports 80 and 443 must be reachable.") + paragraph("Open Web at the server IP and sign in. In System → Domain and HTTPS, enter your DNS hostname; the installation requests and renews its certificate. HTTPS then uses ports 80 and 443: DNS must point to this server, no other program on it may use those ports, and they must be reachable from the internet. Web checks DNS and the ports before it starts.") if mode != "core-only": paragraph("Create a Project and its API key on the Projects and keys page.") else: diff --git a/deploy/install/installer_fakes.py b/deploy/install/installer_fakes.py index 065e81c34..fdff8f6fb 100644 --- a/deploy/install/installer_fakes.py +++ b/deploy/install/installer_fakes.py @@ -2,9 +2,9 @@ Compose is modelled by its observable contract: `up` recreates a container exactly when its resolved configuration (including env_file content) changed, each container -keeps the labels it was created with, and Core loads its Core key digests when it -starts. systemd runs the unit it last loaded; its process keeps the environment it -started with. +keeps the labels it was created with and holds the host ports it publishes while it +runs, and Core loads its Core key digests when it starts. systemd runs the unit it +last loaded; its process keeps the environment it started with. """ import errno import hashlib @@ -49,13 +49,13 @@ def __init__(self, test): self.deployment = {"provider": "", "generation": 0, "reset": None, "resources": {"allocations": 0, "pending": 0}} # what sandbox_setup reads and posts self.deployment_posts = [] self.deployment_refusal = None # Core's message when it refuses the POST - self.busy = set() # (address, port) of every listening socket, the installation's own included + self.busy = set() # (address, port) of listening sockets besides the ports running containers publish self.unassigned = set() # addresses this host does not have for target, name, value in ((subprocess, "run", mock.Mock(side_effect=self.run)), (oac_cli, "http", self.http), (oac_cli, "bind_error", self.bind_error), (oac_cli, "tcp_listeners", lambda: [(ipaddress.ip_address(host), port) - for host, port in self.busy]), + for host, port in self.listening()]), (oac_cli, "time", SimpleNamespace(sleep=lambda seconds: None)), (native_service, "_process_environment", self.process_environment), (sandbox_setup, "send", self.sandbox_send)): @@ -68,11 +68,16 @@ def bind_error(self, host, port): return errno.EADDRNOTAVAIL address = ipaddress.ip_address(host) return errno.EADDRINUSE if any(held == port and oac_cli.overlaps(address, ipaddress.ip_address(other)) - for other, held in self.busy) else 0 + for other, held in self.listening()) else 0 def running(self): return {name for name, item in self.containers.items() if item["running"]} + def listening(self): + """(address, port) of every listening socket: busy, and the ports running containers publish.""" + return self.busy | {tuple(pair) for item in self.containers.values() if item["running"] + for pair in item.get("ports", ())} + def run_container(self, name, port=None, digests=None): """A container of an installation made outside the test, such as an earlier release.""" self.containers[name] = {"hash": "external", "inputs": None, "running": True} @@ -144,7 +149,9 @@ def compose(self, path, args): current = self.containers.get(name) if current is None or current["hash"] != digest: self.containers[name] = {"hash": digest, "inputs": services[name].get("labels", {}).get(LABEL), - "running": False} + "running": False, + "ports": [[host.strip("[]"), int(port)] for host, port, _ in + (item.rsplit(":", 2) for item in services[name].get("ports", []))]} self.recreated.append(name) if name == "core": self.load_core(path.parent.parent, services[name]) diff --git a/deploy/install/oac_cli.py b/deploy/install/oac_cli.py index 64c76ccf9..d7c52f107 100644 --- a/deploy/install/oac_cli.py +++ b/deploy/install/oac_cli.py @@ -245,8 +245,9 @@ def port_free(host, port, own=()): def port_in_use(listener, name, outcome=""): """The message for a port that another program holds; name is its flag or config.json key.""" - return (f"Port {listener.port} ({name}) is already in use on {listener.host}.{outcome} Free it or choose another " - f"port; find the process with: sudo ss -ltnp 'sport = :{listener.port}'") + remedy = "Free it" if listener.purpose == "HTTPS" else "Free it or choose another port" + return (f"Port {listener.port} ({name}) is already in use on {listener.host}.{outcome} {remedy}; " + f"find the process with: sudo ss -ltnp 'sport = :{listener.port}'") def stale(actual, desired, will_run): @@ -467,16 +468,16 @@ def disk_view(disk): return values, document.get("applied_at") -def render_now(root, config, state): +def render_now(root, config, state, candidate=None): """The render of config.json, keeping the written stamp unless something changed.""" names = set((state.get("generated") or {})) disk = read_generated(root, names | {"settings.json", "runtime-history.json"}) previous, stamp = disk_view(disk) - rendered = configuration.render(root, config, state, stamp or now()) + rendered = configuration.render(root, config, state, stamp or now(), candidate) disk = read_generated(root, names | set(rendered.files) | {"runtime-history.json"}) if any(comparable(name, disk.get(name)) != comparable(name, text) for name, text in rendered.files.items()) \ or any(disk.get(name) is not None for name in names - set(rendered.files)): - rendered = configuration.render(root, config, state, now()) + rendered = configuration.render(root, config, state, now(), candidate) return rendered, disk, previous @@ -580,7 +581,22 @@ def check_paired_core(root, config, state, previous, args, interactive, out): return installation -def check_new_listeners(config, previous): +def own_listeners(config, previous, disk): + """(address, port) of this installation's listeners: those of the settings last written, and + those of the written gateway, which domain setup widens before public_url changes.""" + applied = applied_view(config, previous) + return ({(ipaddress.ip_address(listener.host), listener.port) for listener in configuration.listeners(applied)} + | ingress_config.written_listeners(disk.get("compose.json"))) + + +def taken_listeners(config, own, candidate=None): + """The listeners of config, and of a domain candidate, that another program holds.""" + return [listener for listener in configuration.listeners(config, candidate) + if (ipaddress.ip_address(listener.host), listener.port) not in own + and not port_free(listener.host, listener.port, own)] + + +def check_new_listeners(config, previous, disk, candidate=None): """Each listener this change adds must be free; this installation's own listeners do not count.""" if previous is None: return @@ -588,23 +604,22 @@ def check_new_listeners(config, previous): if config["host"] != applied["host"] and not address_available(config["host"]): raise OacError(f"{config['host']} (host) is not an address of this machine; use one of its addresses. " "Nothing was applied.") - own = {(ipaddress.ip_address(listener.host), listener.port) for listener in configuration.listeners(applied)} - for listener in configuration.listeners(config): - if (ipaddress.ip_address(listener.host), listener.port) not in own and \ - not port_free(listener.host, listener.port, own): - raise OacError(port_in_use(listener, listener.setting, " Nothing was applied.")) + taken = taken_listeners(config, own_listeners(config, previous, disk), candidate) + if taken: + raise OacError(port_in_use(taken[0], taken[0].setting, " Nothing was applied.")) -def finish_apply(root, config, state, gateway_document, will_run): +def finish_apply(root, config, state, gateway_document, will_run, candidate=None): + """With a candidate, domain setup verifies it and records its own status.""" managed = ingress_config.enabled(config) and "gateway" in will_run if managed: import ingress # Container input labels cannot prove which configuration Caddy loaded. ingress_config.reload(root, gateway_document) - if config["public_url"]: + if config["public_url"] and not candidate: ingress.verify(config["public_url"], state["installation_id"]) health(root, config, will_run) - if managed: + if managed and not candidate: ingress.save(root, {"state": "ready" if config["public_url"] else "unconfigured", "public_url": config["public_url"], "target_url": config["public_url"], "message": None}) @@ -618,20 +633,21 @@ def apply(root, dry_run=False, yes=False, discard_edits=False, confirm_public_ur return _apply(root, args, discard_edits, start, interactive, out, retry=retry) -def _apply(root, args, discard_edits, start, interactive, out, rollback=True, retry=None): +def _apply(root, args, discard_edits, start, interactive, out, rollback=True, retry=None, candidate=None): + """candidate: see configuration.render.""" retry = retry or f"run {root / 'oac'} apply again" check_directories(root) config = load_config(root) state = load_state(root) check_fixed(config, state) check_secrets(root, config, state) - rendered, disk, previous = render_now(root, config, state) + rendered, disk, previous = render_now(root, config, state, candidate) edited = edited_files(state, disk, rendered) if edited and not discard_edits: raise OacError("\n".join(f"generated/{name} was edited by hand." for name in edited) + "\nPut the change in config.json and run oac apply --discard-edits, which keeps the" " edited copy as generated/.edited-