diff --git a/jobs/games/minecraft/template.hcl b/jobs/games/minecraft/template.hcl new file mode 100644 index 0000000..7fa754d --- /dev/null +++ b/jobs/games/minecraft/template.hcl @@ -0,0 +1,64 @@ +job "minecraft-SERVICENAME" { + datacenters = ["aperture"] + type = "service" + + group "SERVICENAME-mc" { + count = 1 + + network { + port "mc" { + to = 25565 + } + port "rcon" { + to = 25575 + } + } + + service { + name = "SERVICENAME-mc" + port = "mc" + } + + service { + name = "SERVICENAME-mc-rcon" + port = "rcon" + } + + task "minecraft" { + driver = "docker" + config { + image = "itzg/minecraft-server:latest" + ports = ["mc", "rcon"] + volumes = [ + "/storage/nomad/${NOMAD_JOB_NAME}/data:/data", + "local/plugins:/plugins", + "local/mods:/mods", + ] + } + + resources { + cpu = 5000 # 5000 MHz + memory = 8192 # 8 GB + } + + # Server Settings/Enviorments block + template { + destination = "local/.env" + env = true + data = < /tmp/generate-acme-config.py <<'PY' +import hashlib +import json +import os +import re +import time +import urllib.error +import urllib.request + +CONSUL_HTTP_ADDR = os.environ.get("CONSUL_HTTP_ADDR", "http://127.0.0.1:8500") +OUTPUT_FILE = "/dynamic/dynamic-acme.toml" +INTERVAL_SECONDS = int(os.environ.get("ACME_CONFIG_INTERVAL_SECONDS", "60")) + +HTTP_TAG_PREFIX = "redbrick.acme.http-domain=" +DNS_TAG_PREFIX = "redbrick.acme.dns-zone=" + +SUPPORTED_DNS_ZONES = { + "redbrick.dcu.ie", + "rb.dcu.ie", +} + +DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$") +WILDCARD_RE = re.compile(r"^\*\.(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$") + +TRAEFIK_ROUTER_RULE_RE = re.compile( + r"^traefik\.http\.routers\.[^.]+\.rule=(.*)$" +) + +TRAEFIK_TLS_DOMAIN_MAIN_RE = re.compile( + r"^traefik\.http\.routers\.[^.]+\.tls\.domains\[[0-9]+\]\.main=(.*)$" +) + +TRAEFIK_TLS_DOMAIN_SANS_RE = re.compile( + r"^traefik\.http\.routers\.[^.]+\.tls\.domains\[[0-9]+\]\.sans=(.*)$" +) + +HOST_CALL_RE = re.compile(r"Host\(([^)]*)\)") + + +def fetch_json(path): + url = CONSUL_HTTP_ADDR.rstrip("/") + path + + with urllib.request.urlopen(url, timeout=10) as response: + return json.loads(response.read().decode("utf-8")) + + +def normalise_domain(value): + value = value.strip().lower().rstrip(".") + return value + + +def split_values(value): + return [normalise_domain(item) for item in value.split(",") if item.strip()] + + +def valid_http_domain(domain): + if "*" in domain: + return False + + if "$" in domain or "{" in domain or "}" in domain: + return False + + return bool(DOMAIN_RE.match(domain)) + + +def valid_dns_zone(zone): + if "*" in zone: + return False + + if "$" in zone or "{" in zone or "}" in zone: + return False + + return bool(DOMAIN_RE.match(zone)) + + +def valid_wildcard_domain(domain): + if "$" in domain or "{" in domain or "}" in domain: + return False + + return bool(WILDCARD_RE.match(domain)) + + +def router_name(prefix, value): + digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:12] + safe = re.sub(r"[^a-z0-9-]+", "-", value.lower()).strip("-") + + if len(safe) > 40: + safe = safe[:40].strip("-") + + return f"{prefix}-{safe}-{digest}" + + +def strip_traefik_arg(value): + value = value.strip() + + if value.startswith("`") and value.endswith("`"): + value = value[1:-1] + elif value.startswith("'") and value.endswith("'"): + value = value[1:-1] + elif value.startswith('"') and value.endswith('"'): + value = value[1:-1] + + return normalise_domain(value) + + +def domains_from_host_rule(rule): + domains = [] + + for host_call in HOST_CALL_RE.finditer(rule): + args = host_call.group(1) + + for item in args.split(","): + domain = strip_traefik_arg(item) + + if valid_http_domain(domain): + domains.append(domain) + elif domain: + print(f"Skipping invalid Traefik Host() domain: {domain}", flush=True) + + return domains + + +def http_domains_from_traefik_tls_value(value): + domains = [] + + for domain in split_values(value): + if valid_http_domain(domain): + domains.append(domain) + + return domains + + +def dns_zone_for_domain(domain, dns_zones): + for zone in dns_zones: + if domain == zone or domain.endswith("." + zone): + return zone + + return None + + +def discover_acme_intent(): + services = fetch_json("/v1/catalog/services") + + http_domains = set() + dns_zones = set() + + for service_name, tags in services.items(): + if not isinstance(tags, list): + continue + + for tag in tags: + if not isinstance(tag, str): + continue + + if tag.startswith(HTTP_TAG_PREFIX): + raw_value = tag[len(HTTP_TAG_PREFIX):] + + for domain in split_values(raw_value): + if valid_http_domain(domain): + http_domains.add(domain) + else: + print(f"Skipping invalid HTTP ACME domain tag on {service_name}: {domain}", flush=True) + + continue + + if tag.startswith(DNS_TAG_PREFIX): + raw_value = tag[len(DNS_TAG_PREFIX):] + + for zone in split_values(raw_value): + if valid_dns_zone(zone) and zone in SUPPORTED_DNS_ZONES: + dns_zones.add(zone) + elif valid_dns_zone(zone): + print(f"Skipping unsupported DNS ACME zone tag on {service_name}: {zone}", flush=True) + else: + print(f"Skipping invalid DNS ACME zone tag on {service_name}: {zone}", flush=True) + + continue + + rule_match = TRAEFIK_ROUTER_RULE_RE.match(tag) + if rule_match: + for domain in domains_from_host_rule(rule_match.group(1)): + http_domains.add(domain) + + continue + + tls_main_match = TRAEFIK_TLS_DOMAIN_MAIN_RE.match(tag) + if tls_main_match: + for domain in http_domains_from_traefik_tls_value(tls_main_match.group(1)): + http_domains.add(domain) + + continue + + tls_sans_match = TRAEFIK_TLS_DOMAIN_SANS_RE.match(tag) + if tls_sans_match: + for domain in split_values(tls_sans_match.group(1)): + if valid_http_domain(domain): + http_domains.add(domain) + elif valid_wildcard_domain(domain): + print( + f"Skipping wildcard Traefik TLS domain on {service_name}; " + f"HTTP-01 cannot issue wildcard certs: {domain}", + flush=True, + ) + else: + print(f"Skipping invalid Traefik TLS SAN on {service_name}: {domain}", flush=True) + + continue + + http_domains = { + domain + for domain in http_domains + if dns_zone_for_domain(domain, dns_zones) is None + } + + return sorted(http_domains), sorted(dns_zones) + + +def render_config(http_domains, dns_zones): + lines = [ + "# Generated by traefik-acme-renewer acme-config-generator.", + "# Do not edit by hand.", + "", + "[http]", + "", + "[http.routers]", + "", + ] + + for zone in dns_zones: + name = router_name("cert-dns", zone) + + lines.extend([ + f" [http.routers.{name}]", + f' rule = "Host(`{zone}`)"', + ' entryPoints = ["websecure"]', + ' service = "noop"', + "", + f" [http.routers.{name}.tls]", + ' certResolver = "rb"', + "", + f" [[http.routers.{name}.tls.domains]]", + f' main = "{zone}"', + f' sans = ["*.{zone}"]', + "", + ]) + + for domain in http_domains: + name = router_name("cert-http", domain) + + lines.extend([ + f" [http.routers.{name}]", + f' rule = "Host(`{domain}`)"', + ' entryPoints = ["websecure"]', + ' service = "noop"', + "", + f" [http.routers.{name}.tls]", + ' certResolver = "lets-encrypt"', + "", + ]) + + lines.extend([ + "[http.services]", + "", + " [http.services.noop.loadBalancer]", + "", + " [[http.services.noop.loadBalancer.servers]]", + ' url = "http://127.0.0.1:9"', + "", + ]) + + return "\n".join(lines) + + +def write_if_changed(content): + os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True) + + previous = None + + if os.path.exists(OUTPUT_FILE): + with open(OUTPUT_FILE, "r") as f: + previous = f.read() + + if previous == content + "\n": + return False + + tmp = OUTPUT_FILE + ".tmp" + + with open(tmp, "w") as f: + f.write(content) + f.write("\n") + + os.replace(tmp, OUTPUT_FILE) + + return True + + +while True: + try: + http_domains, dns_zones = discover_acme_intent() + content = render_config(http_domains, dns_zones) + changed = write_if_changed(content) + + print( + f"ACME config generated: {len(dns_zones)} DNS zone(s), " + f"{len(http_domains)} HTTP domain(s), changed={changed}", + flush=True, + ) + + if dns_zones: + print("DNS-01 zones: " + ", ".join(dns_zones), flush=True) + + if http_domains: + print("HTTP-01 domains: " + ", ".join(http_domains), flush=True) + + except urllib.error.URLError as e: + print(f"Could not reach Consul at {CONSUL_HTTP_ADDR}: {e}", flush=True) + + except Exception as e: + print(f"ACME config generation failed: {e}", flush=True) + + time.sleep(INTERVAL_SECONDS) +PY + +python /tmp/generate-acme-config.py +EOS + ] + } + + env { + CONSUL_HTTP_ADDR = "http://127.0.0.1:8500" + ACME_CONFIG_INTERVAL_SECONDS = "60" + } + + resources { + cpu = 100 + memory = 128 + } + } + + task "traefik-acme" { + driver = "docker" + + config { + image = "traefik:v3.3" + network_mode = "host" + + volumes = [ + "local/traefik-acme.toml:/etc/traefik/traefik.toml:ro", + "/storage/nomad/traefik/acme-dynamic:/dynamic", + "/storage/nomad/traefik/acme:/acme", + ] + + args = [ + "--configFile=/etc/traefik/traefik.toml", + ] + } + + template { + destination = "secrets/env" + env = true + change_mode = "restart" + + data = < /tmp/extract.py <<'PY' +import base64 +import json +import os +import re +import time + +ACME_FILES = [ + "/traefik/acme/acme-dns.json", + "/traefik/acme/acme-http.json", +] + +CERT_ROOT = "/traefik/certs" +DYNAMIC_DIR = "/traefik/dynamic" +TLS_FILE = os.path.join(DYNAMIC_DIR, "90-acme-extracted-certs.toml") + +DEFAULT_CERT_ZONE = "redbrick.dcu.ie" + + +def safe_name(name): + name = name.lower().strip() + name = name.replace("*.", "wildcard.") + return re.sub(r"[^a-z0-9._-]+", "_", name) + + +def normalise_sans(value): + if value is None: + return [] + + if isinstance(value, list): + return value + + return [] + + +def decode_field(value): + if not isinstance(value, str): + raise ValueError("ACME field is not a string") + + return base64.b64decode(value) + + +def load_acme(path): + if not os.path.exists(path): + return [] + + try: + with open(path, "r") as f: + data = json.load(f) + + except Exception as e: + print(f"Could not load {path}: {e}", flush=True) + return [] + + all_certs = [] + + for resolver_name, resolver_data in data.items(): + certs = resolver_data.get("Certificates") or [] + + for cert in certs: + domain = cert.get("domain") or cert.get("Domain") or {} + main = domain.get("main") or domain.get("Main") + sans = normalise_sans(domain.get("sans") or domain.get("SANs")) + + if not main: + continue + + try: + fullchain = decode_field(cert["certificate"]) + key = decode_field(cert["key"]) + + except Exception as e: + print(f"Could not decode certificate for {main}: {e}", flush=True) + continue + + all_certs.append({ + "resolver": resolver_name, + "main": main.lower().strip().rstrip("."), + "sans": [item.lower().strip().rstrip(".") for item in sans], + "fullchain": fullchain, + "key": key, + }) + + return all_certs + + +def cert_output_dir(cert): + main = cert["main"] + sans = cert.get("sans") or [] + + if main.startswith("*."): + return main[2:] + + wildcard_sans = [item for item in sans if item.startswith("*.")] + + if wildcard_sans: + return main + + return os.path.join("external", safe_name(main)) + + +def write_cert(cert): + rel_dir = cert_output_dir(cert) + outdir = os.path.join(CERT_ROOT, rel_dir) + + os.makedirs(outdir, exist_ok=True) + + fullchain_path = os.path.join(outdir, "fullchain.pem") + key_path = os.path.join(outdir, "privkey.pem") + + with open(fullchain_path, "wb") as f: + f.write(cert["fullchain"]) + + with open(key_path, "wb") as f: + f.write(cert["key"]) + + os.chmod(fullchain_path, 0o644) + os.chmod(key_path, 0o600) + + return rel_dir + + +def extract_once(): + os.makedirs(CERT_ROOT, exist_ok=True) + os.makedirs(DYNAMIC_DIR, exist_ok=True) + + cert_entries = [] + seen = set() + + for acme_path in ACME_FILES: + for cert in load_acme(acme_path): + sans_tuple = tuple(sorted(cert.get("sans") or [])) + dedupe_key = (cert["main"], sans_tuple) + + if dedupe_key in seen: + continue + + seen.add(dedupe_key) + + rel_dir = write_cert(cert) + + if rel_dir not in cert_entries: + cert_entries.append(rel_dir) + + print(f"Extracted {cert['main']} to /traefik/certs/{rel_dir}", flush=True) + + if not cert_entries: + return False + + lines = [ + "# Generated from Traefik ACME stores. Do not edit by hand.", + "", + ] + + if DEFAULT_CERT_ZONE in cert_entries: + lines.extend([ + "[tls.stores]", + " [tls.stores.default.defaultCertificate]", + f' certFile = "/certs/{DEFAULT_CERT_ZONE}/fullchain.pem"', + f' keyFile = "/certs/{DEFAULT_CERT_ZONE}/privkey.pem"', + "", + ]) + + for rel_dir in sorted(cert_entries): + lines.extend([ + "[[tls.certificates]]", + f' certFile = "/certs/{rel_dir}/fullchain.pem"', + f' keyFile = "/certs/{rel_dir}/privkey.pem"', + "", + ]) + + tmp = TLS_FILE + ".tmp" + + with open(tmp, "w") as f: + f.write("\n".join(lines)) + f.write("\n") + + os.replace(tmp, TLS_FILE) + os.utime(TLS_FILE, None) + + return True + + +while True: + try: + if extract_once(): + print("ACME extraction complete; dynamic TLS file touched", flush=True) + + except Exception as e: + print(f"Extraction failed: {e}", flush=True) + + time.sleep(300) +PY + +python /tmp/extract.py +EOS + ] + } + + resources { + cpu = 100 + memory = 128 + } + } + } +} \ No newline at end of file diff --git a/jobs/ingress/traefik.hcl b/jobs/ingress/traefik.hcl index 302ef06..ec5644c 100644 --- a/jobs/ingress/traefik.hcl +++ b/jobs/ingress/traefik.hcl @@ -59,6 +59,14 @@ job "traefik" { port "managesieve" { static = 4190 } + + port "palworld-game" { + static = 8211 + } + + port "palworld-query" { + static = 27015 + } } service { @@ -148,8 +156,8 @@ job "traefik" { [entryPoints.voice-udp] address = ":4503/udp" - [entryPoints.voice-udp.udp] # this will help reduce random dropouts in audio https://github.com/mumble-voip/mumble/issues/3550#issuecomment-441495977 - timeout = "15s" + [entryPoints.voice-udp.udp] + timeout = "15s" # this will help reduce random dropouts in audio https://github.com/mumble-voip/mumble/issues/3550#issuecomment-441495977 [entryPoints.matrix] address = ":8448" @@ -177,6 +185,17 @@ job "traefik" { [entryPoints.managesieve] address = "136.206.16.50:4190" + + [entryPoints.palworld-game] + address = "136.206.16.50:8211/udp" + [entryPoints.palworld-game.udp] + timeout = "30s" + + [entryPoints.palworld-query] + address = "136.206.16.50:27015/udp" + [entryPoints.palworld-query.udp] + timeout = "30s" + [tls.options] [tls.options.default] @@ -251,7 +270,6 @@ EOF # # Example: # redirect/redbrick/wiki = https://wiki.redbrick.dcu.ie/ - # --- Short-link redirects for redbrick.dcu.ie --- {{ range $pair := tree "redirect/redbrick" }} {{ $name := trimPrefix "redirect/redbrick/" $pair.Key }} @@ -322,6 +340,7 @@ EOF {{ end }} + # Default TLS router. # This exists so Traefik can present an extracted cert for known Redbrick zones # even when no higher-priority router has matched. @@ -514,4 +533,4 @@ EOF } } } -} \ No newline at end of file +} diff --git a/jobs/monitoring/gatus.hcl b/jobs/monitoring/gatus.hcl index fc1ca41..95441d2 100644 --- a/jobs/monitoring/gatus.hcl +++ b/jobs/monitoring/gatus.hcl @@ -3,7 +3,7 @@ job "gatus" { type = "service" meta { - domain = "status.redbrick.dcu.ie" + domain = "gatus.redbrick.dcu.ie" } group "db-web" { @@ -163,6 +163,13 @@ defaults_tcp: &defaults_tcp conditions: - "[CONNECTED] == true" +defaults_udp: &defaults_udp + interval: 60s + alerts: + - type: discord + conditions: + - "[CONNECTED] == true" + defaults: &defaults interval: 60s alerts: @@ -176,6 +183,13 @@ defaults_https: &defaults_https - "[STATUS] == 200" - "[CERTIFICATE_EXPIRATION] > 48h" +defaults_shortlinks: &defaults_shortlinks + interval: 60m + alerts: + - type: discord + conditions: + - "[STATUS] == 200" + endpoints: # --- All Redbrick Monitors --- - name: Atlas @@ -193,7 +207,7 @@ endpoints: url: "https://api.redbrick.dcu.ie" <<: *defaults_https - - name: Better Timetable + - name: Timetable Sync group: Services url: "https://timetable.redbrick.dcu.ie" <<: *defaults_https @@ -264,11 +278,6 @@ endpoints: url: "https://outline.solarracing.ie" <<: *defaults_https - - name: Plume (on RB) - group: Other Socs - url: "https://cspp.rb.dcu.ie" - <<: *defaults_https - - name: Amikon Website group: Other Socs url: "https://amikon.me" @@ -276,27 +285,22 @@ endpoints: - name: Glados group: Servers - url: "tcp://10.10.0.4:22" + url: "tcp://10.10.10.4:22" <<: *defaults_tcp - name: Wheatley group: Servers - url: "tcp://10.10.0.5:22" + url: "tcp://10.10.10.5:22" <<: *defaults_tcp - - name: Bastion VM + - name: Mirage group: Servers - url: "tcp://136.206.16.50:2269" - <<: *defaults_tcp - - - name: Johnson - group: Servers - url: "tcp://10.10.0.7:22" + url: "tcp://10.10.30.9:22" <<: *defaults_tcp - name: Chell group: Servers - url: "tcp://10.10.0.6:22" + url: "tcp://10.10.10.6:22" <<: *defaults_tcp - name: Minecraft Vanilla @@ -304,40 +308,50 @@ endpoints: url: "tcp://vanilla-mc.rb.dcu.ie:25565" <<: *defaults_tcp + - name: Palworld Dedicated Server (Game Port) + group: Game Servers + url: "udp://redbrick.dcu.ie:8211" + <<: *defaults_udp + + - name: Palworld Dedicated Server (Query Port) + group: Game Servers + url: "udp://redbrick.dcu.ie:27015" + <<: *defaults_udp + - name: Discord Shortlink group: Short Links url: "https://discord.redbrick.dcu.ie" - <<: *defaults_https + <<: *defaults_shortlinks - name: Github Shortlink group: Short Links url: "https://github.redbrick.dcu.ie" - <<: *defaults_https + <<: *defaults_shortlinks - name: Instagram Shortlink group: Short Links url: "https://instagram.redbrick.dcu.ie" - <<: *defaults_https + <<: *defaults_shortlinks - name: Youtube Shortlink group: Short Links url: "https://youtube.redbrick.dcu.ie" - <<: *defaults_https + <<: *defaults_shortlinks - name: LinkedIn Shortlink group: Short Links url: "https://linkedin.redbrick.dcu.ie" - <<: *defaults_https + <<: *defaults_shortlinks - name: Twitch Shortlink group: Short Links url: "https://twitch.redbrick.dcu.ie" - <<: *defaults_https + <<: *defaults_shortlinks - name: Tiktok Shortlink group: Short Links url: "https://tiktok.redbrick.dcu.ie" - <<: *defaults_https + <<: *defaults_shortlinks EOH } diff --git a/jobs/nginx/index.html b/jobs/nginx/index.html index 5d37960..2182ea5 100644 --- a/jobs/nginx/index.html +++ b/jobs/nginx/index.html @@ -1,3 +1,3 @@ diff --git a/jobs/nginx/karting.hcl b/jobs/nginx/karting.hcl index c574bbc..3d57cf3 100644 --- a/jobs/nginx/karting.hcl +++ b/jobs/nginx/karting.hcl @@ -42,4 +42,4 @@ job "nginx-karting" { } } } -} +} \ No newline at end of file diff --git a/jobs/nginx/nginx.hcl b/jobs/nginx/nginx.hcl index 93b3aa9..2eb18da 100644 --- a/jobs/nginx/nginx.hcl +++ b/jobs/nginx/nginx.hcl @@ -224,4 +224,4 @@ job "nginx" { } } } -} +} \ No newline at end of file diff --git a/jobs/services/api.hcl b/jobs/services/api.hcl index 4e1c2a5..c3cc503 100644 --- a/jobs/services/api.hcl +++ b/jobs/services/api.hcl @@ -27,7 +27,6 @@ job "api" { "traefik.enable=true", "traefik.http.routers.api.rule=Host(`api.redbrick.dcu.ie`)", "traefik.http.routers.api.entrypoints=web,websecure", - "traefik.http.routers.api.tls.certresolver=rb", ] } diff --git a/jobs/services/gh-actions-runner.hcl b/jobs/services/gh-actions-runner.hcl index eb94ef5..7ff62f7 100644 --- a/jobs/services/gh-actions-runner.hcl +++ b/jobs/services/gh-actions-runner.hcl @@ -29,6 +29,7 @@ job "github-actions-runner" { #!/bin/bash export RUNNER_ALLOW_RUNASROOT=1 +export ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true echo "Querying API for registration token..." @@ -49,9 +50,9 @@ EOF destination = "local/bootstrap.sh" } artifact { - source = "https://github.com/actions/runner/releases/download/v2.320.0/actions-runner-linux-x64-2.320.0.tar.gz" + source = "https://github.com/actions/runner/releases/download/v2.335.1/actions-runner-linux-x64-2.335.1.tar.gz" options { - checksum = "sha256:93ac1b7ce743ee85b5d386f5c1787385ef07b3d7c728ff66ce0d3813d5f46900" + checksum = "sha256:4ef2f25285f0ae4477f1fe1e346db76d2f3ebf03824e2ddd1973a2819bf6c8cf" } } } diff --git a/jobs/services/hedgedoc.hcl b/jobs/services/hedgedoc.hcl index 032be59..da6d68d 100644 --- a/jobs/services/hedgedoc.hcl +++ b/jobs/services/hedgedoc.hcl @@ -34,7 +34,6 @@ job "hedgedoc" { "traefik.port=${NOMAD_PORT_http}", "traefik.http.routers.md.entrypoints=web,websecure", "traefik.http.routers.md.rule=Host(`md.redbrick.dcu.ie`) || Host(`md.rb.dcu.ie`)", - "traefik.http.routers.md.tls.certresolver=rb", ] } diff --git a/jobs/services/intersocs-wiki.hcl b/jobs/services/intersocs-wiki.hcl index 7cea939..c488d94 100644 --- a/jobs/services/intersocs-wiki.hcl +++ b/jobs/services/intersocs-wiki.hcl @@ -38,7 +38,6 @@ job "intersocs-wiki" { "traefik.http.routers.intersocs-wiki.entrypoints=web,websecure", "traefik.http.routers.intersocs-wiki.rule=Host(`${NOMAD_META_domain}`) || Host(`${NOMAD_META_domain2}`)", "traefik.http.routers.intersocs-wiki.tls=true", - "traefik.http.routers.intersocs-wiki.tls.certresolver=lets-encrypt", ] } @@ -161,4 +160,4 @@ EOH } } } -} +} \ No newline at end of file diff --git a/jobs/services/ldap/openldap-backup.hcl b/jobs/services/ldap/openldap-backup.hcl index 96a4f2e..8f02c86 100644 --- a/jobs/services/ldap/openldap-backup.hcl +++ b/jobs/services/ldap/openldap-backup.hcl @@ -22,22 +22,22 @@ job "openldap-backup" { job_name=$(echo ${NOMAD_JOB_NAME} | cut -d "/" -f 1) -file=/storage/backups/nomad/${job_name}/${job_name}-$(date +%Y-%m-%d_%H-%M-%S).ldif.gpg +file=/storage/backups/nomad/${job_name}/${job_name}-$(date +%Y-%m-%d_%H-%M-%S).ldif mkdir -p /storage/backups/nomad/${job_name} alloc_id=$(nomad job status openldap | grep running | tail -n 1 | cut -d " " -f 1) +nomad alloc exec -task=openldap $alloc_id slapcat -F /bitnami/openldap/slapd.d -n 2 > "${file}" -nomad alloc exec -task=openldap $alloc_id slapcat -F /bitnami/openldap/slapd.d -n 2 - -find /storage/backups/nomad/${job_name}/${job_name}* -ctime +3 -exec rm {} \; || true +find /storage/backups/nomad/${job_name}/ -name "${job_name}*" -ctime +3 -exec rm {} \; || true if [ -s "$file" ]; then # check if file exists and is not empty echo "Backup successful" exit 0 else - rm $file + # Check if file exists before trying to remove it to avoid secondary error spam + [ -f "$file" ] && rm "$file" curl -H "Content-Type: application/json" -d \ '{"content": "<@&585512338728419341> `OPENLDAP` backup for **'"${job_name}"'** has just **FAILED**\nFile name: `'"$file"'`\nDate: `'"$(TZ=Europe/Dublin date)"'`\nTurn off this script with `nomad job stop '"${job_name}"'` \n\n## Remember to restart this backup job when fixed!!!"}' \ {{ key "mysql/webhook/discord" }} @@ -45,6 +45,7 @@ fi EOH destination = "local/script.sh" } + } } } diff --git a/jobs/services/ldap/openldap.hcl b/jobs/services/ldap/openldap.hcl index eb9b42c..58a648c 100644 --- a/jobs/services/ldap/openldap.hcl +++ b/jobs/services/ldap/openldap.hcl @@ -213,4 +213,4 @@ EOH } } } -} +} \ No newline at end of file diff --git a/jobs/services/linkstack.hcl b/jobs/services/linkstack.hcl index a5916a1..d01bda7 100644 --- a/jobs/services/linkstack.hcl +++ b/jobs/services/linkstack.hcl @@ -37,7 +37,6 @@ job "linkstack" { "traefik.enable=true", "traefik.http.routers.linkstack.rule=Host(`${NOMAD_META_domain}`)", "traefik.http.routers.linkstack.entrypoints=web,websecure", - "traefik.http.routers.linkstack.tls.certresolver=rb", "traefik.http.routers.linkstack.tls=true", ] } diff --git a/jobs/services/linkwarden.hcl b/jobs/services/linkwarden.hcl index c5cf238..0b2af75 100644 --- a/jobs/services/linkwarden.hcl +++ b/jobs/services/linkwarden.hcl @@ -29,7 +29,6 @@ job "linkwarden" { "traefik.enable=true", "traefik.http.routers.linkwarden.rule=Host(`${NOMAD_META_domain}`)", "traefik.http.routers.linkwarden.entrypoints=web,websecure", - "traefik.http.routers.linkwarden.tls.certresolver=rb", ] } diff --git a/jobs/services/mail/aliases b/jobs/services/mail/aliases index a70c032..f1d148f 100644 --- a/jobs/services/mail/aliases +++ b/jobs/services/mail/aliases @@ -462,3 +462,11 @@ dme3@redbrick.dcu.ie dme4@redbrick.dcu.ie garyod2@redbrick.dcu.ie gary@redbrick.dcu.ie haus17@redbrick.dcu.ie haus@redbrick.dcu.ie nadned@redbrick.dcu.ie damnson@redbrick.dcu.ie + +# Esports aliases +ahri-dcuesports@redbrick.dcu.ie dcuesportssoc@gmail.com +bastion-dcuesports@redbrick.dcu.ie dcuesportssoc@gmail.com +clove-dcuesports@redbrick.dcu.ie dcuesportssoc@gmail.com +draven-dcuesports@redbrick.dcu.ie dcuesportssoc@gmail.com +echo-dcuesports@redbrick.dcu.ie dcuesportssoc@gmail.com +supernova-dcuesports@redbrick.dcu.ie dcuesportssoc@gmail.com \ No newline at end of file diff --git a/jobs/services/mail/autodiscover.hcl b/jobs/services/mail/autodiscover.hcl index 7abe61b..92b8110 100644 --- a/jobs/services/mail/autodiscover.hcl +++ b/jobs/services/mail/autodiscover.hcl @@ -32,7 +32,6 @@ job "autodiscover" { "traefik.enable=true", "traefik.http.routers.autodiscover.rule=Host(`autoconfig.${NOMAD_META_tld}`) || Host(`autodiscover.${NOMAD_META_tld}`)", "traefik.http.routers.autodiscover.entrypoints=web,websecure", - "traefik.http.routers.autodiscover.tls.certresolver=lets-encrypt", ] } diff --git a/jobs/services/mail/mailman.hcl b/jobs/services/mail/mailman.hcl index 8a4c566..409187d 100644 --- a/jobs/services/mail/mailman.hcl +++ b/jobs/services/mail/mailman.hcl @@ -35,7 +35,6 @@ job "mailman" { "traefik.enable=true", "traefik.http.routers.mailman.rule=Host(`${NOMAD_META_domain}`)", "traefik.http.routers.mailman.entrypoints=web,websecure", - "traefik.http.routers.mailman.tls.certresolver=lets-encrypt", ] } diff --git a/jobs/services/mail/mailserver.hcl b/jobs/services/mail/mailserver.hcl index 20932ff..ab66e04 100644 --- a/jobs/services/mail/mailserver.hcl +++ b/jobs/services/mail/mailserver.hcl @@ -1,7 +1,11 @@ job "mailserver" { datacenters = ["aperture"] type = "service" - node_pool = "ingress" + + constraint { + attribute = "${meta.ingress_vip_node}" + value = "1" + } meta { tld = "redbrick.dcu.ie" @@ -16,38 +20,78 @@ job "mailserver" { } port "smtp" { - static = 25 + to = 25 } port "submissions" { - static = 465 + to = 465 } port "submission" { - static = 587 + to = 587 } port "imap" { - static = 143 + to = 143 } port "imaps" { - static = 993 + to = 993 } port "pop3" { - static = 110 + to = 110 } port "pop3s" { - static = 995 + to = 995 } port "managesieve" { - static = 4190 + to = 4190 } } + service { + name = "mailserver-smtp" + port = "smtp" + } + + service { + name = "mailserver-submissions" + port = "submissions" + } + + service { + name = "mailserver-submission" + port = "submission" + } + + service { + name = "mailserver-imap" + port = "imap" + } + + service { + name = "mailserver-imaps" + port = "imaps" + } + + service { + name = "mailserver-pop3" + port = "pop3" + } + + service { + name = "mailserver-pop3s" + port = "pop3s" + } + + service { + name = "mailserver-managesieve" + port = "managesieve" + } + task "whoami" { driver = "docker" @@ -57,7 +101,7 @@ job "mailserver" { } service { - name = "whoami" + name = "mail-http" port = "http" check { @@ -71,18 +115,75 @@ job "mailserver" { "traefik.enable=true", "traefik.port=${NOMAD_PORT_http}", "traefik.http.routers.mail-http.rule=Host(`${NOMAD_META_domain}`)", - "traefik.http.routers.mail-http.entrypoints=web,websecure", - "traefik.http.routers.mail-http.tls.certresolver=lets-encrypt", + "traefik.http.routers.mail-http.entrypoints=websecure", + "traefik.http.routers.mail-http.tls=true", + ] + } + } + + task "configure-mail-egress" { + driver = "raw_exec" + + lifecycle { + hook = "prestart" + sidecar = false + } + + config { + command = "/bin/bash" + args = [ + "-ec", + <<-EOF + MAIL_IP="136.206.16.50" + MAIL_INTERFACE="eno1" + DOCKER_SUBNET="172.17.0.0/16" + + ip -4 address show dev "$MAIL_INTERFACE" | + grep -qE "[[:space:]]$MAIL_IP/" || + ip address add "$MAIL_IP/32" dev "$MAIL_INTERFACE" + + while iptables -t nat -C POSTROUTING \ + -s "$DOCKER_SUBNET" \ + -p tcp --dport 25 \ + -j SNAT --to-source "$MAIL_IP" 2>/dev/null + do + iptables -t nat -D POSTROUTING \ + -s "$DOCKER_SUBNET" \ + -p tcp --dport 25 \ + -j SNAT --to-source "$MAIL_IP" + done + + iptables -t nat -I POSTROUTING 1 \ + -s "$DOCKER_SUBNET" \ + -p tcp --dport 25 \ + -j SNAT --to-source "$MAIL_IP" + EOF ] } + + resources { + cpu = 50 + memory = 32 + } } task "mail-server" { driver = "docker" config { - image = "ghcr.io/docker-mailserver/docker-mailserver:latest" - ports = ["smtp", "submissions", "submission", "imap", "imaps", "pop3", "pop3s", "managesieve"] + image = "ghcr.io/docker-mailserver/docker-mailserver:latest" + hostname = "mail.redbrick.dcu.ie" + + ports = [ + "smtp", + "submissions", + "submission", + "imap", + "imaps", + "pop3", + "pop3s", + "managesieve" + ] volumes = [ # mount mailserver dirs @@ -91,19 +192,21 @@ job "mailserver" { "/storage/nomad/mail/logs/:/var/log/mail/", "/storage/nomad/mail/config/:/tmp/docker-mailserver/", - # acme.json in read-only mode so certs can be generated - "/storage/nomad/traefik/acme/acme.json:/etc/letsencrypt/acme.json:ro", + # Use extracted wildcard PEM certs, not Traefik ACME JSON. + "/storage/nomad/traefik/certs/redbrick.dcu.ie:/etc/docker-mailserver/ssl/redbrick.dcu.ie:ro", "local/postfix-main.cf:/tmp/docker-mailserver/postfix-main.cf", "local/transport:/etc/postfix/transport", + # Add a blocklist and whitelist for senders to control who can send to us and who we will accept mail from. + "local/sender_blocklist:/etc/postfix/sender_blocklist:ro", "local/sender_whitelist:/etc/postfix/sender_whitelist:ro", "local/postfix-sender-login.pcre:/etc/postfix/postfix-sender-login.pcre:ro", "local/10-auth.conf:/etc/dovecot/conf.d/10-auth.conf:ro", "local/aliases:/tmp/docker-mailserver/aliases:ro", "local/sasl_access:/etc/postfix/sasl_access:ro", + "local/99-proxy-protocol.conf:/etc/dovecot/conf.d/99-proxy-protocol.conf:ro", "/etc/localtime:/etc/localtime:ro", - "/storage/home:/home/:ro", # Mount persistant master.cf to keep smtpd_client_restrictions settings across restarts @@ -136,6 +239,7 @@ smtpd_sender_login_maps = pcre:/etc/postfix/postfix-sender-login.pcre # Allow only mailman senders (Mailman) to send as list addresses # and allow authenticated users/mynetworks before rejecting mismatches. smtpd_sender_restrictions = + check_sender_access texthash:/etc/postfix/sender_blocklist, check_sender_access texthash:/etc/postfix/sender_whitelist, permit_mynetworks, permit_sasl_authenticated, @@ -155,6 +259,13 @@ virtual_alias_maps = texthash:/tmp/docker-mailserver/aliases EOH } + template { + destination = "local/sender_blocklist" + data = < [ + 'class' => 'JobQueueMemory', + 'wiki' => $wgDBname + ] +]; + + +$wgSessionCacheType = CACHE_DB; // Forces session safety in the DB if Memcached isn't built yet diff --git a/jobs/services/wiki/mediawiki.hcl b/jobs/services/wiki/mediawiki.hcl index 3849e01..e0ceb1d 100644 --- a/jobs/services/wiki/mediawiki.hcl +++ b/jobs/services/wiki/mediawiki.hcl @@ -38,7 +38,7 @@ job "mediawiki" { "traefik.port=${NOMAD_PORT_http}", "traefik.http.routers.rbwiki.rule=Host(`${NOMAD_META_domain}`) || Host(`wiki.rb.dcu.ie`)", "traefik.http.routers.rbwiki.entrypoints=web,websecure", - "traefik.http.routers.rbwiki.tls.certresolver=rb", + "traefik.http.routers.rbwiki.tls.certresolver=lets-encrypt", "traefik.http.routers.rbwiki.middlewares=rbwiki-redirect-root, rbwiki-redirect-mw", "traefik.http.middlewares.rbwiki-redirect-root.redirectregex.regex=^https://wiki\\.redbrick\\.dcu\\.ie/?$", "traefik.http.middlewares.rbwiki-redirect-root.redirectregex.replacement=https://wiki.redbrick.dcu.ie/Main_Page", @@ -65,7 +65,6 @@ job "mediawiki" { memory = 100 } template { - destination = "local/nginx.conf" data = <