Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/web/src/features/sandbox/enrollment-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ printf '==> Verifying node installer...\\n' &&
printf '%s %s\\n' '${digest}' "$d/node-install.pyz" | sha256sum -c --status &&
printf '%s\\n' 'secret'\\''onetime' | $s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HTTP_PROXY,HTTPS_PROXY,NO_PROXY} python3 "$d/node-install.pyz" \${NO_COLOR+--no-color} --enrollment-token-stdin --source-url 'https://console.example' --core-url 'https://core.example' --provider 'docker' --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')`);
});
it("appends --allow-insecure-origin right after --core-url when the switch is on", () => {
const command = nodeInstallCommand({ token: "secret'onetime", coreUrl: "http://10.0.0.5:8080", sourceUrl: "http://10.0.0.5:8080", provider: "docker", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest, allowInsecureOrigin: true });
expect(command).toContain("--core-url 'http://10.0.0.5:8080' --allow-insecure-origin --provider 'docker'");
// The flag is forwarded once, and only in the installer's own argument list.
expect(command.match(/--allow-insecure-origin/g)).toHaveLength(1);
expect(command.endsWith("--provider 'docker' --installation-id '7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f')")).toBe(true);
});
it("leaves the command byte-for-byte unchanged when the switch is off or omitted", () => {
const args = { token: "secret'onetime", coreUrl: "https://core.example", sourceUrl: "https://console.example", provider: "docker" as const, installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest };
expect(nodeInstallCommand({ ...args, allowInsecureOrigin: false })).toBe(install());
expect(nodeInstallCommand(args)).toBe(install());
expect(nodeInstallCommand({ ...args, allowInsecureOrigin: false })).not.toContain("--allow-insecure-origin");
});
it("creates the exact uninstall commands, with no token", () => {
const uninstall = () => nodeUninstallCommand({ sourceUrl: "https://console.example", installationId: "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f", scriptDigest: digest });
expect(uninstall()).toBe(` (umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT; s=; [ "$(id -u)" -eq 0 ] || s=sudo
Expand Down
11 changes: 7 additions & 4 deletions apps/web/src/features/sandbox/enrollment-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,15 @@ const runInstaller = `$s \${s:+--preserve-env=http_proxy,https_proxy,no_proxy,HT
/**
* Adds this host as a node. The one-time token reaches the installer only on
* standard input (`printf` is a shell builtin), never in an argument, the
* environment or sudo's command line.
* environment or sudo's command line. `allowInsecureOrigin` forwards the
* installer's `--allow-insecure-origin`, which lets a plain-HTTP Core origin
* enroll; it stays off unless the caller explicitly asks for it, so the default
* command is byte-for-byte unchanged.
*/
export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest }: {
token: string; coreUrl: string; sourceUrl: string; provider: "docker" | "microsandbox"; installationId: string; scriptDigest: string;
export function nodeInstallCommand({ token, coreUrl, sourceUrl, provider, installationId, scriptDigest, allowInsecureOrigin = false }: {
token: string; coreUrl: string; sourceUrl: string; provider: "docker" | "microsandbox"; installationId: string; scriptDigest: string; allowInsecureOrigin?: boolean;
}): string {
return `${nodeInstaller(sourceUrl, scriptDigest)}printf '%s\\n' ${quote(token)} | ${runInstaller} --enrollment-token-stdin --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)} --provider ${quote(provider)} --installation-id ${quote(installationId)})`;
return `${nodeInstaller(sourceUrl, scriptDigest)}printf '%s\\n' ${quote(token)} | ${runInstaller} --enrollment-token-stdin --source-url ${quote(sourceUrl)} --core-url ${quote(coreUrl)}${allowInsecureOrigin ? " --allow-insecure-origin" : ""} --provider ${quote(provider)} --installation-id ${quote(installationId)})`;
}

/**
Expand Down
11 changes: 11 additions & 0 deletions deploy/install/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@
"install_flag": "--public-url"
}
},
"allow_insecure_origin": {
"type": "boolean",
"default": false,
"description": "Allow a non-loopback HTTP public_url. For development and testing only: credentials and API keys then travel in plaintext.",
"x-oac": {
"changeable": true,
"restarts": ["core", "web"],
"derives": ["OAC_ALLOW_INSECURE_ORIGIN"],
"install_flag": "--allow-insecure-origin"
}
},
"host": {
"type": "string",
"default": "127.0.0.1",
Expand Down
31 changes: 18 additions & 13 deletions deploy/install/config_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ def lookup(config, key):


# Checks named by x-oac.check. Core stays the authority for its own semantic rules.
def _origin(value, https_only=False):
def _origin(value, allow_insecure=False, https_only=False):
"""Core's deployment.ValidateCoreURL rule, through the installer's one implementation of it."""
from configuration import valid_core_origin # configuration imports this module at load time
return valid_core_origin(value) and (not https_only or value.startswith("https://"))
origin_ok = valid_core_origin(value, allow_insecure=allow_insecure and not https_only)
return origin_ok and (not https_only or value.startswith("https://"))


_DURATION_UNITS = {"ns": 1e-9, "us": 1e-6, "µs": 1e-6, "μs": 1e-6, "ms": 1e-3, "s": 1, "m": 60, "h": 3600}
Expand Down Expand Up @@ -91,12 +92,15 @@ def _listen_host(value):


CHECKS = {
"listen_host": (_listen_host, "must be an IPv4 or IPv6 address without a port or zone"),
"listen_host": (lambda value, allow_insecure=False: _listen_host(value),
"must be an IPv4 or IPv6 address without a port or zone"),
"origin": (_origin, "must be a canonical origin such as https://core.example: lowercase, no path or "
"trailing slash, and HTTP only for a loopback host"),
"https_origin": (lambda value: _origin(value, https_only=True), "must be a canonical HTTPS origin"),
"go_duration": (lambda value: duration_seconds(value) is not None, "must be a Go duration such as 30m or 1h"),
"go_duration_min_1h": (lambda value: (duration_seconds(value) or 0) >= 3600,
"https_origin": (lambda value, allow_insecure=False: _origin(value, https_only=True),
"must be a canonical HTTPS origin"),
"go_duration": (lambda value, allow_insecure=False: duration_seconds(value) is not None,
"must be a Go duration such as 30m or 1h"),
"go_duration_min_1h": (lambda value, allow_insecure=False: (duration_seconds(value) or 0) >= 3600,
"must be a Go duration of at least 1h"),
}

Expand All @@ -108,7 +112,7 @@ def _type_ok(value, name):
"null": type(None)}[name])


def _validate(node, value, key, problems):
def _validate(node, value, key, problems, allow_insecure=False):
label = key or "config.json"
types = node.get("type")
if types is not None:
Expand All @@ -130,15 +134,15 @@ def _validate(node, value, key, problems):
if isinstance(value, str) and "pattern" in node and not re.search(node["pattern"], value):
problems.append(f"{label}: has an invalid format")
check = annotation(node, "check")
if check and isinstance(value, str) and not CHECKS[check][0](value):
if check and isinstance(value, str) and not CHECKS[check][0](value, allow_insecure):
problems.append(f"{label}: {CHECKS[check][1]}")
if isinstance(value, list):
if len(value) < node.get("minItems", 0):
problems.append(f"{label}: needs at least {node['minItems']} item(s)")
if node.get("uniqueItems") and len({json.dumps(item, sort_keys=True) for item in value}) != len(value):
problems.append(f"{label}: lists an item twice")
for index, item in enumerate(value):
_validate(node.get("items", {}), item, f"{label}[{index}]", problems)
_validate(node.get("items", {}), item, f"{label}[{index}]", problems, allow_insecure)
if isinstance(value, dict):
properties = node.get("properties", {})
for name in node.get("required", []):
Expand All @@ -147,9 +151,9 @@ def _validate(node, value, key, problems):
for name, item in value.items():
child = f"{key}.{name}" if key else name
if name in properties:
_validate(properties[name], item, child, problems)
_validate(properties[name], item, child, problems, allow_insecure)
elif isinstance(node.get("additionalProperties"), dict):
_validate(node["additionalProperties"], item, child, problems)
_validate(node["additionalProperties"], item, child, problems, allow_insecure)
else:
problems.append(f"{child}: unknown key")

Expand All @@ -174,7 +178,7 @@ def validate(config):
if not isinstance(config, dict):
raise ConfigError(["config.json: must be a JSON object"])
problems = []
_validate(SCHEMA, config, "", problems)
_validate(SCHEMA, config, "", problems, config.get("allow_insecure_origin") is True)
if problems:
raise ConfigError(problems)
full = copy.deepcopy(config)
Expand All @@ -193,7 +197,8 @@ def validate(config):
raise ValueError()
except ValueError:
problems.append("public_url: managed HTTPS requires https:// followed by a DNS hostname, without a port")
if not managed and not loopback_listener(full["host"]) and not (full["public_url"] or "").startswith("https://"):
if (not managed and not loopback_listener(full["host"])
and not (full["public_url"] or "").startswith("https://") and not full["allow_insecure_origin"]):
problems.append("public_url: an HTTPS origin is required when host is not loopback")
Comment on lines +200 to 202

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a public URL for external listeners

For an external install with a non-loopback host, such as ingress: "external", host: "0.0.0.0", and allow_insecure_origin: true, omitting public_url now passes validation because the switch bypasses the whole HTTPS-origin requirement. The rendered services still bind Core and Web publicly, while local_public_url falls back to http://127.0.0.1:8091; this creates an externally exposed installation with no declared reachable public address and no plaintext warning. The switch should relax HTTPS to an explicit HTTP public_url, not allow a missing URL.

Useful? React with 👍 / 👎.

core = full["core"]
if core["default_harness"] not in core["harnesses"]:
Expand Down
12 changes: 9 additions & 3 deletions deploy/install/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
_HOST_LABEL = re.compile(r"[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?")


def valid_core_origin(value):
def valid_core_origin(value, allow_insecure=False):
"""Accept exactly the origins Core's deployment.ValidateCoreURL accepts
(services/core/internal/deployment/public_url.go), so an
installer value never fails Core's OAC_PUBLIC_URL check at startup."""
installer value never fails Core's OAC_PUBLIC_URL check at startup.
allow_insecure additionally accepts a non-loopback HTTP origin, which Core
accepts only when OAC_ALLOW_INSECURE_ORIGIN is set."""
if not isinstance(value, str) or any(char in value for char in "?#@\\% \t\r\n"):
return False
try:
Expand Down Expand Up @@ -57,7 +59,7 @@ def valid_core_origin(value):
if netloc.startswith("[") or len(host) > 253 or not all(_HOST_LABEL.fullmatch(label) for label in host.split(".")):
return False
loopback = host == "localhost"
return parsed.scheme == "https" or loopback
return parsed.scheme == "https" or loopback or (allow_insecure and parsed.scheme == "http")


def environment_text(values, header):
Expand Down Expand Up @@ -204,6 +206,8 @@ def core_environment(root, config, state):
"OAC_EXECUTION_CONCURRENCY": str(core["execution_concurrency"]),
"OAC_WRITE_AUDIT_RETENTION": core["write_audit_retention"],
}
if config["allow_insecure_origin"]:
result["OAC_ALLOW_INSECURE_ORIGIN"] = "1"
Comment on lines +209 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Teach Core to honor the insecure-origin switch

With --allow-insecure-origin --public-url http://10.0.0.5:8080, this writes OAC_ALLOW_INSECURE_ORIGIN=1, but Core never reads that variable: publicURL still calls deployment.ValidateCoreURL, which rejects every non-loopback HTTP origin. Core therefore exits during startup for the newly documented configuration, so the installer cannot actually provision an insecure external HTTP origin until the Core validator is updated to honor the switch.

Useful? React with 👍 / 👎.

if (root / "native-installers/catalog.json").is_file():
result["OAC_NATIVE_INSTALLER_DIR"] = "/opt/oac/native-installers"
if core["oauth_trusted_origins"]:
Expand Down Expand Up @@ -260,6 +264,8 @@ def compose_config(root, config, state, candidate=None):
"OAC_WEB_CORE_KEY_FILE": f"{RUN}/core.key",
"OAC_WEB_NODE_PAYLOAD_DIR": "/node-payload",
}
if config["allow_insecure_origin"]:
environment["OAC_ALLOW_INSECURE_ORIGIN"] = "1"
environment.update(log_environment(config["log"]))
web = {"image": images["web"], "user": identity, "restart": "unless-stopped",
"ports": [service_address(config, "web") + ":8080"], "read_only": True,
Expand Down
24 changes: 17 additions & 7 deletions deploy/install/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def public_origin(value):
value = parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower()).geturl()
except ValueError:
value = ""
if not valid_core_origin(value):
if not valid_core_origin(value, allow_insecure=True):
raise argparse.ArgumentTypeError("Public URL must be an HTTPS origin such as https://core.example, "
"without path, credentials, query or fragment; plain HTTP only for a loopback host")
return value
Expand All @@ -136,13 +136,21 @@ def arguments(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--install-dir", type=Path)
for flag, (_, node) in SETTING_ARGUMENTS.items():
if node.get("type") == "boolean":
parser.add_argument(flag, action="store_true", help=node["description"])
continue
value_type = int if node.get("type") == "integer" else public_origin if config_model.annotation(node, "check") == "origin" else str
parser.add_argument(flag, type=value_type, help=node["description"])
parser.add_argument("--config", type=Path, help="Seed a new installation's config.json from this file")
args = parser.parse_args(argv)
args.install_dir = args.install_dir or Path.home() / ".oac/core"
if not args.install_dir.is_absolute():
parser.error("--install-dir must be absolute")
# public_origin accepts a non-loopback HTTP origin so --allow-insecure-origin can seed one;
# without the flag the strict rule stays the parse-time error.
if args.public_url and not args.allow_insecure_origin and not valid_core_origin(args.public_url):
parser.error("argument --public-url: Public URL must be an HTTPS origin such as https://core.example, "
"without path, credentials, query or fragment; plain HTTP only for a loopback host")
args.given = [name for name, value in vars(args).items()
if name not in ("install_dir", "given") and value not in (None, False)]
return args
Expand All @@ -152,7 +160,7 @@ def seed_document(args):
"""The --config file, which replaces the setting flags."""
if args.config is None:
return None
if any(getattr(args, name) is not None for name in SETTING_FLAGS):
if any(getattr(args, name) not in (None, False) for name in SETTING_FLAGS):
raise InstallError("--config replaces the setting flags; put those settings in the file")
try:
document = json.loads(args.config.read_text())
Expand Down Expand Up @@ -186,7 +194,8 @@ def check_listeners(args, document, config):
"""
if document is None:
names, where = {key: flag for flag, (key, _) in SETTING_ARGUMENTS.items()}, ""
given = {key for key, flag in names.items() if getattr(args, flag.removeprefix("--").replace("-", "_")) is not None}
given = {key for key, flag in names.items()
if getattr(args, flag.removeprefix("--").replace("-", "_")) not in (None, False)}
else:
names, where = {}, " in the --config file"
given = {key for key in ("ports.core", "ports.web") if config_model.lookup(document, key) is not None}
Expand Down Expand Up @@ -233,9 +242,10 @@ def origin_port(value):
return parsed.port or (443 if parsed.scheme == "https" else 80)


def nodes_reach(public_url):
"""Nodes and their sandboxes need an HTTPS public URL that is not loopback."""
return urlsplit(public_url or "").scheme == "https" and not loopback_origin(public_url)
def nodes_reach(public_url, allow_insecure=False):
"""Nodes and their sandboxes need a public URL that is not loopback; plain HTTP only with the switch."""
scheme = urlsplit(public_url or "").scheme
return not loopback_origin(public_url) and (scheme == "https" or allow_insecure and scheme == "http")


def check_compose():
Expand Down Expand Up @@ -527,7 +537,7 @@ def summary(root, config, fresh, selection=None, deployment=None, incomplete=Fal
# The loopback Web port does not serve the public API.
addresses.append("API base URL: " + api + " (local only)")
install_output.summary(root, config, addresses, fresh, selection, deployment,
nodes_reach(public_url), incomplete, moved)
nodes_reach(public_url, config["allow_insecure_origin"]), incomplete, moved)


def main(argv=None):
Expand Down
12 changes: 12 additions & 0 deletions deploy/install/install_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,25 @@ def sandbox_lines(config, selection, deployment, reachable):
return lines


def insecure_origin_warning(config):
"""The plaintext warning for allow_insecure_origin, or None when the origin is safe."""
origin = config.get("public_url") or ""
if not config.get("allow_insecure_origin") or not origin.startswith("http://"):
return None
return ("allow_insecure_origin is enabled: " + origin + " serves Core and Web over plaintext HTTP. "
"Credentials and API keys travel unencrypted; use this only on a trusted network.")


def summary(root, config, addresses, fresh, selection, deployment, reachable, incomplete, moved=()):
status = ("Services are running; sandbox setup needs attention." if incomplete else
"Installation complete." if fresh else "Installation settings checked. Use Status below to inspect service health.")
print("\n" + color(status, "33" if incomplete else "32"))
heading("Access")
for address in addresses:
print(" " + address)
warning = insecure_origin_warning(config)
if warning:
paragraph(color("Warning: " + warning, "33"))
for purpose, taken, port in moved:
print(f" Port {taken} was in use; {purpose} uses {port}.")
heading("Sign in")
Expand Down
4 changes: 4 additions & 0 deletions deploy/install/oac_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,7 @@ def written_view(root, state, config):
def applied_view(values):
"""The config the settings last written describe."""
return {"ingress": values.get("ingress"), "public_url": values.get("public_url"), "host": values["host"],
"allow_insecure_origin": values.get("allow_insecure_origin", False),
"ports": {name: values[f"ports.{name}"] for name in ("core", "web") if f"ports.{name}" in values}}


Expand Down Expand Up @@ -876,6 +877,9 @@ def status(root, out=print):
healthy = healthy and web_ok
out("Web: " + ("healthy" if web_ok else "unavailable"))
out("Public URL: " + (config["public_url"] or ("not configured; set up HTTPS in Web" if ingress_config.enabled(config) else "none (local access only)")))
if config.get("allow_insecure_origin") and (config.get("public_url") or "").startswith("http://"):
out("Warning: allow_insecure_origin is enabled; Core and Web are served over plaintext HTTP, "
"and credentials and API keys travel unencrypted")
out("API base URL: " + configuration.local_public_url(config) + "/v1")
out("Console: " + (ingress_config.console_origin(config) if ingress_config.enabled(config) else
config["public_url"] or configuration.service_origin(config, "web")))
Expand Down
Loading