Add central proxy registration
This commit is contained in:
@@ -60,15 +60,48 @@
|
||||
};
|
||||
};
|
||||
project-visualizer-static = projectVisualizerProject;
|
||||
quixos-proxy-heartbeat-script = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "quixos-proxy-heartbeat-script";
|
||||
version = "0.1.0";
|
||||
src = ./quixos-instance/proxy-heartbeat;
|
||||
nativeBuildInputs = [ pkgs.esbuild ];
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
esbuild src/main.ts \
|
||||
--bundle \
|
||||
--platform=node \
|
||||
--target=node24 \
|
||||
--format=esm \
|
||||
--outfile=proxy-heartbeat.mjs
|
||||
runHook postBuild
|
||||
'';
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
install -Dm755 proxy-heartbeat.mjs "$out/libexec/quixos-proxy-heartbeat/proxy-heartbeat.mjs"
|
||||
runHook postInstall
|
||||
'';
|
||||
};
|
||||
quixos-proxy-heartbeat = pkgs.writeShellApplication {
|
||||
name = "quixos-proxy-heartbeat";
|
||||
runtimeInputs = [ pkgs.nodejs_24 ];
|
||||
text = ''
|
||||
exec ${pkgs.nodejs_24}/bin/node ${quixos-proxy-heartbeat-script}/libexec/quixos-proxy-heartbeat/proxy-heartbeat.mjs "$@"
|
||||
'';
|
||||
};
|
||||
quixos-deploy-script = pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "quixos-deploy-script";
|
||||
version = "0.1.0";
|
||||
src = ./tofu/runtime/deploy.ts;
|
||||
nativeBuildInputs = [ pkgs.esbuild ];
|
||||
dontUnpack = true;
|
||||
src = ./tofu/runtime;
|
||||
npmDeps = pkgs.importNpmLock { npmRoot = ./tofu/runtime; };
|
||||
npmConfigHook = pkgs.importNpmLock.npmConfigHook;
|
||||
nativeBuildInputs = [
|
||||
pkgs.esbuild
|
||||
pkgs.importNpmLock.npmConfigHook
|
||||
pkgs.nodejs_24
|
||||
];
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
esbuild "$src" \
|
||||
esbuild deploy.ts \
|
||||
--bundle \
|
||||
--platform=node \
|
||||
--target=node24 \
|
||||
@@ -101,6 +134,7 @@
|
||||
packages.qx-control = qx-control;
|
||||
packages.quixos-orchestrator = quixos-orchestrator;
|
||||
packages.project-visualizer-static = project-visualizer-static;
|
||||
packages.quixos-proxy-heartbeat = quixos-proxy-heartbeat;
|
||||
packages.quixos-deploy = quixos-deploy;
|
||||
packages.quixos-app-server = app-server.packages.${system}.default;
|
||||
packages.default = quixos-orchestrator;
|
||||
|
||||
@@ -16,6 +16,7 @@ let
|
||||
stateContextSecret = "${secretsCfg.runtimeDirectory}/state-context-secret";
|
||||
packageTreeGitUsername = "${secretsCfg.runtimeDirectory}/package-tree-git-username";
|
||||
packageTreeGitPassword = "${secretsCfg.runtimeDirectory}/package-tree-git-password";
|
||||
proxyRegistrationGrant = "${secretsCfg.runtimeDirectory}/proxy-registration-grant.jwt";
|
||||
};
|
||||
fetchAwsSsmParameter = parameterName: destination: ''
|
||||
${pkgs.awscli2}/bin/aws ssm get-parameter \
|
||||
@@ -299,6 +300,44 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
proxyRegistration = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Register this deployment with the central edge proxy.";
|
||||
};
|
||||
|
||||
deploymentId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Deployment ID presented to the central edge proxy.";
|
||||
};
|
||||
|
||||
registrationUrl = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = "Central proxy registration URL.";
|
||||
};
|
||||
|
||||
grantParameter = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "SSM parameter containing the signed proxy registration grant.";
|
||||
};
|
||||
|
||||
upstream = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "auto";
|
||||
description = "Internal upstream URL that central Caddy should proxy to. Use auto to discover the instance address from EC2 metadata.";
|
||||
};
|
||||
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "dev" "prod" ];
|
||||
default = "prod";
|
||||
description = "Deployment mode reported to the central edge proxy.";
|
||||
};
|
||||
};
|
||||
|
||||
sourceCheckout = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
@@ -481,6 +520,14 @@ in
|
||||
assertion = (!cfg.visualizer.vite.enable) || cfg.sourceCheckout.enable;
|
||||
message = "services.quixosHost.visualizer.vite.enable requires services.quixosHost.sourceCheckout.enable.";
|
||||
}
|
||||
{
|
||||
assertion = (!cfg.proxyRegistration.enable) || (
|
||||
cfg.proxyRegistration.deploymentId != ""
|
||||
&& cfg.proxyRegistration.registrationUrl != ""
|
||||
&& cfg.proxyRegistration.grantParameter != null
|
||||
);
|
||||
message = "services.quixosHost.proxyRegistration requires deploymentId, registrationUrl, and grantParameter.";
|
||||
}
|
||||
{
|
||||
assertion = cfg.sourceCheckout.path != cfg.packagesPath;
|
||||
message = "services.quixosHost.sourceCheckout.path must be separate from services.quixosHost.packagesPath.";
|
||||
@@ -986,6 +1033,9 @@ in
|
||||
${fetchAwsSsmParameter secretsCfg.awsSsm.stateContextSecretParameter secretFiles.stateContextSecret}
|
||||
${fetchAwsSsmParameter secretsCfg.awsSsm.packageTreeGitUsernameParameter secretFiles.packageTreeGitUsername}
|
||||
${fetchAwsSsmParameter secretsCfg.awsSsm.packageTreeGitPasswordParameter secretFiles.packageTreeGitPassword}
|
||||
${lib.optionalString cfg.proxyRegistration.enable ''
|
||||
${fetchAwsSsmParameter cfg.proxyRegistration.grantParameter secretFiles.proxyRegistrationGrant}
|
||||
''}
|
||||
'' else ''
|
||||
${installFileSecret secretsCfg.files.controlSecretPath secretFiles.controlSecret}
|
||||
${installFileSecret secretsCfg.files.stateContextSecretPath secretFiles.stateContextSecret}
|
||||
@@ -1207,26 +1257,29 @@ in
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
dataDir = "/persist/var/lib/caddy";
|
||||
virtualHosts.${cfg.domain}.extraConfig = ''
|
||||
handle_path /control/* {
|
||||
reverse_proxy unix//run/quixos/control.sock
|
||||
}
|
||||
|
||||
handle_path /relay/* {
|
||||
reverse_proxy unix//run/quixos/relay.sock {
|
||||
header_up X-Forwarded-Prefix /relay
|
||||
virtualHosts.${cfg.domain} = {
|
||||
hostName = if cfg.proxyRegistration.enable then "http://${cfg.domain}" else cfg.domain;
|
||||
extraConfig = ''
|
||||
handle_path /control/* {
|
||||
reverse_proxy unix//run/quixos/control.sock
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
${if cfg.visualizer.vite.enable then ''
|
||||
reverse_proxy ${cfg.visualizer.vite.host}:${toString cfg.visualizer.vite.port}
|
||||
'' else ''
|
||||
root * ${quixosPackages.project-visualizer-static}
|
||||
file_server
|
||||
''}
|
||||
}
|
||||
'';
|
||||
handle_path /relay/* {
|
||||
reverse_proxy unix//run/quixos/relay.sock {
|
||||
header_up X-Forwarded-Prefix /relay
|
||||
}
|
||||
}
|
||||
|
||||
handle {
|
||||
${if cfg.visualizer.vite.enable then ''
|
||||
reverse_proxy ${cfg.visualizer.vite.host}:${toString cfg.visualizer.vite.port}
|
||||
'' else ''
|
||||
root * ${quixosPackages.project-visualizer-static}
|
||||
file_server
|
||||
''}
|
||||
}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.caddy = {
|
||||
@@ -1238,5 +1291,37 @@ in
|
||||
wants = lib.optionals cfg.visualizer.vite.enable [ visualizerDevService ];
|
||||
requires = [ "quixos-persist-layout.service" ];
|
||||
};
|
||||
|
||||
systemd.services.quixos-proxy-heartbeat = lib.mkIf cfg.proxyRegistration.enable {
|
||||
description = "Register Quixos runtime with the central edge proxy";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
upheldBy = [ "multi-user.target" ];
|
||||
after = [
|
||||
"network-online.target"
|
||||
"quixos-secrets.service"
|
||||
"caddy.service"
|
||||
];
|
||||
wants = [ "network-online.target" ];
|
||||
requires = [ "quixos-secrets.service" ];
|
||||
serviceConfig = {
|
||||
User = "quixos";
|
||||
Group = "quixos-control";
|
||||
Restart = "always";
|
||||
RestartSec = "10s";
|
||||
};
|
||||
environment = {
|
||||
QUIXOS_PROXY_REGISTRATION_GRANT_FILE = secretFiles.proxyRegistrationGrant;
|
||||
QUIXOS_PROXY_REGISTRATION_URL = cfg.proxyRegistration.registrationUrl;
|
||||
QUIXOS_DEPLOYMENT_ID = cfg.proxyRegistration.deploymentId;
|
||||
QUIXOS_DOMAIN = cfg.domain;
|
||||
QUIXOS_PROXY_UPSTREAM = cfg.proxyRegistration.upstream;
|
||||
QUIXOS_PROXY_MODE = cfg.proxyRegistration.mode;
|
||||
QUIXOS_PROXY_HEARTBEAT_INTERVAL_SECONDS = "20";
|
||||
QUIXOS_PROXY_HEARTBEAT_TTL_SECONDS = if cfg.proxyRegistration.mode == "dev" then "30" else "180";
|
||||
};
|
||||
script = ''
|
||||
exec ${quixosPackages.quixos-proxy-heartbeat}/bin/quixos-proxy-heartbeat
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const env = (name: string, fallback = "") => process.env[name] ?? fallback;
|
||||
|
||||
const requiredEnv = (name: string) => {
|
||||
const value = env(name);
|
||||
if (!value) {
|
||||
throw new Error(`${name} is not set`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const grantPath = requiredEnv("QUIXOS_PROXY_REGISTRATION_GRANT_FILE");
|
||||
const registrationUrl = requiredEnv("QUIXOS_PROXY_REGISTRATION_URL");
|
||||
const deploymentId = requiredEnv("QUIXOS_DEPLOYMENT_ID");
|
||||
const domain = requiredEnv("QUIXOS_DOMAIN");
|
||||
const mode = env("QUIXOS_PROXY_MODE", "prod");
|
||||
const upstream = env("QUIXOS_PROXY_UPSTREAM", "auto");
|
||||
const intervalSeconds = Number.parseInt(env("QUIXOS_PROXY_HEARTBEAT_INTERVAL_SECONDS", "20"), 10);
|
||||
const ttlSeconds = Number.parseInt(env("QUIXOS_PROXY_HEARTBEAT_TTL_SECONDS", "60"), 10);
|
||||
|
||||
const metadataToken = async () => {
|
||||
const response = await fetch("http://169.254.169.254/latest/api/token", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"x-aws-ec2-metadata-token-ttl-seconds": "21600",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`IMDS token request failed with ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
};
|
||||
|
||||
const metadataText = async (token: string, path: string) => {
|
||||
const response = await fetch(new URL(path, "http://169.254.169.254/latest/meta-data/"), {
|
||||
headers: {
|
||||
"x-aws-ec2-metadata-token": token,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`IMDS request failed for ${path} with ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
};
|
||||
|
||||
const discoverUpstream = async () => {
|
||||
if (upstream !== "auto") {
|
||||
return upstream;
|
||||
}
|
||||
|
||||
const token = await metadataToken();
|
||||
const macs = (await metadataText(token, "network/interfaces/macs/"))
|
||||
.split(/\s+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (macs.length === 0) {
|
||||
throw new Error("Could not discover any EC2 network interfaces from IMDS");
|
||||
}
|
||||
|
||||
const ipv6s = (await metadataText(token, `network/interfaces/macs/${macs[0]}/ipv6s`).catch(() => ""))
|
||||
.split(/\s+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
if (ipv6s[0]) {
|
||||
return `http://[${ipv6s[0]}]:80`;
|
||||
}
|
||||
|
||||
const privateIpv4 = (await metadataText(token, "local-ipv4")).trim();
|
||||
return `http://${privateIpv4}:80`;
|
||||
};
|
||||
|
||||
const heartbeat = async () => {
|
||||
const grant = await fs.readFile(grantPath, "utf8");
|
||||
const resolvedUpstream = await discoverUpstream();
|
||||
const response = await fetch(registrationUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deploymentId,
|
||||
grant: grant.trim(),
|
||||
mode,
|
||||
routes: [{ host: domain, upstream: resolvedUpstream }],
|
||||
ttlSeconds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`proxy registration failed with ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await heartbeat();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
await sleep(intervalSeconds * 1000);
|
||||
}
|
||||
@@ -14,7 +14,13 @@
|
||||
pname = "quixos-app-server";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
nativeBuildInputs = [ pkgs.esbuild ];
|
||||
npmDeps = pkgs.importNpmLock { npmRoot = ./.; };
|
||||
npmConfigHook = pkgs.importNpmLock.npmConfigHook;
|
||||
nativeBuildInputs = [
|
||||
pkgs.esbuild
|
||||
pkgs.importNpmLock.npmConfigHook
|
||||
pkgs.nodejs_24
|
||||
];
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
esbuild src/server.ts \
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
let
|
||||
cfg = config.services.quixosAppServer;
|
||||
package = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
proxyGrantPublicKeyFile = pkgs.writeText "quixos-proxy-grant-public-key.pem" cfg.proxyGrantPublicKeyPem;
|
||||
in
|
||||
{
|
||||
options.services.quixosAppServer = {
|
||||
@@ -14,6 +15,28 @@ in
|
||||
description = "Public DNS name served by Caddy for the central app server.";
|
||||
};
|
||||
|
||||
appWildcardDomain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "app.quixos.org";
|
||||
description = "Wildcard deployment DNS suffix. Caddy serves *.appWildcardDomain.";
|
||||
};
|
||||
|
||||
devAppWildcardDomain = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "dev.app.quixos.org";
|
||||
description = "Wildcard dev deployment DNS suffix. Caddy serves *.devAppWildcardDomain.";
|
||||
};
|
||||
|
||||
route53HostedZoneId = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Route53 hosted zone ID used by Caddy DNS-01 challenges.";
|
||||
};
|
||||
|
||||
proxyGrantPublicKeyPem = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Public ECDSA key used to verify deployment proxy registration grants.";
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
@@ -194,6 +217,13 @@ in
|
||||
environment = {
|
||||
QUIXOS_APP_SERVER_HOST = cfg.host;
|
||||
QUIXOS_APP_SERVER_PORT = toString cfg.port;
|
||||
QUIXOS_CADDY_ADMIN_URL = "http://127.0.0.1:2019";
|
||||
QUIXOS_CADDY_BIN = "${config.services.caddy.package}/bin/caddy";
|
||||
QUIXOS_CENTRAL_DOMAIN = cfg.domain;
|
||||
QUIXOS_APP_WILDCARD_DOMAIN = cfg.appWildcardDomain;
|
||||
QUIXOS_DEV_APP_WILDCARD_DOMAIN = cfg.devAppWildcardDomain;
|
||||
QUIXOS_ROUTE53_HOSTED_ZONE_ID = cfg.route53HostedZoneId;
|
||||
QUIXOS_PROXY_GRANT_PUBLIC_KEY_PEM_FILE = "${proxyGrantPublicKeyFile}";
|
||||
};
|
||||
script = ''
|
||||
exec ${package}/bin/quixos-app-server
|
||||
@@ -202,10 +232,34 @@ in
|
||||
|
||||
services.caddy = {
|
||||
enable = true;
|
||||
package = pkgs.caddy.withPlugins {
|
||||
plugins = [ "github.com/caddy-dns/route53@v1.6.2" ];
|
||||
hash = "sha256-dxrfc6o6PBxRqMRUDpenHDctHUNQx4ZmAy9577RTTKg=";
|
||||
};
|
||||
dataDir = "/persist/var/lib/caddy";
|
||||
virtualHosts.${cfg.domain}.extraConfig = ''
|
||||
reverse_proxy ${cfg.host}:${toString cfg.port}
|
||||
globalConfig = ''
|
||||
admin 127.0.0.1:2019
|
||||
'';
|
||||
virtualHosts.${cfg.domain} = {
|
||||
serverAliases = [
|
||||
"*.${cfg.appWildcardDomain}"
|
||||
"*.${cfg.devAppWildcardDomain}"
|
||||
];
|
||||
extraConfig = ''
|
||||
tls {
|
||||
dns route53 {
|
||||
hosted_zone_id ${cfg.route53HostedZoneId}
|
||||
}
|
||||
}
|
||||
|
||||
@central host ${cfg.domain}
|
||||
handle @central {
|
||||
reverse_proxy ${cfg.host}:${toString cfg.port}
|
||||
}
|
||||
|
||||
respond 404
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.caddy = {
|
||||
|
||||
Generated
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@quixos/app-server",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@quixos/app-server",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"jose": "^6.1.2",
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@quixos/app-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"jose": "^6.1.2",
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { importSPKI, jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
export const registerRequestSchema = z.object({
|
||||
deploymentId: z.string().min(1),
|
||||
grant: z.string().min(1),
|
||||
mode: z.enum(["dev", "prod"]),
|
||||
routes: z.array(z.object({
|
||||
host: z.string().min(1),
|
||||
upstream: z.string().min(1),
|
||||
})).min(1),
|
||||
ttlSeconds: z.number().int().min(10).max(600),
|
||||
});
|
||||
|
||||
export type RegisterRequest = z.infer<typeof registerRequestSchema>;
|
||||
|
||||
const grantClaimsSchema = z.object({
|
||||
sub: z.string(),
|
||||
jti: z.string().min(1),
|
||||
deployment_id: z.string().min(1),
|
||||
hosts: z.array(z.string().min(1)).min(1),
|
||||
mode: z.enum(["dev", "prod"]),
|
||||
});
|
||||
|
||||
export type VerifiedGrant = z.infer<typeof grantClaimsSchema>;
|
||||
|
||||
export class ProxyGrantVerifier {
|
||||
#keyPromise: Promise<CryptoKey>;
|
||||
|
||||
constructor(publicKeyPem: string) {
|
||||
this.#keyPromise = importSPKI(publicKeyPem, "ES256");
|
||||
}
|
||||
|
||||
async verify(grant: string): Promise<VerifiedGrant> {
|
||||
const key = await this.#keyPromise;
|
||||
const result = await jwtVerify(grant, key, {
|
||||
audience: "quixos-central-proxy",
|
||||
issuer: "quixos-deployer",
|
||||
});
|
||||
return grantClaimsSchema.parse(result.payload);
|
||||
}
|
||||
}
|
||||
|
||||
export const verifyRegistration = async (
|
||||
verifier: ProxyGrantVerifier,
|
||||
body: unknown,
|
||||
) => {
|
||||
const request = registerRequestSchema.parse(body);
|
||||
const grant = await verifier.verify(request.grant);
|
||||
|
||||
if (grant.deployment_id !== request.deploymentId) {
|
||||
throw new Error("deployment id does not match registration grant");
|
||||
}
|
||||
if (grant.mode !== request.mode) {
|
||||
throw new Error("deployment mode does not match registration grant");
|
||||
}
|
||||
|
||||
const allowedHosts = new Set(grant.hosts);
|
||||
for (const route of request.routes) {
|
||||
if (!allowedHosts.has(route.host)) {
|
||||
throw new Error(`host is not authorized by registration grant: ${route.host}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { request, grant };
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type ProxyRoute = {
|
||||
deploymentId: string;
|
||||
host: string;
|
||||
upstream: string;
|
||||
expiresAt: number;
|
||||
mode: "dev" | "prod";
|
||||
};
|
||||
|
||||
export type CaddyProxyConfig = {
|
||||
caddyAdminUrl: string;
|
||||
caddyBin: string;
|
||||
centralDomain: string;
|
||||
appWildcardDomain: string;
|
||||
devAppWildcardDomain: string;
|
||||
route53HostedZoneId: string;
|
||||
centralUpstream: string;
|
||||
};
|
||||
|
||||
const routeMatcherName = (host: string) =>
|
||||
`route_${host.replace(/[^A-Za-z0-9_]/g, "_")}`;
|
||||
|
||||
const renderCaddyfile = (config: CaddyProxyConfig, routes: ProxyRoute[]) => {
|
||||
const routeBlocks = routes
|
||||
.sort((left, right) => left.host.localeCompare(right.host))
|
||||
.map((route) => {
|
||||
const matcher = routeMatcherName(route.host);
|
||||
return `
|
||||
@${matcher} host ${route.host}
|
||||
handle @${matcher} {
|
||||
reverse_proxy ${route.upstream}
|
||||
}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `{
|
||||
admin ${new URL(config.caddyAdminUrl).host}
|
||||
}
|
||||
|
||||
${config.centralDomain}, *.${config.appWildcardDomain}, *.${config.devAppWildcardDomain} {
|
||||
tls {
|
||||
dns route53 {
|
||||
hosted_zone_id ${config.route53HostedZoneId}
|
||||
}
|
||||
}
|
||||
|
||||
@central host ${config.centralDomain}
|
||||
handle @central {
|
||||
reverse_proxy ${config.centralUpstream}
|
||||
}
|
||||
${routeBlocks}
|
||||
|
||||
respond 404
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
const loadCaddyConfig = async (adminUrl: string, jsonConfig: string) => {
|
||||
const response = await fetch(new URL("/load", adminUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: jsonConfig,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Caddy config load failed with ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
};
|
||||
|
||||
export class ProxyRegistry {
|
||||
readonly routes = new Map<string, ProxyRoute>();
|
||||
#lastConfig = "";
|
||||
#reconcileInFlight: Promise<void> | null = null;
|
||||
|
||||
constructor(readonly config: CaddyProxyConfig) {}
|
||||
|
||||
register(route: ProxyRoute) {
|
||||
this.routes.set(route.host, route);
|
||||
}
|
||||
|
||||
expire(now = Date.now()) {
|
||||
for (const [host, route] of this.routes) {
|
||||
if (route.expiresAt <= now) {
|
||||
this.routes.delete(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(now = Date.now()) {
|
||||
this.expire(now);
|
||||
return [...this.routes.values()].sort((left, right) => left.host.localeCompare(right.host));
|
||||
}
|
||||
|
||||
async reconcile() {
|
||||
if (this.#reconcileInFlight) {
|
||||
return this.#reconcileInFlight;
|
||||
}
|
||||
this.#reconcileInFlight = this.#reconcileNow()
|
||||
.finally(() => {
|
||||
this.#reconcileInFlight = null;
|
||||
});
|
||||
return this.#reconcileInFlight;
|
||||
}
|
||||
|
||||
async #reconcileNow() {
|
||||
const caddyfile = renderCaddyfile(this.config, this.snapshot());
|
||||
if (caddyfile === this.#lastConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "quixos-caddy."));
|
||||
const caddyfilePath = path.join(tempDir, "Caddyfile");
|
||||
try {
|
||||
fs.writeFileSync(caddyfilePath, caddyfile);
|
||||
const adapted = spawnSync(this.config.caddyBin, [
|
||||
"adapt",
|
||||
"--config",
|
||||
caddyfilePath,
|
||||
"--adapter",
|
||||
"caddyfile",
|
||||
], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (adapted.error) {
|
||||
throw adapted.error;
|
||||
}
|
||||
if (adapted.status !== 0) {
|
||||
throw new Error(`Caddy adapt failed: ${adapted.stderr.trimEnd()}`);
|
||||
}
|
||||
await loadCaddyConfig(this.config.caddyAdminUrl, adapted.stdout);
|
||||
this.#lastConfig = caddyfile;
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,38 @@
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import { ProxyRegistry } from "./proxy-registry";
|
||||
import { ProxyGrantVerifier, verifyRegistration } from "./proxy-registration";
|
||||
|
||||
const host = process.env.QUIXOS_APP_SERVER_HOST ?? "127.0.0.1";
|
||||
const port = Number.parseInt(process.env.QUIXOS_APP_SERVER_PORT ?? "7100", 10);
|
||||
const centralDomain = process.env.QUIXOS_CENTRAL_DOMAIN ?? "quixos.org";
|
||||
const route53HostedZoneId = process.env.QUIXOS_ROUTE53_HOSTED_ZONE_ID ?? "";
|
||||
const proxyGrantPublicKeyPemFile = process.env.QUIXOS_PROXY_GRANT_PUBLIC_KEY_PEM_FILE ?? "";
|
||||
const proxyGrantPublicKeyPem = proxyGrantPublicKeyPemFile
|
||||
? fs.readFileSync(proxyGrantPublicKeyPemFile, "utf8")
|
||||
: (process.env.QUIXOS_PROXY_GRANT_PUBLIC_KEY_PEM ?? "");
|
||||
const caddyBin = process.env.QUIXOS_CADDY_BIN ?? "caddy";
|
||||
const caddyAdminUrl = process.env.QUIXOS_CADDY_ADMIN_URL ?? "http://127.0.0.1:2019";
|
||||
const appWildcardDomain = process.env.QUIXOS_APP_WILDCARD_DOMAIN ?? `app.${centralDomain}`;
|
||||
const devAppWildcardDomain = process.env.QUIXOS_DEV_APP_WILDCARD_DOMAIN ?? `dev.app.${centralDomain}`;
|
||||
|
||||
if (!route53HostedZoneId) {
|
||||
throw new Error("QUIXOS_ROUTE53_HOSTED_ZONE_ID is not set");
|
||||
}
|
||||
if (!proxyGrantPublicKeyPem) {
|
||||
throw new Error("QUIXOS_PROXY_GRANT_PUBLIC_KEY_PEM is not set");
|
||||
}
|
||||
|
||||
const proxyRegistry = new ProxyRegistry({
|
||||
caddyAdminUrl,
|
||||
caddyBin,
|
||||
centralDomain,
|
||||
appWildcardDomain,
|
||||
devAppWildcardDomain,
|
||||
route53HostedZoneId,
|
||||
centralUpstream: `${host}:${port}`,
|
||||
});
|
||||
const proxyGrantVerifier = new ProxyGrantVerifier(proxyGrantPublicKeyPem);
|
||||
|
||||
const writeJson = (
|
||||
response: http.ServerResponse,
|
||||
@@ -14,6 +45,46 @@ const writeJson = (
|
||||
response.end(`${JSON.stringify(body)}\n`);
|
||||
};
|
||||
|
||||
const readJsonBody = async (request: http.IncomingMessage) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of request) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
total += buffer.length;
|
||||
if (total > 1024 * 1024) {
|
||||
throw new Error("request body too large");
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
};
|
||||
|
||||
const handleRegister = async (
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse,
|
||||
) => {
|
||||
const body = await readJsonBody(request);
|
||||
const { request: registration } = await verifyRegistration(proxyGrantVerifier, body);
|
||||
const expiresAt = Date.now() + registration.ttlSeconds * 1000;
|
||||
|
||||
for (const route of registration.routes) {
|
||||
proxyRegistry.register({
|
||||
deploymentId: registration.deploymentId,
|
||||
host: route.host,
|
||||
upstream: route.upstream,
|
||||
expiresAt,
|
||||
mode: registration.mode,
|
||||
});
|
||||
}
|
||||
|
||||
await proxyRegistry.reconcile();
|
||||
writeJson(response, 200, {
|
||||
ok: true,
|
||||
registered: registration.routes.length,
|
||||
expiresAt: new Date(expiresAt).toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? host}`);
|
||||
|
||||
@@ -25,6 +96,30 @@ const server = http.createServer((request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/internal/proxy/register") {
|
||||
handleRegister(request, response).catch((error) => {
|
||||
console.error(error);
|
||||
writeJson(response, 400, {
|
||||
ok: false,
|
||||
error: "invalid proxy registration",
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/internal/proxy/routes") {
|
||||
writeJson(response, 200, {
|
||||
routes: proxyRegistry.snapshot().map((route) => ({
|
||||
deploymentId: route.deploymentId,
|
||||
host: route.host,
|
||||
upstream: route.upstream,
|
||||
mode: route.mode,
|
||||
expiresAt: new Date(route.expiresAt).toISOString(),
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(response, 200, {
|
||||
service: "quixos-app-server",
|
||||
status: "ok",
|
||||
@@ -35,8 +130,20 @@ server.listen(port, host, () => {
|
||||
console.log(`quixos-app-server listening on http://${host}:${port}`);
|
||||
});
|
||||
|
||||
proxyRegistry.reconcile().catch((error) => {
|
||||
console.error("initial Caddy reconciliation failed", error);
|
||||
});
|
||||
|
||||
const expiryTimer = setInterval(() => {
|
||||
proxyRegistry.expire();
|
||||
proxyRegistry.reconcile().catch((error) => {
|
||||
console.error("Caddy reconciliation failed", error);
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
const shutdown = (signal: NodeJS.Signals) => {
|
||||
console.log(`received ${signal}; shutting down`);
|
||||
clearInterval(expiryTimer);
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
|
||||
Generated
+23
@@ -41,3 +41,26 @@ provider "registry.opentofu.org/hashicorp/random" {
|
||||
"zh:f81afe4eb63e8aa9e0ea71be6c990f0dc69cb360e7191c0742a991f4a5081b64",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.opentofu.org/hashicorp/tls" {
|
||||
version = "4.3.0"
|
||||
constraints = "~> 4.0"
|
||||
hashes = [
|
||||
"h1:SGBiqFFxGryTOiaNNWlC7CCba1hjSsSwcJeg6rOLJrc=",
|
||||
"zh:07bb8c6e64124dada7dff57a38a46f2f323b3fd77920404c0c550293d1cf6188",
|
||||
"zh:0b3bfda2df39c52f1c5452d05cf3107bedd5d20ab6977c90ede540c695fb6c3e",
|
||||
"zh:110a055289f0400a63ac172bedb0e671d059b7a5ba22d4a3f5f246ccac0ad676",
|
||||
"zh:15e532d8c711377499dece832e60170a8bef39830125b8154f4bda81d9721d29",
|
||||
"zh:22ca65d96e9fc1be5605372d855c9e1eba2d86d510f7ac8593968f5649435e47",
|
||||
"zh:36df38dfd03e8c1298c5704fd85e28b69a3927ed0b339f9628d0b56dac99c6b5",
|
||||
"zh:429e2bfcb81656e1fe90b7b284767d1453c1a4100b16d27e4b29c34aa12f0ce1",
|
||||
"zh:5b6679953065f0279bf018426c6fb06dd93a851a7a9369f2e3a1fec5bc417e83",
|
||||
"zh:6a72c88d5aa945ddb32041350755377c96681563136decfe7e05c7cdea7988f1",
|
||||
"zh:6f05757c50da9f8354a735b5756bd63a71126fcd142129525b90c56bfd081d61",
|
||||
"zh:751703b7a4d40c3a111c4ed0d5da3ec91c14f880faf6f010a5000a2eb5366011",
|
||||
"zh:87a5279e61b8198798a2fe86cfe3b74e5340bb486f4e148bb5b4d46f860cf1db",
|
||||
"zh:942af95e9fd73327a7e9ab0803c4d701b782ddacd78c9b7ce9c91e38b3051522",
|
||||
"zh:a457d0efea3c404178a182d240ba21cdeb0c620ffabeeb9a8977b024a85e1360",
|
||||
"zh:d5eac8f4f0ae1ff41cbcc1008e6a74a8491dc27f4c6e5a0c32c5c4b6ef2e4087",
|
||||
]
|
||||
}
|
||||
|
||||
+109
-37
@@ -31,7 +31,9 @@ locals {
|
||||
resource_name = "${var.name}-${random_id.central.hex}"
|
||||
bucket_name = var.runtime_state_bucket_name == null ? "${local.resource_name}-runtime-state" : var.runtime_state_bucket_name
|
||||
base_domain = trimsuffix(var.base_domain, ".")
|
||||
app_server_domain = var.app_server_domain == null ? "app.${local.base_domain}" : trimsuffix(var.app_server_domain, ".")
|
||||
app_server_domain = var.app_server_domain == null ? local.base_domain : trimsuffix(var.app_server_domain, ".")
|
||||
app_wildcard_domain = "app.${local.base_domain}"
|
||||
dev_app_wildcard_domain = "dev.app.${local.base_domain}"
|
||||
central_ami_id = var.ami_id == null && var.ami_ssm_parameter_name != null ? data.aws_ssm_parameter.ami[0].value : var.ami_id
|
||||
app_server_ami_id = var.app_server_ami_id == null ? local.central_ami_id : var.app_server_ami_id
|
||||
app_server_enabled = local.app_server_ami_id != null
|
||||
@@ -50,10 +52,11 @@ locals {
|
||||
}
|
||||
|
||||
resource "aws_vpc" "platform" {
|
||||
count = var.platform_vpc_enable ? 1 : 0
|
||||
cidr_block = var.platform_vpc_cidr
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
count = var.platform_vpc_enable ? 1 : 0
|
||||
cidr_block = var.platform_vpc_cidr
|
||||
assign_generated_ipv6_cidr_block = true
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
|
||||
tags = merge(local.tags, {
|
||||
Name = "${local.resource_name}-vpc"
|
||||
@@ -72,11 +75,13 @@ resource "aws_internet_gateway" "platform" {
|
||||
}
|
||||
|
||||
resource "aws_subnet" "central_public" {
|
||||
count = var.platform_vpc_enable ? 1 : 0
|
||||
vpc_id = aws_vpc.platform[0].id
|
||||
availability_zone = data.aws_subnet.selected.availability_zone
|
||||
cidr_block = var.platform_public_subnet_cidr
|
||||
map_public_ip_on_launch = true
|
||||
count = var.platform_vpc_enable ? 1 : 0
|
||||
vpc_id = aws_vpc.platform[0].id
|
||||
availability_zone = data.aws_subnet.selected.availability_zone
|
||||
cidr_block = var.platform_public_subnet_cidr
|
||||
ipv6_cidr_block = cidrsubnet(aws_vpc.platform[0].ipv6_cidr_block, 8, 0)
|
||||
assign_ipv6_address_on_creation = true
|
||||
map_public_ip_on_launch = true
|
||||
|
||||
tags = merge(local.tags, {
|
||||
Name = "${local.resource_name}-central-public"
|
||||
@@ -85,11 +90,13 @@ resource "aws_subnet" "central_public" {
|
||||
}
|
||||
|
||||
resource "aws_subnet" "runtime_public" {
|
||||
count = var.platform_vpc_enable ? 1 : 0
|
||||
vpc_id = aws_vpc.platform[0].id
|
||||
availability_zone = data.aws_subnet.selected.availability_zone
|
||||
cidr_block = var.platform_runtime_subnet_cidr
|
||||
map_public_ip_on_launch = true
|
||||
count = var.platform_vpc_enable ? 1 : 0
|
||||
vpc_id = aws_vpc.platform[0].id
|
||||
availability_zone = data.aws_subnet.selected.availability_zone
|
||||
cidr_block = var.platform_runtime_subnet_cidr
|
||||
ipv6_cidr_block = cidrsubnet(aws_vpc.platform[0].ipv6_cidr_block, 8, 1)
|
||||
assign_ipv6_address_on_creation = true
|
||||
map_public_ip_on_launch = true
|
||||
|
||||
tags = merge(local.tags, {
|
||||
Name = "${local.resource_name}-runtime-public"
|
||||
@@ -106,6 +113,11 @@ resource "aws_route_table" "platform_public" {
|
||||
gateway_id = aws_internet_gateway.platform[0].id
|
||||
}
|
||||
|
||||
route {
|
||||
ipv6_cidr_block = "::/0"
|
||||
gateway_id = aws_internet_gateway.platform[0].id
|
||||
}
|
||||
|
||||
tags = merge(local.tags, {
|
||||
Name = "${local.resource_name}-public-rt"
|
||||
Role = "platform-public-route-table"
|
||||
@@ -181,26 +193,29 @@ resource "aws_security_group" "app_server" {
|
||||
vpc_id = local.platform_vpc_id
|
||||
|
||||
ingress {
|
||||
description = "HTTP"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
description = "HTTP"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
ipv6_cidr_blocks = ["::/0"]
|
||||
}
|
||||
|
||||
ingress {
|
||||
description = "HTTPS"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
description = "HTTPS"
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
ipv6_cidr_blocks = ["::/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
ipv6_cidr_blocks = ["::/0"]
|
||||
}
|
||||
|
||||
tags = merge(local.tags, {
|
||||
@@ -226,10 +241,11 @@ resource "aws_security_group" "runtime_from_proxy" {
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
ipv6_cidr_blocks = ["::/0"]
|
||||
}
|
||||
|
||||
tags = merge(local.tags, {
|
||||
@@ -238,6 +254,11 @@ resource "aws_security_group" "runtime_from_proxy" {
|
||||
})
|
||||
}
|
||||
|
||||
resource "tls_private_key" "proxy_grants" {
|
||||
algorithm = "ECDSA"
|
||||
ecdsa_curve = "P256"
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "app_server" {
|
||||
count = local.app_server_enabled ? 1 : 0
|
||||
name = "${local.resource_name}-app-server"
|
||||
@@ -264,6 +285,34 @@ resource "aws_iam_role_policy_attachment" "app_server_ssm" {
|
||||
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "app_server_route53" {
|
||||
count = local.app_server_enabled && var.route53_zone_id != null ? 1 : 0
|
||||
name = "${local.resource_name}-route53"
|
||||
role = aws_iam_role.app_server[0].name
|
||||
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"route53:ChangeResourceRecordSets",
|
||||
"route53:ListResourceRecordSets"
|
||||
]
|
||||
Resource = "arn:aws:route53:::hostedzone/${var.route53_zone_id}"
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
Action = [
|
||||
"route53:GetChange",
|
||||
"route53:ListHostedZonesByName"
|
||||
]
|
||||
Resource = "*"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_instance_profile" "app_server" {
|
||||
count = local.app_server_enabled ? 1 : 0
|
||||
name = "${local.resource_name}-app-server"
|
||||
@@ -278,13 +327,18 @@ resource "aws_instance" "app_server" {
|
||||
vpc_security_group_ids = [aws_security_group.app_server[0].id]
|
||||
iam_instance_profile = aws_iam_instance_profile.app_server[0].name
|
||||
associate_public_ip_address = true
|
||||
ipv6_address_count = var.platform_vpc_enable ? 1 : null
|
||||
user_data_replace_on_change = true
|
||||
|
||||
user_data = templatefile("${path.module}/user-data-flake.nix.tftpl", {
|
||||
app_server_flake_uri = local.app_server_flake_uri
|
||||
domain = local.app_server_domain
|
||||
persist_device = local.app_server_persist_device
|
||||
system = var.app_server_system
|
||||
app_server_flake_uri = local.app_server_flake_uri
|
||||
app_wildcard_domain = local.app_wildcard_domain
|
||||
dev_app_wildcard_domain = local.dev_app_wildcard_domain
|
||||
domain = local.app_server_domain
|
||||
persist_device = local.app_server_persist_device
|
||||
proxy_grant_public_key_pem = jsonencode(tls_private_key.proxy_grants.public_key_pem)
|
||||
route53_zone_id = var.route53_zone_id
|
||||
system = var.app_server_system
|
||||
})
|
||||
|
||||
root_block_device {
|
||||
@@ -329,3 +383,21 @@ resource "aws_route53_record" "app_server" {
|
||||
ttl = 60
|
||||
records = [aws_eip.app_server[0].public_ip]
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "app_wildcard" {
|
||||
count = local.app_server_enabled && var.route53_zone_id != null ? 1 : 0
|
||||
zone_id = var.route53_zone_id
|
||||
name = "*.${local.app_wildcard_domain}"
|
||||
type = "A"
|
||||
ttl = 60
|
||||
records = [aws_eip.app_server[0].public_ip]
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "dev_app_wildcard" {
|
||||
count = local.app_server_enabled && var.route53_zone_id != null ? 1 : 0
|
||||
zone_id = var.route53_zone_id
|
||||
name = "*.${local.dev_app_wildcard_domain}"
|
||||
type = "A"
|
||||
ttl = 60
|
||||
records = [aws_eip.app_server[0].public_ip]
|
||||
}
|
||||
|
||||
@@ -51,10 +51,22 @@ output "app_server_domain" {
|
||||
value = local.app_server_domain
|
||||
}
|
||||
|
||||
output "app_wildcard_domain" {
|
||||
value = local.app_wildcard_domain
|
||||
}
|
||||
|
||||
output "dev_app_wildcard_domain" {
|
||||
value = local.dev_app_wildcard_domain
|
||||
}
|
||||
|
||||
output "app_server_public_ip" {
|
||||
value = local.app_server_enabled ? aws_eip.app_server[0].public_ip : null
|
||||
}
|
||||
|
||||
output "app_server_ipv6_addresses" {
|
||||
value = local.app_server_enabled ? aws_instance.app_server[0].ipv6_addresses : []
|
||||
}
|
||||
|
||||
output "app_server_instance_id" {
|
||||
value = local.app_server_enabled ? aws_instance.app_server[0].id : null
|
||||
}
|
||||
@@ -62,3 +74,16 @@ output "app_server_instance_id" {
|
||||
output "app_server_ssm_start_session" {
|
||||
value = local.app_server_enabled ? "aws ssm start-session --target ${aws_instance.app_server[0].id}" : null
|
||||
}
|
||||
|
||||
output "proxy_registration_url" {
|
||||
value = local.app_server_enabled ? "https://${local.app_server_domain}/internal/proxy/register" : null
|
||||
}
|
||||
|
||||
output "proxy_registration_public_key_pem" {
|
||||
value = tls_private_key.proxy_grants.public_key_pem
|
||||
}
|
||||
|
||||
output "proxy_registration_private_key_pem" {
|
||||
value = tls_private_key.proxy_grants.private_key_pem
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
@@ -21,8 +21,12 @@
|
||||
services.quixosAppServer = {
|
||||
enable = true;
|
||||
domain = "${domain}";
|
||||
appWildcardDomain = "${app_wildcard_domain}";
|
||||
devAppWildcardDomain = "${dev_app_wildcard_domain}";
|
||||
persistDevice = "${persist_device}";
|
||||
persistZfsPool = "quixos-shared";
|
||||
route53HostedZoneId = "${route53_zone_id}";
|
||||
proxyGrantPublicKeyPem = ${proxy_grant_public_key_pem};
|
||||
};
|
||||
|
||||
system.stateVersion = "25.05";
|
||||
|
||||
@@ -15,6 +15,10 @@ terraform {
|
||||
source = "hashicorp/random"
|
||||
version = "~> 3.6"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = "~> 4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+72
-2
@@ -6,6 +6,7 @@ import crypto from "node:crypto";
|
||||
import readline from "node:readline/promises";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { SignJWT, importPKCS8 } from "jose";
|
||||
|
||||
const sourceScriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = process.env.QUIXOS_REPO_ROOT ?? path.resolve(sourceScriptDir, "../..");
|
||||
@@ -26,6 +27,10 @@ const deployExclusiveVars = new Set([
|
||||
"shared_persist_volume_id",
|
||||
"caddy_persist_volume_id",
|
||||
"central_proxy_security_group_id",
|
||||
"assign_ipv6_address",
|
||||
"proxy_registration_grant",
|
||||
"proxy_registration_url",
|
||||
"proxy_registration_mode",
|
||||
"package_tree_ref",
|
||||
"quixos_ref",
|
||||
"quixos_ref_is_rev",
|
||||
@@ -309,8 +314,8 @@ const deploymentStateKey = (state) => `${state.runtimeStatePrefix}/${state.deplo
|
||||
|
||||
const defaultDomainForDeployment = (state) =>
|
||||
state.devMode
|
||||
? `${state.deploymentId}.dev.${state.baseDomain}`
|
||||
: `${state.deploymentId}.${state.baseDomain}`;
|
||||
? `${state.deploymentId}.dev.app.${state.baseDomain}`
|
||||
: `${state.deploymentId}.app.${state.baseDomain}`;
|
||||
|
||||
const deploymentRegistryKey = (stateOrId) => {
|
||||
const id = typeof stateOrId === "string" ? stateOrId : stateOrId.deploymentId;
|
||||
@@ -400,6 +405,10 @@ const parseArgs = (argv) => {
|
||||
amiProjectName: process.env.QUIXOS_AMI_PROJECT_NAME ?? "nixos-zfs-ec2",
|
||||
sharedPersistVolumeId: process.env.QUIXOS_SHARED_PERSIST_VOLUME_ID ?? process.env.QUIXOS_CADDY_PERSIST_VOLUME_ID ?? "",
|
||||
centralProxySecurityGroupId: process.env.QUIXOS_CENTRAL_PROXY_SECURITY_GROUP_ID ?? "",
|
||||
proxyRegistrationUrl: process.env.QUIXOS_PROXY_REGISTRATION_URL ?? "",
|
||||
proxyRegistrationGrant: process.env.QUIXOS_PROXY_REGISTRATION_GRANT ?? "",
|
||||
proxyRegistrationPrivateKeyPem: "",
|
||||
assignIpv6Address: false,
|
||||
baseDomain: String(process.env.QUIXOS_BASE_DOMAIN ?? process.env.TF_VAR_base_domain ?? operatorTfvars.base_domain ?? "quixos.org").replace(/\.$/, ""),
|
||||
baseDomainSpecified: Boolean(process.env.QUIXOS_BASE_DOMAIN || process.env.TF_VAR_base_domain || operatorTfvars.base_domain),
|
||||
domain: process.env.QUIXOS_DOMAIN ?? "",
|
||||
@@ -842,6 +851,7 @@ const resolveRuntimeStateBucket = (state, tofuEnv) => {
|
||||
|
||||
const resolveCentralProxySecurityGroup = (state, tofuEnv) => {
|
||||
if (state.centralProxySecurityGroupId) {
|
||||
state.assignIpv6Address = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -850,11 +860,62 @@ const resolveCentralProxySecurityGroup = (state, tofuEnv) => {
|
||||
const securityGroupId = result.stdout.trim();
|
||||
if (result.status === 0 && /^sg-[0-9a-f]+$/.test(securityGroupId)) {
|
||||
state.centralProxySecurityGroupId = securityGroupId;
|
||||
state.assignIpv6Address = true;
|
||||
console.error(`Using central proxy security group from tofu/central: ${state.centralProxySecurityGroupId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resolveCentralProxyRegistration = (state, tofuEnv) => {
|
||||
if (!state.centralProxySecurityGroupId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.proxyRegistrationUrl && fs.existsSync(centralDir)) {
|
||||
const result = captureMaybe("tofu", ["-chdir=" + centralDir, "output", "-raw", "proxy_registration_url"], { env: tofuEnv });
|
||||
const registrationUrl = result.stdout.trim();
|
||||
if (result.status === 0 && registrationUrl) {
|
||||
state.proxyRegistrationUrl = registrationUrl;
|
||||
console.error(`Using central proxy registration endpoint from tofu/central: ${state.proxyRegistrationUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.proxyRegistrationPrivateKeyPem && fs.existsSync(centralDir)) {
|
||||
const result = captureMaybe("tofu", ["-chdir=" + centralDir, "output", "-raw", "proxy_registration_private_key_pem"], { env: tofuEnv });
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
state.proxyRegistrationPrivateKeyPem = result.stdout;
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.proxyRegistrationUrl) {
|
||||
throw new Error("Central proxy mode is enabled, but no proxy registration URL is available. Run tofu/central apply or pass QUIXOS_PROXY_REGISTRATION_URL.");
|
||||
}
|
||||
if (!state.proxyRegistrationPrivateKeyPem && !state.proxyRegistrationGrant) {
|
||||
throw new Error("Central proxy mode is enabled, but no proxy registration signing key is available from tofu/central.");
|
||||
}
|
||||
};
|
||||
|
||||
const signProxyRegistrationGrant = async (state) => {
|
||||
if (!state.centralProxySecurityGroupId || state.proxyRegistrationGrant) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await importPKCS8(state.proxyRegistrationPrivateKeyPem, "ES256");
|
||||
state.proxyRegistrationGrant = await new SignJWT({
|
||||
deployment_id: state.deploymentId,
|
||||
mode: state.devMode ? "dev" : "prod",
|
||||
hosts: [state.domain],
|
||||
})
|
||||
.setProtectedHeader({ alg: "ES256", typ: "JWT" })
|
||||
.setIssuer("quixos-deployer")
|
||||
.setAudience("quixos-central-proxy")
|
||||
.setSubject(state.deploymentId)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("365d")
|
||||
.setJti(crypto.randomUUID())
|
||||
.sign(key);
|
||||
};
|
||||
|
||||
const resolveCentralRuntimeNetwork = (state, tofuEnv) => {
|
||||
if (state.vpcId && state.subnetId) {
|
||||
return;
|
||||
@@ -1120,6 +1181,7 @@ const printDeploySummary = (state, resolvedPackagesRef) => {
|
||||
}
|
||||
if (state.centralProxySecurityGroupId) {
|
||||
console.error(`Ingress proxy SG: ${state.centralProxySecurityGroupId}`);
|
||||
console.error(`Proxy registration: ${state.proxyRegistrationUrl}`);
|
||||
}
|
||||
console.error(`Package tree: ${resolvedPackagesRef}`);
|
||||
console.error(state.quixosRefIsRev ? `Quixos rev: ${state.quixosRef}` : `Quixos ref: ${state.quixosRef}`);
|
||||
@@ -1186,6 +1248,10 @@ const writeGeneratedDeployVars = (state, resolvedPackagesRef) => {
|
||||
ami_project_name: state.amiProjectName,
|
||||
shared_persist_volume_id: state.sharedPersistVolumeId,
|
||||
central_proxy_security_group_id: state.centralProxySecurityGroupId || null,
|
||||
assign_ipv6_address: state.assignIpv6Address,
|
||||
proxy_registration_grant: state.proxyRegistrationGrant || null,
|
||||
proxy_registration_url: state.proxyRegistrationUrl || null,
|
||||
proxy_registration_mode: state.devMode ? "dev" : "prod",
|
||||
subdomain: state.domain,
|
||||
enable_ssh: state.enableSshWithLocalKey || Boolean(state.sshPublicKey),
|
||||
ssh_public_key: state.sshPublicKey,
|
||||
@@ -1338,6 +1404,8 @@ const registryRecord = (state, resolvedPackagesRef, tofuEnv, status) => ({
|
||||
amiProjectName: state.amiProjectName,
|
||||
sharedPersistVolumeId: state.sharedPersistVolumeId,
|
||||
centralProxySecurityGroupId: state.centralProxySecurityGroupId,
|
||||
proxyRegistrationUrl: state.proxyRegistrationUrl,
|
||||
assignIpv6Address: state.assignIpv6Address,
|
||||
vpcId: state.vpcId,
|
||||
subnetId: state.subnetId,
|
||||
baseDomain: state.baseDomain,
|
||||
@@ -1749,6 +1817,8 @@ const main = async () => {
|
||||
resolveSharedPersistVolume(state, tofuEnv);
|
||||
resolveCentralRuntimeNetwork(state, tofuEnv);
|
||||
resolveCentralProxySecurityGroup(state, tofuEnv);
|
||||
resolveCentralProxyRegistration(state, tofuEnv);
|
||||
await signProxyRegistrationGrant(state);
|
||||
const resolvedPackagesRef = resolvePackageTree(state);
|
||||
initRuntimeBackend(state, tofuEnv);
|
||||
|
||||
|
||||
+30
-12
@@ -24,7 +24,7 @@ locals {
|
||||
vpc_id = var.vpc_id == null ? data.aws_vpc.default[0].id : var.vpc_id
|
||||
subnet_id = var.subnet_id == null ? data.aws_subnets.default[0].ids[0] : var.subnet_id
|
||||
base_domain = trimsuffix(var.base_domain, ".")
|
||||
domain = var.subdomain == null ? "${local.deployment_id}${var.dev_mode ? ".dev" : ""}.${local.base_domain}" : trimsuffix(var.subdomain, ".")
|
||||
domain = var.subdomain == null ? "${local.deployment_id}${var.dev_mode ? ".dev" : ""}.app.${local.base_domain}" : trimsuffix(var.subdomain, ".")
|
||||
deployment_id = var.deployment_id == null ? random_id.deployment.hex : var.deployment_id
|
||||
resource_name = "${var.name}-${local.deployment_id}"
|
||||
quixos_flake_uri = "git+${var.quixos_repo_url}?${var.quixos_ref_is_rev ? "rev" : "ref"}=${urlencode(var.quixos_ref)}"
|
||||
@@ -32,6 +32,7 @@ locals {
|
||||
ssm_parameter_prefix = "/${var.name}/${local.deployment_id}"
|
||||
effective_ami_id = var.ami_id == null ? "ami-00000000000000000" : var.ami_id
|
||||
shared_volume_id = var.shared_persist_volume_id == null ? var.caddy_persist_volume_id : var.shared_persist_volume_id
|
||||
central_proxy_enabled = var.central_proxy_security_group_id != null && var.proxy_registration_grant != null && var.proxy_registration_url != null
|
||||
persist_device = "/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_${replace(aws_ebs_volume.persist_local.id, "-", "")}"
|
||||
shared_persist_device = local.shared_volume_id == null ? null : "/dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_${replace(local.shared_volume_id, "-", "")}"
|
||||
tags = {
|
||||
@@ -84,6 +85,14 @@ resource "aws_ssm_parameter" "package_tree_git_password" {
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_ssm_parameter" "proxy_registration_grant" {
|
||||
count = local.central_proxy_enabled ? 1 : 0
|
||||
name = "${local.ssm_parameter_prefix}/proxy-registration-grant"
|
||||
type = "SecureString"
|
||||
value = var.proxy_registration_grant
|
||||
tags = local.tags
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "instance" {
|
||||
name = "${local.resource_name}-instance"
|
||||
|
||||
@@ -119,12 +128,12 @@ resource "aws_iam_role_policy" "quixos_secrets" {
|
||||
"ssm:GetParameter",
|
||||
"ssm:GetParameters"
|
||||
]
|
||||
Resource = [
|
||||
Resource = concat([
|
||||
aws_ssm_parameter.control_secret.arn,
|
||||
aws_ssm_parameter.state_context_secret.arn,
|
||||
aws_ssm_parameter.package_tree_git_username.arn,
|
||||
aws_ssm_parameter.package_tree_git_password.arn
|
||||
]
|
||||
], aws_ssm_parameter.proxy_registration_grant[*].arn)
|
||||
},
|
||||
{
|
||||
Effect = "Allow"
|
||||
@@ -148,11 +157,12 @@ resource "aws_security_group" "instance" {
|
||||
dynamic "ingress" {
|
||||
for_each = var.central_proxy_security_group_id == null ? [80, 443] : []
|
||||
content {
|
||||
description = ingress.value == 80 ? "HTTP" : "HTTPS"
|
||||
from_port = ingress.value
|
||||
to_port = ingress.value
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
description = ingress.value == 80 ? "HTTP" : "HTTPS"
|
||||
from_port = ingress.value
|
||||
to_port = ingress.value
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
ipv6_cidr_blocks = ["::/0"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,10 +189,11 @@ resource "aws_security_group" "instance" {
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
ipv6_cidr_blocks = ["::/0"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,12 +210,14 @@ resource "aws_instance" "host" {
|
||||
vpc_security_group_ids = [aws_security_group.instance.id]
|
||||
iam_instance_profile = aws_iam_instance_profile.instance.name
|
||||
associate_public_ip_address = true
|
||||
ipv6_address_count = var.assign_ipv6_address ? 1 : null
|
||||
key_name = var.ssh_public_key == null ? null : aws_key_pair.admin[0].key_name
|
||||
user_data_replace_on_change = true
|
||||
|
||||
user_data = templatefile("${path.module}/user-data-flake.nix.tftpl", {
|
||||
admin_user = var.admin_user
|
||||
control_secret_parameter = aws_ssm_parameter.control_secret.name
|
||||
deployment_id = local.deployment_id
|
||||
dev_mode = var.dev_mode
|
||||
domain = local.domain
|
||||
enable_ssh = var.enable_ssh
|
||||
@@ -214,6 +227,10 @@ resource "aws_instance" "host" {
|
||||
package_tree_ref = var.package_tree_ref
|
||||
package_tree_repo_url = var.package_tree_repo_url
|
||||
persist_device = local.persist_device
|
||||
proxy_registration_enabled = local.central_proxy_enabled
|
||||
proxy_registration_grant_parameter = local.central_proxy_enabled ? aws_ssm_parameter.proxy_registration_grant[0].name : ""
|
||||
proxy_registration_mode = var.proxy_registration_mode
|
||||
proxy_registration_url = var.proxy_registration_url == null ? "" : var.proxy_registration_url
|
||||
quixos_ref = var.quixos_ref
|
||||
quixos_repo_url = var.quixos_repo_url
|
||||
quixos_flake_uri = local.quixos_flake_uri
|
||||
@@ -266,6 +283,7 @@ resource "aws_eip_association" "host" {
|
||||
}
|
||||
|
||||
resource "aws_route53_record" "host" {
|
||||
count = local.central_proxy_enabled ? 0 : 1
|
||||
zone_id = var.route53_zone_id
|
||||
name = local.domain
|
||||
type = "A"
|
||||
|
||||
Generated
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@quixos/deploy",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@quixos/deploy",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"jose": "^6.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@quixos/deploy",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"jose": "^6.1.2"
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,14 @@
|
||||
static.enable = ${!dev_mode};
|
||||
vite.enable = ${dev_mode};
|
||||
};
|
||||
proxyRegistration = {
|
||||
enable = ${proxy_registration_enabled};
|
||||
deploymentId = "${deployment_id}";
|
||||
registrationUrl = "${proxy_registration_url}";
|
||||
grantParameter = "${proxy_registration_grant_parameter}";
|
||||
upstream = "auto";
|
||||
mode = "${proxy_registration_mode}";
|
||||
};
|
||||
|
||||
secrets = {
|
||||
provider = "aws-ssm";
|
||||
|
||||
@@ -35,13 +35,13 @@ variable "route53_zone_id" {
|
||||
|
||||
variable "subdomain" {
|
||||
type = string
|
||||
description = "Optional full DNS name override. When unset, the runtime derives one from deployment_id, dev_mode, and base_domain."
|
||||
description = "Optional full DNS name override. When unset, the runtime derives <deployment-id>.app.<base-domain> or <deployment-id>.dev.app.<base-domain>."
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "base_domain" {
|
||||
type = string
|
||||
description = "Base DNS domain used for generated deployment hostnames. Prod uses <deployment-id>.<base_domain>; dev uses <deployment-id>.dev.<base_domain>."
|
||||
description = "Base DNS domain used for generated deployment hostnames. Prod uses <deployment-id>.app.<base_domain>; dev uses <deployment-id>.dev.app.<base_domain>."
|
||||
default = "quixos.org"
|
||||
}
|
||||
|
||||
@@ -111,6 +111,36 @@ variable "central_proxy_security_group_id" {
|
||||
description = "Optional central proxy security group. When set, runtime HTTP/HTTPS ingress is allowed only from this security group."
|
||||
}
|
||||
|
||||
variable "assign_ipv6_address" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Assign an IPv6 address to the runtime instance. Used by central proxy mode when the selected subnet has IPv6 enabled."
|
||||
}
|
||||
|
||||
variable "proxy_registration_grant" {
|
||||
type = string
|
||||
default = null
|
||||
sensitive = true
|
||||
description = "Signed JWT authorizing this runtime to register its hostname with the central proxy."
|
||||
}
|
||||
|
||||
variable "proxy_registration_url" {
|
||||
type = string
|
||||
default = null
|
||||
description = "Central proxy registration endpoint."
|
||||
}
|
||||
|
||||
variable "proxy_registration_mode" {
|
||||
type = string
|
||||
default = "prod"
|
||||
description = "Mode reported to the central proxy registration service."
|
||||
|
||||
validation {
|
||||
condition = contains(["dev", "prod"], var.proxy_registration_mode)
|
||||
error_message = "proxy_registration_mode must be dev or prod."
|
||||
}
|
||||
}
|
||||
|
||||
variable "caddy_persist_volume_id" {
|
||||
type = string
|
||||
default = null
|
||||
|
||||
Reference in New Issue
Block a user