Add central proxy registration

This commit is contained in:
Timothy J. Aveni
2026-06-28 19:37:47 -07:00
parent 75b836b5c0
commit 6e57673404
21 changed files with 1006 additions and 78 deletions
+38 -4
View File
@@ -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;
+103 -18
View File
@@ -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
'';
};
};
}
+104
View File
@@ -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);
}
+7 -1
View File
@@ -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 \
+56 -2
View File
@@ -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 = {
+34
View File
@@ -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"
}
}
}
}
+10
View File
@@ -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 };
};
+141
View File
@@ -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 });
}
}
}
+107
View File
@@ -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);
+23
View File
@@ -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
View File
@@ -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]
}
+25
View File
@@ -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
}
+4
View File
@@ -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";
+4
View File
@@ -15,6 +15,10 @@ terraform {
source = "hashicorp/random"
version = "~> 3.6"
}
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
}
+72 -2
View File
@@ -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
View File
@@ -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"
+24
View File
@@ -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"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@quixos/deploy",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"jose": "^6.1.2"
}
}
+8
View File
@@ -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";
+32 -2
View File
@@ -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