From e65aead5f0b7f4556859ed12346e1dab297fc8d3 Mon Sep 17 00:00:00 2001 From: KevinMidboe Date: Wed, 31 Dec 2025 14:46:53 +0100 Subject: [PATCH] defines network, subnets, cloudflare dns & floatingip --- hetzner-pulumi/index.ts | 107 +++++++++++++++++------ hetzner-pulumi/package.json | 1 + hetzner-pulumi/resources/cloudflare.ts | 44 ++++++++++ hetzner-pulumi/resources/compute.ts | 93 ++++++++++++++------ hetzner-pulumi/resources/network.ts | 79 +++++++++++------ hetzner-pulumi/resources/types/server.ts | 5 +- hetzner-pulumi/resources/utils.ts | 60 +++++++++++++ hetzner-pulumi/yarn.lock | 7 ++ 8 files changed, 312 insertions(+), 84 deletions(-) create mode 100644 hetzner-pulumi/resources/cloudflare.ts diff --git a/hetzner-pulumi/index.ts b/hetzner-pulumi/index.ts index 105a55d..aa45033 100644 --- a/hetzner-pulumi/index.ts +++ b/hetzner-pulumi/index.ts @@ -2,9 +2,19 @@ import { subNetwork, regionalNetwork, allowHttp, - allowSSH, + allowSSHToCurrentIP, + floatingIP, + attach, } from "./resources/network"; import { server } from "./resources/compute"; +import { dns } from "./resources/cloudflare"; +import { + summarizeServer, + summarizeNetwork, + summarizeSubNetwork, + summarizeFloatingIp, + summarizeFirewall, +} from "./resources/utils"; import { VmSize, @@ -15,15 +25,19 @@ import { } from "./resources/types"; // regional vnet -const eu = regionalNetwork("ha", "10.24.0.0/18", NetworkRegion.eu); -const usEast = regionalNetwork("ha", "10.25.0.0/18", NetworkRegion.usEast); +const eu = regionalNetwork("ha-net-eu", "10.24.0.0/18", NetworkRegion.eu); +const usEast = regionalNetwork( + "ha-net-us", + "10.25.0.0/18", + NetworkRegion.usEast, +); // subnets for reginal vnets const network = { eu: { - lb: subNetwork(eu, NetworkRole.lb, NetworkRegion.eu, "10.24.1.0/24"), - cache: subNetwork(eu, NetworkRole.cache, NetworkRegion.eu, "10.24.2.0/24"), - web: subNetwork(eu, NetworkRole.web, NetworkRegion.eu, "10.24.3.0/24"), + lb: subNetwork(eu, NetworkRole.lb, NetworkRegion.eu, "10.24.1.0/26"), + cache: subNetwork(eu, NetworkRole.cache, NetworkRegion.eu, "10.24.2.0/26"), + web: subNetwork(eu, NetworkRole.web, NetworkRegion.eu, "10.24.3.0/26"), // db: subNetwork(eu, NetworkRole.db, "10.24.4.0/24") }, usEast: { @@ -31,26 +45,26 @@ const network = { usEast, NetworkRole.lb, NetworkRegion.usEast, - "10.25.1.0/24", + "10.25.1.0/26", ), cache: subNetwork( usEast, NetworkRole.cache, NetworkRegion.usEast, - "10.25.2.0/24", + "10.25.2.0/26", ), web: subNetwork( usEast, NetworkRole.web, NetworkRegion.usEast, - "10.25.3.0/24", + "10.25.3.0/26", ), }, }; // variable un-maps -const hel1 = ServerLocations.helsinki; -const hil = ServerLocations.hillsboro; +const nbg = ServerLocations.nuremberg; +const ash = ServerLocations.ashburn; const [EU_LB, US_LB, EU_CACHE, US_CACHE, EU_WEB, US_WEB] = [ network.eu.lb, network.usEast.lb, @@ -61,32 +75,67 @@ const [EU_LB, US_LB, EU_CACHE, US_CACHE, EU_WEB, US_WEB] = [ ]; // compute - server resources -const haEU1 = server("haproxy-1", VmSize.small, OS.debian, hel1, EU_LB); -const haEU2 = server("haproxy-2", VmSize.small, OS.debian, hel1, EU_LB); -const haUS1 = server("haproxy-1", VmSize.small, OS.debian, hil, US_LB); -// const haUS2 = server("haproxy-2", VmSize.small, OS.debian, hil, US_LB); +const haEU1 = server("haproxy-1", VmSize.cx23, OS.debian, nbg, EU_LB, true); +const haEU2 = server("haproxy-2", VmSize.cx23, OS.debian, nbg, EU_LB, true); +const haUS1 = server("haproxy-1", VmSize.cpx11, OS.debian, ash, US_LB, true); +const haUS2 = server("haproxy-2", VmSize.cpx11, OS.debian, ash, US_LB, true); -const cacheEU1 = server("varnish-1", VmSize.small, OS.debian, hel1, EU_CACHE); -const cacheEU2 = server("varnish-2", VmSize.small, OS.debian, hil, EU_CACHE); -// const cacheUS1 = server("varnish-1", VmSize.small, OS.debian, hil, US_CACHE); -// const cacheUS2 = server("varnish-2", VmSize.small, OS.debian, hil, US_CACHE); +const cacheEU1 = server("varnish-1", VmSize.cx23, OS.debian, nbg, EU_CACHE); +const cacheEU2 = server("varnish-2", VmSize.cx23, OS.debian, nbg, EU_CACHE); +const cacheUS1 = server("varnish-1", VmSize.cpx11, OS.debian, ash, US_CACHE); +const cacheUS2 = server("varnish-2", VmSize.cpx11, OS.debian, ash, US_CACHE); -const webEU1 = server("web-1", VmSize.small, OS.debian, hel1, EU_WEB); -// const webEU2 = server("web-2", VmSize.small, OS.debian, hel1, EU_WEB); -// const webUS1 = server("web-1", VmSize.small, OS.debian, hil, US_WEB); +const webEU1 = server("web-1", VmSize.cx23, OS.debian, nbg, EU_WEB); +const webEU2 = server("web-2", VmSize.cx23, OS.debian, nbg, EU_WEB); +const webUS1 = server("web-1", VmSize.cpx11, OS.debian, ash, US_WEB); -// firewall & exports -export const firewalls = [allowHttp, allowSSH]; +// floating IPs +const euFloatingIP = floatingIP("schleppe-ha-nbg", haEU1); +const usFloatingIP = floatingIP("schleppe-ha-va", haUS1); +const floatingIPs = [euFloatingIP, usFloatingIP]; +const domains = ["k9e.no", "planetposen.no", "whoami.schleppe.cloud"]; -// exports contd. -export const servers = [haEU1, haEU2, haUS1, cacheEU1, cacheEU2, webEU1]; +// Update Cloudflare DNS +domains.forEach((domain) => { + dns(domain, euFloatingIP, "eu-fip"); + dns(domain, usFloatingIP, "us-fip"); +}); -export const networks = [ - eu, - usEast, +// firewall +const allowSSH = allowSSHToCurrentIP(); +const firewalls = [allowHttp, allowSSH]; +// DISABLED +attach("ssh-fa", allowSSH, [haEU1, haEU2, haUS1, haUS2]); + +// exports +const servers = [ + haEU1, + haEU2, + haUS1, + haUS2, + cacheEU1, + cacheEU2, + cacheUS1, + cacheUS2, + webEU1, + webEU2, + webUS1, +]; + +const networks = [eu, usEast]; +const subNetworks = [ network.eu.lb, network.eu.cache, network.eu.web, network.usEast.lb, network.usEast.web, ]; + +export const inventory = { + vms: servers.map(summarizeServer), + networks: networks.map(summarizeNetwork), + subnetworks: subNetworks.map(summarizeSubNetwork), + firewalls: firewalls.map(summarizeFirewall), + floatingIps: floatingIPs.map(summarizeFloatingIp), + domains, +}; diff --git a/hetzner-pulumi/package.json b/hetzner-pulumi/package.json index 86fed3f..0272875 100644 --- a/hetzner-pulumi/package.json +++ b/hetzner-pulumi/package.json @@ -6,6 +6,7 @@ "typescript": "^5.0.0" }, "dependencies": { + "@pulumi/cloudflare": "^6.12.0", "@pulumi/hcloud": "^1.29.0", "@pulumi/pulumi": "^3.213.0", "@pulumi/random": "^4.18.4", diff --git a/hetzner-pulumi/resources/cloudflare.ts b/hetzner-pulumi/resources/cloudflare.ts new file mode 100644 index 0000000..21ddf95 --- /dev/null +++ b/hetzner-pulumi/resources/cloudflare.ts @@ -0,0 +1,44 @@ +import * as hcloud from "@pulumi/hcloud"; +import * as cloudflare from "@pulumi/cloudflare"; + +async function getZone(domain: string): Promise { + let match; + const zones = await cloudflare.getZones(); + + zones.results.forEach((zone) => { + if (domain.includes(zone.name)) match = zone; + }); + + if (match) return match; + return null; +} + +export async function dns( + domain: string, + ipAddress: hcloud.FloatingIp, + suffix: string, +) { + const ip = ipAddress.ipAddress.apply((ip) => ip); + const name = `${domain}-${suffix}_dns_record`; + const comment = "managed by pulumi - schleppe-ha-project"; + + const zone = await getZone(domain); + if (!zone) + throw new Error( + "no matching zone found! check cloudflare token scopes & registration", + ); + + return new cloudflare.DnsRecord( + name, + { + zoneId: zone.id, + name: domain, + ttl: 1, + type: "A", + content: ip, + proxied: false, + comment, + }, + { dependsOn: [ipAddress] }, + ); +} diff --git a/hetzner-pulumi/resources/compute.ts b/hetzner-pulumi/resources/compute.ts index 7cbf151..02039c5 100644 --- a/hetzner-pulumi/resources/compute.ts +++ b/hetzner-pulumi/resources/compute.ts @@ -1,51 +1,86 @@ import * as pulumi from "@pulumi/pulumi"; import * as hcloud from "@pulumi/hcloud"; import * as random from "@pulumi/random"; -import { config } from './config'; -import { getCheapestServerType } from './utils'; +import { config } from "./config"; +import { getCheapestServerType, topicedLabel } from "./utils"; import { VmSize, OS, ServerLocations } from "./types"; // “Tag” servers using labels. Hetzner firewalls can target servers by label selectors. :contentReference[oaicite:2]{index=2} const serverLabels = { - app: "demo", - role: "web", env: pulumi.getStack(), + managed: "pulumi", }; const sshPublicKey = config.require("sshPublicKey"); - const sshKey = new hcloud.SshKey("ssh-key", { - name: `pulumi-${pulumi.getStack()}-ssh`, - publicKey: sshPublicKey, - }); +const sshKey = new hcloud.SshKey("ssh-key", { + name: `pulumi-${pulumi.getStack()}-ssh`, + publicKey: sshPublicKey, +}); + +const serverName = (name: string, location: string) => { + if (name.includes("-")) { + const [n, id] = name.split("-"); + return `${n}-${location}-${id}`; + } + + return `${name}-${location}`; +}; export function server( name: string, size: VmSize, os: OS = OS.debian, location: ServerLocations, - network: hcloud.NetworkSubnet + network: hcloud.NetworkSubnet, + ipv4: boolean = false, ): hcloud.Server { - const ceap = getCheapestServerType('eu'); + const extraLabel = topicedLabel(name) + name = serverName(name, location); + const networkId = network.networkId.apply((id) => String(id).split("-")[0]); - const hexId = new random.RandomId(`${name}-${location}`, { - byteLength: 2, // 2 bytes = 4 hex characters - }); - - name = `${name}-${location}` - - return new hcloud.Server(name, { + const server = new hcloud.Server( name, - image: os, - serverType: ceap, - location, - backups: false, - publicNets: [{ - ipv4Enabled: false, - ipv6Enabled: true, - }], - networks: [network], - sshKeys: [sshKey.name], - labels: serverLabels - }) + { + name, + image: os, + serverType: size, + location, + backups: false, + publicNets: [ + { + ipv4Enabled: ipv4, + ipv6Enabled: true, + }, + ], + networks: [ + { + networkId: networkId.apply((nid) => Number(nid)), + }, + ], + sshKeys: [sshKey.name], + labels: { + ...serverLabels, + ...extraLabel + }, + }, + { dependsOn: [network] }, + ); + + const serverNet = new hcloud.ServerNetwork( + `${name}-servernet-${location}`, + { + serverId: server.id.apply((id) => Number(id)), + subnetId: network.id, + }, + { + dependsOn: [network, server], + parent: server, + deleteBeforeReplace: true, + + ignoreChanges: [ 'serverId', 'ip', 'aliasIps', 'networkId', 'subnetId' ] + }, + ); + + return server; } diff --git a/hetzner-pulumi/resources/network.ts b/hetzner-pulumi/resources/network.ts index fd85d67..8d2025e 100644 --- a/hetzner-pulumi/resources/network.ts +++ b/hetzner-pulumi/resources/network.ts @@ -2,24 +2,22 @@ import * as pulumi from "@pulumi/pulumi"; import * as hcloud from "@pulumi/hcloud"; import type { NetworkRegion } from "./types"; +import { currentIPAddress } from "./utils"; -// Required - -// make sure to have regional parent networks - +// NETWORKS const networkName = (name: string, region: NetworkRegion) => `${name}-net-${region}`; export function regionalNetwork( - prefix: string, + name: string, cidr: string, region: NetworkRegion, ) { - const name = networkName(prefix, region); - const parentNetworkRange = 8; + const parentNetworkRange = 22; const [ip, _] = cidr.split("/"); const net = new hcloud.Network(name, { + name, ipRange: `${ip}/${parentNetworkRange}`, labels: { region, @@ -38,16 +36,33 @@ export function subNetwork( ): hcloud.NetworkSubnet { const name = `${prefix}-subnet-${region}`; - const net = new hcloud.NetworkSubnet(name, { - networkId: parentNetwork.id.apply(id => Number(id)), - type: "cloud", - networkZone: "eu-central", - ipRange: cidr, - }); + const net = new hcloud.NetworkSubnet( + name, + { + networkId: parentNetwork.id.apply((id) => Number(id)), + type: "cloud", + networkZone: region, + ipRange: cidr, + }, + { parent: parentNetwork, dependsOn: [parentNetwork] }, + ); return net; } +// FLOATING IPs +export function floatingIP(name: string, server: hcloud.Server) { + return new hcloud.FloatingIp( + name, + { + type: "ipv4", + serverId: server.id.apply((i) => Number(i)), + }, + { dependsOn: [server] }, + ); +} + +// FIREWALL RULES export const allowHttp = new hcloud.Firewall("allow-http", { name: "allow-http", applyTos: [ @@ -80,16 +95,30 @@ export const allowHttp = new hcloud.Firewall("allow-http", { ], }); -export const allowSSH = new hcloud.Firewall("allow-ssh", { - name: "allow-ssh", - rules: [ - { - direction: "in", - protocol: "tcp", - port: "22", - sourceIps: ["127.0.0.0/24"], - description: "Allow SSH from approved CIDRs only", - }, - ], -}); +export function allowSSHToCurrentIP() { + const ip = currentIPAddress() + return new hcloud.Firewall("allow-ssh", { + name: "allow-ssh", + rules: [ + { + direction: "in", + protocol: "tcp", + port: "22", + sourceIps: [ip], + description: "Allow SSH from approved CIDRs only", + }, + ], + }); +} + +export function attach( + name: string, + firewall: hcloud.Firewall, + servers: hcloud.Server[], +) { + return new hcloud.FirewallAttachment(name, { + firewallId: firewall.id.apply((id) => Number(id)), + serverIds: servers.map((server) => server.id.apply((id) => Number(id))), + }); +} diff --git a/hetzner-pulumi/resources/types/server.ts b/hetzner-pulumi/resources/types/server.ts index 270548b..e8d20d6 100644 --- a/hetzner-pulumi/resources/types/server.ts +++ b/hetzner-pulumi/resources/types/server.ts @@ -2,10 +2,13 @@ export enum VmSize { small = "small", medium = "medium", large = "large", + cx23 = "cx23", + cax11 = "cax11", + cpx11 = "cpx11" } export enum OS { - debian = "debian", + debian = "debian-13", ubuntu = "ubuntu", } diff --git a/hetzner-pulumi/resources/utils.ts b/hetzner-pulumi/resources/utils.ts index 67d9483..0d4ff4a 100644 --- a/hetzner-pulumi/resources/utils.ts +++ b/hetzner-pulumi/resources/utils.ts @@ -1,4 +1,5 @@ import * as pulumi from "@pulumi/pulumi"; +import * as hcloud from "@pulumi/hcloud"; import { z } from "zod"; import * as crypto from "node:crypto"; @@ -103,3 +104,62 @@ export function getCheapestServerType( }); } +interface Label { + role?: string +} + +export function topicedLabel(name: string) { + let labels: Label = {}; + if (name.includes("haproxy")) { + labels.role = 'load-balancer'; + } else if (name.includes("web")) { + labels.role = 'web' + } + + return labels +} + + +export const summarizeServer = (s: hcloud.Server) => ({ + name: s.name, + publicIpv4: s.ipv4Address, + publicIpv6: s.ipv6Address, + privateIp: s.networks.apply(nets => nets?.[0]?.ip ?? 'null'), +}); + +export const summarizeNetwork = (n: hcloud.Network) => ({ + name: n.name, + cidr: n.ipRange +}); + +export const summarizeSubNetwork = (n: hcloud.NetworkSubnet) => ({ + gateway: n.gateway, + cidr: n.ipRange, + zone: n.networkZone, + type: n.type +}); + +export const summarizeFloatingIp = (floatingIp: hcloud.FloatingIp) => ({ + name: floatingIp.name, + address: floatingIp.ipAddress, + attachedTo: floatingIp.serverId, + location: floatingIp.homeLocation, + labels: floatingIp.labels +}) + +export const summarizeFirewall = (firewall: hcloud.Firewall) => ({ + name: firewall.name, + rules: firewall.rules, + labels: firewall.labels +}) + +export const summarizeDns = (firewall: hcloud.Firewall) => ({ + name: firewall.name, + rules: firewall.rules, + labels: firewall.labels +}) + +export async function currentIPAddress(): Promise { + return fetch('https://ifconfig.me/ip') + .then(resp => resp.text()) +} diff --git a/hetzner-pulumi/yarn.lock b/hetzner-pulumi/yarn.lock index c8abaa1..1e3376e 100644 --- a/hetzner-pulumi/yarn.lock +++ b/hetzner-pulumi/yarn.lock @@ -369,6 +369,13 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@pulumi/cloudflare@^6.12.0": + version "6.12.0" + resolved "https://registry.yarnpkg.com/@pulumi/cloudflare/-/cloudflare-6.12.0.tgz#edb2c59d1b594a0c64f12ba8cf63b84ed3937fcc" + integrity sha512-Evhj9u+ZVCO6Cu9+xon4F3dY4RA7U4ZTWc19MJAIa5GgLeG8X0C84iyo5aruZf2nK7nfJ2d47lyRSaXPBHOzjw== + dependencies: + "@pulumi/pulumi" "^3.142.0" + "@pulumi/hcloud@^1.29.0": version "1.29.0" resolved "https://registry.yarnpkg.com/@pulumi/hcloud/-/hcloud-1.29.0.tgz#4c622aa3ae9c4590af783eefbeee1e62e711c7f3"