Adds custom domains.
This commit is contained in:
@@ -41,6 +41,9 @@ class GatewayRoute:
|
||||
name: str | None = None # backing program/service
|
||||
node: str | None = None
|
||||
public: bool = False # also served under public_domain
|
||||
# Optional exact public-facing FQDN override (apex allowed). When set, the
|
||||
# public name is this instead of the derived <address>.<public_domain>.
|
||||
public_host: str | None = None
|
||||
|
||||
@property
|
||||
def is_host(self) -> bool:
|
||||
@@ -67,15 +70,15 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
|
||||
|
||||
def _local_routes(
|
||||
config: CastleConfig | None, registry: NodeRegistry
|
||||
) -> list[tuple[str, str, str, bool]]:
|
||||
"""Each local deployment's route as ``(name, kind, target, public)``.
|
||||
) -> list[tuple[str, str, str, bool, str | None]]:
|
||||
"""Each local deployment's route as ``(name, kind, target, public, public_host)``.
|
||||
|
||||
``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or
|
||||
``proxy`` (a proxied systemd process). Prefers ``castle.yaml``
|
||||
(``config.deployments``) so a regenerated Caddyfile reflects the current spec;
|
||||
falls back to the deployed registry snapshot when config isn't available.
|
||||
"""
|
||||
out: list[tuple[str, str, str, bool]] = []
|
||||
out: list[tuple[str, str, str, bool, str | None]] = []
|
||||
if config is not None:
|
||||
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
|
||||
# jobs/tools/references never do.
|
||||
@@ -87,20 +90,22 @@ def _local_routes(
|
||||
if kind == "static" and isinstance(dep, CaddyDeployment):
|
||||
src = _program_source(config, dep.program)
|
||||
if src is not None:
|
||||
out.append((name, "static", str(src / dep.root), bool(dep.public)))
|
||||
pub_host = dep.public_host if dep.public else None
|
||||
out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host))
|
||||
elif kind == "service" and isinstance(dep, SystemdDeployment):
|
||||
expose, port, base_url = service_proxy_targets(name, dep)
|
||||
if expose and (port or base_url):
|
||||
out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public)))
|
||||
pub_host = dep.public_host if dep.public else None
|
||||
out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host))
|
||||
return out
|
||||
# No config → route from the deployed registry snapshot.
|
||||
for _kind, name, d in registry.all():
|
||||
if not d.enabled:
|
||||
continue
|
||||
if d.static_root:
|
||||
out.append((name, "static", d.static_root, d.public))
|
||||
out.append((name, "static", d.static_root, d.public, d.public_host))
|
||||
elif d.subdomain and (d.port or d.base_url):
|
||||
out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public))
|
||||
out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host))
|
||||
return out
|
||||
|
||||
|
||||
@@ -141,8 +146,8 @@ def compute_routes(
|
||||
# built dir; everything else that's exposed reverse-proxies its port/base_url.
|
||||
# (Static frontends are `runner: static` services now — no separate program
|
||||
# branch, so routing derives from one place.)
|
||||
for name, kind, target, is_public in _local_routes(config, registry):
|
||||
routes.append(GatewayRoute(name, kind, target, name, node, is_public))
|
||||
for name, kind, target, is_public, pub_host in _local_routes(config, registry):
|
||||
routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host))
|
||||
|
||||
if remote_registries:
|
||||
routes.extend(_remote_routes(config, registry, remote_registries))
|
||||
@@ -238,6 +243,34 @@ def _host_static_block(label: str, host: str, serve_dir: str) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def _public_site_block(host: str, kind: str, target: str) -> list[str]:
|
||||
"""A standalone Caddy site for a custom ``public_host`` (apex or another zone).
|
||||
|
||||
Unlike the ``*.<public_domain>`` wildcard site, an apex/foreign host isn't
|
||||
covered by a wildcard cert, so it gets its own site. Caddy issues that exact
|
||||
host's cert via the global ``acme_dns`` (DNS-01) — provided the gateway's
|
||||
Cloudflare token can edit the host's zone."""
|
||||
if kind == "static":
|
||||
body = [
|
||||
f" root * {target}",
|
||||
" try_files {path} /index.html",
|
||||
" file_server",
|
||||
]
|
||||
elif kind == "remote":
|
||||
body = [
|
||||
f" reverse_proxy {target} {{",
|
||||
" lb_try_duration 1s",
|
||||
" fail_duration 30s",
|
||||
" transport http {",
|
||||
" dial_timeout 2s",
|
||||
" }",
|
||||
" }",
|
||||
]
|
||||
else:
|
||||
body = [f" reverse_proxy {target}"]
|
||||
return [f"{host} {{", *body, "}", ""]
|
||||
|
||||
|
||||
# Castle's own control plane: the dashboard frontend and the API it calls. These
|
||||
# names are the subdomains they're published at in acme mode, and the pair served
|
||||
# on the :<port> site in off mode (no domain → no subdomains).
|
||||
@@ -308,14 +341,21 @@ def generate_caddyfile_from_registry(
|
||||
lines += _host_matcher_block(r.name or r.address, host, r.target)
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
# Public domain: public services are also reachable under a separate zone
|
||||
# (e.g. domain0.org) so LAN clients can access them by their public name.
|
||||
# Central passes TLS through; Caddy obtains a separate wildcard cert.
|
||||
# Public exposure: public deployments are also reachable by their public
|
||||
# name so LAN clients can use it directly. Two shapes:
|
||||
# - default → <address>.<public_domain>, all served by one wildcard site
|
||||
# (*.<public_domain>, its own wildcard cert), when a node-wide
|
||||
# public_domain distinct from the internal zone is configured.
|
||||
# - override → an exact `public_host` (an apex or a name in another zone),
|
||||
# which a wildcard can't cover, so each gets a standalone site with its
|
||||
# own cert (DNS-01 via the global acme_dns).
|
||||
public_domain = node.public_domain
|
||||
public_routes = [r for r in routes if r.public]
|
||||
if public_domain and public_domain != domain and public_routes:
|
||||
default_pub = [r for r in public_routes if not r.public_host]
|
||||
custom_pub = [r for r in public_routes if r.public_host]
|
||||
if public_domain and public_domain != domain and default_pub:
|
||||
lines.append(f"*.{public_domain} {{")
|
||||
for r in public_routes:
|
||||
for r in default_pub:
|
||||
host = f"{r.address}.{public_domain}"
|
||||
label = f"{r.name or r.address}_pub"
|
||||
if r.kind == "static":
|
||||
@@ -326,6 +366,9 @@ def generate_caddyfile_from_registry(
|
||||
lines += _host_matcher_block(label, host, r.target)
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
for r in custom_pub:
|
||||
if r.public_host: # always true (custom_pub filter); narrows the type
|
||||
lines += _public_site_block(r.public_host, r.kind, r.target)
|
||||
# Redirect the bare gateway port to the dashboard subdomain.
|
||||
lines += [
|
||||
f":{gw_port} {{",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
|
||||
|
||||
Castle owns the CNAMEs in the public zone that point at its Cloudflare tunnel:
|
||||
on deploy it creates one per public service and deletes any that point at this
|
||||
tunnel but no longer correspond to a public service. It **only ever touches
|
||||
records whose content is `<tunnel_id>.cfargotunnel.com`** — never other records in
|
||||
the zone — so a hand-managed A/CNAME in the same zone is safe.
|
||||
Castle owns the CNAMEs — across every zone the token can see — that point at its
|
||||
Cloudflare tunnel: on deploy it creates one per public host (each routed to the
|
||||
accessible zone whose name is its longest suffix, so apex and multi-zone hosts both
|
||||
work) and deletes any that point at this tunnel but no longer correspond to a
|
||||
public service. It **only ever touches records whose content is
|
||||
`<tunnel_id>.cfargotunnel.com`** — never other records in a zone — so a
|
||||
hand-managed A/CNAME in the same zone is safe.
|
||||
|
||||
Needs a Cloudflare API token with **DNS:Edit** on the public zone (Cloudflare's
|
||||
"Edit zone DNS" template — that single permission both resolves the zone by name
|
||||
Needs a Cloudflare API token with **DNS:Edit** on every target zone (Cloudflare's
|
||||
"Edit zone DNS" template — that single permission both lists the accessible zones
|
||||
and edits records; no separate Zone:Read is needed), stored at
|
||||
`~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN`. Absent → this is a no-op and the
|
||||
caller falls back to surfacing the manual `cloudflared tunnel route dns` hints.
|
||||
@@ -43,65 +45,100 @@ def _api(token: str, method: str, path: str, body: dict | None = None) -> dict:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def _zone_for(host: str, zones: list[dict]) -> dict | None:
|
||||
"""The visible zone whose name is the longest suffix of ``host`` (or None).
|
||||
|
||||
Longest-suffix so an apex (``example.com`` in zone ``example.com``) and a
|
||||
subdomain in any accessible zone both resolve, even when zones nest.
|
||||
"""
|
||||
matches = [
|
||||
z
|
||||
for z in zones
|
||||
if host == z["name"] or host.endswith("." + z["name"])
|
||||
]
|
||||
return max(matches, key=lambda z: len(z["name"])) if matches else None
|
||||
|
||||
|
||||
def reconcile_public_dns(
|
||||
public_domain: str | None,
|
||||
tunnel_id: str | None,
|
||||
desired_hosts: list[str],
|
||||
messages: list[str],
|
||||
token: str | None = None,
|
||||
) -> bool:
|
||||
"""Make the public zone's tunnel CNAMEs exactly `desired_hosts`.
|
||||
"""Make the tunnel CNAMEs across every accessible zone exactly `desired_hosts`.
|
||||
|
||||
Creates missing CNAMEs (proxied → the tunnel) and deletes castle-managed ones
|
||||
(content == `<tunnel_id>.cfargotunnel.com`) not in `desired_hosts`. Never
|
||||
touches records pointing elsewhere.
|
||||
Each desired host is routed to the accessible zone whose name is its longest
|
||||
suffix (so apex hosts and hosts in different zones are handled), then per zone
|
||||
castle creates missing CNAMEs (proxied → the tunnel; Cloudflare flattens apex
|
||||
CNAMEs) and deletes castle-managed ones (content == `<tunnel_id>.cfargotunnel.com`)
|
||||
no longer desired. Never touches records pointing elsewhere. Scanning every
|
||||
visible zone also cleans up stale CNAMEs after a host moves zones or all public
|
||||
services are removed.
|
||||
|
||||
Returns True if reconciliation was attempted (a token was configured) — the
|
||||
caller then suppresses the manual route hints — or False if skipped (no token /
|
||||
no tunnel / no public domain), so the caller can fall back to those hints.
|
||||
no tunnel), so the caller can fall back to those hints.
|
||||
"""
|
||||
token = token or public_dns_token()
|
||||
if not (token and public_domain and tunnel_id):
|
||||
if not (token and tunnel_id):
|
||||
return False
|
||||
target = f"{tunnel_id}.cfargotunnel.com"
|
||||
try:
|
||||
zres = (_api(token, "GET", f"/zones?name={public_domain}").get("result")) or []
|
||||
if not zres:
|
||||
zones = (_api(token, "GET", "/zones?per_page=50").get("result")) or []
|
||||
if not zones:
|
||||
messages.append(
|
||||
f"Warning: DNS token can't see zone '{public_domain}' — public "
|
||||
"CNAMEs not reconciled. The token needs DNS:Edit (Cloudflare's "
|
||||
f"'Edit zone DNS' template) scoped to {public_domain}."
|
||||
"Warning: DNS token can't see any zone — public CNAMEs not "
|
||||
"reconciled. The token needs DNS:Edit (Cloudflare's 'Edit zone "
|
||||
"DNS' template) on the target zone(s)."
|
||||
)
|
||||
return False
|
||||
zone_id = zres[0]["id"]
|
||||
# Castle-managed set = existing CNAMEs whose content is our tunnel.
|
||||
recs = _api(
|
||||
token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
|
||||
).get("result") or []
|
||||
managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
|
||||
return True
|
||||
|
||||
desired = set(desired_hosts)
|
||||
created = sorted(desired - set(managed))
|
||||
removed = sorted(set(managed) - desired)
|
||||
for host in created:
|
||||
_api(
|
||||
token,
|
||||
"POST",
|
||||
f"/zones/{zone_id}/dns_records",
|
||||
{"type": "CNAME", "name": host, "content": target, "proxied": True},
|
||||
)
|
||||
for host in removed:
|
||||
_api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
|
||||
# Route each desired host to its zone (longest-suffix match). Hosts with no
|
||||
# accessible zone can't be created — surface them rather than silently drop.
|
||||
desired_by_zone: dict[str, set[str]] = {z["id"]: set() for z in zones}
|
||||
for host in desired_hosts:
|
||||
z = _zone_for(host, zones)
|
||||
if z is None:
|
||||
messages.append(
|
||||
f"Warning: no accessible Cloudflare zone for public host "
|
||||
f"'{host}' — its CNAME was not created. The DNS token needs "
|
||||
f"DNS:Edit on that host's zone."
|
||||
)
|
||||
continue
|
||||
desired_by_zone[z["id"]].add(host)
|
||||
|
||||
created: list[str] = []
|
||||
removed: list[str] = []
|
||||
# Reconcile every visible zone (not just those with desired hosts) so a
|
||||
# CNAME orphaned by a host moving zones / going internal is cleaned up.
|
||||
for z in zones:
|
||||
zone_id = z["id"]
|
||||
recs = _api(
|
||||
token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
|
||||
).get("result") or []
|
||||
managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
|
||||
desired = desired_by_zone[zone_id]
|
||||
for host in sorted(desired - set(managed)):
|
||||
_api(
|
||||
token,
|
||||
"POST",
|
||||
f"/zones/{zone_id}/dns_records",
|
||||
{"type": "CNAME", "name": host, "content": target, "proxied": True},
|
||||
)
|
||||
created.append(host)
|
||||
for host in sorted(set(managed) - desired):
|
||||
_api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
|
||||
removed.append(host)
|
||||
|
||||
if created or removed:
|
||||
parts = []
|
||||
if created:
|
||||
parts.append(f"+{len(created)} ({', '.join(created)})")
|
||||
parts.append(f"+{len(created)} ({', '.join(sorted(created))})")
|
||||
if removed:
|
||||
parts.append(f"-{len(removed)} ({', '.join(removed)})")
|
||||
parts.append(f"-{len(removed)} ({', '.join(sorted(removed))})")
|
||||
messages.append(f"Public DNS reconciled: {' '.join(parts)}")
|
||||
else:
|
||||
messages.append(f"Public DNS up to date ({len(desired)} CNAME(s)).")
|
||||
messages.append(f"Public DNS up to date ({len(desired_hosts)} CNAME(s)).")
|
||||
return True
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace")[:200]
|
||||
|
||||
@@ -37,21 +37,42 @@ def tunnel_credentials_path(tunnel_id: str) -> Path:
|
||||
return TUNNEL_CREDENTIALS_DIR / f"{tunnel_id}.json"
|
||||
|
||||
|
||||
def public_fqdn(d: Deployment, node) -> str | None:
|
||||
"""The public-facing hostname for a deployment, or None if it has none.
|
||||
|
||||
A deployment may override its public name with an exact FQDN (``public_host`` —
|
||||
an apex like ``example.com`` or a name in another zone); otherwise it publishes
|
||||
at ``<subdomain>.<public_domain>`` using the node-wide default public domain.
|
||||
None when neither an override nor a default public domain is available.
|
||||
"""
|
||||
if d.public_host:
|
||||
return d.public_host
|
||||
if node.public_domain and d.subdomain:
|
||||
return f"{d.subdomain}.{node.public_domain}"
|
||||
return None
|
||||
|
||||
|
||||
def public_deployments(registry: NodeRegistry) -> list[tuple[str, Deployment]]:
|
||||
"""The deployed services flagged public (and actually routed), name-sorted."""
|
||||
return sorted(
|
||||
(name, d)
|
||||
for _kind, name, d in registry.all()
|
||||
if d.public and d.subdomain
|
||||
(
|
||||
(name, d)
|
||||
for _kind, name, d in registry.all()
|
||||
if d.public and d.subdomain
|
||||
),
|
||||
key=lambda nd: nd[0],
|
||||
)
|
||||
|
||||
|
||||
def public_hostnames(registry: NodeRegistry) -> list[str]:
|
||||
"""The public hostnames that need a DNS route (``<sub>.<public_domain>``)."""
|
||||
dom = registry.node.public_domain
|
||||
if not dom:
|
||||
return []
|
||||
return [f"{d.subdomain}.{dom}" for _, d in public_deployments(registry)]
|
||||
"""The public hostnames that need a DNS route.
|
||||
|
||||
Each is either a per-deployment ``public_host`` override or the default
|
||||
``<sub>.<public_domain>``; deployments with neither are skipped.
|
||||
"""
|
||||
node = registry.node
|
||||
hosts = [public_fqdn(d, node) for _, d in public_deployments(registry)]
|
||||
return [h for h in hosts if h]
|
||||
|
||||
|
||||
def generate_tunnel_config(registry: NodeRegistry) -> str | None:
|
||||
@@ -62,7 +83,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
|
||||
removes any stale config and leaves the tunnel down.
|
||||
"""
|
||||
node = registry.node
|
||||
if not (node.tunnel_id and node.public_domain and node.gateway_domain):
|
||||
# A public deployment needs a tunnel + an internal host to bridge to; the
|
||||
# node-wide public_domain is only the *default* public name, so it isn't
|
||||
# required (a deployment may carry its own public_host override instead).
|
||||
if not (node.tunnel_id and node.gateway_domain):
|
||||
return None
|
||||
pubs = public_deployments(registry)
|
||||
if not pubs:
|
||||
@@ -70,7 +94,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
|
||||
|
||||
ingress: list[dict] = []
|
||||
for _name, d in pubs:
|
||||
public_host = f"{d.subdomain}.{node.public_domain}"
|
||||
public_host = public_fqdn(d, node)
|
||||
if not public_host:
|
||||
# public but no override and no default public domain — nothing to map.
|
||||
continue
|
||||
internal_host = f"{d.subdomain}.{node.gateway_domain}"
|
||||
ingress.append(
|
||||
{
|
||||
@@ -84,6 +111,9 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
|
||||
},
|
||||
}
|
||||
)
|
||||
if not ingress:
|
||||
# Every public deployment was skipped (no override, no default domain).
|
||||
return None
|
||||
# Cloudflared requires a terminal catch-all; anything unmapped is refused.
|
||||
ingress.append({"service": "http_status:404"})
|
||||
|
||||
|
||||
@@ -33,6 +33,28 @@ class Reach(str, Enum):
|
||||
PUBLIC = "public"
|
||||
|
||||
|
||||
def _validate_public_host(host: str | None, reach: Reach) -> None:
|
||||
"""Validate an optional ``public_host`` override on an exposable deployment.
|
||||
|
||||
A ``public_host`` only makes sense for a publicly-projected deployment, and
|
||||
must be a bare hostname (no scheme, path, port, or whitespace) — it becomes a
|
||||
tunnel ingress ``hostname`` and a Caddy site address verbatim.
|
||||
"""
|
||||
if host is None:
|
||||
return
|
||||
if reach != Reach.PUBLIC:
|
||||
raise ValueError(
|
||||
f"public_host is only valid with reach: public (got reach: {reach.value})"
|
||||
)
|
||||
bad = any(c.isspace() for c in host) or any(
|
||||
tok in host for tok in ("://", "/", ":")
|
||||
)
|
||||
if not host or host != host.strip(".") or bad:
|
||||
raise ValueError(
|
||||
f"public_host must be a bare hostname (e.g. example.com), got {host!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Launch specs — how systemd starts a process (discriminated union on `launcher`)
|
||||
# ---------------------
|
||||
@@ -420,6 +442,11 @@ class SystemdDeployment(DeploymentBase):
|
||||
expose: ExposeSpec | None = None
|
||||
# How far this process is exposed (off | internal | public). See `Reach`.
|
||||
reach: Reach = Reach.OFF
|
||||
# Optional public hostname override (an exact FQDN, e.g. `api.example.com` or an
|
||||
# apex `example.com`). Only meaningful with `reach: public`; when set it is the
|
||||
# public-facing name instead of the derived `<name>.<gateway.public_domain>`.
|
||||
# Unset → the node-wide public domain is the default. See docs/tunnel-setup.md.
|
||||
public_host: str | None = None
|
||||
manage: ManageSpec | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -448,6 +475,7 @@ class SystemdDeployment(DeploymentBase):
|
||||
"reach: public for a raw-TCP service isn't supported yet "
|
||||
"(see docs/tcp-exposure.md step 5); use reach: internal"
|
||||
)
|
||||
_validate_public_host(self.public_host, self.reach)
|
||||
return self
|
||||
|
||||
# Derived, read-only back-compat accessors (not serialized) so existing
|
||||
@@ -488,11 +516,15 @@ class CaddyDeployment(DeploymentBase):
|
||||
# A static site is inherently served at its subdomain, so `reach` is
|
||||
# `internal` or `public` (never `off`). `public` = also project via the tunnel.
|
||||
reach: Reach = Reach.INTERNAL
|
||||
# Optional public hostname override (exact FQDN, apex allowed). Only meaningful
|
||||
# with `reach: public`; see SystemdDeployment.public_host.
|
||||
public_host: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_reach(self) -> CaddyDeployment:
|
||||
if self.reach == Reach.OFF:
|
||||
raise ValueError("a static (caddy) deployment is always served; reach must be internal|public")
|
||||
_validate_public_host(self.public_host, self.reach)
|
||||
return self
|
||||
|
||||
@property
|
||||
|
||||
@@ -83,6 +83,9 @@ class Deployment:
|
||||
# Also projected to the public internet via the tunnel at
|
||||
# <subdomain>.<gateway.public_domain>. Requires subdomain.
|
||||
public: bool = False
|
||||
# Optional public hostname override (exact FQDN, apex allowed). When set it is
|
||||
# the public-facing name instead of the derived <subdomain>.<public_domain>.
|
||||
public_host: str | None = None
|
||||
# Raw-TCP exposure port (postgres, redis, …). Set → reachable at
|
||||
# <name>.<gateway.domain>:<tcp_port> via bind + wildcard DNS (no Caddy route).
|
||||
tcp_port: int | None = None
|
||||
@@ -186,6 +189,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||
health_path=comp_data.get("health_path"),
|
||||
subdomain=comp_data.get("subdomain"),
|
||||
public=comp_data.get("public", False),
|
||||
public_host=comp_data.get("public_host"),
|
||||
tcp_port=comp_data.get("tcp_port"),
|
||||
static_root=comp_data.get("static_root"),
|
||||
base_url=comp_data.get("base_url"),
|
||||
@@ -263,6 +267,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||
entry["subdomain"] = comp.subdomain
|
||||
if comp.public:
|
||||
entry["public"] = comp.public
|
||||
if comp.public_host:
|
||||
entry["public_host"] = comp.public_host
|
||||
if comp.tcp_port is not None:
|
||||
entry["tcp_port"] = comp.tcp_port
|
||||
if comp.static_root:
|
||||
|
||||
Reference in New Issue
Block a user