initial commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
BasedOnStyle: microsoft
|
||||
AccessModifierOffset: -4
|
||||
AlignConsecutiveAssignments: false
|
||||
AlwaysBreakTemplateDeclarations: Yes
|
||||
ColumnLimit: 150
|
||||
NamespaceIndentation: All
|
||||
PointerAlignment: Left
|
||||
TabWidth: 4
|
||||
AllowShortBlocksOnASingleLine: true
|
||||
AllowShortFunctionsOnASingleLine: All
|
||||
UseTab: Never
|
||||
AlignAfterOpenBracket: BlockIndent
|
||||
AllowAllArgumentsOnNextLine: true
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
Checks: >
|
||||
bugprone*,
|
||||
cert*,
|
||||
cppcore*,
|
||||
misc*,
|
||||
modernize*,
|
||||
performance*,
|
||||
portability*,
|
||||
readability*,
|
||||
-modernize-avoid-c-arrays,
|
||||
-cppcoreguidelines-avoid-c-arrays,
|
||||
-cppcoreguidelines-avoid-const-or-ref-data-members,
|
||||
-cert-dcl58-cpp,
|
||||
-misc-non-private-member-variables-in-classes,
|
||||
-cppcoreguidelines-macro-usage,
|
||||
-cppcoreguidelines-non-private-member-variables-in-classes,
|
||||
-cppcoreguidelines-avoid-do-while
|
||||
-cert-dcl21-cpp,
|
||||
-cert-dcl51-cpp,
|
||||
-cppcoreguidelines-avoid-magic-numbers,
|
||||
-cert-dcl37-c,
|
||||
-bugprone-reserved-identifier
|
||||
FormatStyle: file
|
||||
CheckOptions:
|
||||
- key: readability-implicit-bool-conversion.AllowIntegerConditions
|
||||
value: "1"
|
||||
- key: readability-identifier-length.IgnoredVariableNames
|
||||
value: ch
|
||||
- key: readability-identifier-length.IgnoredParameterNames
|
||||
value: ch
|
||||
- key: cppcoreguidelines-special-member-functions.AllowSoleDefaultDtor
|
||||
value: true
|
||||
- key: readability-magic-numbers.IgnorePowersOf2IntegerValues
|
||||
value: true
|
||||
@@ -0,0 +1,45 @@
|
||||
name: diff-pr
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: diff-pr-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- name: worktree base for diff
|
||||
run: git worktree add "$RUNNER_TEMP/base" "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
- name: build closures and render diff
|
||||
run: |
|
||||
nix run .#ci -- diff "$RUNNER_TEMP/base" . \
|
||||
"### System closure diff vs \`${{ github.event.pull_request.base.ref }}\`" \
|
||||
> "$RUNNER_TEMP/comment.md"
|
||||
nix run .#ci -- inspect-diff "$RUNNER_TEMP/base" . \
|
||||
"### Inspection diff vs \`${{ github.event.pull_request.base.ref }}\`" \
|
||||
>> "$RUNNER_TEMP/comment.md"
|
||||
|
||||
- name: post/update PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.api_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
run: nix run .#ci -- post-comment "<!-- dix-diff -->" "$RUNNER_TEMP/comment.md"
|
||||
@@ -0,0 +1,37 @@
|
||||
name: diff-tag
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- name: diff latest tag against HEAD
|
||||
run: |
|
||||
tag=$(git tag --sort=-creatordate | head -n1)
|
||||
if [ -z "$tag" ]; then
|
||||
echo "No tags yet, skipping diff." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
git worktree add "$RUNNER_TEMP/old" "$tag"
|
||||
head=$(git rev-parse --short HEAD)
|
||||
{
|
||||
nix run .#ci -- diff "$RUNNER_TEMP/old" . \
|
||||
"# System closure diff: \`$tag\` -> \`$head\`"
|
||||
nix run .#ci -- inspect-diff "$RUNNER_TEMP/old" . \
|
||||
"# Inspection diff: \`$tag\` -> \`$head\`"
|
||||
} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -0,0 +1,26 @@
|
||||
name: flake-check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: flake-check-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
flake-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- run: nix flake check -L
|
||||
@@ -0,0 +1,33 @@
|
||||
name: flake-update
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- run: nix flake update
|
||||
|
||||
- name: open/update PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.api_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
printf 'Automated `nix flake update`.\n' > "$RUNNER_TEMP/body.md"
|
||||
nix run .#ci -- open-pr \
|
||||
flake-update "$GITHUB_REF_NAME" "flake.lock: update inputs" "$RUNNER_TEMP/body.md" automated
|
||||
@@ -0,0 +1,41 @@
|
||||
name: lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- name: pick reporter
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = pull_request ]; then
|
||||
echo "REPORTER=github-pr-review" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "REPORTER=local" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: run linters
|
||||
run: nix run .#ci -- lint . "$RUNNER_TEMP/findings"
|
||||
|
||||
- name: report findings
|
||||
env:
|
||||
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: nix run .#ci -- lint-report "$REPORTER" "$RUNNER_TEMP/findings"
|
||||
@@ -0,0 +1,41 @@
|
||||
name: vulnix
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- name: scan host closures
|
||||
id: scan
|
||||
run: echo "count=$(nix run .#ci -- vulnix . "$RUNNER_TEMP/issue.md")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: file/update issue
|
||||
if: steps.scan.outputs.count != '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.api_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: nix run .#ci -- upsert-issue vulnix "vulnix: known vulnerabilities in system closure" "$RUNNER_TEMP/issue.md"
|
||||
|
||||
- name: close resolved issue
|
||||
if: steps.scan.outputs.count == '0'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.api_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: nix run .#ci -- close-issue vulnix
|
||||
@@ -0,0 +1,4 @@
|
||||
result
|
||||
result-*
|
||||
out.txt
|
||||
__pycache__/
|
||||
@@ -0,0 +1,23 @@
|
||||
{ lib, pkgs }:
|
||||
# tools must track ci.py's subprocess calls; nix stays ambient (uses the
|
||||
# runner's own nix, not a pinned one).
|
||||
pkgs.writers.writePython3Bin "ci" {
|
||||
flakeIgnore = [ "E501" ];
|
||||
makeWrapperArgs = [
|
||||
"--prefix"
|
||||
"PATH"
|
||||
":"
|
||||
(lib.makeBinPath (
|
||||
with pkgs;
|
||||
[
|
||||
dix
|
||||
vulnix
|
||||
nixfmt
|
||||
statix
|
||||
deadnix
|
||||
reviewdog
|
||||
git
|
||||
]
|
||||
))
|
||||
];
|
||||
} (builtins.readFile ./ci.py)
|
||||
@@ -0,0 +1,451 @@
|
||||
import argparse
|
||||
import base64
|
||||
import datetime
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run(*args, **kw):
|
||||
return subprocess.run(args, text=True, **kw)
|
||||
|
||||
|
||||
def nix(*args):
|
||||
return run("nix", "--extra-experimental-features", "nix-command flakes", *args, check=True, capture_output=True).stdout
|
||||
|
||||
|
||||
def git(*args, check=True):
|
||||
return run("git", *args, check=check, capture_output=True)
|
||||
|
||||
|
||||
def gitea(method, path, body=None):
|
||||
url = f'{os.environ["API"]}/repos/{os.environ["REPO"]}{path}'
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
req.add_header("Authorization", f'token {os.environ["GH_TOKEN"]}')
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
raw = resp.read()
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
|
||||
def find_open_issue(label):
|
||||
issues = gitea("GET", f"/issues?state=open&labels={label}")
|
||||
return issues[0] if issues else None
|
||||
|
||||
|
||||
def build_hosts(ref):
|
||||
hosts = json.loads(nix("eval", "--json", f"{ref}#nixosConfigurations", "--apply", "builtins.attrNames"))
|
||||
return {
|
||||
host: nix("build", f"{ref}#nixosConfigurations.{host}.config.system.build.toplevel",
|
||||
"--no-link", "--print-out-paths").strip()
|
||||
for host in hosts
|
||||
}
|
||||
|
||||
|
||||
def diff(args):
|
||||
old, new = build_hosts(args.old), build_hosts(args.new)
|
||||
|
||||
def section(host):
|
||||
if host not in old:
|
||||
return f"## {host}\n\n_new host_"
|
||||
if host not in new:
|
||||
return f"## {host}\n\n_host removed_"
|
||||
rendered = run("dix", "--color", "never", old[host], new[host], capture_output=True).stdout
|
||||
return f"## {host}\n\n```\n{rendered}```"
|
||||
|
||||
parts = ([args.title] if args.title else []) + [section(h) for h in sorted(set(old) | set(new))]
|
||||
print("\n\n".join(parts))
|
||||
|
||||
|
||||
# meta.toplevel is the closure path dix already covers; nftables.ruleset/tables
|
||||
# are duplicated by nftables.rendered (the assembled ruleset we keep and diff).
|
||||
# Pruned so inspect-diff reports only source config topology, once.
|
||||
INSPECT_PRUNE = [
|
||||
("meta", "toplevel"),
|
||||
("nftables", "ruleset"),
|
||||
("nftables", "tables"),
|
||||
]
|
||||
|
||||
|
||||
def compact(value):
|
||||
return json.dumps(value, sort_keys=True)
|
||||
|
||||
|
||||
def scalar(value):
|
||||
if value is None:
|
||||
return "null"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def fmt(value, indent=0):
|
||||
pad = " " * indent
|
||||
lines = []
|
||||
if isinstance(value, dict):
|
||||
for key in sorted(value):
|
||||
child = value[key]
|
||||
if isinstance(child, (dict, list)) and child:
|
||||
lines.append(f"{pad}{key}:")
|
||||
lines += fmt(child, indent + 1)
|
||||
else:
|
||||
lines.append(f"{pad}{key}: {scalar(child)}")
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, (dict, list)) and item:
|
||||
body = fmt(item)
|
||||
lines.append(f"{pad}- {body[0]}")
|
||||
lines += [f"{pad} {line}" for line in body[1:]]
|
||||
else:
|
||||
lines.append(f"{pad}- {scalar(item)}")
|
||||
else:
|
||||
lines.append(f"{pad}{scalar(value)}")
|
||||
return lines
|
||||
|
||||
|
||||
STORE_HASH = re.compile(r"/nix/store/[a-z0-9]{32}-")
|
||||
|
||||
|
||||
def strip_store(value):
|
||||
# closure hashes churn on any rebuild and are dix's job; drop them so exec
|
||||
# commands / paths read as "<name>/bin/x" and diff only on real changes
|
||||
if isinstance(value, dict):
|
||||
return {k: strip_store(v) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [strip_store(v) for v in value]
|
||||
if isinstance(value, str):
|
||||
return STORE_HASH.sub("", value)
|
||||
return value
|
||||
|
||||
|
||||
def inspect_hosts(ref):
|
||||
try:
|
||||
names = json.loads(nix("eval", "--json", f"{ref}#inspect", "--apply", "builtins.attrNames"))
|
||||
except subprocess.CalledProcessError as e:
|
||||
if "does not provide attribute" in e.stderr:
|
||||
return None
|
||||
raise RuntimeError(f"inspect eval failed for {ref}:\n{e.stderr}") from e
|
||||
return {
|
||||
host: nix("build", f"{ref}#inspect.{host}",
|
||||
"--no-link", "--print-out-paths").strip()
|
||||
for host in names
|
||||
}
|
||||
|
||||
|
||||
def read_inspect(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def inspect_data(ref):
|
||||
paths = inspect_hosts(ref)
|
||||
if paths is None:
|
||||
return None
|
||||
reports = {host: read_inspect(path) for host, path in paths.items()}
|
||||
for report in reports.values():
|
||||
for parent, key in INSPECT_PRUNE:
|
||||
report.get(parent, {}).pop(key, None)
|
||||
return {host: strip_store(report) for host, report in reports.items()}
|
||||
|
||||
|
||||
def tree_changes(old, new, path=()):
|
||||
if old == new:
|
||||
return
|
||||
if isinstance(old, dict) and isinstance(new, dict):
|
||||
for key in sorted(old.keys() | new.keys()):
|
||||
if key not in old:
|
||||
yield path + (key,), "add", new[key]
|
||||
elif key not in new:
|
||||
yield path + (key,), "del", old[key]
|
||||
else:
|
||||
yield from tree_changes(old[key], new[key], path + (key,))
|
||||
elif isinstance(old, list) and isinstance(new, list):
|
||||
# order-independent: a reordered list is not a change
|
||||
okeys, nkeys = list(map(compact, old)), list(map(compact, new))
|
||||
values = dict(zip(okeys, old)) | dict(zip(nkeys, new))
|
||||
for key in (Counter(okeys) - Counter(nkeys)).elements():
|
||||
yield path, "delitem", values[key]
|
||||
for key in (Counter(nkeys) - Counter(okeys)).elements():
|
||||
yield path, "additem", values[key]
|
||||
else:
|
||||
yield path, "change", (old, new)
|
||||
|
||||
|
||||
def render_change(path, kind, payload):
|
||||
label = ".".join(map(str, path[1:])) or path[0]
|
||||
if kind in ("add", "del", "additem", "delitem"):
|
||||
sign = "+" if kind in ("add", "additem") else "-"
|
||||
value = [payload] if kind in ("additem", "delitem") else payload
|
||||
if isinstance(value, (dict, list)) and value:
|
||||
return [f"{sign} {label}:"] + [f"{sign} {line}" for line in fmt(value, 1)]
|
||||
return [f"{sign} {label}: {scalar(value)}"]
|
||||
old, new = payload
|
||||
if isinstance(old, str) and isinstance(new, str) and "\n" in old + new:
|
||||
body = difflib.unified_diff(old.splitlines(), new.splitlines(), lineterm="")
|
||||
return [f" {label}:"] + [ln for ln in body if not ln.startswith(("---", "+++"))]
|
||||
if isinstance(old, (dict, list)) or isinstance(new, (dict, list)):
|
||||
return render_change(path, "del", old) + render_change(path, "add", new)
|
||||
return [f"- {label}: {scalar(old)}", f"+ {label}: {scalar(new)}"]
|
||||
|
||||
|
||||
def host_diff(old, new):
|
||||
lines = []
|
||||
attr = None
|
||||
for path, kind, payload in tree_changes(old, new):
|
||||
if path[0] != attr:
|
||||
attr = path[0]
|
||||
lines.append(f"@@ {attr} @@")
|
||||
lines += render_change(path, kind, payload)
|
||||
return "```diff\n" + "\n".join(lines) + "\n```" if lines else None
|
||||
|
||||
|
||||
def host_added(report):
|
||||
return "```diff\n" + "\n".join(f"+ {line}" for line in fmt(report)) + "\n```"
|
||||
|
||||
|
||||
def inspect_diff(args):
|
||||
old, new = inspect_data(args.old), inspect_data(args.new)
|
||||
if old is None or new is None:
|
||||
return
|
||||
|
||||
def block(host):
|
||||
if host not in old:
|
||||
return f"## {host} (new)\n\n{host_added(new[host])}"
|
||||
if host not in new:
|
||||
return f"## {host}\n\n_host removed_"
|
||||
body = host_diff(old[host], new[host])
|
||||
return f"## {host}\n\n{body}" if body else None
|
||||
|
||||
blocks = [b for b in map(block, sorted(set(old) | set(new))) if b]
|
||||
if blocks:
|
||||
print("\n\n".join(([args.title] if args.title else []) + blocks))
|
||||
|
||||
|
||||
def format_vulnix(vulns):
|
||||
rows = []
|
||||
for v in vulns:
|
||||
for cve in v["affected_by"]:
|
||||
score = v.get("cvssv3_basescore", {}).get(cve)
|
||||
desc = v.get("description", {}).get(cve, "")
|
||||
if len(desc) > 120:
|
||||
desc = desc[:117] + "..."
|
||||
rows.append((v["pname"], v["version"], cve, score, desc))
|
||||
rows.sort(key=lambda r: -(r[3] or 0))
|
||||
|
||||
lines = [
|
||||
"| Package | Version | CVE | CVSS | Description |",
|
||||
"|---------|---------|-----|------|-------------|",
|
||||
]
|
||||
for pname, version, cve, score, desc in rows:
|
||||
score_str = f"{score:.1f}" if score else "?"
|
||||
cve_link = f"[{cve}](https://nvd.nist.gov/vuln/detail/{cve})"
|
||||
lines.append(f"| {pname} | {version} | {cve_link} | {score_str} | {desc} |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def vulnix(args):
|
||||
paths = list(build_hosts(args.ref).values())
|
||||
result = run("vulnix", "-j", *paths, capture_output=True)
|
||||
if result.returncode not in (0, 2):
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
vulns = json.loads(result.stdout or "[]")
|
||||
if vulns:
|
||||
cve_count = sum(len(v["affected_by"]) for v in vulns)
|
||||
date = datetime.date.today().isoformat()
|
||||
with open(args.body_out, "w") as f:
|
||||
f.write(f"vulnix scan ({date}): **{cve_count}** CVEs across **{len(vulns)}** packages.\n\n")
|
||||
f.write(format_vulnix(vulns))
|
||||
f.write("\n")
|
||||
print(len(vulns))
|
||||
|
||||
|
||||
def find_cycles(edges):
|
||||
adj = {}
|
||||
for e in edges:
|
||||
if e["kind"] == "after":
|
||||
adj.setdefault(e["from"], []).append(e["to"])
|
||||
|
||||
visited, on_path = set(), set()
|
||||
path = []
|
||||
cycles = []
|
||||
|
||||
def dfs(node):
|
||||
visited.add(node)
|
||||
path.append(node)
|
||||
on_path.add(node)
|
||||
for nb in adj.get(node, []):
|
||||
if nb in on_path:
|
||||
i = path.index(nb)
|
||||
cycles.append(path[i:] + [nb])
|
||||
elif nb not in visited:
|
||||
dfs(nb)
|
||||
path.pop()
|
||||
on_path.discard(node)
|
||||
|
||||
for node in adj:
|
||||
if node not in visited:
|
||||
dfs(node)
|
||||
return cycles
|
||||
|
||||
|
||||
def lint(args):
|
||||
out = os.path.abspath(args.out)
|
||||
target = os.path.abspath(args.target)
|
||||
os.makedirs(out, exist_ok=True)
|
||||
os.chdir(target)
|
||||
|
||||
files = [f for f in git("ls-files", "-z", "*.nix").stdout.split("\0") if f]
|
||||
if files:
|
||||
run("nixfmt", *files)
|
||||
with open(f"{out}/nixfmt.diff", "w") as f:
|
||||
f.write(git("diff").stdout)
|
||||
git("checkout", "--", ".")
|
||||
|
||||
with open(f"{out}/statix.errfmt", "w") as f:
|
||||
f.write(run("statix", "check", ".", "-o", "errfmt", capture_output=True).stdout)
|
||||
|
||||
dead = run("deadnix", "-o", "json", ".", capture_output=True).stdout
|
||||
with open(f"{out}/deadnix.rdjsonl", "w") as f:
|
||||
for line in filter(str.strip, dead.splitlines()):
|
||||
report = json.loads(line)
|
||||
for r in report["results"]:
|
||||
loc = {"path": report["file"]}
|
||||
if "line" in r:
|
||||
loc["range"] = {
|
||||
"start": {"line": r["line"], "column": r["column"]},
|
||||
"end": {"line": r["line"], "column": r["endColumn"]},
|
||||
}
|
||||
rec = {
|
||||
"message": r["message"],
|
||||
"location": loc,
|
||||
"severity": "ERROR",
|
||||
}
|
||||
json.dump(rec, f)
|
||||
f.write("\n")
|
||||
|
||||
paths = inspect_hosts(target)
|
||||
with open(f"{out}/cycles.rdjsonl", "w") as f:
|
||||
for host, path in (paths or {}).items():
|
||||
report = read_inspect(path)
|
||||
for cycle in find_cycles(report["dependencies"]["edges"]):
|
||||
chain = " → ".join(u.split(":", 1)[1] for u in cycle)
|
||||
rec = {
|
||||
"message": f"[{host}] systemd ordering cycle: {chain}",
|
||||
"location": {"path": "flake.nix"},
|
||||
"severity": "ERROR",
|
||||
}
|
||||
json.dump(rec, f)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
LINTERS = [
|
||||
("nixfmt.diff", ["-f=diff", "-f.diff.strip=1", "-name=nixfmt", "-level=error"]),
|
||||
("statix.errfmt", ["-efm=%f>%l:%c:%*[A-Z]:%*[0-9]:%m", "-name=statix", "-level=error"]),
|
||||
("deadnix.rdjsonl", ["-f=rdjsonl", "-name=deadnix"]),
|
||||
("cycles.rdjsonl", ["-f=rdjsonl", "-name=systemd-cycles", "-filter-mode=nofilter"]),
|
||||
]
|
||||
|
||||
|
||||
def lint_report(args):
|
||||
def reviewdog(path, flags):
|
||||
with open(path) as f:
|
||||
return run("reviewdog", *flags, f"-reporter={args.reporter}", "-fail-level=error", stdin=f).returncode
|
||||
|
||||
codes = [reviewdog(f"{args.dir}/{name}", flags)
|
||||
for name, flags in LINTERS
|
||||
if os.path.getsize(f"{args.dir}/{name}") > 0]
|
||||
sys.exit(1 if any(codes) else 0)
|
||||
|
||||
|
||||
def post_comment(args):
|
||||
body = args.marker + "\n" + Path(args.body_file).read_text()
|
||||
pr = os.environ["PR"]
|
||||
comments = gitea("GET", f"/issues/{pr}/comments")
|
||||
existing = next((c for c in comments if c["body"].startswith(args.marker)), None)
|
||||
if existing:
|
||||
gitea("PATCH", f"/issues/comments/{existing['id']}", {"body": body})
|
||||
else:
|
||||
gitea("POST", f"/issues/{pr}/comments", {"body": body})
|
||||
|
||||
|
||||
def upsert_issue(args):
|
||||
body = Path(args.body_file).read_text()
|
||||
issue = find_open_issue(args.label)
|
||||
if issue:
|
||||
gitea("PATCH", f"/issues/{issue['number']}", {"body": body})
|
||||
else:
|
||||
gitea("POST", "/issues", {"title": args.title, "body": body, "labels": [label_id(args.label)]})
|
||||
|
||||
|
||||
def close_issue(args):
|
||||
issue = find_open_issue(args.label)
|
||||
if issue:
|
||||
gitea("PATCH", f"/issues/{issue['number']}", {"state": "closed"})
|
||||
|
||||
|
||||
def label_id(name):
|
||||
match = next((x for x in gitea("GET", "/labels") if x["name"] == name), None)
|
||||
return match["id"] if match else gitea("POST", "/labels", {"name": name, "color": "#ededed"})["id"]
|
||||
|
||||
|
||||
def open_pr(args):
|
||||
if git("diff", "--quiet", check=False).returncode == 0:
|
||||
print("no changes; nothing to do")
|
||||
return
|
||||
git("switch", "-C", args.branch)
|
||||
git("add", "-A")
|
||||
git("-c", "user.name=gitea-actions[bot]", "-c", "user.email=gitea-actions@noreply.local",
|
||||
"commit", "-m", args.title)
|
||||
|
||||
host = os.environ["API"].removesuffix("/api/v1")
|
||||
auth = base64.b64encode(f'token:{os.environ["GH_TOKEN"]}'.encode()).decode()
|
||||
git("-c", f"http.extraHeader=Authorization: Basic {auth}",
|
||||
"push", "-f", f'{host}/{os.environ["REPO"]}.git', f"HEAD:{args.branch}")
|
||||
|
||||
owner = os.environ["REPO"].split("/")[0]
|
||||
if gitea("GET", f"/pulls?state=open&head={owner}:{args.branch}"):
|
||||
return
|
||||
labels = [label_id(args.label)] if args.label else []
|
||||
gitea("POST", "/pulls",
|
||||
{"title": args.title, "head": args.branch, "base": args.base,
|
||||
"body": Path(args.body_file).read_text(), "labels": labels})
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog="ci")
|
||||
sub = parser.add_subparsers(required=True)
|
||||
|
||||
def command(name, fn, *positional):
|
||||
p = sub.add_parser(name)
|
||||
for arg in positional:
|
||||
if arg.endswith("?"):
|
||||
p.add_argument(arg[:-1], nargs="?")
|
||||
else:
|
||||
p.add_argument(arg)
|
||||
p.set_defaults(fn=fn)
|
||||
|
||||
command("diff", diff, "old", "new", "title?")
|
||||
command("inspect-diff", inspect_diff, "old", "new", "title?")
|
||||
command("vulnix", vulnix, "ref", "body_out")
|
||||
command("lint", lint, "target", "out")
|
||||
command("lint-report", lint_report, "reporter", "dir")
|
||||
command("post-comment", post_comment, "marker", "body_file")
|
||||
command("upsert-issue", upsert_issue, "label", "title", "body_file")
|
||||
command("close-issue", close_issue, "label")
|
||||
command("open-pr", open_pr, "branch", "base", "title", "body_file", "label?")
|
||||
|
||||
args = parser.parse_args()
|
||||
args.fn(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
config,
|
||||
pkgs,
|
||||
lib,
|
||||
hostInfo,
|
||||
customPkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (hostInfo) paths;
|
||||
in
|
||||
{
|
||||
assertions =
|
||||
let
|
||||
isMounted =
|
||||
path:
|
||||
lib.any (
|
||||
# rootfs may be subject to erasure so it's not persistent
|
||||
fs: fs.mountPoint != "/" && (fs.mountPoint == path || lib.hasPrefix "${fs.mountPoint}/" path)
|
||||
) (lib.attrValues config.fileSystems);
|
||||
|
||||
isMountedEarly = path: (config.fileSystems.${path} or { neededForBoot = false; }).neededForBoot;
|
||||
|
||||
mountAssertion = path: {
|
||||
assertion = isMounted path;
|
||||
message = "configuration.nix needs '${path}' to be a mounted path, but no fileSystems entry covers it.";
|
||||
};
|
||||
|
||||
earlyMountAssertion = path: {
|
||||
assertion = isMountedEarly path;
|
||||
message = "configuration.nix needs '${path}' to be mounted early (neededForBoot = true), but its fileSystems entry doesn't set that.";
|
||||
};
|
||||
|
||||
strictPaths = lib.optionals (!hostInfo.isVm) [
|
||||
"/nix"
|
||||
"/var/log"
|
||||
];
|
||||
in
|
||||
map mountAssertion (
|
||||
[
|
||||
paths.server
|
||||
paths.secrets
|
||||
]
|
||||
++ strictPaths
|
||||
)
|
||||
++ lib.optionals (!hostInfo.isVm) [ (earlyMountAssertion "/var/log") ];
|
||||
|
||||
time.timeZone = "UTC";
|
||||
system.stateVersion = "26.05";
|
||||
|
||||
nix = {
|
||||
settings = {
|
||||
experimental-features = [
|
||||
"nix-command"
|
||||
"flakes"
|
||||
];
|
||||
};
|
||||
nrBuildUsers = 80;
|
||||
};
|
||||
|
||||
powerManagement.cpuFreqGovernor = "performance";
|
||||
|
||||
boot.kernelParams = [ "transparent_hugepage=madvise" ];
|
||||
|
||||
services = {
|
||||
fstrim = {
|
||||
enable = true;
|
||||
interval = "weekly";
|
||||
};
|
||||
|
||||
openssh = {
|
||||
enable = true;
|
||||
ports = [ 5952 ];
|
||||
hostKeys =
|
||||
lib.map
|
||||
(type: {
|
||||
inherit type;
|
||||
path = "${paths.secrets}/ssh_host_${type}_key";
|
||||
})
|
||||
[
|
||||
"ed25519"
|
||||
"ecdsa"
|
||||
"rsa"
|
||||
];
|
||||
settings.PasswordAuthentication = false;
|
||||
};
|
||||
};
|
||||
|
||||
users.mutableUsers = false;
|
||||
users.users.server = {
|
||||
uid = 1000;
|
||||
isNormalUser = true;
|
||||
extraGroups = [
|
||||
"wheel"
|
||||
"systemd-journal"
|
||||
];
|
||||
hashedPassword = "!";
|
||||
openssh.authorizedKeys.keys = [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPaGpTX3EzfezVc2nl3MgsKVamYYP2h2VJaRny8pZkvi flowey@arch"
|
||||
];
|
||||
};
|
||||
|
||||
security.sudo.wheelNeedsPassword = false;
|
||||
|
||||
security.polkit.extraConfig = ''
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id.startsWith("org.freedesktop.machine1.") &&
|
||||
subject.user === "server") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
'';
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"L /home/server/config - - - - /persist/@configuration"
|
||||
"L /home/server/containers - - - - /persist/@containers"
|
||||
"L /home/server/secrets - - - - /persist/@secrets"
|
||||
];
|
||||
|
||||
environment.etc."machine-id".text = "${
|
||||
builtins.substring 0 32 (builtins.hashString "sha256" "machine-id-for-${hostInfo.hostname}")
|
||||
}\n";
|
||||
environment.systemPackages = [
|
||||
customPkgs.ct
|
||||
customPkgs.server-rebuild
|
||||
customPkgs.server-status
|
||||
customPkgs.server-kexec
|
||||
customPkgs.kitty-terminfo
|
||||
pkgs.git
|
||||
pkgs.wezterm.terminfo
|
||||
];
|
||||
|
||||
networking.firewall.allowedTCPPorts = [ 5952 ];
|
||||
|
||||
system.activationScripts.server-keypair = ''
|
||||
if [[ ! -f "${paths.server}/keypair/id_ed25519" ]]; then
|
||||
mkdir -p "${paths.server}/keypair"
|
||||
${lib.getExe' pkgs.openssh "ssh-keygen"} -t ed25519 -f "${paths.server}/keypair/id_ed25519" -N ""
|
||||
fi
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
{
|
||||
name,
|
||||
hostInfo,
|
||||
dsl,
|
||||
pkgs,
|
||||
lib,
|
||||
containers,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer;
|
||||
inherit (hostInfo.paths) data;
|
||||
|
||||
nodePort = 9100;
|
||||
cadvPort = 9200;
|
||||
|
||||
containerNetworkRulesFile =
|
||||
let
|
||||
mkRules =
|
||||
metric: record:
|
||||
lib.mapAttrsToList (cname: c: {
|
||||
inherit record;
|
||||
expr = "rate(${metric}{device=\"${c.iface}\"}[5m])";
|
||||
labels = {
|
||||
container = cname;
|
||||
};
|
||||
}) containers;
|
||||
in
|
||||
pkgs.writeText "container-network.yml" (
|
||||
builtins.toJSON {
|
||||
groups = [
|
||||
{
|
||||
name = "container-network";
|
||||
rules =
|
||||
mkRules "node_network_receive_bytes_total" "container_network_receive_rate"
|
||||
++ mkRules "node_network_transmit_bytes_total" "container_network_transmit_rate";
|
||||
}
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
containerDefinedRulesFile = pkgs.writeText "container-defined.yml" (
|
||||
builtins.toJSON {
|
||||
groups = [
|
||||
{
|
||||
name = "container-defined";
|
||||
rules = lib.mapAttrsToList (cname: _: {
|
||||
record = "container_defined";
|
||||
expr = "1";
|
||||
labels = {
|
||||
container = cname;
|
||||
};
|
||||
}) containers;
|
||||
}
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
alloyConfig = pkgs.writeText "config.alloy" ''
|
||||
loki.source.journal "journal" {
|
||||
max_age = "12h"
|
||||
path = "/var/log/journal"
|
||||
labels = { job = "systemd-journal" }
|
||||
relabel_rules = loki.relabel.journal.rules
|
||||
forward_to = [loki.write.local.receiver]
|
||||
}
|
||||
|
||||
loki.relabel "journal" {
|
||||
forward_to = []
|
||||
|
||||
rule {
|
||||
source_labels = ["__journal__systemd_unit"]
|
||||
target_label = "unit"
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__journal__hostname"]
|
||||
target_label = "host"
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__journal_syslog_identifier"]
|
||||
target_label = "syslog_id"
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__journal_priority_keyword"]
|
||||
target_label = "level"
|
||||
}
|
||||
rule {
|
||||
source_labels = ["__journal__systemd_machine_name"]
|
||||
target_label = "container"
|
||||
}
|
||||
}
|
||||
|
||||
loki.write "local" {
|
||||
endpoint {
|
||||
url = "http://127.0.0.1:3100/loki/api/v1/push"
|
||||
}
|
||||
}
|
||||
'';
|
||||
|
||||
dashboards = lib.filterAttrs (name: _: lib.hasSuffix "-dashboard" name) (
|
||||
dsl.listMap ./overseer (_: path: import path { inherit pkgs lib; })
|
||||
);
|
||||
|
||||
dashboardDir = pkgs.linkFarm "grafana-dashboards" (
|
||||
lib.mapAttrsToList (name: dashboard: {
|
||||
name = "${lib.removeSuffix "-dashboard" name}.json";
|
||||
path = dashboard;
|
||||
}) dashboards
|
||||
);
|
||||
|
||||
landingRoot = pkgs.writeTextDir "index.html" ''
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Overseer</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 480px; margin: 4rem auto; }
|
||||
ul { padding: 0; list-style: none; }
|
||||
li { margin: .5rem 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Overseer</h1>
|
||||
<ul>
|
||||
<li><a data-host="stats.localhost">stats.localhost</a> — Grafana</li>
|
||||
<li><a data-host="prometheus.localhost">prometheus.localhost</a> — Prometheus</li>
|
||||
</ul>
|
||||
<script>
|
||||
const p = location.port && location.port !== "80" ? ":" + location.port : "";
|
||||
document.querySelectorAll("a[data-host]").forEach(a => {
|
||||
const h = a.dataset.host;
|
||||
a.href = "http://" + h + p;
|
||||
a.textContent = h + p;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'';
|
||||
in
|
||||
let
|
||||
c =
|
||||
mkContainer {
|
||||
inherit name;
|
||||
net.access = [
|
||||
{
|
||||
to = "@host";
|
||||
tcp = [
|
||||
nodePort
|
||||
cadvPort
|
||||
];
|
||||
}
|
||||
];
|
||||
svc.autoStart = true;
|
||||
resources.fs = {
|
||||
"/var/lib/loki" = {
|
||||
hostPath = "${data}/overseer/loki/data";
|
||||
isReadOnly = false;
|
||||
};
|
||||
"/var/lib/prometheus2" = {
|
||||
hostPath = "${data}/overseer/prometheus/data";
|
||||
isReadOnly = false;
|
||||
};
|
||||
"/var/lib/grafana" = {
|
||||
hostPath = "${data}/overseer/grafana";
|
||||
isReadOnly = false;
|
||||
};
|
||||
"/var/log/journal" = {
|
||||
hostPath = "/var/log/journal";
|
||||
isReadOnly = true;
|
||||
};
|
||||
};
|
||||
containerConfig = _: {
|
||||
services.loki = {
|
||||
enable = true;
|
||||
configuration = {
|
||||
auth_enabled = false;
|
||||
server = {
|
||||
http_listen_port = 3100;
|
||||
grpc_listen_port = 9096;
|
||||
};
|
||||
common = {
|
||||
instance_addr = "127.0.0.1";
|
||||
path_prefix = "/var/lib/loki";
|
||||
storage.filesystem = {
|
||||
chunks_directory = "/var/lib/loki/chunks";
|
||||
rules_directory = "/var/lib/loki/rules";
|
||||
};
|
||||
replication_factor = 1;
|
||||
ring.kvstore.store = "inmemory";
|
||||
};
|
||||
schema_config.configs = [
|
||||
{
|
||||
from = "2020-10-24";
|
||||
store = "tsdb";
|
||||
object_store = "filesystem";
|
||||
schema = "v13";
|
||||
index = {
|
||||
prefix = "index_";
|
||||
period = "24h";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
services.alloy = {
|
||||
enable = true;
|
||||
configPath = alloyConfig;
|
||||
};
|
||||
|
||||
services.prometheus = {
|
||||
enable = true;
|
||||
port = 9090;
|
||||
globalConfig.scrape_interval = "15s";
|
||||
scrapeConfigs = [
|
||||
{
|
||||
job_name = "node";
|
||||
static_configs = [ { targets = [ "${hostAddr}:${toString nodePort}" ]; } ];
|
||||
}
|
||||
{
|
||||
job_name = "cadvisor";
|
||||
static_configs = [ { targets = [ "${hostAddr}:${toString cadvPort}" ]; } ];
|
||||
}
|
||||
{
|
||||
job_name = "prometheus";
|
||||
static_configs = [ { targets = [ "localhost:9090" ]; } ];
|
||||
}
|
||||
];
|
||||
ruleFiles = [
|
||||
containerNetworkRulesFile
|
||||
containerDefinedRulesFile
|
||||
(dsl.mkContainerLimitsRuleFile pkgs containers)
|
||||
];
|
||||
rules = [
|
||||
''
|
||||
groups:
|
||||
- name: containers
|
||||
rules:
|
||||
- alert: ContainerFailed
|
||||
expr: node_systemd_unit_state{name=~"container@.*",state="failed"} == 1
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "{{ $labels.name }} entered failed state"
|
||||
- alert: ContainerOOMKill
|
||||
expr: increase(container_oom_events_total{id=~"/machine.slice/container@.+\\.service.*"}[5m]) > 0
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "OOM kill in {{ $labels.id }}"
|
||||
''
|
||||
];
|
||||
};
|
||||
|
||||
services.grafana = {
|
||||
enable = true;
|
||||
dataDir = "/var/lib/grafana";
|
||||
settings = {
|
||||
server.http_port = 3000;
|
||||
server.http_addr = "127.0.0.1";
|
||||
# WORKAROUND: Grafana 10+ requires an explicit secret_key; omitting it
|
||||
# causes auth session tokens to be regenerated on every restart
|
||||
security.secret_key = "SW2YcwTIb9zpOOhoPsMm";
|
||||
"auth".disable_login_form = true;
|
||||
"auth.anonymous" = {
|
||||
enabled = true;
|
||||
org_role = "Admin";
|
||||
};
|
||||
};
|
||||
provision = {
|
||||
enable = true;
|
||||
datasources.settings = {
|
||||
apiVersion = 1;
|
||||
datasources = [
|
||||
{
|
||||
name = "Prometheus";
|
||||
type = "prometheus";
|
||||
uid = "prometheus";
|
||||
access = "proxy";
|
||||
url = "http://localhost:9090";
|
||||
isDefault = true;
|
||||
jsonData.timeInterval = "15s";
|
||||
}
|
||||
{
|
||||
name = "Loki";
|
||||
type = "loki";
|
||||
uid = "loki";
|
||||
access = "proxy";
|
||||
url = "http://127.0.0.1:3100";
|
||||
}
|
||||
];
|
||||
};
|
||||
dashboards.settings = {
|
||||
apiVersion = 1;
|
||||
providers = [
|
||||
{
|
||||
name = "default";
|
||||
type = "file";
|
||||
disableDeletion = true;
|
||||
options.path = toString dashboardDir;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."_" = {
|
||||
listen = [
|
||||
{
|
||||
addr = "0.0.0.0";
|
||||
port = 80;
|
||||
ssl = false;
|
||||
extraParameters = [ "default_server" ];
|
||||
}
|
||||
];
|
||||
root = "${landingRoot}";
|
||||
};
|
||||
virtualHosts."stats.localhost" = {
|
||||
listen = [
|
||||
{
|
||||
addr = "0.0.0.0";
|
||||
port = 80;
|
||||
ssl = false;
|
||||
}
|
||||
];
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:3000";
|
||||
proxyWebsockets = true;
|
||||
extraConfig = "proxy_set_header Host $host;";
|
||||
};
|
||||
};
|
||||
virtualHosts."prometheus.localhost" = {
|
||||
listen = [
|
||||
{
|
||||
addr = "0.0.0.0";
|
||||
port = 80;
|
||||
ssl = false;
|
||||
}
|
||||
];
|
||||
locations."/" = {
|
||||
proxyPass = "http://127.0.0.1:9090";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services = lib.genAttrs [ "loki" "alloy" "prometheus" "grafana" "nginx" ] (_: {
|
||||
serviceConfig.FailureAction = "exit-force";
|
||||
});
|
||||
};
|
||||
net.expose.tcp = [ 80 ];
|
||||
resources.mem = "2G";
|
||||
}
|
||||
// {
|
||||
hostConfig = {
|
||||
services.prometheus.exporters.node = {
|
||||
enable = true;
|
||||
listenAddress = "0.0.0.0";
|
||||
port = nodePort;
|
||||
enabledCollectors = [
|
||||
"systemd"
|
||||
"processes"
|
||||
];
|
||||
disabledCollectors = [
|
||||
"netstat"
|
||||
"softnet"
|
||||
];
|
||||
# radeon sometimes breaks on kexec
|
||||
extraFlags = [
|
||||
"--collector.systemd.enable-task-metrics"
|
||||
"--collector.hwmon.chip-exclude=radeon"
|
||||
];
|
||||
};
|
||||
services.cadvisor = {
|
||||
enable = true;
|
||||
listenAddress = "0.0.0.0";
|
||||
port = cadvPort;
|
||||
};
|
||||
};
|
||||
};
|
||||
hostAddr = c.hostAddr;
|
||||
in
|
||||
c
|
||||
@@ -0,0 +1,61 @@
|
||||
{ pkgs, lib }:
|
||||
let
|
||||
inherit (import ./helpers.nix { inherit pkgs lib; }) mkRow loki mkDashboard;
|
||||
|
||||
mkLogs =
|
||||
{
|
||||
title,
|
||||
expr,
|
||||
w ? 24,
|
||||
h ? 10,
|
||||
}:
|
||||
{
|
||||
inherit title;
|
||||
type = "logs";
|
||||
datasource = loki;
|
||||
gridPos = { inherit w h; };
|
||||
options = {
|
||||
showTime = true;
|
||||
showLabels = true;
|
||||
sortOrder = "Descending";
|
||||
wrapLogMessage = true;
|
||||
enableLogDetails = true;
|
||||
};
|
||||
targets = [
|
||||
{
|
||||
datasource = loki;
|
||||
inherit expr;
|
||||
}
|
||||
];
|
||||
};
|
||||
in
|
||||
mkDashboard {
|
||||
name = "access-logs.json";
|
||||
title = "Access Logs";
|
||||
uid = "access-logs";
|
||||
from = "now-6h";
|
||||
panels = [
|
||||
(mkRow "HTTP")
|
||||
(mkLogs {
|
||||
title = "nginx (access + error)";
|
||||
expr = ''{syslog_id="nginx"}'';
|
||||
})
|
||||
(mkRow "SSH")
|
||||
(mkLogs {
|
||||
title = "sshpiper (all connections)";
|
||||
expr = ''{container="sshpiper"}'';
|
||||
})
|
||||
(mkLogs {
|
||||
title = "Failed / invalid auth attempts (all backends, incl. host)";
|
||||
expr = ''{syslog_id=~"sshd.*"} |~ "Failed|Invalid|error:"'';
|
||||
})
|
||||
(mkLogs {
|
||||
title = "sshd auth events (all backends, incl. host)";
|
||||
expr = ''{syslog_id=~"sshd.*"}'';
|
||||
})
|
||||
(mkLogs {
|
||||
title = "Host sshd (port 5952)";
|
||||
expr = ''{syslog_id=~"sshd.*", container=""}'';
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
{ pkgs, lib }:
|
||||
let
|
||||
inherit (import ./helpers.nix { inherit pkgs lib; }) prom mkDashboard;
|
||||
in
|
||||
mkDashboard {
|
||||
name = "containers.json";
|
||||
title = "Containers";
|
||||
uid = "containers";
|
||||
from = "now-3h";
|
||||
panels = [
|
||||
{
|
||||
title = "Container States";
|
||||
type = "piechart";
|
||||
gridPos = {
|
||||
h = 8;
|
||||
w = 8;
|
||||
};
|
||||
datasource = prom;
|
||||
options = {
|
||||
pieType = "donut";
|
||||
legend = {
|
||||
displayMode = "table";
|
||||
placement = "right";
|
||||
values = [
|
||||
"value"
|
||||
"percent"
|
||||
];
|
||||
};
|
||||
tooltip.mode = "single";
|
||||
};
|
||||
fieldConfig.overrides = [
|
||||
{
|
||||
matcher = {
|
||||
id = "byName";
|
||||
options = "active";
|
||||
};
|
||||
properties = [
|
||||
{
|
||||
id = "color";
|
||||
value = {
|
||||
mode = "fixed";
|
||||
fixedColor = "green";
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
matcher = {
|
||||
id = "byName";
|
||||
options = "failed";
|
||||
};
|
||||
properties = [
|
||||
{
|
||||
id = "color";
|
||||
value = {
|
||||
mode = "fixed";
|
||||
fixedColor = "red";
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
matcher = {
|
||||
id = "byName";
|
||||
options = "inactive";
|
||||
};
|
||||
properties = [
|
||||
{
|
||||
id = "color";
|
||||
value = {
|
||||
mode = "fixed";
|
||||
fixedColor = "blue";
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
matcher = {
|
||||
id = "byName";
|
||||
options = "activating";
|
||||
};
|
||||
properties = [
|
||||
{
|
||||
id = "color";
|
||||
value = {
|
||||
mode = "fixed";
|
||||
fixedColor = "yellow";
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
matcher = {
|
||||
id = "byName";
|
||||
options = "unknown";
|
||||
};
|
||||
properties = [
|
||||
{
|
||||
id = "color";
|
||||
value = {
|
||||
mode = "fixed";
|
||||
fixedColor = "gray";
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
targets = [
|
||||
{
|
||||
datasource = prom;
|
||||
expr = ''count by (state) ((label_replace(node_systemd_unit_state{name=~"container@.*"} == 1, "container", "$1", "name", "container@(.+)\\.service")) or on(container) label_replace(container_defined, "state", "unknown", "container", ".+"))'';
|
||||
instant = true;
|
||||
legendFormat = "{{state}}";
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
title = "Container States";
|
||||
type = "table";
|
||||
gridPos = {
|
||||
h = 16;
|
||||
w = 24;
|
||||
};
|
||||
datasource = prom;
|
||||
options.footer.show = false;
|
||||
fieldConfig = {
|
||||
defaults = { };
|
||||
overrides = [
|
||||
{
|
||||
matcher = {
|
||||
id = "byName";
|
||||
options = "State";
|
||||
};
|
||||
properties = [
|
||||
{
|
||||
id = "custom.displayMode";
|
||||
value = "color-text";
|
||||
}
|
||||
{
|
||||
id = "mappings";
|
||||
value = [
|
||||
{
|
||||
type = "value";
|
||||
options.active = {
|
||||
text = "active";
|
||||
color = "green";
|
||||
index = 0;
|
||||
};
|
||||
}
|
||||
{
|
||||
type = "value";
|
||||
options.failed = {
|
||||
text = "failed";
|
||||
color = "red";
|
||||
index = 1;
|
||||
};
|
||||
}
|
||||
{
|
||||
type = "value";
|
||||
options.activating = {
|
||||
text = "activating";
|
||||
color = "yellow";
|
||||
index = 2;
|
||||
};
|
||||
}
|
||||
{
|
||||
type = "value";
|
||||
options.inactive = {
|
||||
text = "inactive";
|
||||
color = "blue";
|
||||
index = 3;
|
||||
};
|
||||
}
|
||||
{
|
||||
type = "value";
|
||||
options.unknown = {
|
||||
text = "unknown";
|
||||
color = "gray";
|
||||
index = 4;
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
targets = [
|
||||
{
|
||||
datasource = prom;
|
||||
expr = ''
|
||||
(label_replace(node_systemd_unit_state{name=~"container@.*"} == 1, "container", "$1", "name", "container@(.+)\\.service"))
|
||||
or on(container)
|
||||
label_replace(container_defined, "state", "unknown", "container", ".+")
|
||||
'';
|
||||
instant = true;
|
||||
format = "table";
|
||||
legendFormat = "__auto";
|
||||
}
|
||||
];
|
||||
transformations = [
|
||||
{
|
||||
id = "organize";
|
||||
options = {
|
||||
excludeByName = {
|
||||
Time = true;
|
||||
Value = true;
|
||||
"__name__" = true;
|
||||
instance = true;
|
||||
job = true;
|
||||
name = true;
|
||||
};
|
||||
renameByName = {
|
||||
container = "Container";
|
||||
state = "State";
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
title = "Container State (1 = running, 0 = stopped/failed)";
|
||||
type = "timeseries";
|
||||
gridPos = {
|
||||
h = 8;
|
||||
w = 24;
|
||||
};
|
||||
datasource = prom;
|
||||
fieldConfig.defaults = {
|
||||
custom.lineWidth = 2;
|
||||
color.mode = "palette-classic";
|
||||
min = 0;
|
||||
max = 1;
|
||||
};
|
||||
options = {
|
||||
tooltip.mode = "multi";
|
||||
legend = {
|
||||
displayMode = "table";
|
||||
placement = "bottom";
|
||||
};
|
||||
};
|
||||
targets = [
|
||||
{
|
||||
datasource = prom;
|
||||
expr = ''label_replace(node_systemd_unit_state{name=~"container@.*",state="active"}, "container", "$1", "name", "container@(.+).service")'';
|
||||
legendFormat = "{{container}}";
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
{ pkgs, lib }:
|
||||
let
|
||||
prom = {
|
||||
type = "prometheus";
|
||||
uid = "prometheus";
|
||||
};
|
||||
loki = {
|
||||
type = "loki";
|
||||
uid = "loki";
|
||||
};
|
||||
|
||||
autoLayout =
|
||||
panels:
|
||||
let
|
||||
step =
|
||||
acc: panel:
|
||||
let
|
||||
w = panel.gridPos.w;
|
||||
h = panel.gridPos.h;
|
||||
wrap = acc.x + w > 24;
|
||||
x = if wrap then 0 else acc.x;
|
||||
y = if wrap then acc.y + acc.rowH else acc.y;
|
||||
rowH = if wrap then h else lib.max acc.rowH h;
|
||||
in
|
||||
{
|
||||
x = x + w;
|
||||
inherit y rowH;
|
||||
result = acc.result ++ [
|
||||
(
|
||||
panel
|
||||
// {
|
||||
gridPos = panel.gridPos // {
|
||||
inherit x y;
|
||||
};
|
||||
}
|
||||
)
|
||||
];
|
||||
};
|
||||
in
|
||||
(lib.foldl' step {
|
||||
x = 0;
|
||||
y = 0;
|
||||
rowH = 0;
|
||||
result = [ ];
|
||||
} panels).result;
|
||||
|
||||
T = expr: legendFormat: {
|
||||
datasource = prom;
|
||||
inherit expr legendFormat;
|
||||
};
|
||||
|
||||
mkRow = title: {
|
||||
inherit title;
|
||||
type = "row";
|
||||
collapsed = false;
|
||||
panels = [ ];
|
||||
gridPos = {
|
||||
w = 24;
|
||||
h = 1;
|
||||
};
|
||||
};
|
||||
|
||||
mkStat =
|
||||
{
|
||||
title,
|
||||
w ? 4,
|
||||
h ? 4,
|
||||
expr,
|
||||
unit ? "short",
|
||||
decimals ? 2,
|
||||
}:
|
||||
{
|
||||
inherit title;
|
||||
type = "stat";
|
||||
datasource = prom;
|
||||
gridPos = { inherit w h; };
|
||||
fieldConfig.defaults = {
|
||||
inherit unit decimals;
|
||||
color.mode = "thresholds";
|
||||
thresholds = {
|
||||
mode = "absolute";
|
||||
steps = [
|
||||
{
|
||||
color = "blue";
|
||||
value = null;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
options = {
|
||||
reduceOptions.calcs = [ "lastNotNull" ];
|
||||
colorMode = "value";
|
||||
textMode = "value";
|
||||
};
|
||||
targets = [
|
||||
{
|
||||
datasource = prom;
|
||||
inherit expr;
|
||||
instant = true;
|
||||
legendFormat = "";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
mkGauge =
|
||||
{
|
||||
title,
|
||||
w ? 4,
|
||||
h ? 4,
|
||||
expr,
|
||||
unit ? "percent",
|
||||
min ? 0,
|
||||
max ? 100,
|
||||
}:
|
||||
{
|
||||
inherit title;
|
||||
type = "gauge";
|
||||
datasource = prom;
|
||||
gridPos = { inherit w h; };
|
||||
fieldConfig.defaults = {
|
||||
inherit unit min max;
|
||||
thresholds = {
|
||||
mode = "absolute";
|
||||
steps = [
|
||||
{
|
||||
color = "green";
|
||||
value = null;
|
||||
}
|
||||
{
|
||||
color = "yellow";
|
||||
value = 70;
|
||||
}
|
||||
{
|
||||
color = "red";
|
||||
value = 90;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
options = {
|
||||
reduceOptions.calcs = [ "lastNotNull" ];
|
||||
orientation = "auto";
|
||||
showThresholdLabels = false;
|
||||
showThresholdMarkers = true;
|
||||
};
|
||||
targets = [
|
||||
{
|
||||
datasource = prom;
|
||||
inherit expr;
|
||||
instant = true;
|
||||
legendFormat = "";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
mkTs =
|
||||
{
|
||||
title,
|
||||
w ? 24,
|
||||
h ? 8,
|
||||
unit ? "short",
|
||||
targets,
|
||||
}:
|
||||
{
|
||||
inherit title targets;
|
||||
type = "timeseries";
|
||||
datasource = prom;
|
||||
gridPos = { inherit w h; };
|
||||
fieldConfig.defaults = {
|
||||
inherit unit;
|
||||
custom = {
|
||||
lineWidth = 1;
|
||||
fillOpacity = 5;
|
||||
};
|
||||
color.mode = "palette-classic";
|
||||
};
|
||||
options = {
|
||||
tooltip.mode = "multi";
|
||||
legend = {
|
||||
displayMode = "table";
|
||||
placement = "bottom";
|
||||
calcs = [
|
||||
"mean"
|
||||
"max"
|
||||
"lastNotNull"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
mkDashboard =
|
||||
{
|
||||
name,
|
||||
title,
|
||||
uid,
|
||||
from ? "now-1h",
|
||||
panels,
|
||||
templating ? [ ],
|
||||
}:
|
||||
pkgs.writeText name (
|
||||
builtins.toJSON (
|
||||
{
|
||||
inherit title uid;
|
||||
schemaVersion = 37;
|
||||
refresh = "30s";
|
||||
time = {
|
||||
inherit from;
|
||||
to = "now";
|
||||
};
|
||||
panels = lib.imap1 (id: p: p // { inherit id; }) (autoLayout panels);
|
||||
}
|
||||
// lib.optionalAttrs (templating != [ ]) { templating.list = templating; }
|
||||
)
|
||||
);
|
||||
in
|
||||
{
|
||||
inherit
|
||||
prom
|
||||
loki
|
||||
autoLayout
|
||||
T
|
||||
mkRow
|
||||
mkStat
|
||||
mkGauge
|
||||
mkTs
|
||||
mkDashboard
|
||||
;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{ pkgs, lib }:
|
||||
let
|
||||
inherit (import ./helpers.nix { inherit pkgs lib; })
|
||||
T
|
||||
mkRow
|
||||
mkStat
|
||||
mkGauge
|
||||
mkTs
|
||||
mkDashboard
|
||||
;
|
||||
in
|
||||
mkDashboard {
|
||||
name = "host.json";
|
||||
title = "Host";
|
||||
uid = "host";
|
||||
panels = [
|
||||
|
||||
(mkRow "Overview")
|
||||
(mkGauge {
|
||||
title = "CPU";
|
||||
expr = ''100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m])))'';
|
||||
})
|
||||
(mkGauge {
|
||||
title = "Memory used";
|
||||
expr = "100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)";
|
||||
})
|
||||
(mkStat {
|
||||
title = "Load (1m)";
|
||||
unit = "short";
|
||||
decimals = 2;
|
||||
expr = "node_load1";
|
||||
})
|
||||
(mkStat {
|
||||
title = "Load (5m)";
|
||||
unit = "short";
|
||||
decimals = 2;
|
||||
expr = "node_load5";
|
||||
})
|
||||
(mkStat {
|
||||
title = "Uptime";
|
||||
unit = "dtdurations";
|
||||
decimals = 0;
|
||||
expr = "node_time_seconds - node_boot_time_seconds";
|
||||
})
|
||||
|
||||
(mkRow "CPU")
|
||||
(mkTs {
|
||||
title = "CPU usage (%)";
|
||||
unit = "percent";
|
||||
targets = [
|
||||
(T ''avg(rate(node_cpu_seconds_total{mode="user"}[5m])) * 100'' "user")
|
||||
(T ''avg(rate(node_cpu_seconds_total{mode="system"}[5m])) * 100'' "system")
|
||||
(T ''avg(rate(node_cpu_seconds_total{mode="iowait"}[5m])) * 100'' "iowait")
|
||||
(T ''avg(rate(node_cpu_seconds_total{mode="steal"}[5m])) * 100'' "steal")
|
||||
(T ''avg(rate(node_cpu_seconds_total{mode="softirq"}[5m])) * 100'' "softirq")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Per-core usage (%)";
|
||||
unit = "percent";
|
||||
targets = [
|
||||
(T ''(1 - rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100'' "cpu{{cpu}}")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "CPU temperatures";
|
||||
unit = "celsius";
|
||||
targets = [
|
||||
(T "node_hwmon_temp_celsius" "{{chip}} {{sensor}}")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Load average";
|
||||
targets = [
|
||||
(T "node_load1" "1m")
|
||||
(T "node_load5" "5m")
|
||||
(T "node_load15" "15m")
|
||||
];
|
||||
})
|
||||
|
||||
(mkRow "Memory")
|
||||
(mkTs {
|
||||
title = "Memory";
|
||||
unit = "bytes";
|
||||
targets = [
|
||||
(T "node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes" "used")
|
||||
(T "node_memory_Cached_bytes + node_memory_Buffers_bytes" "cache + buffers")
|
||||
(T "node_memory_MemAvailable_bytes" "available")
|
||||
(T "node_memory_MemTotal_bytes" "total")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Swap";
|
||||
unit = "bytes";
|
||||
targets = [
|
||||
(T "node_memory_SwapTotal_bytes - node_memory_SwapFree_bytes" "used")
|
||||
(T "node_memory_SwapTotal_bytes" "total")
|
||||
];
|
||||
})
|
||||
|
||||
(mkRow "Disk")
|
||||
(mkTs {
|
||||
title = "Filesystem usage (%)";
|
||||
unit = "percent";
|
||||
targets = [
|
||||
(T ''100 * (1 - node_filesystem_avail_bytes{fstype=~"btrfs|ext4|xfs|vfat|f2fs"} / node_filesystem_size_bytes{fstype=~"btrfs|ext4|xfs|vfat|f2fs"})'' "{{mountpoint}}")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Disk IO (bytes/s)";
|
||||
unit = "Bps";
|
||||
targets = [
|
||||
(T ''rate(node_disk_read_bytes_total{device!~"loop.*"}[5m])'' "{{device}} read")
|
||||
(T ''rate(node_disk_written_bytes_total{device!~"loop.*"}[5m])'' "{{device}} write")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Disk IO utilisation (%)";
|
||||
unit = "percent";
|
||||
targets = [
|
||||
(T ''rate(node_disk_io_time_seconds_total{device!~"loop.*"}[5m]) * 100'' "{{device}}")
|
||||
];
|
||||
})
|
||||
|
||||
(mkRow "Network")
|
||||
(mkTs {
|
||||
title = "Network (bytes/s)";
|
||||
unit = "Bps";
|
||||
targets = [
|
||||
(T ''rate(node_network_receive_bytes_total{device!~"lo|br-.*|vh-.*|vc-.*"}[5m])'' "{{device}} rx")
|
||||
(T ''rate(node_network_transmit_bytes_total{device!~"lo|br-.*|vh-.*|vc-.*"}[5m])'' "{{device}} tx")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Network errors + drops (pps)";
|
||||
targets = [
|
||||
(T ''rate(node_network_receive_errs_total{device!~"lo|br-.*|vh-.*|vc-.*"}[5m])'' "{{device}} rx err")
|
||||
(T ''rate(node_network_transmit_errs_total{device!~"lo|br-.*|vh-.*|vc-.*"}[5m])'' "{{device}} tx err")
|
||||
(T ''rate(node_network_receive_drop_total{device!~"lo|br-.*|vh-.*|vc-.*"}[5m])'' "{{device}} rx drop")
|
||||
(T ''rate(node_network_transmit_drop_total{device!~"lo|br-.*|vh-.*|vc-.*"}[5m])'' "{{device}} tx drop")
|
||||
];
|
||||
})
|
||||
|
||||
(mkRow "System")
|
||||
(mkTs {
|
||||
title = "Context switches + forks (ops/s)";
|
||||
targets = [
|
||||
(T "rate(node_context_switches_total[5m])" "context switches")
|
||||
(T "rate(node_forks_total[5m])" "forks")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "File descriptors";
|
||||
targets = [
|
||||
(T "node_filefd_allocated" "allocated")
|
||||
(T "node_filefd_maximum" "limit")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Processes";
|
||||
targets = [
|
||||
(T "node_procs_running" "running")
|
||||
(T "node_procs_blocked" "blocked on IO")
|
||||
];
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
{ pkgs, lib }:
|
||||
let
|
||||
inherit (import ./helpers.nix { inherit pkgs lib; })
|
||||
T
|
||||
mkRow
|
||||
mkStat
|
||||
mkTs
|
||||
prom
|
||||
mkDashboard
|
||||
;
|
||||
|
||||
ctid = "id=~\"/machine.slice/container@.+\\\\.service/payload/.+\"";
|
||||
ctsel = "id=~\"/machine.slice/container@$container\\\\.service/payload/.+\"";
|
||||
|
||||
ctall = "id=~\"/machine.slice/container@.+\\\\.service.*\"";
|
||||
ctnet = "id=~\"/machine.slice/container@$container\\\\.service.*\"";
|
||||
|
||||
labeled =
|
||||
m:
|
||||
"label_replace(${m}, \"container\", \"$1\", \"id\", \"/machine.slice/container@(.+)\\\\.service/payload.+\")";
|
||||
byContainer = m: "sum by (container) (${labeled m})";
|
||||
|
||||
labeledAll =
|
||||
m:
|
||||
"label_replace(${m}, \"container\", \"$1\", \"id\", \"/machine.slice/container@(.+)\\\\.service.*\")";
|
||||
byContainerAll = m: "sum by (container) (${labeledAll m})";
|
||||
|
||||
nmSvc = m: "label_replace(${m}, \"container\", \"$1\", \"name\", \"container@(.+)\\\\.service\")";
|
||||
|
||||
orZero = m: fb: "(${m}) or on(container) (${fb} * 0)";
|
||||
|
||||
cpuAll =
|
||||
rate:
|
||||
orZero (byContainer "rate(container_cpu_usage_seconds_total{${ctid}}[${rate}])") "container_limit_cpu_cores";
|
||||
memAll = orZero (byContainerAll "container_memory_rss{${ctall}}") "container_limit_memory_bytes";
|
||||
taskAll = nmSvc "node_systemd_unit_tasks_current{name=~\"container@.+\"}";
|
||||
in
|
||||
mkDashboard {
|
||||
name = "resources.json";
|
||||
title = "Container Resources";
|
||||
uid = "container-resources";
|
||||
|
||||
templating = [
|
||||
{
|
||||
name = "container";
|
||||
type = "query";
|
||||
datasource = prom;
|
||||
query = "label_values(container_limit_cpu_cores, container)";
|
||||
refresh = 2;
|
||||
sort = 1;
|
||||
includeAll = true;
|
||||
multi = true;
|
||||
current = {
|
||||
selected = true;
|
||||
text = [ "All" ];
|
||||
value = [ "$__all" ];
|
||||
};
|
||||
}
|
||||
];
|
||||
|
||||
panels = [
|
||||
(mkRow "Overview")
|
||||
(mkStat {
|
||||
title = "Running";
|
||||
decimals = 0;
|
||||
expr = "count(node_systemd_unit_state{name=~\"container@.*\",state=\"active\"} == 1) or vector(0)";
|
||||
})
|
||||
(mkStat {
|
||||
title = "CPU used";
|
||||
decimals = 2;
|
||||
expr = "sum(${cpuAll "5m"}) or vector(0)";
|
||||
})
|
||||
(mkStat {
|
||||
title = "CPU limit";
|
||||
decimals = 2;
|
||||
expr = "sum(container_limit_cpu_cores) or vector(0)";
|
||||
})
|
||||
(mkStat {
|
||||
title = "Mem RSS";
|
||||
decimals = 1;
|
||||
unit = "bytes";
|
||||
expr = "sum(${memAll}) or vector(0)";
|
||||
})
|
||||
(mkStat {
|
||||
title = "Mem limit";
|
||||
decimals = 1;
|
||||
unit = "bytes";
|
||||
expr = "sum(container_limit_memory_bytes) or vector(0)";
|
||||
})
|
||||
(mkStat {
|
||||
title = "Tasks";
|
||||
decimals = 0;
|
||||
expr = "sum(${taskAll}) or vector(0)";
|
||||
})
|
||||
|
||||
(mkRow "All Containers")
|
||||
(mkTs {
|
||||
title = "CPU (cores)";
|
||||
h = 24;
|
||||
targets = [ (T (cpuAll "5m") "{{container}}") ];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Memory RSS";
|
||||
unit = "bytes";
|
||||
h = 24;
|
||||
targets = [ (T memAll "{{container}}") ];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Tasks";
|
||||
h = 24;
|
||||
targets = [ (T taskAll "{{container}}") ];
|
||||
})
|
||||
(mkTs {
|
||||
title = "CPU throttled %";
|
||||
unit = "percent";
|
||||
h = 12;
|
||||
targets = [
|
||||
(T "(${byContainer "rate(container_cpu_cfs_throttled_periods_total{${ctid}}[5m])"} / ${byContainer "rate(container_cpu_cfs_periods_total{${ctid}}[5m])"}) * 100 or on(container) (container_limit_cpu_cores * 0)" "{{container}}")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Network egress (bytes/s)";
|
||||
unit = "Bps";
|
||||
h = 24;
|
||||
targets = [ (T "sum by (container) (container_network_receive_rate)" "{{container}}") ];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Network ingress (bytes/s)";
|
||||
unit = "Bps";
|
||||
h = 24;
|
||||
targets = [ (T "sum by (container) (container_network_transmit_rate)" "{{container}}") ];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Disk read (bytes/s)";
|
||||
unit = "Bps";
|
||||
h = 24;
|
||||
targets = [ (T (byContainer "rate(container_fs_reads_bytes_total{${ctid}}[5m])") "{{container}}") ];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Disk write (bytes/s)";
|
||||
unit = "Bps";
|
||||
h = 24;
|
||||
targets = [
|
||||
(T (byContainer "rate(container_fs_writes_bytes_total{${ctid}}[5m])") "{{container}}")
|
||||
];
|
||||
})
|
||||
|
||||
{
|
||||
type = "row";
|
||||
title = "$container";
|
||||
repeat = "container";
|
||||
collapsed = true;
|
||||
panels = [ ];
|
||||
gridPos = {
|
||||
w = 24;
|
||||
h = 1;
|
||||
};
|
||||
}
|
||||
(mkTs {
|
||||
title = "CPU (cores)";
|
||||
targets = [
|
||||
(T "sum(rate(container_cpu_usage_seconds_total{${ctsel}}[5m]))" "used")
|
||||
(T "container_limit_cpu_cores{container=\"$container\"}" "limit")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "CPU throttled %";
|
||||
unit = "percent";
|
||||
targets = [
|
||||
(T "(sum(rate(container_cpu_cfs_throttled_periods_total{${ctsel}}[5m])) / sum(rate(container_cpu_cfs_periods_total{${ctsel}}[5m]))) * 100 or vector(0)" "throttled %")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Memory";
|
||||
unit = "bytes";
|
||||
targets = [
|
||||
(T "sum(container_memory_rss{${ctnet}})" "RSS")
|
||||
(T "sum(container_memory_working_set_bytes{${ctnet}})" "working set")
|
||||
(T "container_limit_memory_bytes{container=\"$container\"}" "limit")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Network (bytes/s)";
|
||||
unit = "Bps";
|
||||
targets = [
|
||||
(T "container_network_receive_rate{container=\"$container\"}" "{{device}} ↑")
|
||||
(T "container_network_transmit_rate{container=\"$container\"}" "{{device}} ↓")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Disk IO (bytes/s)";
|
||||
unit = "Bps";
|
||||
targets = [
|
||||
(T "sum(rate(container_fs_reads_bytes_total{${ctsel}}[5m]))" "read")
|
||||
(T "sum(rate(container_fs_writes_bytes_total{${ctsel}}[5m]))" "write")
|
||||
];
|
||||
})
|
||||
(mkTs {
|
||||
title = "Tasks";
|
||||
targets = [ (T "node_systemd_unit_tasks_current{name=\"container@$container.service\"}" "tasks") ];
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
name,
|
||||
hostInfo,
|
||||
dsl,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer mkService;
|
||||
inherit (hostInfo.paths) data secrets;
|
||||
in
|
||||
mkContainer {
|
||||
inherit name;
|
||||
net.access = [ { to = "@internet"; } ];
|
||||
svc.autoStart = true;
|
||||
resources.fs = {
|
||||
"/config" = {
|
||||
hostPath = "${data}/web/devenv/config";
|
||||
isReadOnly = false;
|
||||
};
|
||||
"/workspace" = {
|
||||
hostPath = "${data}/web/devenv/workspace";
|
||||
isReadOnly = false;
|
||||
};
|
||||
"/run/secrets/code-server-env" = {
|
||||
hostPath = "${secrets}/code-server-env";
|
||||
isReadOnly = true;
|
||||
};
|
||||
};
|
||||
users = [
|
||||
{
|
||||
name = "coder";
|
||||
uid = 1000;
|
||||
home = "/config";
|
||||
}
|
||||
];
|
||||
service =
|
||||
{ pkgs, lib, ... }:
|
||||
mkService {
|
||||
description = "code-server";
|
||||
execStart = "${lib.getExe' pkgs.code-server "code-server"} --bind-addr 0.0.0.0:8080 --user-data-dir /config/data --extensions-dir /config/extensions --auth password /workspace";
|
||||
networkOnline = true;
|
||||
serviceConfig = {
|
||||
EnvironmentFile = "/run/secrets/code-server-env";
|
||||
User = "coder";
|
||||
ReadWritePaths = [
|
||||
"/config"
|
||||
"/workspace"
|
||||
];
|
||||
};
|
||||
};
|
||||
net.expose.tcp = [ 8080 ];
|
||||
resources.mem = "4G";
|
||||
resources.cpu = 2;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
{
|
||||
name,
|
||||
hostInfo,
|
||||
dsl,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer mkVm;
|
||||
inherit (hostInfo.paths) data secrets;
|
||||
|
||||
ciRunnerData = "${data}/web/git-ci-runner";
|
||||
ciRunnerSecrets = "${secrets}/ci-runner";
|
||||
in
|
||||
{
|
||||
${name} = mkContainer {
|
||||
inherit name;
|
||||
net.access = [ { to = "@internet"; } ];
|
||||
net.forward.tcp = [
|
||||
{
|
||||
host = 23;
|
||||
container = 8023;
|
||||
}
|
||||
];
|
||||
svc.autoStart = true;
|
||||
resources.fs."/var/lib/gitea" = {
|
||||
hostPath = "${data}/web/git";
|
||||
isReadOnly = false;
|
||||
};
|
||||
containerConfig = _: {
|
||||
services.gitea = {
|
||||
enable = true;
|
||||
stateDir = "/var/lib/gitea";
|
||||
settings = {
|
||||
server = {
|
||||
HTTP_ADDR = "0.0.0.0";
|
||||
HTTP_PORT = 3000;
|
||||
DOMAIN = "git.floweytf.com";
|
||||
ROOT_URL = "https://git.floweytf.com/";
|
||||
START_SSH_SERVER = true;
|
||||
BUILTIN_SSH_SERVER_USER = "git";
|
||||
SSH_PORT = 8023;
|
||||
};
|
||||
service.DISABLE_REGISTRATION = true;
|
||||
};
|
||||
};
|
||||
users.users.gitea.uid = 1000;
|
||||
users.groups.gitea.gid = 1000;
|
||||
time.timeZone = "UTC";
|
||||
systemd.services.gitea.serviceConfig.FailureAction = "exit-force";
|
||||
};
|
||||
net.expose.tcp = [ 3000 ];
|
||||
};
|
||||
|
||||
ci-runner = mkVm {
|
||||
name = "ci-runner";
|
||||
net.access = [
|
||||
{
|
||||
to = "git";
|
||||
tcp = [ 3000 ];
|
||||
}
|
||||
{ to = "@internet"; }
|
||||
];
|
||||
svc.autoStart = true;
|
||||
resources.cpu = 4;
|
||||
resources.mem = "8G";
|
||||
resources.fs = [
|
||||
{
|
||||
proto = "virtiofs";
|
||||
tag = "nix-rw-store";
|
||||
source = "${ciRunnerData}/nix-rw-store";
|
||||
mountPoint = "/nix/.rw-store";
|
||||
}
|
||||
{
|
||||
proto = "virtiofs";
|
||||
tag = "docker-data";
|
||||
source = "${ciRunnerData}/docker";
|
||||
mountPoint = "/var/lib/docker";
|
||||
}
|
||||
{
|
||||
proto = "virtiofs";
|
||||
tag = "runner-state";
|
||||
source = "${ciRunnerData}/runner-state";
|
||||
mountPoint = "/var/lib/gitea-runner";
|
||||
}
|
||||
{
|
||||
proto = "virtiofs";
|
||||
tag = "runner-token";
|
||||
source = ciRunnerSecrets;
|
||||
mountPoint = "/run/ci-runner-secrets";
|
||||
readOnly = true;
|
||||
}
|
||||
];
|
||||
guestConfig =
|
||||
{
|
||||
pkgs,
|
||||
utils,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
runnerInstanceName = "ci-runner";
|
||||
|
||||
ciJobImage = pkgs.dockerTools.buildLayeredImage {
|
||||
name = "ci-job-image";
|
||||
tag = "latest";
|
||||
contents = with pkgs; [
|
||||
bashInteractive
|
||||
coreutils
|
||||
gitMinimal
|
||||
nixVersions.latest
|
||||
nodejs
|
||||
cacert
|
||||
];
|
||||
config = {
|
||||
Cmd = [ "/bin/sh" ];
|
||||
Env = [
|
||||
"SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
|
||||
"NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
|
||||
"NIX_REMOTE=daemon"
|
||||
];
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
boot.kernelModules = [ "kvm-intel" ];
|
||||
boot.extraModprobeConfig = "options kvm_intel nested=1";
|
||||
|
||||
microvm.writableStoreOverlay = "/nix/.rw-store";
|
||||
|
||||
nix.settings = {
|
||||
experimental-features = [
|
||||
"nix-command"
|
||||
"flakes"
|
||||
];
|
||||
max-jobs = "auto";
|
||||
};
|
||||
|
||||
nix.gc = {
|
||||
automatic = true;
|
||||
dates = "daily";
|
||||
options = "--delete-older-than 14d";
|
||||
};
|
||||
# nix.optimise.automatic = true; - doesn't work with microvm.writableStoreOverlay
|
||||
|
||||
virtualisation.docker.enable = true;
|
||||
|
||||
systemd.services.load-ci-job-image = {
|
||||
description = "Load the CI job Docker image";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "docker.service" ];
|
||||
requires = [ "docker.service" ];
|
||||
before = [ "gitea-runner-${utils.escapeSystemdPath runnerInstanceName}.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
ExecStart = "${lib.getExe' pkgs.docker "docker"} load -i ${ciJobImage}";
|
||||
};
|
||||
};
|
||||
|
||||
services.gitea-actions-runner.instances.${runnerInstanceName} = {
|
||||
enable = true;
|
||||
name = runnerInstanceName;
|
||||
url = "http://git:3000/";
|
||||
tokenFile = "/run/ci-runner-secrets/token";
|
||||
labels = [
|
||||
"nix:docker://ci-job-image:latest"
|
||||
];
|
||||
settings.container = {
|
||||
options = "--device=/dev/kvm --volume=/nix/store:/nix/store:ro --volume=/nix/var/nix/daemon-socket:/nix/var/nix/daemon-socket";
|
||||
valid_volumes = [
|
||||
"/nix/store"
|
||||
"/nix/var/nix/daemon-socket"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
name,
|
||||
pkgs,
|
||||
lib,
|
||||
dsl,
|
||||
containers,
|
||||
customPkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer mkService collectContainers;
|
||||
|
||||
mkHopperConfig =
|
||||
{ routes }:
|
||||
let
|
||||
header = ''
|
||||
listen = "0.0.0.0:25565"
|
||||
|
||||
[routing.routes]
|
||||
'';
|
||||
mkRoute = { domain, ip }: ''
|
||||
"${domain}" = { ip = "${ip}" }
|
||||
'';
|
||||
in
|
||||
header + lib.concatMapStrings mkRoute routes;
|
||||
|
||||
backends = collectContainers "hopperRegister" containers;
|
||||
|
||||
mcRoutes = map ({ container, data }: {
|
||||
domain = "${data.hopperSubdomain}.mc.floweytf.com";
|
||||
ip = "${container.name}:${toString data.hopperPort}";
|
||||
}) backends;
|
||||
|
||||
hopperCfg = pkgs.writeText "Config.toml" (mkHopperConfig {
|
||||
routes = mcRoutes;
|
||||
});
|
||||
in
|
||||
{
|
||||
${name} = mkContainer {
|
||||
inherit name;
|
||||
net.access = map ({ container, data }: {
|
||||
to = container.name;
|
||||
tcp = [ data.hopperPort ];
|
||||
}) backends;
|
||||
svc.autoStart = true;
|
||||
net.expose.tcp = [ 25565 ];
|
||||
net.forward.tcp = [ 25565 ];
|
||||
resources.fs."/Config.toml" = {
|
||||
hostPath = toString hopperCfg;
|
||||
isReadOnly = true;
|
||||
};
|
||||
service =
|
||||
_:
|
||||
mkService {
|
||||
description = "Hopper Minecraft reverse proxy";
|
||||
execStart = lib.getExe' customPkgs.hopper-rs "hopper";
|
||||
workingDir = "/";
|
||||
networkOnline = true;
|
||||
serviceConfig = {
|
||||
DynamicUser = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
CapabilityBoundingSet = "";
|
||||
RestrictAddressFamilies = "AF_INET AF_INET6";
|
||||
};
|
||||
};
|
||||
};
|
||||
_hopperRegister =
|
||||
{
|
||||
hopperSubdomain ? null,
|
||||
hopperPort ? 25565,
|
||||
}:
|
||||
self: {
|
||||
hopperRegister = {
|
||||
hopperSubdomain = if hopperSubdomain == null then self.name else hopperSubdomain;
|
||||
inherit hopperPort;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
{
|
||||
name,
|
||||
pkgs,
|
||||
lib,
|
||||
hostInfo,
|
||||
dsl,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer mkService;
|
||||
inherit (hostInfo.paths) data secrets;
|
||||
|
||||
domain = "mail.floweytf.com";
|
||||
accountName = "flowey";
|
||||
stateDir = "/var/lib/stalwart";
|
||||
acmeDir = "/etc/mail-acme";
|
||||
|
||||
taggingScript = ''
|
||||
require ["envelope", "variables", "imap4flags", "fileinto"];
|
||||
|
||||
if envelope :localpart :matches "to" "*" {
|
||||
addflag "''${1}";
|
||||
}
|
||||
|
||||
fileinto "INBOX";
|
||||
'';
|
||||
|
||||
cli = lib.getExe' pkgs.stalwart-cli "stalwart-cli";
|
||||
jq = lib.getExe pkgs.jq;
|
||||
curl = lib.getExe pkgs.curl;
|
||||
grep = lib.getExe' pkgs.gnugrep "grep";
|
||||
paste = lib.getExe' pkgs.coreutils "paste";
|
||||
|
||||
# use sqlite b/c rocksdb segfaults
|
||||
configJsonTemplate = pkgs.writeText "stalwart-config.json" (
|
||||
builtins.toJSON {
|
||||
"@type" = "Sqlite";
|
||||
path = "${stateDir}/data.sqlite3";
|
||||
}
|
||||
);
|
||||
|
||||
staticPlan = pkgs.writeText "mail-static-plan.json" (
|
||||
builtins.concatStringsSep "\n" (
|
||||
map builtins.toJSON [
|
||||
{
|
||||
"@type" = "upsert";
|
||||
object = "Domain";
|
||||
matchOn = [ "name" ];
|
||||
value."domain-mail" = {
|
||||
name = domain;
|
||||
catchAllAddress = "${accountName}@${domain}";
|
||||
isEnabled = true;
|
||||
};
|
||||
}
|
||||
{
|
||||
"@type" = "upsert";
|
||||
object = "SieveSystemScript";
|
||||
matchOn = [ "name" ];
|
||||
value."sieve-tag" = {
|
||||
name = "tag-by-recipient";
|
||||
isActive = true;
|
||||
contents = taggingScript;
|
||||
};
|
||||
}
|
||||
]
|
||||
)
|
||||
+ "\n"
|
||||
);
|
||||
|
||||
stalwartAuthEnv = ''
|
||||
export STALWART_URL="http://127.0.0.1:8080"
|
||||
export STALWART_USER="admin"
|
||||
export STALWART_PASSWORD="$(cat "$CREDENTIALS_DIRECTORY/admin_password")"
|
||||
'';
|
||||
|
||||
readyGate = pkgs.writeShellScript "mail-ready-gate" ''
|
||||
set -uo pipefail
|
||||
until ${curl} -sS -o /dev/null "http://127.0.0.1:8080/api/account"; do
|
||||
if ! systemctl -q is-active stalwart.service; then
|
||||
echo "mail: stalwart is not running" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
'';
|
||||
|
||||
bootstrapScript = pkgs.writeShellScript "mail-bootstrap" ''
|
||||
set -uo pipefail
|
||||
${stalwartAuthEnv}
|
||||
|
||||
out="$(${cli} update Bootstrap singleton \
|
||||
--field defaultDomain=${domain} \
|
||||
--field serverHostname=${domain} \
|
||||
--field generateDkimKeys=true \
|
||||
--field requestTlsCertificate=false \
|
||||
--field 'directory={"@type":"Internal"}' 2>&1)"
|
||||
status=$?
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "mail-bootstrap: first-time setup complete, restarting stalwart into normal mode" >&2
|
||||
systemctl restart stalwart.service
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ${grep} -qi 'bootstrap mode' <<<"$out"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "mail-bootstrap: $out" >&2
|
||||
exit 1
|
||||
'';
|
||||
|
||||
certCredCert = "/run/credentials/stalwart.service/mail_tls_cert";
|
||||
certCredKey = "/run/credentials/stalwart.service/mail_tls_key";
|
||||
|
||||
retryOnFailure = {
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
};
|
||||
|
||||
retryLimit = {
|
||||
StartLimitBurst = 5;
|
||||
StartLimitIntervalSec = 120;
|
||||
};
|
||||
|
||||
provisionScript = pkgs.writeShellScript "mail-provision" ''
|
||||
set -euo pipefail
|
||||
${stalwartAuthEnv}
|
||||
export account_pass="$(cat "$CREDENTIALS_DIRECTORY/account_password")"
|
||||
|
||||
account_entry="$(${jq} -nc --arg name "${accountName}" \
|
||||
'{"@type":"upsert","object":"Account","matchOn":["name"],"value":{("account-"+$name):{"@type":"User","name":$name,"domainId":"#domain-mail","credentials":{"0":{"@type":"Password","secret":env.account_pass}}}}}')"
|
||||
|
||||
{ cat ${staticPlan}; echo "$account_entry"; } | ${cli} apply --stdin
|
||||
|
||||
existing_cert_ids="$(${cli} query Certificate --json --fields id | ${jq} -r .id | ${paste} -sd, -)"
|
||||
if [ -n "$existing_cert_ids" ]; then
|
||||
${cli} delete Certificate --ids "$existing_cert_ids"
|
||||
fi
|
||||
${cli} create Certificate \
|
||||
--field 'certificate={"@type":"File","filePath":"${certCredCert}"}' \
|
||||
--field 'privateKey={"@type":"File","filePath":"${certCredKey}"}'
|
||||
|
||||
systemctl restart stalwart.service
|
||||
'';
|
||||
in
|
||||
mkContainer {
|
||||
inherit name;
|
||||
net.access = [ { to = "@internet"; } ];
|
||||
svc.autoStart = true;
|
||||
net.forward.tcp = [
|
||||
25
|
||||
993
|
||||
];
|
||||
resources.fs = {
|
||||
"${stateDir}" = {
|
||||
hostPath = "${data}/core/mail";
|
||||
isReadOnly = false;
|
||||
};
|
||||
"${acmeDir}" = {
|
||||
hostPath = "${data}/core/nginx/acme/${domain}";
|
||||
isReadOnly = true;
|
||||
};
|
||||
"/run/secrets/mail-admin-password" = {
|
||||
hostPath = "${secrets}/mail-admin-password";
|
||||
isReadOnly = true;
|
||||
};
|
||||
"/run/secrets/mail-account-password" = {
|
||||
hostPath = "${secrets}/mail-account-password";
|
||||
isReadOnly = true;
|
||||
};
|
||||
};
|
||||
users = [
|
||||
{
|
||||
name = "stalwart";
|
||||
uid = 1000;
|
||||
home = stateDir;
|
||||
}
|
||||
];
|
||||
containerConfig = _: {
|
||||
systemd.tmpfiles.rules = [
|
||||
"Z ${stateDir} 0750 stalwart stalwart -"
|
||||
"C ${stateDir}/config.json 0640 stalwart stalwart - ${configJsonTemplate}"
|
||||
];
|
||||
|
||||
systemd.services.stalwart = mkService {
|
||||
description = "Stalwart Mail Server";
|
||||
execStart = pkgs.writeShellScript "stalwart-run" ''
|
||||
set -euo pipefail
|
||||
export STALWART_RECOVERY_ADMIN="admin:$(cat "$CREDENTIALS_DIRECTORY/admin_password")"
|
||||
exec ${lib.getExe' pkgs.stalwart_0_16 "stalwart"} -c ${stateDir}/config.json
|
||||
'';
|
||||
serviceConfig = {
|
||||
User = "stalwart";
|
||||
Group = "stalwart";
|
||||
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
|
||||
ReadWritePaths = [ stateDir ];
|
||||
LoadCredential = [
|
||||
"admin_password:/run/secrets/mail-admin-password"
|
||||
"mail_tls_cert:${acmeDir}/fullchain.pem"
|
||||
"mail_tls_key:${acmeDir}/key.pem"
|
||||
];
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.paths.mail-cert-renew = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
pathConfig = {
|
||||
PathChanged = "${acmeDir}/key.pem";
|
||||
Unit = "mail-provision.service";
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.mail-ready = mkService {
|
||||
description = "Wait for the Stalwart management API";
|
||||
execStart = readyGate;
|
||||
after = [ "stalwart.service" ];
|
||||
wants = [ "stalwart.service" ];
|
||||
wantedBy = [ ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
TimeoutStartSec = 60;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.mail-bootstrap = mkService {
|
||||
description = "One-time Stalwart bootstrap (server identity + admin account)";
|
||||
execStart = bootstrapScript;
|
||||
after = [ "mail-ready.service" ];
|
||||
wants = [ "mail-ready.service" ];
|
||||
unitConfig = retryLimit;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
LoadCredential = [ "admin_password:/run/secrets/mail-admin-password" ];
|
||||
}
|
||||
// retryOnFailure;
|
||||
};
|
||||
|
||||
systemd.services.mail-provision = mkService {
|
||||
description = "Provision mail.floweytf.com domain + catch-all account in Stalwart";
|
||||
execStart = provisionScript;
|
||||
after = [
|
||||
"mail-ready.service"
|
||||
"mail-bootstrap.service"
|
||||
];
|
||||
wants = [
|
||||
"mail-ready.service"
|
||||
"mail-bootstrap.service"
|
||||
];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
LoadCredential = [
|
||||
"admin_password:/run/secrets/mail-admin-password"
|
||||
"account_password:/run/secrets/mail-account-password"
|
||||
];
|
||||
}
|
||||
// retryOnFailure;
|
||||
unitConfig = retryLimit;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
name,
|
||||
hostInfo,
|
||||
dsl,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer mkService;
|
||||
inherit (hostInfo.paths) data;
|
||||
in
|
||||
mkContainer {
|
||||
inherit name;
|
||||
svc.autoStart = true;
|
||||
resources.fs."/app/data" = {
|
||||
hostPath = "${data}/web/reposilite";
|
||||
isReadOnly = false;
|
||||
};
|
||||
users = [
|
||||
{
|
||||
name = "reposilite";
|
||||
uid = 1000;
|
||||
home = "/app";
|
||||
}
|
||||
];
|
||||
service =
|
||||
{ pkgs, lib, ... }:
|
||||
mkService {
|
||||
description = "Reposilite";
|
||||
execStart = "${lib.getExe' pkgs.reposilite "reposilite"} --working-directory /app/data";
|
||||
workingDir = "/app/data";
|
||||
serviceConfig = {
|
||||
User = "reposilite";
|
||||
PrivateTmp = true;
|
||||
ReadWritePaths = [ "/app/data" ];
|
||||
};
|
||||
}
|
||||
// {
|
||||
environment.JAVA_OPTS = "-Xmx128M";
|
||||
};
|
||||
net.expose.tcp = [ 8080 ];
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
hostInfo,
|
||||
dsl,
|
||||
self,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer mkService;
|
||||
inherit (hostInfo.paths) data;
|
||||
|
||||
mkMcTrait =
|
||||
{
|
||||
jdk,
|
||||
dataPath,
|
||||
extraPackages ? [ ],
|
||||
}:
|
||||
{
|
||||
resources.fs."/data" = {
|
||||
hostPath = dataPath;
|
||||
isReadOnly = false;
|
||||
};
|
||||
users = [
|
||||
{
|
||||
name = "runner";
|
||||
uid = 1000;
|
||||
}
|
||||
];
|
||||
containerConfig =
|
||||
{
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
{
|
||||
environment.systemPackages = [
|
||||
jdk
|
||||
pkgs.bash
|
||||
]
|
||||
++ extraPackages;
|
||||
systemd.services.minecraft = mkService {
|
||||
description = "Minecraft server";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [
|
||||
"local-fs.target"
|
||||
"container-ready.target"
|
||||
];
|
||||
wants = [ "container-ready.target" ];
|
||||
path = [
|
||||
jdk
|
||||
pkgs.bash
|
||||
];
|
||||
execStart = "${lib.getExe' pkgs.bash "bash"} -c ${
|
||||
lib.escapeShellArg (
|
||||
"exec ${lib.getExe' pkgs.dtach "dtach"} -N /run/ct-attach/attach.sock "
|
||||
+ "${lib.getExe' pkgs.util-linux "script"} -qfc /data/launch.sh "
|
||||
+ ">(exec ${lib.getExe' pkgs.systemd "systemd-cat"} -t minecraft)"
|
||||
)
|
||||
}";
|
||||
serviceConfig = {
|
||||
WorkingDirectory = "/data";
|
||||
User = "runner";
|
||||
RuntimeDirectory = "ct-attach";
|
||||
RuntimeDirectoryMode = "0700";
|
||||
StandardInput = "null";
|
||||
StandardOutput = "journal";
|
||||
StandardError = "journal";
|
||||
ReadWritePaths = [ "/data" ];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
extractAttrs =
|
||||
l: s: lib.mergeAttrsList (map (f: lib.optionalAttrs (s ? ${f}) { ${f} = s.${f}; }) l);
|
||||
|
||||
mkMcSet =
|
||||
{ dataDir }:
|
||||
servers:
|
||||
lib.listToAttrs (
|
||||
map (
|
||||
args@{
|
||||
name,
|
||||
jdk,
|
||||
autoStart ? false,
|
||||
allowInternet ? true,
|
||||
dataPath ? "${dataDir}/${name}",
|
||||
extraPackages ? [ ],
|
||||
mem ? "6G",
|
||||
cpu ? 2,
|
||||
...
|
||||
}:
|
||||
lib.nameValuePair "mc-${name}" (mkContainer {
|
||||
name = "mc-${name}";
|
||||
net.access = lib.optional allowInternet { to = "@internet"; };
|
||||
net.expose.tcp = [ (args.hopperPort or 25565) ];
|
||||
svc.autoStart = autoStart;
|
||||
resources = { inherit cpu mem; };
|
||||
traits = [ (mkMcTrait { inherit jdk dataPath extraPackages; }) ];
|
||||
miscProperties = self._hopperRegister (extractAttrs [ "hopperSubdomain" "hopperPort" ] args);
|
||||
includedInTests = false;
|
||||
})
|
||||
) servers
|
||||
);
|
||||
|
||||
jdk8FontPkgs = with pkgs; [
|
||||
freetype
|
||||
fontconfig
|
||||
dejavu_fonts
|
||||
];
|
||||
|
||||
mcServers = [
|
||||
{
|
||||
name = "nomifactory";
|
||||
jdk = pkgs.jdk8;
|
||||
extraPackages = jdk8FontPkgs;
|
||||
hopperSubdomain = "nomi";
|
||||
hopperPort = 25555;
|
||||
}
|
||||
{
|
||||
name = "monifactory";
|
||||
jdk = pkgs.jdk21;
|
||||
hopperSubdomain = "moni";
|
||||
}
|
||||
{
|
||||
name = "rad2";
|
||||
jdk = pkgs.jdk8;
|
||||
extraPackages = jdk8FontPkgs;
|
||||
}
|
||||
{
|
||||
name = "smp";
|
||||
jdk = pkgs.jdk17;
|
||||
}
|
||||
{
|
||||
name = "rc4";
|
||||
jdk = pkgs.jdk17;
|
||||
}
|
||||
{
|
||||
name = "vh";
|
||||
jdk = pkgs.jdk17;
|
||||
hopperPort = 25555;
|
||||
}
|
||||
{
|
||||
name = "gregblock";
|
||||
jdk = pkgs.jdk8;
|
||||
extraPackages = jdk8FontPkgs;
|
||||
}
|
||||
{
|
||||
name = "tfg";
|
||||
jdk = pkgs.jdk17;
|
||||
}
|
||||
{
|
||||
name = "ratgame";
|
||||
jdk = pkgs.jdk21;
|
||||
}
|
||||
{
|
||||
name = "maidrpg";
|
||||
jdk = pkgs.jdk17;
|
||||
}
|
||||
{
|
||||
name = "maidsmp";
|
||||
jdk = pkgs.jdk21;
|
||||
}
|
||||
{
|
||||
name = "maid-event";
|
||||
jdk = pkgs.jdk21;
|
||||
hopperSubdomain = "maidevent";
|
||||
dataPath = "${data}/mc/maidevent/speedrun";
|
||||
}
|
||||
{
|
||||
name = "gtnh";
|
||||
jdk = pkgs.jdk21;
|
||||
}
|
||||
{
|
||||
name = "turing";
|
||||
jdk = pkgs.jdk21;
|
||||
}
|
||||
{
|
||||
name = "tfg2";
|
||||
jdk = pkgs.jdk21;
|
||||
autoStart = true;
|
||||
mem = "10G";
|
||||
cpu = 8;
|
||||
}
|
||||
{
|
||||
name = "tl";
|
||||
jdk = pkgs.jdk21;
|
||||
}
|
||||
{
|
||||
name = "botm";
|
||||
jdk = pkgs.jdk17;
|
||||
}
|
||||
{
|
||||
name = "rogule-build";
|
||||
jdk = pkgs.jdk21;
|
||||
}
|
||||
{
|
||||
name = "rogule-dev";
|
||||
jdk = pkgs.jdk21;
|
||||
}
|
||||
];
|
||||
in
|
||||
mkMcSet {
|
||||
dataDir = "${data}/mc";
|
||||
} mcServers
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
name,
|
||||
pkgs,
|
||||
lib,
|
||||
hostInfo,
|
||||
dsl,
|
||||
inputs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer mkService;
|
||||
inherit (hostInfo.paths) data secrets;
|
||||
inherit (inputs) mtce;
|
||||
dataDir = "/var/lib/mtce-bot";
|
||||
|
||||
threads = 64;
|
||||
timeout = 1800;
|
||||
|
||||
configJson = pkgs.writeText "mtce-bot-config.json" (
|
||||
builtins.toJSON {
|
||||
inherit threads timeout;
|
||||
}
|
||||
);
|
||||
|
||||
mtce-engine = mtce.lib.mkMtce pkgs {
|
||||
cpuFlags = [
|
||||
"-O3"
|
||||
"-march=cascadelake"
|
||||
];
|
||||
vectorizedBitSize = 512;
|
||||
};
|
||||
mtce-bot = mtce.lib.mkBot pkgs;
|
||||
in
|
||||
mkContainer {
|
||||
inherit name;
|
||||
svc.autoStart = true;
|
||||
resources.fs = {
|
||||
"${dataDir}/data.json" = {
|
||||
hostPath = "${data}/misc/mtce-discord-bot/data.json";
|
||||
isReadOnly = false;
|
||||
};
|
||||
"${dataDir}/audit" = {
|
||||
hostPath = "${data}/misc/mtce-discord-bot/audit";
|
||||
isReadOnly = false;
|
||||
};
|
||||
"${dataDir}/config.json" = {
|
||||
hostPath = "${configJson}";
|
||||
isReadOnly = true;
|
||||
};
|
||||
"${dataDir}/mtce" = {
|
||||
hostPath = lib.getExe' mtce-engine "mtce";
|
||||
isReadOnly = true;
|
||||
};
|
||||
"/run/secrets/mtce-discord-bot-env" = {
|
||||
hostPath = "${secrets}/mtce-discord-bot-env";
|
||||
isReadOnly = true;
|
||||
};
|
||||
};
|
||||
users = [
|
||||
{
|
||||
name = "bot";
|
||||
uid = 1000;
|
||||
}
|
||||
];
|
||||
service =
|
||||
_:
|
||||
mkService {
|
||||
description = "Discord bot (monumenta-charm-eval)";
|
||||
execStart = lib.getExe' mtce-bot "mtce-bot";
|
||||
workingDir = dataDir;
|
||||
networkOnline = true;
|
||||
serviceConfig = {
|
||||
User = "bot";
|
||||
EnvironmentFile = "/run/secrets/mtce-discord-bot-env";
|
||||
PrivateTmp = true;
|
||||
CapabilityBoundingSet = "";
|
||||
ReadWritePaths = [ dataDir ];
|
||||
};
|
||||
};
|
||||
resources.mem = "2G";
|
||||
resources.cpu = 64;
|
||||
net.access = [ { to = "@internet"; } ];
|
||||
includedInTests = false;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
{
|
||||
name,
|
||||
hostInfo,
|
||||
dsl,
|
||||
lib,
|
||||
containers,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer;
|
||||
inherit (hostInfo.paths) data;
|
||||
|
||||
mkNginxConfig =
|
||||
{
|
||||
email,
|
||||
vhosts,
|
||||
resolver,
|
||||
}:
|
||||
let
|
||||
mkVhostEntries =
|
||||
{
|
||||
domain,
|
||||
upstream ? null,
|
||||
websockets ? true,
|
||||
redirect ? true,
|
||||
listenHttp ? false,
|
||||
extraLocations ? { },
|
||||
}:
|
||||
let
|
||||
httpsListen = {
|
||||
addr = "0.0.0.0";
|
||||
port = 443;
|
||||
ssl = true;
|
||||
};
|
||||
httpListen = {
|
||||
addr = "0.0.0.0";
|
||||
port = 80;
|
||||
ssl = false;
|
||||
};
|
||||
defaultLoc = lib.optionalAttrs (upstream != null) {
|
||||
"/" = {
|
||||
proxyWebsockets = websockets;
|
||||
# avoid startup error when containers aren't registered yet
|
||||
# proxyPass is left unset for that reason, so recommendedProxySettings
|
||||
# won't auto-include its headers here - set them manually to match
|
||||
extraConfig = ''
|
||||
set $backend "${upstream.container.name}:${toString upstream.port}";
|
||||
proxy_pass http://$backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Server $hostname;
|
||||
'';
|
||||
};
|
||||
};
|
||||
acmePrimaryOnMain = !redirect && listenHttp;
|
||||
mainVhost = lib.nameValuePair domain (
|
||||
{
|
||||
listen = [ httpsListen ] ++ lib.optional listenHttp httpListen;
|
||||
locations = defaultLoc // extraLocations;
|
||||
}
|
||||
// (
|
||||
if acmePrimaryOnMain then
|
||||
{
|
||||
addSSL = true;
|
||||
enableACME = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
onlySSL = true;
|
||||
useACMEHost = domain;
|
||||
}
|
||||
)
|
||||
);
|
||||
redirectVhost = lib.nameValuePair "${domain}-redirect" {
|
||||
serverName = domain;
|
||||
enableACME = true;
|
||||
listen = [ httpListen ];
|
||||
locations = {
|
||||
"/" = {
|
||||
return = "301 https://$host$request_uri";
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
[ mainVhost ] ++ lib.optional redirect redirectVhost;
|
||||
in
|
||||
{
|
||||
access = map (v: {
|
||||
to = v.upstream.container.name;
|
||||
tcp = [ v.upstream.port ];
|
||||
}) (lib.filter (v: v.upstream or null != null) vhosts);
|
||||
|
||||
containerConfig =
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
{
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
recommendedTlsSettings = true;
|
||||
recommendedOptimisation = true;
|
||||
recommendedGzipSettings = true;
|
||||
recommendedProxySettings = true;
|
||||
clientMaxBodySize = "100M";
|
||||
appendHttpConfig = ''
|
||||
access_log syslog:server=unix:/dev/log,tag=nginx combined;
|
||||
'';
|
||||
resolver = {
|
||||
addresses = [ resolver ];
|
||||
ipv6 = false;
|
||||
valid = "30s";
|
||||
};
|
||||
virtualHosts = lib.listToAttrs (lib.concatLists (map mkVhostEntries vhosts)) // {
|
||||
"_" = {
|
||||
listen = [
|
||||
{
|
||||
addr = "0.0.0.0";
|
||||
port = 443;
|
||||
extraParameters = [
|
||||
"ssl"
|
||||
"default_server"
|
||||
];
|
||||
}
|
||||
{
|
||||
addr = "0.0.0.0";
|
||||
port = 80;
|
||||
extraParameters = [ "default_server" ];
|
||||
}
|
||||
];
|
||||
extraConfig = "ssl_reject_handshake on;";
|
||||
locations."/".return = "404";
|
||||
};
|
||||
};
|
||||
};
|
||||
security.acme = {
|
||||
acceptTerms = true;
|
||||
defaults.email = email;
|
||||
};
|
||||
systemd.services =
|
||||
lib.mapAttrs' (
|
||||
cert: _:
|
||||
lib.nameValuePair "acme-${cert}" {
|
||||
after = [ "container-ready.target" ];
|
||||
wants = [ "container-ready.target" ];
|
||||
}
|
||||
) config.security.acme.certs
|
||||
// {
|
||||
nginx.serviceConfig.FailureAction = "exit-force";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
up = container: port: { inherit container port; };
|
||||
|
||||
nginxSetup =
|
||||
hostAddr:
|
||||
mkNginxConfig {
|
||||
email = "floweydev@gmail.com";
|
||||
resolver = hostAddr;
|
||||
vhosts = [
|
||||
{
|
||||
domain = "devenv.floweytf.com";
|
||||
upstream = up containers.devenv 8080;
|
||||
}
|
||||
{
|
||||
domain = "maven.floweytf.com";
|
||||
upstream = up containers.maven 8080;
|
||||
}
|
||||
{
|
||||
domain = "git.floweytf.com";
|
||||
upstream = up containers.git 3000;
|
||||
}
|
||||
{
|
||||
domain = "utils.floweytf.com";
|
||||
redirect = false;
|
||||
listenHttp = true;
|
||||
extraLocations = {
|
||||
"/static/" = {
|
||||
alias = "/usr/share/nginx/static/";
|
||||
extraConfig = "autoindex on;";
|
||||
};
|
||||
};
|
||||
}
|
||||
{
|
||||
# no upstream, vhost only exists for certs
|
||||
domain = "mail.floweytf.com";
|
||||
}
|
||||
];
|
||||
};
|
||||
in
|
||||
let
|
||||
setup = nginxSetup hostAddr;
|
||||
c = mkContainer {
|
||||
inherit name;
|
||||
net.access = setup.access ++ [ { to = "@internet"; } ];
|
||||
svc.autoStart = true;
|
||||
svc.depends = lib.attrVals [ "devenv" "maven" "git" ] containers;
|
||||
net.forward.tcp = [
|
||||
80
|
||||
443
|
||||
];
|
||||
resources.fs."/var/lib/acme" = {
|
||||
hostPath = "${data}/core/nginx/acme";
|
||||
isReadOnly = false;
|
||||
};
|
||||
inherit (setup) containerConfig;
|
||||
};
|
||||
hostAddr = c.hostAddr;
|
||||
in
|
||||
c
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
name,
|
||||
hostInfo,
|
||||
dsl,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer;
|
||||
inherit (hostInfo.paths) data;
|
||||
in
|
||||
mkContainer {
|
||||
inherit name;
|
||||
net.access = [ { to = "@internet"; } ];
|
||||
svc.autoStart = false;
|
||||
net.expose.tcp = [ 53 ];
|
||||
# TODO: figure out how to have this be the external dns resolver only
|
||||
resources.fs."/var/lib/pihole" = {
|
||||
hostPath = "${data}/misc/pihole";
|
||||
isReadOnly = false;
|
||||
};
|
||||
containerConfig = _: {
|
||||
services.pihole-ftl = {
|
||||
enable = true;
|
||||
stateDirectory = "/var/lib/pihole";
|
||||
openFirewallDNS = true;
|
||||
settings.dns.upstreams = [
|
||||
"1.1.1.1"
|
||||
"8.8.8.8"
|
||||
];
|
||||
lists = [
|
||||
{
|
||||
url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts";
|
||||
type = "block";
|
||||
description = "StevenBlack unified hosts";
|
||||
enabled = true;
|
||||
}
|
||||
{
|
||||
url = "https://big.oisd.nl/domainswild";
|
||||
type = "block";
|
||||
description = "OISD Big";
|
||||
enabled = true;
|
||||
}
|
||||
{
|
||||
url = "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt";
|
||||
type = "block";
|
||||
description = "HaGeZi Multi Pro";
|
||||
enabled = true;
|
||||
}
|
||||
{
|
||||
url = "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/tif.txt";
|
||||
type = "block";
|
||||
description = "HaGeZi Threat Intelligence Feeds";
|
||||
enabled = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
services.pihole-web = {
|
||||
enable = true;
|
||||
ports = [ "443s" ];
|
||||
};
|
||||
systemd.services.pihole-ftl.serviceConfig.FailureAction = "exit-force";
|
||||
};
|
||||
resources.mem = "512M";
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
pkgs,
|
||||
hostInfo,
|
||||
dsl,
|
||||
self,
|
||||
lib,
|
||||
customPkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer;
|
||||
inherit (hostInfo.paths) server data;
|
||||
|
||||
sshTrait = {
|
||||
resources.fs."/etc/ssh/authorized_keys" = {
|
||||
hostPath = "${server}/keypair/id_ed25519.pub";
|
||||
isReadOnly = true;
|
||||
};
|
||||
users = [
|
||||
{
|
||||
name = "runner";
|
||||
uid = 1000;
|
||||
}
|
||||
];
|
||||
containerConfig =
|
||||
{
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
lib.mkMerge [
|
||||
{
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
authorizedKeysFiles = [ "/etc/ssh/authorized_keys" ];
|
||||
settings = {
|
||||
PasswordAuthentication = false;
|
||||
UseDns = false;
|
||||
PrintMotd = false;
|
||||
};
|
||||
};
|
||||
systemd.services.sshd.serviceConfig.FailureAction = "exit-force";
|
||||
}
|
||||
{
|
||||
environment.systemPackages = [
|
||||
customPkgs.kitty-terminfo
|
||||
pkgs.wezterm.terminfo
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
mkSsh =
|
||||
{
|
||||
name,
|
||||
packages ? [ ],
|
||||
nixDaemon ? true,
|
||||
routingInfo ? {
|
||||
username = lib.strings.removePrefix "ssh-" name;
|
||||
},
|
||||
net ? { },
|
||||
resources ? { },
|
||||
traits ? [ ],
|
||||
}:
|
||||
mkContainer {
|
||||
inherit name;
|
||||
inherit net resources;
|
||||
svc.autoStart = true;
|
||||
isolation.nixDaemon = nixDaemon;
|
||||
traits = [ sshTrait ] ++ traits;
|
||||
packages = with pkgs; [ tmux ] ++ packages;
|
||||
miscProperties = self._sshpiperRegister routingInfo;
|
||||
};
|
||||
in
|
||||
{
|
||||
ssh-admin = mkSsh {
|
||||
name = "ssh-admin";
|
||||
net.access = [
|
||||
{
|
||||
to = "overseer";
|
||||
tcp = [ 80 ];
|
||||
}
|
||||
];
|
||||
resources.fs = {
|
||||
"/home/runner/data" = {
|
||||
hostPath = server;
|
||||
isReadOnly = false;
|
||||
};
|
||||
"/run/dbus/system_bus_socket" = {
|
||||
hostPath = "/run/dbus/system_bus_socket";
|
||||
isReadOnly = false;
|
||||
};
|
||||
};
|
||||
packages = with pkgs; [
|
||||
git
|
||||
systemd
|
||||
];
|
||||
};
|
||||
|
||||
ssh-botm = mkSsh {
|
||||
name = "ssh-botm";
|
||||
resources.fs."/home/runner/botm" = {
|
||||
hostPath = "${data}/mc/botm";
|
||||
isReadOnly = false;
|
||||
};
|
||||
packages = with pkgs; [
|
||||
python3
|
||||
jdk17
|
||||
tmux
|
||||
];
|
||||
};
|
||||
|
||||
ssh-generic = mkSsh {
|
||||
name = "ssh-generic";
|
||||
net.access = [ { to = "@internet"; } ];
|
||||
resources = {
|
||||
fs."/home/runner/data" = {
|
||||
hostPath = "${data}/ssh/generic";
|
||||
isReadOnly = false;
|
||||
};
|
||||
mem = "16G";
|
||||
cpu = 65;
|
||||
};
|
||||
routingInfo = [
|
||||
{
|
||||
username = "autumn";
|
||||
keysName = "generic";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
ssh-proxy = mkSsh {
|
||||
name = "ssh-proxy";
|
||||
resources.fs."/home/runner/data" = {
|
||||
hostPath = "${data}/ssh/nyaa";
|
||||
isReadOnly = true;
|
||||
};
|
||||
packages = with pkgs; [ python3 ];
|
||||
routingInfo = [
|
||||
{
|
||||
username = "nyaa";
|
||||
keysName = "nyaa";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
ssh-rogule = mkSsh {
|
||||
name = "ssh-rogule";
|
||||
resources.fs = {
|
||||
"/home/runner/rogule-dev" = {
|
||||
hostPath = "${data}/mc/rogule-dev";
|
||||
isReadOnly = false;
|
||||
};
|
||||
"/home/runner/rogule-build" = {
|
||||
hostPath = "${data}/mc/rogule-build";
|
||||
isReadOnly = false;
|
||||
};
|
||||
};
|
||||
packages = with pkgs; [
|
||||
python3
|
||||
jdk21
|
||||
];
|
||||
};
|
||||
|
||||
ssh-starwalker = mkSsh {
|
||||
name = "ssh-starwalker";
|
||||
resources.fs."/home/runner/starwalker" = {
|
||||
hostPath = "${data}/mc/starwalker";
|
||||
isReadOnly = false;
|
||||
};
|
||||
packages = with pkgs; [
|
||||
jdk21
|
||||
];
|
||||
};
|
||||
|
||||
ssh-tl = mkSsh {
|
||||
name = "ssh-tl";
|
||||
resources.fs."/home/runner/tl" = {
|
||||
hostPath = "${data}/mc/tl";
|
||||
isReadOnly = false;
|
||||
};
|
||||
packages = with pkgs; [
|
||||
jdk21
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
name,
|
||||
pkgs,
|
||||
hostInfo,
|
||||
dsl,
|
||||
lib,
|
||||
containers,
|
||||
customPkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
inherit (dsl) mkContainer mkService collectContainers;
|
||||
inherit (hostInfo.paths) server secrets;
|
||||
dataDir = hostInfo.paths.data;
|
||||
|
||||
mkSshpiperConfig =
|
||||
{
|
||||
pipes,
|
||||
privateKey ? "/common_id",
|
||||
}:
|
||||
let
|
||||
header = ''
|
||||
version: "1.0"
|
||||
pipes:
|
||||
'';
|
||||
mkPipe =
|
||||
{
|
||||
username,
|
||||
host,
|
||||
keysName ? username,
|
||||
}:
|
||||
''
|
||||
- from:
|
||||
- username: "${username}"
|
||||
authorized_keys: /etc/sshpiperd/keys/user_${keysName}/authorized_keys
|
||||
to:
|
||||
host: ${host}:22
|
||||
username: "runner"
|
||||
private_key: "${privateKey}"
|
||||
ignore_hostkey: true
|
||||
'';
|
||||
in
|
||||
header + lib.concatMapStrings mkPipe pipes;
|
||||
|
||||
sshPipes = lib.concatMap (
|
||||
{ container, data }: map (lib.mergeAttrs { host = container.name; }) data
|
||||
) (collectContainers "sshpiperRoutes" containers);
|
||||
|
||||
sshpiperYaml = pkgs.writeText "sshpiper.yaml" (mkSshpiperConfig {
|
||||
pipes = sshPipes;
|
||||
});
|
||||
in
|
||||
{
|
||||
${name} = mkContainer {
|
||||
inherit name;
|
||||
net.access = map (p: {
|
||||
to = p.host;
|
||||
tcp = [ 22 ];
|
||||
}) sshPipes;
|
||||
svc.autoStart = true;
|
||||
net.forward.tcp = [
|
||||
{
|
||||
host = 22;
|
||||
container = 2222;
|
||||
}
|
||||
];
|
||||
resources.fs = {
|
||||
"/etc/sshpiperd/keys" = {
|
||||
hostPath = "${dataDir}/core/sshpiper";
|
||||
isReadOnly = true;
|
||||
};
|
||||
"/common_id" = {
|
||||
hostPath = "${server}/keypair/id_ed25519";
|
||||
isReadOnly = true;
|
||||
};
|
||||
"/etc/ssh/ssh_host_ed25519_key" = {
|
||||
hostPath = "${secrets}/ssh_host_ed25519_key";
|
||||
isReadOnly = true;
|
||||
};
|
||||
"/etc/ssh/ssh_host_rsa_key" = {
|
||||
hostPath = "${secrets}/ssh_host_rsa_key";
|
||||
isReadOnly = true;
|
||||
};
|
||||
"/etc/ssh/ssh_host_ecdsa_key" = {
|
||||
hostPath = "${secrets}/ssh_host_ecdsa_key";
|
||||
isReadOnly = true;
|
||||
};
|
||||
};
|
||||
service =
|
||||
{ pkgs, ... }:
|
||||
mkService {
|
||||
description = "sshpiper SSH multiplexer";
|
||||
execStart = "${lib.getExe' customPkgs.sshpiper "sshpiperd"} --log-level trace --server-key '/etc/ssh/ssh_host_*_key' ${lib.getExe' customPkgs.sshpiper "yaml"} --config /run/sshpiperd/config.yaml";
|
||||
networkOnline = true;
|
||||
serviceConfig = {
|
||||
# sshpiperd rejects world-readable configs; copy store (444) -> runtime (600)
|
||||
ExecStartPre = "${lib.getExe' pkgs.coreutils "install"} -m 600 ${sshpiperYaml} /run/sshpiperd/config.yaml";
|
||||
RuntimeDirectory = "sshpiperd";
|
||||
PrivateTmp = true;
|
||||
RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
_sshpiperRegister =
|
||||
let
|
||||
doTranslate =
|
||||
self:
|
||||
{
|
||||
username,
|
||||
keysName ? username,
|
||||
}:
|
||||
{
|
||||
inherit username keysName;
|
||||
host = self.name;
|
||||
};
|
||||
in
|
||||
args: self: {
|
||||
sshpiperRoutes =
|
||||
if lib.isAttrs args then [ (doTranslate self args) ] else lib.map (doTranslate self) args;
|
||||
};
|
||||
}
|
||||
Generated
+121
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"microvm": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"spectrum": "spectrum"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1786300091,
|
||||
"narHash": "sha256-4UFJOVGpaYtHW5yasSv80MaJxTBYdk2zyf3jhKtt0wA=",
|
||||
"owner": "microvm-nix",
|
||||
"repo": "microvm.nix",
|
||||
"rev": "71beea0076cd46dafcee97a5a2e7d00cbba5bd2f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "microvm-nix",
|
||||
"repo": "microvm.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"mtce": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1782711974,
|
||||
"narHash": "sha256-ju0/qU6ZGqRxc5yDhuljrprG0BSPgguc8Kz3txktSjI=",
|
||||
"owner": "Floweynt",
|
||||
"repo": "monumenta-charm-eval",
|
||||
"rev": "1aa8b532975679787f7c5bed180fd7fc0da6b905",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "Floweynt",
|
||||
"ref": "master",
|
||||
"repo": "monumenta-charm-eval",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1786384358,
|
||||
"narHash": "sha256-RzPPiWeUtuvymnpuEWsdtzli5w4kjZs49FqEs3/1u+I=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "2fcb964de67fcf60b43471c55d5d99e61a9ccb5a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"microvm": "microvm",
|
||||
"mtce": "mtce",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"spectrum": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1785761586,
|
||||
"narHash": "sha256-MWOMVqJJERwjQGgRIv8d6rlEBqbLM3VZWxHkNKIIZNw=",
|
||||
"ref": "refs/heads/main",
|
||||
"rev": "a7762d6f54b40560dd5255ce902e6e6a5d980fe9",
|
||||
"revCount": 1416,
|
||||
"type": "git",
|
||||
"url": "https://spectrum-os.org/git/spectrum"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://spectrum-os.org/git/spectrum"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
description = "Server infrastructure";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
mtce.url = "github:Floweynt/monumenta-charm-eval/master";
|
||||
mtce.inputs.nixpkgs.follows = "nixpkgs";
|
||||
microvm.url = "github:microvm-nix/microvm.nix";
|
||||
microvm.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
nixpkgs,
|
||||
mtce,
|
||||
microvm,
|
||||
...
|
||||
}:
|
||||
let
|
||||
system = "x86_64-linux";
|
||||
inherit (nixpkgs) lib;
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
customPkgs = {
|
||||
hopper-rs = pkgs.callPackage ./packages/hopper-rs.nix { };
|
||||
sshpiper = pkgs.callPackage ./packages/sshpiper.nix { };
|
||||
kitty-terminfo = pkgs.callPackage ./packages/kitty-terminfo.nix { };
|
||||
ct = pkgs.callPackage ./packages/ct.nix { };
|
||||
server-rebuild = pkgs.callPackage ./packages/server-rebuild.nix { };
|
||||
server-status = pkgs.callPackage ./packages/server-status.nix { };
|
||||
server-kexec = pkgs.callPackage ./packages/server-kexec.nix { };
|
||||
};
|
||||
|
||||
ci = import ./ci.nix { inherit lib pkgs; };
|
||||
|
||||
inputs = { inherit mtce microvm; };
|
||||
|
||||
dsl = import ./dsl.nix { inherit lib microvm; };
|
||||
introspect = import ./introspect.nix { inherit lib dsl; };
|
||||
|
||||
hostInfoFor = hostName: {
|
||||
hostname = hostName;
|
||||
mainGateway = "10.0.0.1";
|
||||
isVm = false;
|
||||
paths = {
|
||||
server = "/persist/@containers";
|
||||
data = "/persist/@containers/data";
|
||||
secrets = "/persist/@secrets/";
|
||||
};
|
||||
};
|
||||
|
||||
specialArgsFor = hostName: {
|
||||
hostInfo = hostInfoFor hostName;
|
||||
inherit inputs customPkgs;
|
||||
};
|
||||
|
||||
hostDefFor =
|
||||
hostName:
|
||||
import (./hosts + "/${hostName}.nix") {
|
||||
inherit lib;
|
||||
hostInfo = hostInfoFor hostName;
|
||||
};
|
||||
|
||||
commonModule = import ./configuration.nix;
|
||||
|
||||
prepareHost =
|
||||
hostName:
|
||||
let
|
||||
hostDef = hostDefFor hostName;
|
||||
containerDirs = [
|
||||
./containers/common
|
||||
(./containers + "/${hostName}")
|
||||
];
|
||||
specialArgs = specialArgsFor hostName;
|
||||
ctx = specialArgs // {
|
||||
inherit pkgs lib;
|
||||
};
|
||||
containers = dsl.loadContainers {
|
||||
inherit
|
||||
hostName
|
||||
containerDirs
|
||||
ctx
|
||||
;
|
||||
};
|
||||
|
||||
self = {
|
||||
inherit
|
||||
hostName
|
||||
hostDef
|
||||
containerDirs
|
||||
specialArgs
|
||||
ctx
|
||||
containers
|
||||
;
|
||||
};
|
||||
|
||||
hostCfg = lib.nixosSystem {
|
||||
inherit system specialArgs;
|
||||
modules = [
|
||||
commonModule
|
||||
microvm.nixosModules.host
|
||||
hostDef.module
|
||||
(dsl.mkHostFromContainers { inherit containers ctx; })
|
||||
];
|
||||
};
|
||||
|
||||
testDir = ./tests + "/${hostName}";
|
||||
|
||||
tests =
|
||||
if builtins.pathExists testDir then
|
||||
lib.mapAttrs' (testName: testCfg: lib.nameValuePair "${testName}-${hostName}" testCfg) (
|
||||
dsl.listMap testDir (
|
||||
_name: path:
|
||||
pkgs.testers.nixosTest (
|
||||
import path {
|
||||
inherit lib dsl commonModule;
|
||||
host = self;
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
else
|
||||
{ };
|
||||
in
|
||||
self // { inherit hostCfg tests; };
|
||||
|
||||
allHosts = lib.genAttrs (lib.attrNames (dsl.listMap ./hosts (hostName: _: hostName))) prepareHost;
|
||||
in
|
||||
{
|
||||
nixosConfigurations = lib.mapAttrs (_: host: host.hostCfg) allHosts;
|
||||
|
||||
inspect = lib.mapAttrs
|
||||
(name: host:
|
||||
pkgs.writeText "inspect-${name}.json"
|
||||
(builtins.toJSON (introspect.hostReport { inherit host; })))
|
||||
allHosts;
|
||||
|
||||
packages.${system} = customPkgs // {
|
||||
inherit ci;
|
||||
};
|
||||
|
||||
formatter.${system} = pkgs.nixfmt;
|
||||
|
||||
checks.${system} = lib.foldl' (acc: host: acc // host.tests) { } (lib.attrValues allHosts) // {
|
||||
inherit ci;
|
||||
inherit (customPkgs)
|
||||
ct
|
||||
server-rebuild
|
||||
server-status
|
||||
server-kexec
|
||||
;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
_: {
|
||||
# New host: copy this file to hosts/<name>.nix. Replace fileSystems/boot
|
||||
# below with the real hardware (see hosts/server-n0.nix for a bigger
|
||||
# example). Add a containers/<name>/ directory for its containers.
|
||||
|
||||
module =
|
||||
{ lib, hostInfo, ... }:
|
||||
{
|
||||
networking.hostName = hostInfo.hostname;
|
||||
|
||||
fileSystems."/" = {
|
||||
device = "/dev/disk/by-label/NIXOS_ROOT";
|
||||
fsType = "ext4";
|
||||
};
|
||||
|
||||
fileSystems."/persist" = {
|
||||
device = "/dev/disk/by-label/NIXOS_PERSIST";
|
||||
fsType = "ext4";
|
||||
};
|
||||
|
||||
fileSystems."/nix" = {
|
||||
device = "/dev/disk/by-label/NIXOS_NIX";
|
||||
fsType = "ext4";
|
||||
};
|
||||
|
||||
fileSystems."/var/log" = {
|
||||
device = "/dev/disk/by-label/NIXOS_VARLOG";
|
||||
fsType = "ext4";
|
||||
neededForBoot = true;
|
||||
};
|
||||
|
||||
boot.loader.grub.device = "nodev";
|
||||
boot.loader.grub.efiSupport = true;
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
|
||||
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
mkdir -p /mnt
|
||||
|
||||
echo "--- erasing system state begin ---"
|
||||
# We first mount the btrfs root to /mnt
|
||||
# so we can manipulate btrfs subvolumes.
|
||||
mount -o subvol=/ /dev/disk/by-uuid/5dfd3b74-2162-4167-825b-21259ca48820 /mnt
|
||||
|
||||
# back up the old root (important!!)
|
||||
BOOTID=$(date +"@boot-%Y_%m_%d-%H_%M_%S-%s")
|
||||
echo "backing up old root: /mnt/@root/ -> /mnt/@old-roots/$BOOTID..."
|
||||
echo "note that the timestamp is of *this* boot, not of the boot that created the old root!"
|
||||
mv /mnt/@root /mnt/@old-roots/$BOOTID
|
||||
btrfs property set -f -ts /mnt/@old-roots/$BOOTID ro true
|
||||
|
||||
echo "restoring blank /@root subvolume..."
|
||||
btrfs subvolume snapshot /mnt/@root-blank /mnt/@root
|
||||
|
||||
# Once we're done rolling back to a blank snapshot,
|
||||
# we can unmount /mnt and continue on the boot process.
|
||||
echo "--- erasing system state end ---"
|
||||
|
||||
umount /mnt
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
_: {
|
||||
module =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
modulesPath,
|
||||
hostInfo,
|
||||
...
|
||||
}:
|
||||
{
|
||||
imports = [
|
||||
(modulesPath + "/installer/scan/not-detected.nix")
|
||||
];
|
||||
|
||||
networking.hostName = hostInfo.hostname;
|
||||
|
||||
fileSystems."/" = {
|
||||
device = "/dev/disk/by-uuid/5dfd3b74-2162-4167-825b-21259ca48820";
|
||||
fsType = "btrfs";
|
||||
options = [
|
||||
"subvol=@root"
|
||||
"noatime"
|
||||
"compress=zstd"
|
||||
];
|
||||
};
|
||||
|
||||
fileSystems."/nix" = {
|
||||
device = "/dev/disk/by-uuid/5dfd3b74-2162-4167-825b-21259ca48820";
|
||||
fsType = "btrfs";
|
||||
options = [
|
||||
"subvol=@nix"
|
||||
"noatime"
|
||||
"compress=zstd"
|
||||
];
|
||||
};
|
||||
|
||||
fileSystems."/persist" = {
|
||||
device = "/dev/disk/by-uuid/5dfd3b74-2162-4167-825b-21259ca48820";
|
||||
fsType = "btrfs";
|
||||
options = [
|
||||
"subvol=@persist"
|
||||
"noatime"
|
||||
"compress=zstd"
|
||||
];
|
||||
};
|
||||
|
||||
fileSystems."/btrfs_root" = {
|
||||
device = "/dev/disk/by-uuid/5dfd3b74-2162-4167-825b-21259ca48820";
|
||||
fsType = "btrfs";
|
||||
options = [
|
||||
"noatime"
|
||||
"ro"
|
||||
"compress=zstd"
|
||||
];
|
||||
};
|
||||
|
||||
fileSystems."/var/log" = {
|
||||
device = "/dev/disk/by-uuid/5dfd3b74-2162-4167-825b-21259ca48820";
|
||||
fsType = "btrfs";
|
||||
options = [
|
||||
"subvol=@logs"
|
||||
"noatime"
|
||||
"compress=zstd"
|
||||
];
|
||||
neededForBoot = true;
|
||||
};
|
||||
|
||||
fileSystems."/boot" = {
|
||||
device = "/dev/disk/by-uuid/7155-EF89";
|
||||
fsType = "vfat";
|
||||
options = [
|
||||
"fmask=0022"
|
||||
"dmask=0022"
|
||||
];
|
||||
};
|
||||
|
||||
boot = {
|
||||
loader.limine.enable = true;
|
||||
|
||||
kernelPackages = pkgs.linuxPackages_latest;
|
||||
kernelModules = [ "kvm-intel" ];
|
||||
extraModprobeConfig = "options kvm_intel nested=1";
|
||||
extraModulePackages = [ ];
|
||||
supportedFilesystems = [ "btrfs" ];
|
||||
initrd = {
|
||||
compressorArgs = [
|
||||
"-22"
|
||||
"-T0"
|
||||
"--long"
|
||||
"--ultra"
|
||||
];
|
||||
availableKernelModules = [
|
||||
"nvme"
|
||||
"xhci_pci"
|
||||
"ahci"
|
||||
"usbhid"
|
||||
];
|
||||
kernelModules = [ ];
|
||||
systemd.enable = false;
|
||||
postDeviceCommands = lib.mkBefore (builtins.readFile ./server-n0-darling-erasure.sh);
|
||||
};
|
||||
};
|
||||
|
||||
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
|
||||
hardware.enableRedistributableFirmware = true;
|
||||
hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
|
||||
};
|
||||
}
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
{ lib, dsl }:
|
||||
let
|
||||
plain =
|
||||
v:
|
||||
if lib.isFunction v then
|
||||
null
|
||||
else if lib.isDerivation v then
|
||||
"${v}"
|
||||
else if lib.isList v then
|
||||
map plain v
|
||||
else if lib.isAttrs v then
|
||||
lib.mapAttrs (_: plain) v
|
||||
else if builtins.isPath v then
|
||||
toString v
|
||||
else
|
||||
v;
|
||||
|
||||
relations = [
|
||||
{
|
||||
field = "after";
|
||||
kind = "after";
|
||||
reverse = false;
|
||||
}
|
||||
{
|
||||
field = "before";
|
||||
kind = "after";
|
||||
reverse = true;
|
||||
}
|
||||
{
|
||||
field = "wants";
|
||||
kind = "wants";
|
||||
reverse = false;
|
||||
}
|
||||
{
|
||||
field = "wantedBy";
|
||||
kind = "wants";
|
||||
reverse = true;
|
||||
}
|
||||
{
|
||||
field = "requires";
|
||||
kind = "requires";
|
||||
reverse = false;
|
||||
}
|
||||
{
|
||||
field = "requiredBy";
|
||||
kind = "requires";
|
||||
reverse = true;
|
||||
}
|
||||
{
|
||||
field = "upholds";
|
||||
kind = "upholds";
|
||||
reverse = false;
|
||||
}
|
||||
{
|
||||
field = "upheldBy";
|
||||
kind = "upholds";
|
||||
reverse = true;
|
||||
}
|
||||
{
|
||||
field = "bindsTo";
|
||||
kind = "bindsTo";
|
||||
reverse = false;
|
||||
}
|
||||
{
|
||||
field = "partOf";
|
||||
kind = "partOf";
|
||||
reverse = false;
|
||||
}
|
||||
{
|
||||
field = "requisite";
|
||||
kind = "requisite";
|
||||
reverse = false;
|
||||
}
|
||||
{
|
||||
field = "conflicts";
|
||||
kind = "conflicts";
|
||||
reverse = false;
|
||||
}
|
||||
{
|
||||
field = "onFailure";
|
||||
kind = "onFailure";
|
||||
reverse = false;
|
||||
}
|
||||
{
|
||||
field = "onSuccess";
|
||||
kind = "onSuccess";
|
||||
reverse = false;
|
||||
}
|
||||
];
|
||||
|
||||
relationFields = map (r: r.field) relations;
|
||||
|
||||
# raw NixOS command options, mirrors every generated Exec* wrapper
|
||||
commandFields = [
|
||||
"script"
|
||||
"scriptArgs"
|
||||
"preStart"
|
||||
"postStart"
|
||||
"reload"
|
||||
"preStop"
|
||||
"postStop"
|
||||
];
|
||||
|
||||
allUnits =
|
||||
c:
|
||||
lib.concatMap lib.attrValues [
|
||||
c.systemd.paths
|
||||
c.systemd.services
|
||||
c.systemd.slices
|
||||
c.systemd.sockets
|
||||
c.systemd.targets
|
||||
c.systemd.timers
|
||||
]
|
||||
++ c.systemd.mounts
|
||||
++ c.systemd.automounts;
|
||||
|
||||
unitSummary =
|
||||
u:
|
||||
{
|
||||
inherit (u) name;
|
||||
description = u.description or "";
|
||||
enable = u.enable or true;
|
||||
unitConfig = u.unitConfig or { };
|
||||
}
|
||||
// lib.filterAttrs (_: v: v != [ ]) (lib.genAttrs relationFields (f: u.${f} or [ ]))
|
||||
// lib.optionalAttrs (u ? serviceConfig) (
|
||||
{
|
||||
inherit (u) serviceConfig;
|
||||
environment = u.environment or { };
|
||||
path = u.path or [ ];
|
||||
restartIfChanged = u.restartIfChanged or null;
|
||||
stopIfChanged = u.stopIfChanged or null;
|
||||
}
|
||||
# raw command text: the real logic behind the store-path Exec* wrappers
|
||||
// lib.filterAttrs (_: v: v != "") (lib.genAttrs commandFields (f: u.${f} or ""))
|
||||
)
|
||||
// lib.optionalAttrs (u ? timerConfig) { inherit (u) timerConfig; }
|
||||
// lib.optionalAttrs (u ? socketConfig) {
|
||||
inherit (u) socketConfig;
|
||||
listenStreams = u.listenStreams or [ ];
|
||||
listenDatagrams = u.listenDatagrams or [ ];
|
||||
}
|
||||
// lib.optionalAttrs (u ? pathConfig) { inherit (u) pathConfig; }
|
||||
// lib.optionalAttrs (u ? what) {
|
||||
inherit (u) what;
|
||||
where = u.where or "";
|
||||
type = u.type or "";
|
||||
options = u.options or "";
|
||||
};
|
||||
|
||||
unitEdges =
|
||||
node: u:
|
||||
let
|
||||
ref = n: "${node}:${n}";
|
||||
in
|
||||
lib.concatMap (
|
||||
r:
|
||||
map (
|
||||
peer:
|
||||
{
|
||||
inherit (r) kind;
|
||||
}
|
||||
// (
|
||||
if r.reverse then
|
||||
{
|
||||
from = ref peer;
|
||||
to = ref u.name;
|
||||
}
|
||||
else
|
||||
{
|
||||
from = ref u.name;
|
||||
to = ref peer;
|
||||
}
|
||||
)
|
||||
) (u.${r.field} or [ ])
|
||||
) relations;
|
||||
|
||||
fileSystemsOf =
|
||||
c:
|
||||
lib.mapAttrsToList (mountPoint: fs: {
|
||||
inherit mountPoint;
|
||||
inherit (fs)
|
||||
device
|
||||
fsType
|
||||
options
|
||||
neededForBoot
|
||||
depends
|
||||
;
|
||||
}) c.fileSystems;
|
||||
|
||||
firewallOf =
|
||||
c:
|
||||
let
|
||||
fw = c.networking.firewall;
|
||||
portsOf = f: {
|
||||
inherit (f)
|
||||
allowedTCPPorts
|
||||
allowedUDPPorts
|
||||
allowedTCPPortRanges
|
||||
allowedUDPPortRanges
|
||||
;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit (fw) enable trustedInterfaces;
|
||||
interfaces = lib.mapAttrs (_: portsOf) fw.interfaces;
|
||||
}
|
||||
// portsOf fw;
|
||||
|
||||
isBuildUser = n: builtins.match "nixbld[0-9]+" n != null;
|
||||
|
||||
usersOf = c: {
|
||||
inherit (c.users) mutableUsers;
|
||||
groups = lib.mapAttrs (_: g: {
|
||||
inherit (g) gid;
|
||||
members = lib.filter (m: !isBuildUser m) g.members;
|
||||
}) c.users.groups;
|
||||
users = lib.mapAttrs (_: u: {
|
||||
inherit (u)
|
||||
uid
|
||||
group
|
||||
home
|
||||
shell
|
||||
description
|
||||
createHome
|
||||
isNormalUser
|
||||
isSystemUser
|
||||
extraGroups
|
||||
;
|
||||
authorizedKeys = u.openssh.authorizedKeys.keys;
|
||||
passwordSet = lib.any (v: v != null) [
|
||||
u.hashedPassword
|
||||
u.hashedPasswordFile
|
||||
u.password
|
||||
u.initialPassword
|
||||
u.initialHashedPassword
|
||||
];
|
||||
}) (lib.filterAttrs (n: _: !isBuildUser n) c.users.users);
|
||||
};
|
||||
in
|
||||
{
|
||||
hostReport =
|
||||
{ host }:
|
||||
let
|
||||
cfg = host.hostCfg.config;
|
||||
deployments = host.containers;
|
||||
depList = lib.attrValues deployments;
|
||||
|
||||
configOf =
|
||||
c:
|
||||
if c.kind == "nspawn" then
|
||||
cfg.containers.${c.name}.config
|
||||
else
|
||||
cfg.microvm.vms.${c.name}.config.config;
|
||||
unitOf = c: "${dsl.serviceUnitName c}.service";
|
||||
|
||||
nodes = {
|
||||
"@host" = cfg;
|
||||
}
|
||||
// lib.mapAttrs (_: configOf) deployments;
|
||||
|
||||
forward =
|
||||
proto: c: p:
|
||||
let
|
||||
q = dsl.normalizePort p;
|
||||
in
|
||||
{
|
||||
inherit proto;
|
||||
hostPort = q.host;
|
||||
target = c.name;
|
||||
targetPort = q.container;
|
||||
targetAddr = c.addr;
|
||||
};
|
||||
|
||||
mountsOf =
|
||||
c:
|
||||
if c.kind == "nspawn" then
|
||||
lib.mapAttrsToList (mountPoint: bm: {
|
||||
inherit mountPoint;
|
||||
inherit (bm) hostPath isReadOnly;
|
||||
}) c.bindMounts
|
||||
else
|
||||
map (s: {
|
||||
mountPoint = s.mountPoint or null;
|
||||
hostPath = s.source;
|
||||
isReadOnly = s.readOnly or false;
|
||||
inherit (s) proto tag;
|
||||
}) c.shares;
|
||||
in
|
||||
plain {
|
||||
meta = {
|
||||
host = host.hostName;
|
||||
system = cfg.nixpkgs.hostPlatform.system;
|
||||
inherit (cfg.system) stateVersion;
|
||||
inherit (host.specialArgs.hostInfo) paths mainGateway;
|
||||
toplevel = cfg.system.build.toplevel;
|
||||
};
|
||||
|
||||
deployments = lib.mapAttrs (_: c: {
|
||||
inherit (c)
|
||||
name
|
||||
kind
|
||||
autoStart
|
||||
access
|
||||
allowInternet
|
||||
needsGateway
|
||||
includedInTests
|
||||
;
|
||||
# names only; c.depends holds whole deployment objects
|
||||
depends = map (d: d.name) c.depends;
|
||||
unit = unitOf c;
|
||||
limits =
|
||||
if c.kind == "nspawn" then
|
||||
{ inherit (c) memoryMax cpuQuota privateUsers; }
|
||||
else
|
||||
{ inherit (c) vcpu mem hypervisor; };
|
||||
inherit (c) miscProperties;
|
||||
}) deployments;
|
||||
|
||||
access = {
|
||||
specials = [
|
||||
"@host"
|
||||
"@internet"
|
||||
];
|
||||
edges = lib.concatMap (
|
||||
c:
|
||||
map (a: {
|
||||
from = c.name;
|
||||
inherit (a) to;
|
||||
tcp = a.tcp or [ ];
|
||||
udp = a.udp or [ ];
|
||||
}) c.access
|
||||
) depList;
|
||||
};
|
||||
|
||||
kernel = {
|
||||
sysctl = cfg.boot.kernel.sysctl;
|
||||
params = cfg.boot.kernelParams;
|
||||
modules = cfg.boot.kernelModules;
|
||||
};
|
||||
|
||||
network = {
|
||||
inherit (cfg.networking) nameservers;
|
||||
links = lib.mapAttrs (_: c: {
|
||||
inherit (c)
|
||||
kind
|
||||
iface
|
||||
mac
|
||||
addr
|
||||
hostAddr
|
||||
allowInternet
|
||||
needsGateway
|
||||
;
|
||||
}) deployments;
|
||||
hostInterfaces = lib.mapAttrs (_: i: { inherit (i) ipv4; }) cfg.networking.interfaces;
|
||||
dnsmasq = cfg.services.dnsmasq.settings;
|
||||
};
|
||||
|
||||
ports = {
|
||||
forwards = lib.concatMap (
|
||||
c: map (forward "tcp" c) c.hostPorts ++ map (forward "udp" c) c.hostUdpPorts
|
||||
) depList;
|
||||
declared = lib.mapAttrs (_: c: c.ports or [ ]) deployments;
|
||||
firewall = lib.mapAttrs (_: firewallOf) nodes;
|
||||
};
|
||||
|
||||
mounts = {
|
||||
fileSystems = lib.mapAttrs (_: fileSystemsOf) nodes;
|
||||
declared = lib.mapAttrs (_: mountsOf) deployments;
|
||||
};
|
||||
|
||||
users = lib.mapAttrs (_: usersOf) nodes;
|
||||
|
||||
units = lib.mapAttrs (
|
||||
_: c: lib.listToAttrs (map (u: lib.nameValuePair u.name (unitSummary u)) (allUnits c))
|
||||
) nodes;
|
||||
|
||||
dependencies = {
|
||||
edges =
|
||||
lib.concatLists (lib.mapAttrsToList (node: c: lib.concatMap (unitEdges node) (allUnits c)) nodes)
|
||||
++ map (c: {
|
||||
from = "@host:${unitOf c}";
|
||||
to = "${c.name}:multi-user.target";
|
||||
kind = "spawns";
|
||||
}) depList;
|
||||
};
|
||||
|
||||
nftables = {
|
||||
inherit (cfg.networking.nftables) enable ruleset;
|
||||
tables = lib.mapAttrs (n: t: {
|
||||
name = t.name or n;
|
||||
inherit (t) family enable content;
|
||||
}) cfg.networking.nftables.tables;
|
||||
rendered = builtins.readFile (
|
||||
builtins.elemAt cfg.systemd.services.nftables.serviceConfig.ExecStart 1
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DEV=/dev/disk/by-uuid/5dfd3b74-2162-4167-825b-21259ca48820
|
||||
EFI=/dev/disk/by-uuid/7155-EF89
|
||||
|
||||
mount -o subvol=@root "$DEV" /mnt
|
||||
mkdir -p /mnt/{nix,persist,var/log,btrfs_root,boot}
|
||||
mount -o subvol=@nix "$DEV" /mnt/nix
|
||||
mount -o subvol=@persist "$DEV" /mnt/persist
|
||||
mount -o subvol=@logs "$DEV" /mnt/var/log
|
||||
mount -o subvolid=5 "$DEV" /mnt/btrfs_root
|
||||
mount "$EFI" /mnt/boot
|
||||
|
||||
btrfs subvolume create /mnt/persist/@secrets
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
writeShellApplication,
|
||||
dtach,
|
||||
}:
|
||||
import ./mk-script.nix { inherit writeShellApplication; } {
|
||||
name = "ct";
|
||||
runtimeInputs = [ dtach ];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
diff --git a/src/config/router.rs b/src/config/router.rs
|
||||
index cfee4f8..9247cc3 100644
|
||||
--- a/src/config/router.rs
|
||||
+++ b/src/config/router.rs
|
||||
@@ -58,11 +58,10 @@ impl Router for RouterConfig {
|
||||
.or(self.default.as_ref())
|
||||
.ok_or(RouterError::NoServer)?;
|
||||
|
||||
- let address: SocketAddr = match route.ip {
|
||||
- RouteType::Simple(address) => address.into(),
|
||||
- RouteType::Balanced(ref list) => {
|
||||
- let hash = client.hash();
|
||||
- list.get(hash as usize)
|
||||
+ let address: SocketAddr = match &route.ip {
|
||||
+ RouteType::Simple(addr) => addr.resolve().ok_or(RouterError::Unavailable)?,
|
||||
+ RouteType::Balanced(list) => {
|
||||
+ list.get(client.hash() as usize).ok_or(RouterError::Unavailable)?
|
||||
}
|
||||
};
|
||||
|
||||
diff --git a/src/config/router/balancer.rs b/src/config/router/balancer.rs
|
||||
index 770a01b..b2e2491 100644
|
||||
--- a/src/config/router/balancer.rs
|
||||
+++ b/src/config/router/balancer.rs
|
||||
@@ -22,7 +22,7 @@ pub struct Balanced {
|
||||
// }
|
||||
|
||||
impl Balanced {
|
||||
- pub(super) fn get(&self, n: usize) -> SocketAddr {
|
||||
- self.servers[n & self.servers.len()].into()
|
||||
+ pub(super) fn get(&self, n: usize) -> Option<SocketAddr> {
|
||||
+ self.servers[n % self.servers.len()].resolve()
|
||||
}
|
||||
}
|
||||
diff --git a/src/config/router/resolver.rs b/src/config/router/resolver.rs
|
||||
index b66a16a..8fd3b0d 100644
|
||||
--- a/src/config/router/resolver.rs
|
||||
+++ b/src/config/router/resolver.rs
|
||||
@@ -2,26 +2,26 @@ use std::net::{SocketAddr, ToSocketAddrs};
|
||||
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
-#[derive(Deserialize, Debug, Clone, Copy)]
|
||||
-pub(super) struct ResolvableAddr(#[serde(deserialize_with = "resolve_hostname")] SocketAddr);
|
||||
+#[derive(Deserialize, Debug, Clone)]
|
||||
+pub(super) struct ResolvableAddr(#[serde(deserialize_with = "parse_addr_string")] String);
|
||||
|
||||
-fn resolve_hostname<'de, D>(deserializer: D) -> Result<SocketAddr, D::Error>
|
||||
+fn parse_addr_string<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
- let inner = <String>::deserialize(deserializer)?;
|
||||
-
|
||||
- let mut addr = inner
|
||||
- .to_socket_addrs()
|
||||
- .map_err(|err| Error::custom(format!("invalid hostname format: {err}")))?;
|
||||
-
|
||||
- addr.next().ok_or_else(|| Error::missing_field("address"))
|
||||
+ let s = String::deserialize(deserializer)?;
|
||||
+ if !s.contains(':') {
|
||||
+ return Err(Error::custom(format!(
|
||||
+ "invalid socket address syntax, expected host:port: {s}"
|
||||
+ )));
|
||||
+ }
|
||||
+ Ok(s)
|
||||
}
|
||||
|
||||
-impl From<ResolvableAddr> for SocketAddr {
|
||||
- fn from(addr: ResolvableAddr) -> Self {
|
||||
- addr.0
|
||||
+impl ResolvableAddr {
|
||||
+ pub fn resolve(&self) -> Option<SocketAddr> {
|
||||
+ self.0.to_socket_addrs().ok()?.next()
|
||||
}
|
||||
}
|
||||
diff --git a/src/server/router.rs b/src/server/router.rs
|
||||
index 4aae8e7..ad2849b 100644
|
||||
--- a/src/server/router.rs
|
||||
+++ b/src/server/router.rs
|
||||
@@ -7,6 +7,9 @@ use thiserror::Error;
|
||||
pub enum RouterError {
|
||||
#[error("no server with such hostname has been found")]
|
||||
NoServer,
|
||||
+
|
||||
+ #[error("backend server is currently unavailable")]
|
||||
+ Unavailable,
|
||||
}
|
||||
|
||||
// #[async_trait::async_trait]
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
lib,
|
||||
fetchFromGitHub,
|
||||
rustPlatform,
|
||||
}:
|
||||
rustPlatform.buildRustPackage {
|
||||
pname = "hopper-rs";
|
||||
version = "v0.6.2-2-g73cb171";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "bra1l0r";
|
||||
repo = "hopper-rs";
|
||||
rev = "73cb171e203cef6c9c444b69c43ceefa2da213e4";
|
||||
hash = "sha256-QhDH03KHKtsh7Ch44TE02sr42i/CPD9BGK7QQ6PwHKc=";
|
||||
};
|
||||
|
||||
patches = [ ./hopper-lazy-resolve.patch ];
|
||||
|
||||
cargoHash = "sha256-bC5BlP3opYYHkK/mauF9qdTczTdmGQD7yEOCOMylNOc=";
|
||||
|
||||
doCheck = false;
|
||||
|
||||
meta = with lib; {
|
||||
description = "Lightweight Minecraft reverse proxy";
|
||||
homepage = "https://github.com/bra1l0r/hopper-rs";
|
||||
license = licenses.gpl3Only;
|
||||
mainProgram = "hopper";
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
runCommand,
|
||||
ncurses,
|
||||
kitty,
|
||||
}:
|
||||
runCommand "kitty-terminfo" { nativeBuildInputs = [ ncurses ]; } ''
|
||||
mkdir -p $out/share/terminfo
|
||||
tic -x -o $out/share/terminfo ${kitty.src}/terminfo/kitty.terminfo
|
||||
''
|
||||
@@ -0,0 +1,9 @@
|
||||
{ writeShellApplication }:
|
||||
{
|
||||
name,
|
||||
runtimeInputs ? [ ],
|
||||
}:
|
||||
writeShellApplication {
|
||||
inherit name runtimeInputs;
|
||||
text = builtins.readFile (../scripts + "/${name}.sh");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
writeShellApplication,
|
||||
kexec-tools,
|
||||
}:
|
||||
import ./mk-script.nix { inherit writeShellApplication; } {
|
||||
name = "server-kexec";
|
||||
runtimeInputs = [ kexec-tools ];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{ writeShellApplication }:
|
||||
import ./mk-script.nix { inherit writeShellApplication; } { name = "server-rebuild"; }
|
||||
@@ -0,0 +1,2 @@
|
||||
{ writeShellApplication }:
|
||||
import ./mk-script.nix { inherit writeShellApplication; } { name = "server-status"; }
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
lib,
|
||||
buildGoModule,
|
||||
fetchFromGitHub,
|
||||
}:
|
||||
buildGoModule {
|
||||
pname = "sshpiper";
|
||||
version = "unstable-2024";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "tg123";
|
||||
repo = "sshpiper";
|
||||
rev = "e5c5b904cfa57f7d60e37cb38cdb0bbcaadb022e";
|
||||
hash = "sha256-8Lqf7pCcAWAgz7tclKiV0DyHhbaaBule6/X30Fzm8DY=";
|
||||
fetchSubmodules = true;
|
||||
};
|
||||
|
||||
vendorHash = "sha256-6FU1r6vY6AorbP5z+C3zJpHQzpqzkzK9iQFnxDmYv8c=";
|
||||
|
||||
subPackages = [
|
||||
"cmd/sshpiperd"
|
||||
"plugin/yaml"
|
||||
];
|
||||
|
||||
tags = [ "full" ];
|
||||
|
||||
meta = with lib; {
|
||||
description = "SSH reverse proxy / multiplexer";
|
||||
homepage = "https://github.com/tg123/sshpiper";
|
||||
license = licenses.mit;
|
||||
mainProgram = "sshpiperd";
|
||||
};
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
cmd=${1:-help}
|
||||
ct_name=${2:-}
|
||||
|
||||
require_name() {
|
||||
if [ -z "$ct_name" ]; then
|
||||
echo "usage: ct $cmd <container>" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
case "$cmd" in
|
||||
list)
|
||||
sep() { printf '%0.s-' {1..60}; printf '\n'; }
|
||||
uptime_str() {
|
||||
awk '{s=int($1);d=int(s/86400);h=int((s%86400)/3600);m=int((s%3600)/60);
|
||||
if(d>0)printf "%d days %d hours %d minutes",d,h,m;
|
||||
else if(h>0)printf "%d hours %d minutes",h,m;
|
||||
else printf "%d minutes",m}' /proc/uptime
|
||||
}
|
||||
|
||||
printf 'HOST: %s UP: %s\n' "$(hostname)" "$(uptime_str)"
|
||||
sep
|
||||
|
||||
printf 'CONTAINERS\n'
|
||||
mapfile -t all_units < <({
|
||||
systemctl list-unit-files --no-legend --no-pager --plain 2>/dev/null
|
||||
systemctl list-units --all --no-legend --no-pager --plain 2>/dev/null
|
||||
} | awk '$1 ~ /^container@.+\.service$/ {print $1}' | sort -u)
|
||||
|
||||
if [ ${#all_units[@]} -gt 0 ]; then
|
||||
systemctl show "${all_units[@]}" \
|
||||
--property=Id,UnitFileState,ActiveState --no-pager 2>/dev/null \
|
||||
| awk '
|
||||
/^Id=container@/ { n=$0; sub(/^Id=container@/,"",n); sub(/\.service$/,"",n) }
|
||||
/^UnitFileState=/ { f=$0; sub(/^UnitFileState=/,"",f) }
|
||||
/^ActiveState=/ { a=$0; sub(/^ActiveState=/,"",a) }
|
||||
/^[[:space:]]*$/ { if (n!="") { _print(); n=f=a="" } }
|
||||
END { if (n!="") _print() }
|
||||
function _print() {
|
||||
start = (f=="enabled") ? "auto" : "manual"
|
||||
if (a=="active") st="running"
|
||||
else if (a=="failed") st="FAILED"
|
||||
else if (a=="inactive") st="stopped"
|
||||
else st=(a!="") ? a : "not-loaded"
|
||||
mid = "-"
|
||||
if (a == "active") {
|
||||
midcmd = "machinectl show " n " -p Id --value 2>/dev/null"
|
||||
got = ""
|
||||
midcmd | getline got
|
||||
close(midcmd)
|
||||
if (got != "") mid = substr(got,1,12) "..."
|
||||
}
|
||||
printf " %-35s %-8s %-10s %s\n", n, start, st, mid
|
||||
}
|
||||
'
|
||||
else
|
||||
printf ' none\n'
|
||||
fi
|
||||
|
||||
sep
|
||||
|
||||
printf 'FAILED UNITS (host)\n'
|
||||
failed=$(systemctl list-units --state=failed --no-legend --no-pager --plain 2>/dev/null \
|
||||
| awk 'NF>0{print $1}')
|
||||
if [ -z "$failed" ]; then printf ' none\n'
|
||||
else printf '%s\n' "$failed" | sed 's/^/ /'; fi
|
||||
|
||||
sep
|
||||
|
||||
printf 'FAILED UNITS (containers)\n'
|
||||
any_failed=0
|
||||
machines=$(machinectl list --no-legend --no-pager 2>/dev/null | awk 'NF>0{print $1}')
|
||||
for m in $machines; do
|
||||
ct_failed=$(sudo systemctl -M "$m" list-units --state=failed \
|
||||
--no-legend --no-pager --plain 2>/dev/null \
|
||||
| awk 'NF>0 && $1!~/^container@/{print $1}' || true)
|
||||
if [ -n "$ct_failed" ]; then
|
||||
printf ' [%s]\n' "$m"
|
||||
printf '%s\n' "$ct_failed" | sed 's/^/ /'
|
||||
any_failed=1
|
||||
fi
|
||||
done
|
||||
[ "$any_failed" -eq 0 ] && printf ' none\n'
|
||||
|
||||
sep
|
||||
|
||||
df -h /persist | awk 'NR==2{printf "DISK %s used / %s total (%s)\n",$3,$2,$5}'
|
||||
free -h | awk '/^Mem:/{printf "MEM %s used / %s total\n",$3,$2}'
|
||||
;;
|
||||
start)
|
||||
require_name
|
||||
sudo systemctl start "container@$ct_name"
|
||||
;;
|
||||
stop)
|
||||
require_name
|
||||
sudo systemctl stop "container@$ct_name"
|
||||
;;
|
||||
restart)
|
||||
require_name
|
||||
sudo systemctl restart "container@$ct_name"
|
||||
;;
|
||||
status)
|
||||
require_name
|
||||
machinectl status "$ct_name"
|
||||
;;
|
||||
logs)
|
||||
require_name
|
||||
shift 2
|
||||
sudo journalctl -M "$ct_name" --no-hostname "$@"
|
||||
;;
|
||||
shell)
|
||||
require_name
|
||||
exec sudo machinectl shell "$ct_name"
|
||||
;;
|
||||
attach)
|
||||
require_name
|
||||
leader=$(machinectl show "$ct_name" -p Leader --value 2>/dev/null)
|
||||
if [ -z "$leader" ] || [ "$leader" = "0" ]; then
|
||||
printf 'error: container %s is not running\n' "$ct_name" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec sudo nsenter -m -t "$leader" -- dtach -a -r none /run/ct-attach/attach.sock
|
||||
;;
|
||||
*)
|
||||
printf 'usage: ct <cmd> [container]\n\n'
|
||||
printf ' %-9s %-13s %s\n' \
|
||||
"list" "" "list all containers with state (running/stopped/FAILED)" \
|
||||
"start" "<container>" "start a container" \
|
||||
"stop" "<container>" "stop a container" \
|
||||
"restart" "<container>" "restart a container" \
|
||||
"status" "<container>" "show container status" \
|
||||
"logs" "<container>" "view logs (extra args forwarded to journalctl)" \
|
||||
"shell" "<container>" "open a shell" \
|
||||
"attach" "<container>" "attach to Minecraft console (detach: Ctrl+\\)"
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,17 @@
|
||||
SYSTEM=${1:-/nix/var/nix/profiles/system}
|
||||
|
||||
if [[ ! -e "$SYSTEM/kernel" ]]; then
|
||||
echo "error: no kernel found at $SYSTEM" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "kexec: loading $SYSTEM"
|
||||
echo "kernel-params: $(cat "$SYSTEM/kernel-params")"
|
||||
|
||||
sudo kexec \
|
||||
--load "$SYSTEM/kernel" \
|
||||
--initrd="$SYSTEM/initrd" \
|
||||
--command-line="$(cat "$SYSTEM/kernel-params") init=$SYSTEM/init"
|
||||
|
||||
echo "kexec: executing (systemd will stop services first)"
|
||||
sudo systemctl kexec
|
||||
@@ -0,0 +1 @@
|
||||
exec sudo nixos-rebuild switch --flake "/persist/@configuration#$(hostname)" "$@"
|
||||
@@ -0,0 +1,21 @@
|
||||
state=$(systemctl is-system-running 2>/dev/null || true)
|
||||
uptime_str() {
|
||||
awk '{s=int($1);d=int(s/86400);h=int((s%86400)/3600);m=int((s%3600)/60);
|
||||
if(d>0)printf "%d days %d hours",d,h;
|
||||
else if(h>0)printf "%d hours %d min",h,m;
|
||||
else printf "%d min",m}' /proc/uptime
|
||||
}
|
||||
printf 'HOST: %s UP: %s STATE: %s\n' "$(hostname)" "$(uptime_str)" "$state"
|
||||
printf '%0.s-' {1..60}; printf '\n'
|
||||
|
||||
systemctl list-units --all --type=service \
|
||||
--state=active,failed --no-legend --no-pager --plain 2>/dev/null \
|
||||
| awk '
|
||||
NF >= 4 {
|
||||
unit=$1; active=$3; sub=$4
|
||||
if (unit ~ /^(getty@|console-getty\.service)/) next
|
||||
if (unit ~ /^systemd-(tmpfiles|binfmt|modules-load|sysctl|ask-password|remount|random-seed|update-done|hibernate|sleep|suspend|reboot|poweroff)/) next
|
||||
state = (active == "failed") ? "FAILED" : sub
|
||||
printf " %-50s %s\n", unit, state
|
||||
}
|
||||
'
|
||||
@@ -0,0 +1,2 @@
|
||||
disabled = ["repeated_keys"]
|
||||
ignore = [".direnv"]
|
||||
@@ -0,0 +1,190 @@
|
||||
{
|
||||
lib,
|
||||
host,
|
||||
dsl,
|
||||
commonModule,
|
||||
}:
|
||||
pkgs:
|
||||
let
|
||||
inherit (host.specialArgs.hostInfo) paths;
|
||||
serverDataDir = paths.data;
|
||||
serverSecretsDir = paths.secrets;
|
||||
|
||||
testContainers = dsl.filterDeployments (c: c.includedInTests) host.containers;
|
||||
|
||||
it = dsl.mkIntegrationTest { inherit host testContainers pkgs; };
|
||||
|
||||
nginxAcmeDomains = [
|
||||
"devenv.floweytf.com"
|
||||
"maven.floweytf.com"
|
||||
"git.floweytf.com"
|
||||
"utils.floweytf.com"
|
||||
"mail.floweytf.com"
|
||||
];
|
||||
|
||||
nginxAcmeDir = domain: "${serverDataDir}/core/nginx/acme/${domain}";
|
||||
|
||||
fakeCert =
|
||||
domain:
|
||||
pkgs.runCommand "fake-cert-${domain}"
|
||||
{
|
||||
nativeBuildInputs = [ pkgs.openssl ];
|
||||
}
|
||||
''
|
||||
mkdir -p "$out"
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
||||
-subj "/CN=${domain}" \
|
||||
-keyout "$out/key.pem" -out "$out/fullchain.pem" 2>/dev/null
|
||||
'';
|
||||
|
||||
mkdirAcmeDirs = lib.concatMapStrings (
|
||||
domain: ''mkdir -p "${nginxAcmeDir domain}"'' + "\n"
|
||||
) nginxAcmeDomains;
|
||||
|
||||
installFakeCerts = lib.concatMapStrings (
|
||||
domain:
|
||||
let
|
||||
dir = nginxAcmeDir domain;
|
||||
cert = fakeCert domain;
|
||||
in
|
||||
''
|
||||
if [[ ! -f "${dir}/fullchain.pem" ]]; then
|
||||
install -m 644 ${cert}/fullchain.pem "${dir}/fullchain.pem"
|
||||
install -m 600 ${cert}/key.pem "${dir}/key.pem"
|
||||
fi
|
||||
''
|
||||
) nginxAcmeDomains;
|
||||
|
||||
# used in ssh-admin
|
||||
testSshKey =
|
||||
pkgs.runCommand "test-ssh-key"
|
||||
{
|
||||
nativeBuildInputs = [ pkgs.openssh ];
|
||||
}
|
||||
''
|
||||
mkdir -p "$out"
|
||||
ssh-keygen -q -t ed25519 -N "" -f "$out/key" -C test
|
||||
'';
|
||||
sshpiperTestUser = "admin";
|
||||
|
||||
hostExporterPort = 9100;
|
||||
hostCadvisorPort = 9200;
|
||||
hostSshdPort = 5952;
|
||||
in
|
||||
{
|
||||
name = "integration-${host.hostName}";
|
||||
|
||||
nodes = {
|
||||
internet = it.internetNode;
|
||||
|
||||
server =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
imports = [
|
||||
commonModule
|
||||
host.specialArgs.inputs.microvm.nixosModules.host
|
||||
(dsl.mkHostFromContainers {
|
||||
containers = testContainers;
|
||||
inherit (host) ctx;
|
||||
})
|
||||
it.serverModule
|
||||
];
|
||||
|
||||
containers.nginx.config = {
|
||||
systemd.services = lib.genAttrs (map (d: "acme-order-renew-${d}") nginxAcmeDomains) (_: {
|
||||
enable = lib.mkForce false;
|
||||
});
|
||||
};
|
||||
|
||||
system.activationScripts.test-secrets =
|
||||
# bash
|
||||
''
|
||||
mkdir -p /nix/var/nix/db /nix/var/nix/daemon-socket
|
||||
|
||||
mkdir -p /var/log/journal
|
||||
|
||||
${mkdirAcmeDirs}
|
||||
mkdir -p "${serverSecretsDir}" \
|
||||
"${serverDataDir}/core/sshpiper" \
|
||||
"${serverDataDir}/core/mail" \
|
||||
"${serverDataDir}/overseer/loki/data" \
|
||||
"${serverDataDir}/overseer/prometheus/data" \
|
||||
"${serverDataDir}/overseer/grafana" \
|
||||
"${serverDataDir}/web/git" \
|
||||
"${serverDataDir}/web/git-ci-runner/nix-rw-store" \
|
||||
"${serverDataDir}/web/git-ci-runner/docker" \
|
||||
"${serverDataDir}/web/git-ci-runner/runner-state" \
|
||||
"${serverSecretsDir}/ci-runner" \
|
||||
"${serverDataDir}/web/reposilite" \
|
||||
"${serverDataDir}/web/devenv/config" \
|
||||
"${serverDataDir}/web/devenv/workspace" \
|
||||
"${serverDataDir}/ssh/generic" \
|
||||
"${serverDataDir}/ssh/nyaa" \
|
||||
"${serverDataDir}/mc/botm" \
|
||||
"${serverDataDir}/mc/rogule-dev" \
|
||||
"${serverDataDir}/mc/rogule-build" \
|
||||
"${serverDataDir}/mc/starwalker" \
|
||||
"${serverDataDir}/mc/tl"
|
||||
|
||||
chown -R 1000:1000 \
|
||||
"${serverDataDir}/web/git" \
|
||||
"${serverDataDir}/web/reposilite" \
|
||||
"${serverDataDir}/web/devenv/config" \
|
||||
"${serverDataDir}/web/devenv/workspace" \
|
||||
"${serverDataDir}/core/mail" \
|
||||
"${serverDataDir}/ssh/generic" \
|
||||
"${serverDataDir}/mc/botm" \
|
||||
"${serverDataDir}/mc/rogule-dev" \
|
||||
"${serverDataDir}/mc/rogule-build" \
|
||||
"${serverDataDir}/mc/starwalker" \
|
||||
"${serverDataDir}/mc/tl"
|
||||
|
||||
for keytype in ed25519 rsa ecdsa; do
|
||||
keyfile="${serverSecretsDir}/ssh_host_''${keytype}_key"
|
||||
if [[ ! -f "$keyfile" ]]; then
|
||||
${lib.getExe' pkgs.openssh "ssh-keygen"} -q -t "$keytype" -f "$keyfile" -N ""
|
||||
fi
|
||||
done
|
||||
|
||||
# stalwart rejects weak/dictionary passwords
|
||||
for f in mail-admin-password mail-account-password; do
|
||||
target="${serverSecretsDir}/$f"
|
||||
if [[ ! -f "$target" ]]; then
|
||||
head -c 32 /dev/urandom | base64 | tr -d '\n' > "$target"
|
||||
chmod 600 "$target"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! -f "${serverSecretsDir}/ci-runner/token" ]]; then
|
||||
echo "TOKEN=test-registration-token" > "${serverSecretsDir}/ci-runner/token"
|
||||
chmod 600 "${serverSecretsDir}/ci-runner/token"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${serverSecretsDir}/code-server-env" ]]; then
|
||||
echo "test-secret" > "${serverSecretsDir}/code-server-env"
|
||||
chmod 600 "${serverSecretsDir}/code-server-env"
|
||||
fi
|
||||
|
||||
${installFakeCerts}
|
||||
mkdir -p "${serverDataDir}/core/sshpiper/user_${sshpiperTestUser}"
|
||||
install -m 644 ${testSshKey}/key.pub \
|
||||
"${serverDataDir}/core/sshpiper/user_${sshpiperTestUser}/authorized_keys"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
testScript =
|
||||
it.testScriptPreamble
|
||||
+ ''
|
||||
# store paths are world-readable; ssh refuses a key with those permissions
|
||||
SSH_KEY = "/root/test-ssh-key"
|
||||
SSH_OPTS = f"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -i {SSH_KEY}"
|
||||
TEST_SSH_KEY_STORE = "${testSshKey}/key"
|
||||
|
||||
SSHPIPER_TEST_USER = "${sshpiperTestUser}"
|
||||
HOST_EXPORTER_PORT = ${toString hostExporterPort}
|
||||
HOST_CADVISOR_PORT = ${toString hostCadvisorPort}
|
||||
HOST_SSHD_PORT = ${toString hostSshdPort}
|
||||
''
|
||||
+ builtins.readFile ./integration.py;
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
server: Any = None
|
||||
internet: Any = None
|
||||
start_all: Any = None
|
||||
subtest: Any = None
|
||||
retry: Any = None
|
||||
|
||||
NSENTER: str = ""
|
||||
CURL: str = ""
|
||||
IP: str = ""
|
||||
PING: str = ""
|
||||
DIG: str = ""
|
||||
SSH: str = ""
|
||||
OPENSSL: str = ""
|
||||
TIMEOUT: str = ""
|
||||
included: list[str] = []
|
||||
enterable: list[str] = []
|
||||
allow_internet: list[str] = []
|
||||
not_allow_internet: list[str] = []
|
||||
has_gateway: list[str] = []
|
||||
no_gateway: list[str] = []
|
||||
ADDR: dict[str, str] = {}
|
||||
HOST_ADDR: dict[str, str] = {}
|
||||
IFACE: dict[str, str] = {}
|
||||
KIND: dict[str, str] = {}
|
||||
EDGES: list[tuple[str, str, list[int], list[int]]] = []
|
||||
SSH_KEY: str = ""
|
||||
SSH_OPTS: str = ""
|
||||
TEST_SSH_KEY_STORE: str = ""
|
||||
SSHPIPER_TEST_USER: str = ""
|
||||
HOST_EXPORTER_PORT: int = 0
|
||||
HOST_CADVISOR_PORT: int = 0
|
||||
HOST_SSHD_PORT: int = 0
|
||||
|
||||
SW = "/run/current-system/sw/bin"
|
||||
SH = f"{SW}/sh"
|
||||
|
||||
PEER_EDGES = {(src, to) for src, to, _, _ in EDGES if to not in ("@host", "@internet")}
|
||||
|
||||
|
||||
def failed_units(node_arg=""):
|
||||
out = server.succeed(f"systemctl {node_arg} list-units --state=failed --no-legend --plain || true")
|
||||
return [
|
||||
line.split()[0]
|
||||
for line in out.splitlines()
|
||||
if line.strip() and not line.split()[0].startswith("acme-")
|
||||
]
|
||||
|
||||
|
||||
def container_pid(name):
|
||||
return server.succeed(f"machinectl show {name} -p Leader --value").strip()
|
||||
|
||||
|
||||
def net_cmd(name, cmd):
|
||||
pid = container_pid(name)
|
||||
return f"{NSENTER} --net=/proc/{pid}/ns/net -- {cmd}"
|
||||
|
||||
|
||||
def container_ip(name):
|
||||
out = server.succeed(net_cmd(name, f"{IP} -4 -o addr show scope global"))
|
||||
return out.split()[3].split("/")[0]
|
||||
|
||||
|
||||
def has_ipv6_link_local(name):
|
||||
out = server.succeed(net_cmd(name, f"{IP} -6 -o addr show scope link"))
|
||||
return out.strip() != ""
|
||||
|
||||
|
||||
def container_iface(name):
|
||||
out = server.succeed(net_cmd(name, f"{IP} -j link show"))
|
||||
for link in json.loads(out):
|
||||
if link.get("link_type") == "ether" and "LOOPBACK" not in link.get("flags", []):
|
||||
return link["ifname"]
|
||||
raise Exception(f"no ethernet link found for {name}")
|
||||
|
||||
|
||||
def connect(host, port, timeout=5):
|
||||
"""Open a TCP connection and close it again, using only tools every node has."""
|
||||
return f"{SW}/timeout {timeout} {SH} -c 'exec 3<>/dev/tcp/{host}/{port} && exec 3<&-'"
|
||||
|
||||
|
||||
def ct_connect(name, host, port, timeout=5):
|
||||
return net_cmd(name, connect(host, port, timeout))
|
||||
|
||||
|
||||
def ext_connect(port, timeout=5):
|
||||
"""A published port, probed the way a published port is actually used.
|
||||
|
||||
Probing it from a container measures a different path - containers are
|
||||
excluded from DNAT entirely.
|
||||
"""
|
||||
return connect(server_ip, port, timeout)
|
||||
|
||||
|
||||
def spoof_counter():
|
||||
out = server.succeed("nft -j list counter inet container-ip spoofed")
|
||||
return json.loads(out)["nftables"][1]["counter"]["packets"]
|
||||
|
||||
|
||||
start_all()
|
||||
server.succeed(f"install -m 600 {TEST_SSH_KEY_STORE} {SSH_KEY}")
|
||||
|
||||
with subtest("(functional): containers autostart on boot with no errors"):
|
||||
server.wait_for_unit("multi-user.target")
|
||||
for name in included:
|
||||
unit = "container" if KIND[name] == "nspawn" else "microvm"
|
||||
server.wait_for_unit(f"{unit}@{name}.service")
|
||||
server.succeed(f"systemctl is-active {unit}@{name}.service")
|
||||
|
||||
assert failed_units() == [], f"host has failed units: {failed_units()}"
|
||||
for name in enterable:
|
||||
assert failed_units(f"-M {name}") == [], f"{name} has failed units: {failed_units(f'-M {name}')}"
|
||||
|
||||
internet.wait_for_unit("dummy-http.service")
|
||||
internet_ip = internet.succeed("ip -4 -o addr show dev eth1 scope global").split()[3].split("/")[0]
|
||||
server_ip = server.succeed("ip -4 -o addr show dev eth1 scope global").split()[3].split("/")[0]
|
||||
|
||||
with subtest("(functional): derived addressing matches what the ruleset assumes"):
|
||||
for name in enterable:
|
||||
assert container_ip(name) == ADDR[name], f"{name} has {container_ip(name)}, ruleset says {ADDR[name]}"
|
||||
assert container_iface(name) == IFACE[name], f"{name} is on {container_iface(name)}, ruleset says {IFACE[name]}"
|
||||
for name in [c for c in included if KIND[c] == "vm"]:
|
||||
server.succeed(f"{IP} link show {IFACE[name]}")
|
||||
server.wait_until_succeeds(f"{PING} -c 1 -W 2 {ADDR[name]}", timeout=180)
|
||||
|
||||
with subtest("(isolation): internet egress follows the access list"):
|
||||
for name in allow_internet:
|
||||
server.succeed(net_cmd(name, f"{CURL} -sf --max-time 5 http://{internet_ip}/ >&2"))
|
||||
for name in not_allow_internet:
|
||||
server.fail(net_cmd(name, f"{CURL} -sf --max-time 5 http://{internet_ip}/ >&2"))
|
||||
for name in has_gateway:
|
||||
server.succeed(net_cmd(name, f"{IP} route show default | grep -q ."))
|
||||
for name in no_gateway:
|
||||
server.fail(net_cmd(name, f"{IP} route show default | grep -q ."))
|
||||
|
||||
with subtest("(functional): container DNS still resolves"):
|
||||
resolved = server.succeed(
|
||||
net_cmd("git", f"{DIG} +short +time=3 @{HOST_ADDR['git']} nginx.containers")
|
||||
).strip()
|
||||
assert resolved == ADDR["nginx"], f"nginx.containers resolved to {resolved!r}, expected {ADDR['nginx']}"
|
||||
|
||||
with subtest("(functional): web services respond"):
|
||||
for name, port in [("devenv", 8080), ("maven", 8080), ("git", 3000)]:
|
||||
server.succeed(net_cmd(name, f"{CURL} -sf --max-time 5 -o /dev/null http://{ADDR[name]}:{port}/ >&2"))
|
||||
|
||||
with subtest("(functional): mail accepts connections"):
|
||||
smtp_probe = net_cmd("mail", f"{SH} -c 'exec 3<>/dev/tcp/{ADDR['mail']}/25 && head -1 <&3'")
|
||||
server.wait_until_succeeds(f"{smtp_probe} | grep -q '^220'", timeout=60)
|
||||
|
||||
imap_probe = net_cmd(
|
||||
"mail",
|
||||
f"{TIMEOUT} 3 {OPENSSL} s_client -connect {ADDR['mail']}:993 -quiet -verify_quiet 2>/dev/null",
|
||||
)
|
||||
|
||||
def imap_ready(_):
|
||||
greeting = server.execute(f"echo | {imap_probe} | head -1")[1]
|
||||
return greeting.strip().startswith("* OK")
|
||||
|
||||
retry(imap_ready, timeout_seconds=60)
|
||||
|
||||
with subtest("(functional): nginx proxies to backend containers"):
|
||||
server.succeed(
|
||||
net_cmd(
|
||||
"nginx",
|
||||
f"{CURL} -sfk --max-time 5 --resolve git.floweytf.com:443:{ADDR['nginx']} "
|
||||
"https://git.floweytf.com/ >&2",
|
||||
)
|
||||
)
|
||||
|
||||
with subtest("(functional): sshpiper authenticates/routes properly"):
|
||||
whoami = server.succeed(f"{SSH} {SSH_OPTS} -p 2222 {SSHPIPER_TEST_USER}@{ADDR['sshpiper']} whoami").strip()
|
||||
assert whoami == "runner", f"expected to land as runner, got {whoami}"
|
||||
hostname = server.succeed(
|
||||
f"{SSH} {SSH_OPTS} -p 2222 {SSHPIPER_TEST_USER}@{ADDR['sshpiper']} hostname"
|
||||
).strip()
|
||||
assert hostname == "ssh-admin", f"expected to land on ssh-admin, got {hostname}"
|
||||
|
||||
with subtest("(isolation): every declared container edge connects"):
|
||||
for src, to, tcp_ports, _ in EDGES:
|
||||
if to in ("@host", "@internet") or src not in enterable or to not in included:
|
||||
continue
|
||||
for port in tcp_ports:
|
||||
server.succeed(ct_connect(src, ADDR[to], port))
|
||||
|
||||
with subtest("(isolation): undeclared container edges are refused"):
|
||||
for src, dst, port in [
|
||||
("devenv", "git", 3000),
|
||||
("git", "devenv", 8080),
|
||||
("maven", "git", 3000),
|
||||
("nginx", "mail", 25),
|
||||
("ssh-botm", "ssh-admin", 22),
|
||||
("overseer", "git", 3000),
|
||||
]:
|
||||
assert (src, dst) not in PEER_EDGES, f"{src} -> {dst} is a declared edge, not a negative case"
|
||||
server.fail(ct_connect(src, ADDR[dst], port, timeout=2))
|
||||
|
||||
with subtest("(isolation): edges are one-way"):
|
||||
assert ("nginx", "git") in PEER_EDGES
|
||||
assert ("git", "nginx") not in PEER_EDGES
|
||||
server.succeed(ct_connect("nginx", ADDR["git"], 3000))
|
||||
server.fail(ct_connect("git", ADDR["nginx"], 443, timeout=2))
|
||||
|
||||
with subtest("(isolation): host access allowlist"):
|
||||
server.succeed(
|
||||
net_cmd(
|
||||
"overseer",
|
||||
f"{CURL} -sf --max-time 5 http://{HOST_ADDR['overseer']}:{HOST_EXPORTER_PORT}/metrics >&2",
|
||||
)
|
||||
)
|
||||
for port in (HOST_EXPORTER_PORT, HOST_CADVISOR_PORT):
|
||||
server.fail(ct_connect("git", HOST_ADDR["git"], port, timeout=2))
|
||||
server.fail(ct_connect("git", HOST_ADDR["overseer"], port, timeout=2))
|
||||
|
||||
with subtest("(isolation): host services outside the access list"):
|
||||
internet.succeed(connect(server_ip, HOST_SSHD_PORT))
|
||||
for name in ("git", "overseer"):
|
||||
server.fail(ct_connect(name, HOST_ADDR[name], HOST_SSHD_PORT, timeout=2))
|
||||
server.fail(ct_connect(name, server_ip, HOST_SSHD_PORT, timeout=2))
|
||||
|
||||
with subtest("(isolation): source address spoofing is dropped"):
|
||||
before = spoof_counter()
|
||||
server.succeed(net_cmd("git", f"{PING} -c 1 -W 2 {HOST_ADDR['git']} >&2"))
|
||||
assert spoof_counter() == before, "an unspoofed packet hit the source guard"
|
||||
|
||||
iface = IFACE["git"]
|
||||
server.succeed(net_cmd("git", f"{IP} addr add {ADDR['nginx']}/32 dev {iface}"))
|
||||
try:
|
||||
server.execute(
|
||||
net_cmd("git", f"{PING} -c 2 -W 2 -I {ADDR['nginx']} {HOST_ADDR['git']}")
|
||||
)
|
||||
assert spoof_counter() > before, "a forged source address was not dropped"
|
||||
finally:
|
||||
server.succeed(net_cmd("git", f"{IP} addr del {ADDR['nginx']}/32 dev {iface}"))
|
||||
|
||||
with subtest("(isolation): renaming the interface changes nothing"):
|
||||
iface, host_addr = IFACE["nginx"], HOST_ADDR["nginx"]
|
||||
|
||||
def rename(frm, to):
|
||||
server.succeed(net_cmd("nginx", f"{IP} link set dev {frm} down"))
|
||||
server.succeed(net_cmd("nginx", f"{IP} link set dev {frm} name {to}"))
|
||||
server.succeed(net_cmd("nginx", f"{IP} link set dev {to} up"))
|
||||
for route in (host_addr, f"10.0.0.0/8 via {host_addr}", f"default via {host_addr}"):
|
||||
server.succeed(net_cmd("nginx", f"{IP} route replace {route} dev {to}"))
|
||||
|
||||
rename(iface, "evil0")
|
||||
try:
|
||||
server.succeed(ct_connect("nginx", ADDR["git"], 3000))
|
||||
server.fail(ct_connect("nginx", ADDR["mail"], 25, timeout=2))
|
||||
finally:
|
||||
rename("evil0", iface)
|
||||
|
||||
with subtest("(isolation): the veth cannot be moved out of the namespace"):
|
||||
server.fail(net_cmd("git", f"{IP} link set dev {IFACE['git']} netns 1"))
|
||||
|
||||
with subtest("(isolation): route bypass"):
|
||||
iface = IFACE["hopper"]
|
||||
server.succeed(
|
||||
net_cmd("hopper", f"{IP} route replace default via {HOST_ADDR['hopper']} dev {iface} onlink")
|
||||
)
|
||||
server.fail(net_cmd("hopper", f"{CURL} -sf --max-time 5 http://{internet_ip}/ >&2"))
|
||||
server.fail(ct_connect("hopper", ADDR["git"], 3000, timeout=2))
|
||||
|
||||
with subtest("(isolation): no IPv6 link-local addresses"):
|
||||
for name in ["git", "overseer"]:
|
||||
assert not has_ipv6_link_local(name), f"{name} still has an IPv6 link-local address"
|
||||
|
||||
with subtest("(isolation): published ports grant no reach between containers"):
|
||||
for port in (80, 443, 25565, 22, 25):
|
||||
server.fail(ct_connect("overseer", HOST_ADDR["overseer"], port, timeout=2))
|
||||
server.fail(ct_connect("git", server_ip, port, timeout=2))
|
||||
|
||||
with subtest("(isolation): privilege escalation / container escape"):
|
||||
leader = container_pid("git")
|
||||
|
||||
host_uid = server.succeed(f"awk '/^Uid:/{{print $2}}' /proc/{leader}/status").strip()
|
||||
assert host_uid != "0", "container's PID 1 is not idmapped away from host uid 0"
|
||||
|
||||
host_pidns = server.succeed("readlink /proc/1/ns/pid").strip()
|
||||
container_pidns = server.succeed(f"readlink /proc/{leader}/ns/pid").strip()
|
||||
assert host_pidns != container_pidns, "container shares the host's PID namespace"
|
||||
|
||||
server.succeed(
|
||||
f"{NSENTER} --mount=/proc/{leader}/ns/mnt --root=/proc/{leader}/root -- "
|
||||
f"{SH} -c 'echo x >> /var/lib/gitea/canary' >&2"
|
||||
)
|
||||
|
||||
sshpiper_pid = container_pid("sshpiper")
|
||||
server.fail(
|
||||
f"{NSENTER} --mount=/proc/{sshpiper_pid}/ns/mnt --root=/proc/{sshpiper_pid}/root -- "
|
||||
f"{SH} -c 'echo x >> /etc/ssh/ssh_host_ed25519_key' >&2"
|
||||
)
|
||||
server.fail(
|
||||
f"{NSENTER} --mount=/proc/{leader}/ns/mnt --root=/proc/{leader}/root -- "
|
||||
f"{SH} -c 'touch /nix/store/canary' >&2"
|
||||
)
|
||||
server.fail(
|
||||
f"{NSENTER} --mount=/proc/{leader}/ns/mnt --root=/proc/{leader}/root -- "
|
||||
"/run/current-system/sw/bin/test -d /persist >&2"
|
||||
)
|
||||
|
||||
with subtest("(functional): external clients reach published ports"):
|
||||
for port in (80, 25565, 22):
|
||||
internet.succeed(ext_connect(port))
|
||||
internet.fail(ext_connect(8080))
|
||||
|
||||
with subtest("(functional): no failing-reason restarts"):
|
||||
assert failed_units() == [], f"host has failed units: {failed_units()}"
|
||||
for name in included:
|
||||
assert (
|
||||
failed_units(f"-M {name}") == []
|
||||
), f"{name} has failed units: {failed_units(f'-M {name}')}"
|
||||
restarts = server.succeed(f"systemctl show container@{name}.service -p NRestarts --value").strip()
|
||||
assert restarts == "0", f"container@{name}.service restarted {restarts} times"
|
||||
|
||||
with subtest("(functional): port forwards survive an nftables reload"):
|
||||
server.succeed("systemctl reload nftables.service")
|
||||
elements = server.succeed("nft list map ip container-portforward tcp-fwd")
|
||||
assert re.search(r"\b80\s*:", elements), f"nftables reload emptied the map: {elements!r}"
|
||||
internet.wait_until_succeeds(ext_connect(80), timeout=30)
|
||||
|
||||
with subtest("(functional): published ports follow the container's lifetime"):
|
||||
server.succeed("systemctl stop container@nginx")
|
||||
internet.fail(ext_connect(80))
|
||||
server.succeed("systemctl start container@nginx")
|
||||
server.wait_for_unit("container@nginx.service")
|
||||
internet.wait_until_succeeds(ext_connect(80), timeout=30)
|
||||
Reference in New Issue
Block a user