chore: Abstract machines and modules
All checks were successful
Check workflows / check_workflows (push) Successful in 21s
Run pre-commit on all files / check (push) Successful in 24s
Check meta / check_dns (pull_request) Successful in 19s
Check meta / check_meta (pull_request) Successful in 18s
Check workflows / check_workflows (pull_request) Successful in 19s
Build all the nodes / bridge01 (pull_request) Successful in 1m13s
Build all the nodes / geo01 (pull_request) Successful in 1m14s
Build all the nodes / compute01 (pull_request) Successful in 1m44s
Build all the nodes / geo02 (pull_request) Successful in 1m12s
Build all the nodes / rescue01 (pull_request) Successful in 1m30s
Build all the nodes / storage01 (pull_request) Successful in 1m29s
Build all the nodes / vault01 (pull_request) Successful in 1m26s
Build all the nodes / web02 (pull_request) Successful in 1m19s
Run pre-commit on all files / check (pull_request) Successful in 24s
Build all the nodes / web01 (pull_request) Successful in 1m56s
Build all the nodes / web03 (pull_request) Successful in 1m25s

This adds subdirectories for the different types of systems, for the
modules and the machines
This commit is contained in:
Tom Hubrecht 2024-12-08 13:22:07 +01:00
parent c3f4e7ade6
commit ecbad0a638
Signed by: thubrecht
SSH key fingerprint: SHA256:r+nK/SIcWlJ0zFZJGHtlAoRwq1Rm+WcKAm5ADYMoQPc
264 changed files with 49 additions and 38 deletions

77
modules/nixos/default.nix Normal file
View file

@ -0,0 +1,77 @@
# Copyright :
# - Tom Hubrecht <tom.hubrecht@dgnum.eu> 2023
# - Maurice Debray <maurice.debray@dgnum.eu> 2023
#
# Ce logiciel est un programme informatique servant à déployer des
# configurations de serveurs via NixOS.
#
# Ce logiciel est régi par la licence CeCILL soumise au droit français et
# respectant les principes de diffusion des logiciels libres. Vous pouvez
# utiliser, modifier et/ou redistribuer ce programme sous les conditions
# de la licence CeCILL telle que diffusée par le CEA, le CNRS et l'INRIA
# sur le site "http://www.cecill.info".
#
# En contrepartie de l'accessibilité au code source et des droits de copie,
# de modification et de redistribution accordés par cette licence, il n'est
# offert aux utilisateurs qu'une garantie limitée. Pour les mêmes raisons,
# seule une responsabilité restreinte pèse sur l'auteur du programme, le
# titulaire des droits patrimoniaux et les concédants successifs.
#
# A cet égard l'attention de l'utilisateur est attirée sur les risques
# associés au chargement, à l'utilisation, à la modification et/ou au
# développement et à la reproduction du logiciel par l'utilisateur étant
# donné sa spécificité de logiciel libre, qui peut le rendre complexe à
# manipuler et qui le réserve donc à des développeurs et des professionnels
# avertis possédant des connaissances informatiques approfondies. Les
# utilisateurs sont donc invités à charger et tester l'adéquation du
# logiciel à leurs besoins dans des conditions permettant d'assurer la
# sécurité de leurs systèmes et ou de leurs données et, plus généralement,
# à l'utiliser et l'exploiter dans les mêmes conditions de sécurité.
#
# Le fait que vous puissiez accéder à cet en-tête signifie que vous avez
# pris connaissance de la licence CeCILL, et que vous en avez accepté les
# termes.
{
lib,
nodeMeta,
sources,
...
}:
{
imports =
(lib.extra.mkImports ./. [
"dgn-access-control"
"dgn-acme"
"dgn-backups"
"dgn-console"
"dgn-chatops"
"dgn-firewall"
"dgn-hardware"
"dgn-netbox-agent"
"dgn-network"
"dgn-node-monitoring"
"dgn-notify"
"dgn-records"
"dgn-redirections"
"dgn-ssh"
"dgn-vm-variant"
"dgn-web"
"django-apps"
])
++ [
"${sources.agenix}/modules/age.nix"
"${sources.arkheon}/module.nix"
"${sources."microvm.nix"}/nixos-modules/host"
]
++ ((import sources.nix-modules { inherit lib; }).importModules (
[
"age-secrets"
"services/bupstash"
"services/reaction"
"services/systemd-notify"
]
++ nodeMeta.nix-modules
));
}

View file

@ -0,0 +1,101 @@
# Copyright :
# - Tom Hubrecht <tom.hubrecht@dgnum.eu> 2023
#
# Ce logiciel est un programme informatique servant à déployer des
# configurations de serveurs via NixOS.
#
# Ce logiciel est régi par la licence CeCILL soumise au droit français et
# respectant les principes de diffusion des logiciels libres. Vous pouvez
# utiliser, modifier et/ou redistribuer ce programme sous les conditions
# de la licence CeCILL telle que diffusée par le CEA, le CNRS et l'INRIA
# sur le site "http://www.cecill.info".
#
# En contrepartie de l'accessibilité au code source et des droits de copie,
# de modification et de redistribution accordés par cette licence, il n'est
# offert aux utilisateurs qu'une garantie limitée. Pour les mêmes raisons,
# seule une responsabilité restreinte pèse sur l'auteur du programme, le
# titulaire des droits patrimoniaux et les concédants successifs.
#
# A cet égard l'attention de l'utilisateur est attirée sur les risques
# associés au chargement, à l'utilisation, à la modification et/ou au
# développement et à la reproduction du logiciel par l'utilisateur étant
# donné sa spécificité de logiciel libre, qui peut le rendre complexe à
# manipuler et qui le réserve donc à des développeurs et des professionnels
# avertis possédant des connaissances informatiques approfondies. Les
# utilisateurs sont donc invités à charger et tester l'adéquation du
# logiciel à leurs besoins dans des conditions permettant d'assurer la
# sécurité de leurs systèmes et ou de leurs données et, plus généralement,
# à l'utiliser et l'exploiter dans les mêmes conditions de sécurité.
#
# Le fait que vous puissiez accéder à cet en-tête signifie que vous avez
# pris connaissance de la licence CeCILL, et que vous en avez accepté les
# termes.
{
config,
lib,
dgn-keys,
meta,
nodeMeta,
...
}:
let
inherit (lib)
mkDefault
mkEnableOption
mkIf
mkMerge
mkOption
types
;
admins =
meta.organization.groups.root
++ nodeMeta.admins
++ (builtins.concatMap (g: meta.organization.groups.${g}) nodeMeta.adminGroups);
cfg = config.dgn-access-control;
in
{
options.dgn-access-control = {
enable = mkEnableOption "DGNum access control." // {
default = true;
};
users = mkOption {
type = with types; attrsOf (listOf str);
default = { };
description = ''
Attribute set describing which member has access to which user on the node.
Members must be declared in `meta/members.nix`.
'';
example = ''
{
user1 = [ "member1" "member2" ];
}
'';
};
};
config = mkIf cfg.enable (mkMerge [
{
# Admins have root access to the node
dgn-access-control.users.root = mkDefault admins;
users.users = builtins.mapAttrs (_: members: {
openssh.authorizedKeys.keys = dgn-keys.getKeys members;
}) cfg.users;
}
{
users = {
mutableUsers = false;
users.root = {
inherit (nodeMeta) hashedPassword;
};
};
}
]);
}

View file

@ -0,0 +1,53 @@
# Copyright :
# - Tom Hubrecht <tom.hubrecht@dgnum.eu> 2023
#
# Ce logiciel est un programme informatique servant à déployer des
# configurations de serveurs via NixOS.
#
# Ce logiciel est régi par la licence CeCILL soumise au droit français et
# respectant les principes de diffusion des logiciels libres. Vous pouvez
# utiliser, modifier et/ou redistribuer ce programme sous les conditions
# de la licence CeCILL telle que diffusée par le CEA, le CNRS et l'INRIA
# sur le site "http://www.cecill.info".
#
# En contrepartie de l'accessibilité au code source et des droits de copie,
# de modification et de redistribution accordés par cette licence, il n'est
# offert aux utilisateurs qu'une garantie limitée. Pour les mêmes raisons,
# seule une responsabilité restreinte pèse sur l'auteur du programme, le
# titulaire des droits patrimoniaux et les concédants successifs.
#
# A cet égard l'attention de l'utilisateur est attirée sur les risques
# associés au chargement, à l'utilisation, à la modification et/ou au
# développement et à la reproduction du logiciel par l'utilisateur étant
# donné sa spécificité de logiciel libre, qui peut le rendre complexe à
# manipuler et qui le réserve donc à des développeurs et des professionnels
# avertis possédant des connaissances informatiques approfondies. Les
# utilisateurs sont donc invités à charger et tester l'adéquation du
# logiciel à leurs besoins dans des conditions permettant d'assurer la
# sécurité de leurs systèmes et ou de leurs données et, plus généralement,
# à l'utiliser et l'exploiter dans les mêmes conditions de sécurité.
#
# Le fait que vous puissiez accéder à cet en-tête signifie que vous avez
# pris connaissance de la licence CeCILL, et que vous en avez accepté les
# termes.
{ config, lib, ... }:
let
inherit (lib) mkEnableOption mkIf;
cfg = config.dgn-acme;
in
{
options.dgn-acme.enable = mkEnableOption "ACME settings." // {
default = true;
};
config = mkIf cfg.enable {
security.acme = {
acceptTerms = true;
defaults.email = "acme@dgnum.eu";
};
};
}

View file

@ -0,0 +1,130 @@
{
config,
lib,
dgn-keys,
name,
...
}:
let
inherit (lib) mkEnableOption mkOption remove;
inherit (lib.types)
attrs
attrsOf
listOf
str
submodule
;
cfg = config.dgn-backups;
homes = {
compute01 = "/data/slow/bupstash";
geo01 = "/data/bupstash";
geo02 = "/data/bupstash";
storage01 = "/data/slow/bupstash";
};
starts = {
compute01 = "*-*-* *:38:00";
storage01 = "*-*-* *:21:00";
web01 = "*-*-* *:47:00";
};
mkJobs = builtins.mapAttrs (
_:
{ to, settings }:
{
startAt = starts.${name};
key = config.age.secrets."bupstash-put_key".path;
repositoryCommands = lib.extra.mapSingleFuse (
host: "ssh -i /etc/ssh/ssh_host_ed25519_key bupstash-repo@${host}.dgnum"
) to;
}
// settings
);
in
{
options.dgn-backups = {
enable = mkEnableOption "DGNum backup service.";
postgresDatabases = mkOption {
type = listOf str;
default = [ ];
description = ''
List of postgres databases to dump into bupstash.
'';
};
jobs = mkOption {
type = attrsOf (submodule {
options = {
to = mkOption {
type = listOf str;
default = remove name [
"compute01"
"geo01"
"geo02"
"storage01"
];
description = "Hosts to send the backups to.";
};
settings = mkOption {
type = attrs;
default = { };
description = "Base bupstash job config.";
};
};
});
default = { };
description = "List of bupstash jobs.";
};
};
config = {
dgn-backups.jobs = lib.extra.mapFuse (db: {
"${db}-db".settings = {
user = "postgres";
command = [
"${lib.getExe' config.services.postgresql.package "pg_dump"}"
db
];
};
}) cfg.postgresDatabases;
services.bupstash = {
repositories = {
inherit (cfg) enable;
home = homes.${name};
access = [
{
repo = "default";
keys = dgn-keys.getKeys [
"compute01"
"storage01"
"vault01"
"web01"
];
allowed = [ "put" ];
}
];
};
jobs = mkJobs cfg.jobs;
};
programs.ssh.knownHosts =
lib.extra.mapFuse (host: { "${host}.dgnum".publicKey = builtins.head dgn-keys._keys.${host}; })
[
"compute01"
"geo01"
"geo02"
"storage01"
];
};
}

Binary file not shown.

View file

@ -0,0 +1,5 @@
(import ../../../../keys).mkSecrets [ ] [
"compute01.key"
"storage01.key"
"web01.key"
]

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1 @@
use nix

View file

@ -0,0 +1,75 @@
# Copyright :
# - Ryan Lahfa <ryan.lahfa@dgnum.eu> 2024
#
# Ce logiciel est un programme informatique servant à déployer des
# configurations de serveurs via NixOS.
#
# Ce logiciel est régi par la licence CeCILL soumise au droit français et
# respectant les principes de diffusion des logiciels libres. Vous pouvez
# utiliser, modifier et/ou redistribuer ce programme sous les conditions
# de la licence CeCILL telle que diffusée par le CEA, le CNRS et l'INRIA
# sur le site "http://www.cecill.info".
#
# En contrepartie de l'accessibilité au code source et des droits de copie,
# de modification et de redistribution accordés par cette licence, il n'est
# offert aux utilisateurs qu'une garantie limitée. Pour les mêmes raisons,
# seule une responsabilité restreinte pèse sur l'auteur du programme, le
# titulaire des droits patrimoniaux et les concédants successifs.
#
# A cet égard l'attention de l'utilisateur est attirée sur les risques
# associés au chargement, à l'utilisation, à la modification et/ou au
# développement et à la reproduction du logiciel par l'utilisateur étant
# donné sa spécificité de logiciel libre, qui peut le rendre complexe à
# manipuler et qui le réserve donc à des développeurs et des professionnels
# avertis possédant des connaissances informatiques approfondies. Les
# utilisateurs sont donc invités à charger et tester l'adéquation du
# logiciel à leurs besoins dans des conditions permettant d'assurer la
# sécurité de leurs systèmes et ou de leurs données et, plus généralement,
# à l'utiliser et l'exploiter dans les mêmes conditions de sécurité.
#
# Le fait que vous puissiez accéder à cet en-tête signifie que vous avez
# pris connaissance de la licence CeCILL, et que vous en avez accepté les
# termes.
{
config,
pkgs,
lib,
...
}:
let
cfg = config.dgn-chatops;
inherit (lib) mkEnableOption mkIf;
python3 = pkgs.python311;
python3Pkgs = python3.pkgs;
ircrobots = python3Pkgs.callPackage ./ircrobots.nix { };
tortoise-orm = python3Pkgs.callPackage ./tortoise-orm.nix { };
ps = python3Pkgs.makePythonPath [
ircrobots
tortoise-orm
python3Pkgs.aiohttp
];
in
{
options.dgn-chatops = {
enable = mkEnableOption "the ChatOps layer";
};
# Our ChatOps bot.
config = mkIf cfg.enable {
systemd.services.irc-takumi = {
description = "DGNum IRC automation bot, Takumi";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment = {
PYTHONPATH = ps;
};
serviceConfig = {
RuntimeDirectory = "takumi";
StateDirectory = "takumi";
DynamicUser = true;
ExecStart = "${lib.getExe python3} ${./takumi.py}";
};
};
};
}

View file

@ -0,0 +1,56 @@
{
lib,
buildPythonPackage,
fetchFromGitea,
pythonOlder,
anyio,
asyncio-rlock,
asyncio-throttle,
ircstates,
async-stagger,
async-timeout,
python,
}:
buildPythonPackage rec {
pname = "ircrobots";
version = "0.7.0";
format = "setuptools";
disabled = pythonOlder "3.7";
src = fetchFromGitea {
domain = "git.dgnum.eu";
owner = "DGNum";
repo = pname;
# No tag yet :(.
rev = "63aa84b40450bd534fc232eee10e8088028c9f6d";
hash = "sha256-gXiPy6wjPEtc9v0cG0lb2QVXDlU5Q8ncxJO0lBm2RSE=";
};
postPatch = ''
# too specific pins https://github.com/jesopo/ircrobots/issues/3
sed -iE 's/anyio.*/anyio/' requirements.txt
'';
propagatedBuildInputs = [
anyio
asyncio-rlock
asyncio-throttle
ircstates
async-stagger
async-timeout
];
checkPhase = ''
${python.interpreter} -m unittest test
'';
pythonImportsCheck = [ "ircrobots" ];
meta = with lib; {
description = "Asynchronous bare-bones IRC bot framework for python3";
license = licenses.mit;
homepage = "https://github.com/jesopo/ircrobots";
maintainers = with maintainers; [ hexa ];
};
}

View file

@ -0,0 +1,31 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
poetry-core,
}:
buildPythonPackage rec {
pname = "pypika-tortoise";
version = "0.1.6";
pyproject = true;
src = fetchFromGitHub {
owner = "tortoise";
repo = "pypika-tortoise";
rev = "v${version}";
hash = "sha256-xx5FUMHh6413fsvwrEA+Q0tBmJWy00h5O6YijvrJyCE=";
};
build-system = [ poetry-core ];
pythonImportsCheck = [ "pypika" ];
meta = {
description = "";
homepage = "https://github.com/tortoise/pypika-tortoise";
changelog = "https://github.com/tortoise/pypika-tortoise/blob/${src.rev}/CHANGELOG.md";
license = lib.licenses.asl20;
maintainers = with lib.maintainers; [ raitobezarius ];
};
}

View file

@ -0,0 +1,20 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "takumi"
version = "1.1.0"
authors = [
{ name = "Ryan Lahfa", email = "ryan@dgnum.eu" },
]
description = "Fully automatic day-to-day operations at DGNum"
requires-python = ">=3.11"
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
[project.urls]
Homepage = "https://git.dgnum.eu/DGNum/infrastructure"
Issues = "https://git.dgnum.eu/DGNum/infrastructure/issues"

View file

@ -0,0 +1,29 @@
{
pkgs ? import <nixpkgs> { },
python3 ? pkgs.python3,
}:
let
takumi = python3.pkgs.buildPythonPackage rec {
pname = "takumi";
version = "1.1.0";
pyproject = true;
src = ./.;
build-system = [ python3.pkgs.hatchling ];
dependencies = [
(python3.pkgs.callPackage ./ircrobots.nix { })
(python3.pkgs.callPackage ./tortoise-orm.nix { })
python3.pkgs.aiohttp
];
postInstall = ''
mkdir -p $out/bin
cp -v takumi.py $out/bin/takumi.py
chmod +x $out/bin/takumi.py
wrapProgram $out/bin/takumi.py --prefix PYTHONPATH : "$PYTHONPATH"
'';
};
in
pkgs.mkShell { packages = [ takumi ]; }

View file

@ -0,0 +1,121 @@
#!/usr/bin/env python3
import asyncio
from irctokens.line import build, Line
from ircrobots.bot import Bot as BaseBot
from ircrobots.server import Server as BaseServer
from ircrobots.params import ConnectionParams
import aiohttp
BRIDGE_NICKNAME = "hermes"
SERVERS = [
("dgnum", "irc.dgnum.eu")
]
TEAMS = {
"fai": ("tomate", "elias", "JeMaGius", "Luj", "catvayor", "Raito"),
"marketing": ("cst1", "elias"),
"bureau": ("Raito", "JeMaGius", "Luj", "gdd")
}
# times format is 0700-29092024
TRIGGER = '!'
async def create_meet(title: str, times: list[str], timezone: str = "UTC") -> str:
async with aiohttp.ClientSession() as session:
payload = {
'name': title,
'times': times,
'timezone': timezone
}
async with session.post('https://api.meet.dgnum.eu/event', json=payload) as response:
response.raise_for_status()
id = (await response.json()).get('id')
if not id:
raise RuntimeError('No ID attributed to a meet')
return f'https://meet.dgnum.eu/{id}'
def expand_times(times: list[str]) -> list[str]:
expanded = []
# TODO: verify the date exist in the calendar
# TODO: verify that we don't write any duplicates.
for time in times:
if '-' not in time:
for i in range(7, 20):
expanded.append(f'{i:02}00-{time}')
else:
expanded.append(time)
return expanded
def bridge_stripped(possible_command: str, origin_nick: str) -> str | None:
if origin_nick.lower() == BRIDGE_NICKNAME:
stripped_user = possible_command.split(':')[1].lstrip()
return stripped_user if stripped_user.startswith(TRIGGER) else None
else:
return possible_command if possible_command.startswith(TRIGGER) else None
class Server(BaseServer):
def extract_valid_command(self, line: Line) -> str | None:
me = self.nickname_lower
if line.command == "PRIVMSG" and \
self.has_channel(line.params[0]) and \
line.hostmask is not None and \
self.casefold(line.hostmask.nickname) != me and \
self.has_user(line.hostmask.nickname):
return bridge_stripped(line.params[1], line.hostmask.nickname)
async def line_read(self, line: Line):
print(f"{self.name} < {line.format()}")
if line.command == "001":
print(f"connected to {self.isupport.network}")
await self.send(build("JOIN", ["#dgnum-bridge-test"]))
# In case `!probe_meet <title> <team> <time_1> <time_2> … <time_N> [<timezone>]`
if (command := self.extract_valid_command(line)) is not None:
text = command.lstrip(TRIGGER)
if text.startswith('probe_meet') or text.startswith('pm'):
args = text.split(' ')
if len(args) < 4:
await self.send(build("PRIVMSG", [line.params[0], "usage is !probe_meet <title> <team> <time_1> [<time_2> <time_3> … <time_N>] ; time is in [00-hour-]DDMMYYYY format."]))
return
title, team = args[1], args[2]
print(f"creating meet '{title}' for team '{team}'")
try:
times = expand_times(args[3:])
link = await create_meet(title, times)
if team not in TEAMS:
await self.send(build("PRIVMSG", [line.params[0], f"team {team} does not exist"]))
return
targets = TEAMS[team]
ping_mentions = ', '.join(targets)
await self.send(build("PRIVMSG", [line.params[0], f'{ping_mentions} {link}']))
except ValueError as e:
print(e)
await self.send(build("PRIVMSG", [line.params[0], "time format is [00-hour-]DDMMYYYY, hour is optional, by default it's 07:00 to 19:00 in Europe/Paris timezone"]))
except aiohttp.ClientError as e:
print(e)
await self.send(build("PRIVMSG", [line.params[0], "failed to create the meet on meet.dgnum.eu, API error, check the logs"]))
async def line_send(self, line: Line):
print(f"{self.name} > {line.format()}")
class Bot(BaseBot):
def create_server(self, name: str):
return Server(self, name)
async def main():
bot = Bot()
for name, host in SERVERS:
# For IPv4-only connections.
params = ConnectionParams("Takumi", host, 6698)
await bot.add_server(name, params)
await bot.run()
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,71 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
poetry-core,
aiosqlite,
iso8601,
callPackage,
pytz,
ciso8601,
orjson,
uvloop,
aiomysql,
asyncmy,
asyncpg,
psycopg,
pydantic,
pythonRelaxDepsHook,
}:
buildPythonPackage rec {
pname = "tortoise-orm";
version = "0.21.6";
pyproject = true;
src = fetchFromGitHub {
owner = "tortoise";
repo = "tortoise-orm";
rev = version;
hash = "sha256-Gu7MSJbPjaGUN6tmHwkmx7Bdy/+V1wZjmTCQrTDDPkw=";
};
buildInputs = [ pythonRelaxDepsHook ];
pythonRelaxDeps = [
"aiosqlite"
"iso8601"
];
build-system = [ poetry-core ];
dependencies = [
aiosqlite
iso8601
pydantic
(callPackage ./pypika-tortoise.nix { })
pytz
];
optional-dependencies = {
accel = [
ciso8601
orjson
uvloop
];
aiomysql = [ aiomysql ];
asyncmy = [ asyncmy ];
asyncpg = [ asyncpg ];
psycopg = [ psycopg ];
};
pythonImportsCheck = [ "tortoise" ];
meta = {
description = "";
homepage = "https://github.com/tortoise/tortoise-orm";
changelog = "https://github.com/tortoise/tortoise-orm/blob/${src.rev}/CHANGELOG.rst";
license = lib.licenses.asl20;
maintainers = with lib.maintainers; [ raitobezarius ];
};
}

View file

@ -0,0 +1,120 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib) mkEnableOption mkOption mkIf;
cfg = config.dgn-console;
in
{
options.dgn-console = {
enable = mkEnableOption "DGNum console setup." // {
default = true;
};
pg-upgrade-from = mkOption {
type = lib.types.package;
default = config.services.postgresql.package;
};
pg-upgrade-to = mkOption {
type = lib.types.package;
default = pkgs.postgresql_15;
};
};
config = mkIf cfg.enable {
time.timeZone = "Europe/Paris";
console = {
keyMap = "fr";
};
environment.variables.EDITOR = "nvim";
programs = {
neovim.vimAlias = true;
rust-motd = {
enable = true;
settings = {
uptime.prefix = "Up";
filesystems.root = "/";
memory.swap_pos = "below";
last_login.root = 5;
};
};
bash.promptInit = ''
FQDN="$(hostname).$(domainname)"
# Provide a nice prompt if the terminal supports it.
if [ "$TERM" != "dumb" ] || [ -n "$INSIDE_EMACS" ]; then
PROMPT_COLOR="1;31m"
((UID)) && PROMPT_COLOR="1;32m"
if [ -n "$INSIDE_EMACS" ] || [ "$TERM" = "eterm" ] || [ "$TERM" = "eterm-color" ]; then
# Emacs term mode doesn't support xterm title escape sequence (\e]0;)
PS1="\n\[\033[$PROMPT_COLOR\][\u@$FQDN:\w]\\$\[\033[0m\] "
else
PS1="\n\[\033[$PROMPT_COLOR\][\[\e]0;\u@\H: \w\a\]\u@$FQDN:\w]\\$\[\033[0m\] "
fi
if test "$TERM" = "xterm"; then
PS1="\[\033]2;$FQDN:\u:\w\007\]$PS1"
fi
fi
'';
};
system.activationScripts.diff = {
supportsDryActivation = true;
text = ''
${pkgs.nvd}/bin/nvd --nix-bin-dir=${pkgs.nix}/bin diff /run/current-system "$systemConfig"
'';
};
hardware.enableRedistributableFirmware = true;
environment.systemPackages =
(with pkgs; [
neovim
wget
kitty.terminfo
# Utilities
bcc
bottom
cpuid
dig
htop
iftop
mtr
tcpdump
])
++ [ config.boot.kernelPackages.perf ]
++ lib.optional (config.services.postgresql.enable && cfg.pg-upgrade-from != cfg.pg-upgrade-to) (
pkgs.writeScriptBin "upgrade-pg-cluster" ''
set -eux
# XXX it's perhaps advisable to stop all services that depend on postgresql
systemctl stop postgresql
export NEWDATA="/var/lib/postgresql/${cfg.pg-upgrade-to.psqlSchema}"
export NEWBIN="${cfg.pg-upgrade-to}/bin"
export OLDDATA="${config.services.postgresql.dataDir}"
export OLDBIN="${cfg.pg-upgrade-from}/bin"
install -d -m 0700 -o postgres -g postgres "$NEWDATA"
cd "$NEWDATA"
sudo -u postgres $NEWBIN/initdb -D "$NEWDATA"
sudo -u postgres $NEWBIN/pg_upgrade \
--old-datadir "$OLDDATA" --new-datadir "$NEWDATA" \
--old-bindir $OLDBIN --new-bindir $NEWBIN \
"$@"
''
);
};
}

View file

@ -0,0 +1,87 @@
{
pkgs,
lib,
name,
...
}:
let
inherit (lib)
concatStringsSep
length
replicate
splitString
;
inherit (lib.lists) map;
c4 = "(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)";
mkV4' = p: concatStringsSep "\\." (p ++ (replicate (4 - (length p)) c4));
mkV4 = s: mkV4' (splitString "." s);
nft = s: [ "nft" ] ++ [ s ];
streams' = import ./streams.nix;
in
{
# Switch to nftables
networking.nftables.enable = true;
services.reaction = {
enable = true;
extraPackages = [ pkgs.nftables ];
runAsRoot = true;
logLevel = "WARN";
settings = {
patterns = {
ip = {
regex = "(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}|(?:(?:[0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?:(?::[0-9a-fA-F]{1,4}){1,6})|:(?:(?::[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(?::[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(?:ffff(?::0{1,4}){0,1}:){0,1}(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9])|(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(?:25[0-5]|(?:2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
ignoreregex = map mkV4 [
"10" # Legacy wireguard
"129.199" # ENS
"100.80" # Netbird internal
];
ignore = [
"127.0.0.1"
"::1"
];
};
};
start = [
(nft ''
table inet reaction {
set ipv4bans {
type ipv4_addr
flags interval
auto-merge
}
set ipv6bans {
type ipv6_addr
flags interval
auto-merge
}
chain input {
type filter hook input priority 0
policy accept
ip saddr @ipv4bans drop
ip6 saddr @ipv6bans drop
}
}
'')
];
stop = [ (nft "delete table inet reaction") ];
streams = streams'.default // (streams'.${name} or { });
};
};
}

View file

@ -0,0 +1,46 @@
let
act = a: [
"nft46"
"${a} element inet reaction ipvXbans { <ip> }"
];
journalctl = u: [
"journalctl"
"-fn0"
"-u"
"${u}.service"
];
ban = after: {
ban.cmd = act "add";
unban = {
inherit after;
cmd = act "delete";
};
};
available = {
ssh = {
cmd = journalctl "sshd";
filters = {
failedlogin = {
regex = [
"authentication failure;.*rhost=<ip>"
"Connection reset by authenticating user .* <ip>"
"Connection closed by invalid user .* <ip> port .*"
"Failed password for .* from <ip>"
"Invalid user .* from <ip> port .*"
"Unable to negotiate with <ip> port .*"
];
actions = ban "48h";
};
};
};
};
in
builtins.mapAttrs (_: builtins.foldl' (a: s: a // { ${s} = available.${s}; }) { }) {
default = [ "ssh" ];
}

View file

@ -0,0 +1,95 @@
{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
mkEnableOption
mkIf
mkMerge
mkOption
;
inherit (lib.types) listOf str;
cfg = config.dgn-hardware;
in
{
options.dgn-hardware = {
enable = mkEnableOption "default hardware configuration." // {
default = true;
};
useSystemd = mkEnableOption "sytemd boot and configuration." // {
default = true;
};
useZfs = mkEnableOption "zfs configuration.";
useBcachefs = mkEnableOption "bcachefs configuration";
zfsPools = mkOption {
type = listOf str;
default = [
"fast01"
"work01"
];
description = ''
ZFS pools present on the machine
'';
};
};
config = mkIf cfg.enable (mkMerge [
{
microvm.host.enable = lib.mkDefault false;
hardware.enableRedistributableFirmware = true;
hardware.cpu.intel.updateMicrocode = true;
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
boot = {
initrd.availableKernelModules = [
"ata_piix"
"uhci_hcd"
"ehci_pci"
"virtio_pci"
"ahci"
"virtio_blk"
];
kernelModules = [ "kvm-intel" ];
kernelParams = [
"cgroup_enable=cpu"
"cgroup_enable=cpuset"
"cgroup_enable=memory"
"cgroup_memory=1"
];
};
}
(mkIf cfg.useSystemd {
boot.loader = {
systemd-boot.enable = true;
efi.canTouchEfiVariables = true;
};
})
(mkIf cfg.useBcachefs {
boot.supportedFilesystems = [ "bcachefs" ];
boot.kernelPackages = pkgs.linuxKernel.packages.linux_6_7;
})
(mkIf cfg.useZfs {
boot = {
supportedFilesystems = [ "zfs" ];
zfs = {
forceImportRoot = false;
extraPools = cfg.zfsPools;
package = pkgs.zfs_2_1;
};
};
})
]);
}

View file

@ -0,0 +1,51 @@
{
config,
lib,
nodeMeta,
...
}:
let
inherit (config.networking) hostName domain;
in
{
imports = [ ./module.nix ];
options.dgn-netbox-agent = {
enable = lib.mkEnableOption "DGNum netbox agent setup." // {
default = false;
};
};
config = lib.mkIf config.dgn-netbox-agent.enable {
services.netbox-agent = {
enable = true;
settings = {
netbox.url = "https://netbox.dgnum.eu/";
network.ignore_interfaces = "(lo|dummy.*|docker.*|podman.*)";
register = true;
update_all = true;
virtual = {
enabled = nodeMeta.vm-cluster != null;
cluster_name = nodeMeta.vm-cluster;
};
purge_old_devices = true;
hostname_cmd = "echo ${hostName}.${domain}";
datacenter_location = {
driver = "cmd:echo ${nodeMeta.site}";
regex = "(.*)";
};
device = {
tags = "netbox-agent";
# Default role
server_role = "Staging infra";
};
};
randomizedDelaySec = "3h";
environmentFile = config.age.secrets."netbox-agent".path;
};
age-secrets.sources = [ ./secrets ];
};
}

View file

@ -0,0 +1,115 @@
{
config,
pkgs,
lib,
utils,
...
}:
let
inherit (lib)
getExe
mkEnableOption
mkIf
mkOption
mkPackageOption
;
inherit (lib.types)
either
listOf
nullOr
path
str
;
settingsFormat = pkgs.formats.yaml { };
cfg = config.services.netbox-agent;
in
{
options.services.netbox-agent = {
enable = mkEnableOption "Netbox-agent";
package = (mkPackageOption pkgs "netbox-agent" { }) // {
default = pkgs.callPackage ./package.nix { };
};
startAt = mkOption {
type = either str (listOf str);
default = "*-*-* 00:00:00";
description = ''
Automatically start this unit at the given date/time, which
must be in the format described in
{manpage}`systemd.time(7)`.
'';
};
randomizedDelaySec = mkOption {
type = str;
default = "0";
example = "45min";
description = ''
Add a randomized delay before each netbox-agent runs.
The delay will be chosen between zero and this value.
This value must be a time span in the format specified by
{manpage}`systemd.time(7)`
'';
};
settings = mkOption {
inherit (settingsFormat) type;
description = ''
Settings to be passed to the netbox agent. Will be converted to a YAML
config file
'';
default = { };
};
environmentFile = mkOption {
type = nullOr path;
default = null;
description = ''
Environment file to pass to netbox-agent. See `netbox-agent --help` for
possible environment variables
'';
};
};
config = mkIf cfg.enable {
systemd.services.netbox-agent = {
description = "Netbox-agent service. It generates an existing infrastructure on Netbox and have the ability to update it regularly through this service.";
wants = [ "network-online.target" ];
after = [ "network-online.target" ];
serviceConfig = {
Type = "oneshot";
# We could link directly into pkgs.tzdata, but at least timedatectl seems
# to expect the symlink to point directly to a file in etc.
# Setting the "debian timezone file" to point at /dev/null stops it doing anything.
ExecStart = utils.escapeSystemdExecArgs [
(getExe cfg.package)
"-c"
(settingsFormat.generate "config.yaml" cfg.settings)
];
EnvironmentFile = cfg.environmentFile;
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateTmp = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectSystem = "strict";
RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK";
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
};
inherit (cfg) startAt;
};
systemd.timers.netbox-agent.timerConfig.RandomizedDelaySec = cfg.randomizedDelaySec;
};
}

View file

@ -0,0 +1,46 @@
{
lib,
buildPythonPackage,
fetchFromGitHub,
cargo,
rustPlatform,
rustc,
typing-extensions,
}:
buildPythonPackage rec {
pname = "netifaces-2";
version = "0.0.22";
pyproject = true;
src = fetchFromGitHub {
owner = "SamuelYvon";
repo = "netifaces-2";
rev = "V${version}";
hash = "sha256-XO3HWq8FOVzvpbK8mIBOup6hFMnhDpqOK/5bPziPZQ8=";
};
cargoDeps = rustPlatform.fetchCargoTarball {
inherit src;
name = "${pname}-${version}";
hash = "sha256-uoUa6DSBuIV3RrE7svT1TVLxPHdx8BFu/C6mbpRmor0=";
};
build-system = [
cargo
rustPlatform.cargoSetupHook
rustPlatform.maturinBuildHook
rustc
];
dependencies = [ typing-extensions ];
pythonImportsCheck = [ "netifaces" ];
meta = {
description = "Netifaces reborn";
homepage = "https://github.com/SamuelYvon/netifaces-2.git";
license = lib.licenses.mit;
maintainers = with lib.maintainers; [ ];
};
}

View file

@ -0,0 +1,64 @@
{
lib,
python3,
fetchgit,
ethtool,
dmidecode,
ipmitool,
lldpd,
lshw,
}:
python3.pkgs.buildPythonApplication {
pname = "netbox-agent";
version = "unstable-2023-03-19";
pyproject = true;
src = fetchgit {
url = "https://git.dgnum.eu/DGNum/netbox-agent";
rev = "424283239658516feb34c0f68496775350b1bf22";
hash = "sha256-sp1QVy8AIezR2LRDDYS9G0g0GQRwGKGmEE7ykITPxtY=";
};
nativeBuildInputs = with python3.pkgs; [
setuptools
wheel
pythonRelaxDepsHook
];
pythonRelaxDeps = true;
propagatedBuildInputs = with python3.pkgs; [
distro
jsonargparse
netaddr
(callPackage ./netifaces2.nix { })
packaging
pynetbox
python-slugify
pyyaml
];
postInstall = ''
wrapProgram $out/bin/netbox_agent \
--prefix PATH ":" ${
lib.makeBinPath [
ethtool
dmidecode
ipmitool
lldpd
lshw
]
}
'';
pythonImportsCheck = [ "netbox_agent" ];
meta = with lib; {
description = "Netbox agent to run on your infrastructure's servers";
homepage = "https://git.dgnum.eu/DGNum/netbox-agent";
license = licenses.asl20;
maintainers = [ ];
mainProgram = "netbox_agent";
};
}

View file

@ -0,0 +1,53 @@
age-encryption.org/v1
-> ssh-ed25519 jIXfPA zyp8jIQ/BGlaOe2hCYdO2/jpiCJO/yASFn2v4yxF3XE
tnajUOFI/LeiRRK2+XEmgAhU8PfyerYDPZ3CASAx6uE
-> ssh-ed25519 QlRB9Q GTRAu+AUZ2MJs3ZaZR8GcS8U2xyGR0mx1FB78TmVhik
PmenwNgQQUd6JWgUU1zmJWF+Lek4QwCKc0MzD/iLGUE
-> ssh-ed25519 r+nK/Q 2cOo2pK5KN0keAbW62MaC0/wDysciEZPgY8+3vhx30s
ZmjX2vi9qYOVWtctWcEt95l2kBlZH1uNLFUdUxSHyus
-> ssh-rsa krWCLQ
xNCMgSxO8SA2rQqU14RD2TU5PQyssMlWomoA9VjoT6FsYZleRd7nPeABYqlnzUNj
wWk3obSp3AO+NNscnmFrAijYQl0C+hBBplsgEyQ87j60s0ReAZGaURbrxRJr0dr/
2JBrPtQ7tiSQYRZG9DH6ASUYrlVCB3Vq18OOa+os8PpqyL6Q6pglx0ePY1wx9irG
6qj54LAR34C+uOi620LZuJ3YhZYIp0blmxqrXGeVTY1c7mmELKCdslFpiBvKE5jf
71Lj6ihc5Z5kJxi0vPXMXkuGXtmlIr57dre2XWhynuXq9sLj0KEE0GVQa/vMV3Hd
4/ATD4bbpkzwkfZIlL1LRQ
-> ssh-ed25519 /vwQcQ 63EfH8Eu6Rdyz01sN7yfpaQpxJ2w4VqzQRWMw3AMSAk
bj1CFYkCOcoMtuq/mC+vn9YM8aM9rLClcGo1rpytN7k
-> ssh-ed25519 0R97PA gOIroiigdZxulsng29mz0o3gLYnMb5YjmBOmTd9UvHw
mgvgYedm7U1y5BlRcvPEZhHpPEnczungDuBAEGcJwMw
-> ssh-ed25519 JGx7Ng FeQyBpbGZ2WGztFXBpJ5uYXIPIEJqnf2FedleYRQJUc
SzbinTIdwa1pvc9AZSWj2GRR86hD+SHY63QzBSv4D3I
-> ssh-ed25519 5SY7Kg BgCKJrxjRS8QNCndIfySdq2u+hv3Q7Dg/hToWOE8e3g
/rKzCb9fdZTEwTP1/QW4vn1ewQDn5TtV4Ui3MwChdB8
-> ssh-ed25519 p/Mg4Q ftfpqvy3TuWoq+Hcmt+oYiJ1GhwYvR+GDh3MzVsfv3s
I2dj0FSRGfoBqwSetdKz9NX11zUeHxIizmjctYrmjD8
-> ssh-ed25519 DqHxWQ Zs+uNTp/4plSisoBzUpnvlZXLrbYphYvaeogHCyg4As
hvXMQNPnJK7ZQrkYIyHW07rWd06QkNpiNuL3oUXxoQo
-> ssh-ed25519 tDqJRg hMw/doebsExNtZ9oC1OFrnWOsiPOKh3D76RPfw0If00
p5dxioeIt558deMFrRiTMxYocmP6p8kTk/nzSb5yuPA
-> ssh-ed25519 9pVK7Q mctwqK3IkQdbeajO9mbvejtG85rFXTmFdptrzIzP9Cc
sVG1NKMmTR0Sf60hvPJ4QRypmBT4a6yUZ+gyp/Xf+EQ
-> ssh-ed25519 /BRpBQ C6CjF9H+x1fd2s4sjHw0IzKpNvbnr3H0tnxJdwzrzlQ
gcrSM7NoHqeFdsTAWpO23cfAISile0uVEHu4fBvqwME
-> ssh-ed25519 /x+F2Q t6mrvde1VJP7ARlwQAFOQxg6Uu2+GDDzN8GG/F/C5zA
z3jOcIvHjH4TgiMHqABBU5t9bilBtv5rBKHJLMp9CaA
-> ssh-ed25519 +MNHsw 5FBjw08c8F2wqrJe8KfWdn5bjzYmXXqLpVIozq8c8WE
47oEgYMsl6/JtL1JqOOajHdB22gIdIGhhtcchUK7ZX4
-> ssh-ed25519 rHotTw 4/W5DKJCc18KOcJQ1s4DveOVEjf5oy3HeQF5AThpvFM
vG9LsTXTFk6TLHNDDS3qtirjm7iyZnhN+FM++xU0qGI
-> ssh-ed25519 +mFdtQ bh0b+b2J2dg9hpBVYM3hDUwJOO/xi+dcH41abtVjt2E
NPU1M+fXjOSROEWY73hftAniWUpr0ymbfo8mqZTPC/M
-> ssh-ed25519 0IVRbA ioMW4UYJ+kKlZBdf430FHnbqdw3BcwWSr2RmOHCv+hA
qw0VDAu93LSEZqhs9nRTCMGWsXKjxK65VfkKJbUU5fY
-> ssh-ed25519 IY5FSQ 1aD4KWKITo+88CEwuTKq1QH+Pf5qoOXlI+EY2FX9IG4
KzOGMeIxLypf7S6WeUM4Zr/S/g9HWXHBGcKkgHMLRJc
-> ssh-ed25519 VQSaNw fCt2YDODTAtamSSYH+RNIpWAQ53WPwOeR92rHa89QBE
2KAY4EgfxnNxvQGV4lgoGT+sb4nJV1eE50GHRljngEo
-> x!p-grease Qza ,IU!}' (fMHX0~ m
DGgaSNyr7o+hl8p9viIHBbTdiTdY79TgFsTdM2oBJAqT5P/LkFzg8TYNsH04eReH
dmTu9wjN2OM
--- +/E2Y1+KnzcreXm8DtJE39wR4dVL6vneloVFzK33c8Y
T|ïá+¡ÆTÔŒÄ
vΧ“8»,OÔ¸lžÇ±z)/0­<30>>hkJMèl öÝ®GØßûGÜ>lU¯1Ÿ}€Š¤£T<C2A3>ÞhèÅý,åÎ8Åç%ßÓ¤lQ‰
ëb©,@

View file

@ -0,0 +1 @@
{ netbox-agent.publicKeys = (import ../../../keys).machineKeys; }

View file

@ -0,0 +1,54 @@
{
config,
lib,
meta,
name,
nodeMeta,
...
}:
let
inherit (lib) mapAttrs' mkEnableOption mkIf;
net' = meta.network.${name};
mkAddress = { address, prefixLength, ... }: "${address}/${builtins.toString prefixLength}";
mkRoute = gateway: {
routeConfig = {
Gateway = gateway;
GatewayOnLink = true;
};
};
mkInterface = interface: net: {
name = "10-${interface}";
value = {
name = interface;
address = builtins.map mkAddress (net.ipv4 ++ net.ipv6);
routes = builtins.map mkRoute net.gateways;
inherit (net) DHCP dns;
};
};
cfg = config.dgn-network;
in
{
options.dgn-network.enable = mkEnableOption "automatic network configuration based on metadata" // {
default = true;
};
config = mkIf cfg.enable {
networking = {
inherit (net') hostId;
hostName = name;
domain = "${nodeMeta.site}.infra.dgnum.eu";
useNetworkd = true;
firewall.logRefusedConnections = false;
};
systemd.network.networks = mapAttrs' mkInterface net'.interfaces;
};
}

View file

@ -0,0 +1,43 @@
{ config, lib, ... }:
let
inherit (lib)
mkEnableOption
mkIf
mkOption
types
;
cfg = config.dgn-node-monitoring;
in
{
options.dgn-node-monitoring = {
enable = mkEnableOption "DGNum nodes monitoring (needs a valid netbird tunnel)" // {
default = true;
};
port = mkOption {
type = types.port;
default = 9002;
description = lib.mdDoc ''
Port to listen on.
'';
};
};
config = mkIf cfg.enable {
services.prometheus = {
exporters = {
node = {
enable = true;
enabledCollectors = [
"processes"
"systemd"
];
inherit (cfg) port;
listenAddress = "0.0.0.0";
};
};
};
networking.firewall.interfaces.wt0.allowedTCPPorts = [ cfg.port ];
};
}

View file

@ -0,0 +1,73 @@
{
config,
pkgs,
lib,
meta,
nodeMeta,
...
}:
let
inherit (lib)
concatStringsSep
mkEnableOption
mkForce
mkIf
;
emails = concatStringsSep ", " (
builtins.map (name: meta.organization.members.${name}.email) (
builtins.foldl' (
admins: group: admins ++ meta.organization.groups.${group}
) nodeMeta.admins nodeMeta.adminGroups
)
);
cfg = config.dgn-notify;
in
{
options.dgn-notify = {
enable = mkEnableOption "DGNum email notification cli" // {
default = true;
};
};
config = mkIf cfg.enable {
services.mail.sendmailSetuidWrapper.group = mkForce "mail";
users.groups.mail = { };
programs.msmtp = {
enable = true;
setSendmail = true;
accounts.default = {
tls = true;
tls_starttls = false;
port = 465;
auth = true;
host = "kurisu.lahfa.xyz";
from = "noreply@infra.dgnum.eu";
user = "web-services@infra.dgnum.eu";
passwordeval = "cat ${config.age.secrets.mail.path}";
};
};
services.systemd-notify = {
enable = true;
command = builtins.toString (
pkgs.writeShellScript "sendmail" ''
${pkgs.msmtp}/bin/sendmail -i -t <<ERRMAIL
To: admins+monitoring@dgnum.eu, ${emails}
Subject: [$HOSTNAME] Systemd failure: $1
Content-Transfer-Encoding: 8bit
Content-Type: text/plain; charset=UTF-8
$(systemctl status --full "$1")
ERRMAIL
''
);
};
age-secrets.sources = [ ./. ];
};
}

View file

@ -0,0 +1,50 @@
age-encryption.org/v1
-> ssh-ed25519 jIXfPA zVe/xwQCEtVnX8qWShePzBmhfQbENRMn8XgPzEqb1gY
CFa4qXtY8lBlSIxnWVbejVta8BFYmsCtp9TdXXexZYE
-> ssh-ed25519 QlRB9Q 3EJeDSBkwJU2LaKHygG/a0tfFRXcp8SNJBxyhIOwBDU
PI63A4YJFC1XTNPbl73SBlUMV25o0ZeojAr8tr5mtlY
-> ssh-ed25519 r+nK/Q /4HQCHPBBk7lD2mwJOMEmTeRpPGnPgTcL+htNRvkxkk
oXLQoX+AK8zn82wsnLHK4BOpK3gn5lThWiFXh8+rxUU
-> ssh-rsa krWCLQ
ox5TBB9buXII2U32S/XpQVdr3r87p9lt/7WwELq//ik7vf4B6mZPbYvIV05JZ5bO
4f5khDdw+q1bbniDCBH2aPKM0ni3wBdLkT3jwdQBL5imSQuly/cFMdvVwSTuwN9k
8smSavsUYK5q5xgE49oMYJBAhNVGFI7NKlx7/a3VaVybsLAnzp3AeWy1o5BB1fKT
7Emt88ht4lymL/gyxSMLT5Dreb5Sm+AcE+gYAK92OnX4Z1k8FqETppTKZDuoZUmv
hJPpylXw7/YJU7Q0CluwtcGWFaTuE6AT0IrlCdY3NuMGA9IfpVsZr1kocdq2qB02
90/yy51Ulwhfhiy1/3mlfw
-> ssh-ed25519 /vwQcQ SGCKP+4m5p9SiYnU3vL0QaKp/3+yztZ0snZ6os+mUAo
ZEVgV6jo7tRdM1KxQJ2UJRDEYaiQy9PYzaeSAstHYFQ
-> ssh-ed25519 0R97PA Bblz2DxUIBovbFqHhwGSRrs3Hbc1vNMtn8SK976YYAU
FtEsqOUChH+uzFuTsATraNyyJXXdkesmbe8T+LeK9nU
-> ssh-ed25519 JGx7Ng 3umTe7hK60ghA4fXbapBRjjJ9K6hXLfV5kQrBzwsmS8
oRBFSJsVStw2ul6JxdJuan18GriwYF+d8asKXnWDpZA
-> ssh-ed25519 5SY7Kg Ft714PG5dVVJWHu0aJh+wdT04vSb/vlDVsWmUhdjUXw
qY7OJduSibFheBQOrGnSFUOhou/WyyY/M5tAYGvaTJI
-> ssh-ed25519 p/Mg4Q u6ES8PpiDb1OY97sMQ/kL6sTIjBhDk1aqoIEd0I5BgA
pbX1Wk+5aTbf5rU2JM0rf4SR/fJGLKcDcqLF1yDXbiE
-> ssh-ed25519 DqHxWQ n8qHGzdwY1RfajPN+oZV0Ps44rqbW5tcUFSSPbyZmAw
EAK0hA/94/ZxBz0iNaTl2RlpswiO+2eIWugozHrZZfw
-> ssh-ed25519 tDqJRg RAEIORbyHLRNkm+mFsq07E1uzbEEIBQ3eG+kpyXLLG0
1S7gL5WgXiFZxgH6kSp1zANafDTEKsC4Wo4kT8oB7b8
-> ssh-ed25519 9pVK7Q p7tGHwbC3CWap6feMXq2twGHkyszLP0EKwhW4McAoj4
7F0zZEON8H2H+v0XRCOiYeUuhJBRUVkFoEP+Cz4vHZo
-> ssh-ed25519 /BRpBQ stXNcOvGwPBPz8TtLhQUVgpcvu4BtfUACAZtrEI0eGY
FN2yFmvc3GhMGNTUCT+XMr1qsfLvmjHIkYoi5B3MDsE
-> ssh-ed25519 /x+F2Q fmGbMAGFJbjR0zVdJqsigKQ28nbDq8Zx1FsgviLWqHc
+v09rkeHZTvFQLaXfOnFaZMBc2G2BD5dXWYg/Nlx2Og
-> ssh-ed25519 +MNHsw KqIxZ4L1aoqLevCwx6Zp0jBHfTOU7WdrE0UN56/xARE
OwQ2/WUEfl/oXxfbv5rlLu4OOdrACzPfSS6HfcLpi60
-> ssh-ed25519 rHotTw hwCwUHi/xbAQaWt26kOn3/QSP0m0ZKRdIYs55TDMLSM
DCvnBearzyPQ6ErYuawsyobpMsD9SSEhkVmFKyp5jUI
-> ssh-ed25519 +mFdtQ ZlEsxLPDfy29aIQ9eNsRkZCHSeRmX8+GsuGtikQF4ms
n1N2xQb4oRWaJgLtrXMFasc8u516e1M4Q/qLNLA0e0A
-> ssh-ed25519 0IVRbA keVcQ4Vx3Avd97N89nUklRnGMABBenHIi+aufVoTABU
yrsC1OitS6sqbUsaIaWeU8vYGOQm9afFfc6DprB8Whc
-> ssh-ed25519 IY5FSQ npdYCAEfVSpuDNMZnWS469BgvivTKHRKtEAtxmxDZl8
gOB1vpBO8ZqtLVwxCj8V/KrWgnYmZGn5QQJzMhiHH4A
-> ssh-ed25519 VQSaNw S3dSnOPVQdMcz1dJYai0DvZATuMBDsG/+a0sJBDc/iE
Q1gl1nIpDESMvTBX03i4lStAtdWqlTkVABHZ3cqocDE
-> t-grease bvZAq
NTQBWWf5UW4zsTEEt7rgmTv+B2rFIk/8WwQPrC/s59Ik
--- 46n57xU0XlDQgUM0vIYveqDifz57FrTcRwCEpoh62+4
[07Æ~Þ3dïálÃÅô!fãš4ÐHßA‡íœ÷<C593>9 ×ò@d»BÌ&½L‰{aãþm…X2ãD‰Å ´

View file

@ -0,0 +1 @@
{ mail.publicKeys = (import ../../../keys).machineKeys; }

View file

@ -0,0 +1,50 @@
age-encryption.org/v1
-> ssh-ed25519 jIXfPA YaucboAId6lgc1Y/jV6hLyovkJQnMBnKhJ2QWAci53U
Q8RUPu4GUC5QbzTROgL9xaG3BUWO1QU/q1p0/yimBQ0
-> ssh-ed25519 QlRB9Q y1tbd/81NoECRtKwOw41Tlls5y+WSu2jGmeOlC939VM
DT1zZgWJkkIWRWxzfu4VgiGpV8CioaDKnVemowH59N4
-> ssh-ed25519 r+nK/Q dDmGkZ3Y7xAzZGKvGIyIdhD+P0tkV6SMPx3UxphoTXo
tkanRbPfu3/cuMPoTrcWBlNcu6RmK+txif+9aIRLy+s
-> ssh-rsa krWCLQ
IZGpFoWjQuQzqkS2KbpVr+fP7NLPhyaxS4yQroVEkPEZnXx2c6eH3ul218zytZld
YRBCxiCtV6VfOB2N2QGuiK7YCGl6oUfN1DePy0jPrGKsnvWBitTuqzADiGQB7aSI
ie7GgblPpi4q3ovJPgf7Bs+Mi2dKW5hiD8Jnped7rEW7SEnESkQa3Cx22Ww/UYcW
9Uj7ZaDVVbP0ZWyc41HdoJwEnV6MYMRnXUJ/qrLMCIvRaYk8UdiCDgco+XxqAnbs
iyUqCvz8iVNsWbJxK+7jJHXp0dQJRciHzSGStIVRSGx4gvuXOGjsuBMjfwoq1XoR
5PE3BnP/atHZg3CkQcC2eA
-> ssh-ed25519 /vwQcQ WL0PdIIsSWzw+ar2QNXCp7Xs1NH9gUk2fSPskGC9o2I
+kHedFsYHgpsGfILtywJaIrTRj8HtHZvVyhtbRhKYC0
-> ssh-ed25519 0R97PA +G7wUHF6NJimsAxe6M9RVVTa3GLPoW1bhsgMsWXKNC8
i++lKoe8hFFb1rilkO9lcwBJujRqFsLGDOPvbaiz6Nw
-> ssh-ed25519 JGx7Ng o66YGXN0uMC2qZo1tVcEMOa4SwxNZaf4HvnGsgzlqjo
Tc4KMMrnJbybrNIkhEJz42PVHc3fVMFFSs96lKsEKCA
-> ssh-ed25519 5SY7Kg P8Xp9wVJDcPdj3uSiq0yLnLMDInMeFs6XX30VwlXWlg
uJfxXOZl8EX8fjRsHZ61JMKFpYksZJucZwVaRJs7qW8
-> ssh-ed25519 p/Mg4Q yUyxue7Oda0b+CjdF9VfUCliWyzXNOsVPH7OFoHzWCw
+zi+TSojvSc+VDXZG8XXSsTezxKRNC2XHc/hGGv4baM
-> ssh-ed25519 DqHxWQ 7Vnq/xidbguw/PkZPUOTHUBTe8/x4PvTjCusUe10jio
7Sl1MptpElvEA9VUj7JiVGuEWC0F3aA2rgYvfIchOB0
-> ssh-ed25519 tDqJRg udOCDV4/vszObNxcQhJ6iGiDkxgZlrBDyKt3MbibMx4
CDDd0LNCCdYvEww/h8q2z4f5QtjnL+kJsnPFtlbiD28
-> ssh-ed25519 9pVK7Q DXqkIewHGpUUDtL2ivAoFwY/HCjoQXjxoHGPGkuFfH0
JZ7xC2kdtnRNq8WADL2SNw/Ukezu1s4TuUbQnbP8L4o
-> ssh-ed25519 /BRpBQ 9j1+wzO733ej03ra8LQOkpOyvY63UCbO9sfT6bV6+zs
2F0UjpAqgCK5JS0y0kkHX30EV8JCcjhnJ1NkW06ww4w
-> ssh-ed25519 /x+F2Q wYchtMn7MCGllfiFwTrycdLEY3dl297ns26PHs7l320
feRd57Z5k6iJ71JRHud0wyYWo3O56q4rrYZt5y3aoqA
-> ssh-ed25519 +MNHsw FHfvx1FQWcsRlKrFF0SRcVZ+XG6LXBwIMcPCVeu/ZCg
w9fZGhZpEJHlf8JPcbWcNoAO9S06hi15LZxkv1dJUWk
-> ssh-ed25519 rHotTw QDcThfb0AJMQBfQDbbtqm6z7BGxC4/sBioprElUTXFA
2JOFoMLcVhMoGzZDDNOTL3PBWsqVnrFx8o/W/cWuzl0
-> ssh-ed25519 +mFdtQ tWg17VH1Q4gQj/1IK9yrxjw4kRPzsp4dDHFwDKYxvDE
9H4ohD3XN4Xtk15SsZQf5k0db+yIVcWp4EV5jKsZgHI
-> ssh-ed25519 0IVRbA rkMPsBgVEaiYtaBN5JzHNCPFYFKr/7dqoY+RX19+03o
baQK5t5sG8WabaCuMTZ2ZIfMTRH0jQU4l7JEyJ6H+LU
-> ssh-ed25519 IY5FSQ c1+2+CMJFMw/iF2XNx5ma28KhwdKKQ9dNC1nBvFz/B0
3AE1FQq+//dNIQfuW9BHcpfNbGn724Ydq7aJc95KmmY
-> ssh-ed25519 VQSaNw t9yLak0T7FO8hgGrPWFeR3Jw0D6cPxjR5LOIcMnAmgo
869SBp0nM5v/9+Xjib6rkmmelhTBfXcyuHiAXh08AWo
-> r32t]I\-grease ka<*
nkxH0w1aQ64
--- LlTR5EcQzCLJ5trkQcomW0+soQoec/IZZNW+g5dyOo0
M"ÏLm“õh]ñÖa£uq±ýÏ4ßÏ+ö“9;ФˆÇ-Z±L»¯H0o1»Eâ<>

View file

@ -0,0 +1,25 @@
{ config, lib, ... }:
let
inherit (lib) mkEnableOption mkIf;
cfg = config.dgn-records;
in
{
options.dgn-records.enable = mkEnableOption "Arkheon deployment recording." // {
default = true;
};
config = mkIf cfg.enable {
services.arkheon.record = {
enable = true;
tokenFile = config.age.secrets."__arkheon-token_file".path;
url = "https://arkheon.dgnum.eu";
};
age-secrets.sources = [ ./. ];
};
}

View file

@ -0,0 +1 @@
{ __arkheon-token_file.publicKeys = (import ../../../keys).machineKeys; }

View file

@ -0,0 +1,136 @@
{ config, lib, ... }:
let
inherit (lib) mkOption;
inherit (lib.types)
attrsOf
ints
listOf
str
submodule
;
mkRetired =
hosts:
builtins.listToAttrs (
builtins.map (name: {
inherit name;
value = {
enableACME = true;
forceSSL = true;
locations."/".return = "301 https://${cfg.retiredHost}/${name}";
};
}) hosts
);
mkPermanent = _: globalRedirect: {
inherit globalRedirect;
enableACME = true;
forceSSL = true;
};
mkTemporary =
_:
{
to,
code,
location,
}:
{
enableACME = true;
forceSSL = true;
locations.${location}.return = "${toString code} ${to}";
};
cfg = config.dgn-redirections;
in
{
options.dgn-redirections = {
permanent = mkOption {
type = attrsOf str;
default = { };
description = ''
Attribute set of redirections, for:
{ a = b; },
a redirection from a to b will be made.
'';
};
temporary = mkOption {
type = attrsOf (submodule {
options = {
to = mkOption {
type = str;
description = "Target of the redirection";
};
code = mkOption {
type = ints.between 300 399;
default = 302;
example = 308;
description = ''
HTTP status used by the redirection. Possible usecases
include temporary (302, 307) redirects, keeping the request method and
body (307, 308), or explicitly resetting the method to GET (303).
See <https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections>.
'';
};
location = mkOption {
type = str;
default = "/";
description = "nginx-style location for the source of the redirection";
};
};
});
default = { };
example = {
"source.dgnum.eu" = {
to = "https://target.dgnum.eu/path_to_page";
code = 307;
location = "/subpath/";
};
};
description = ''
Attribute set of temporary redirections. The attribute is the source
domain.
For:
```
{
"source.dgnum.eu" = {
to = "https://target.dgnum.eu/path_to_page";
code = 307;
};
}
```
a 307 redirect from all the urls within the domain `source.dgnum.eu` to
`https://target.dgnum.eu/path_to_page` will be made.
'';
};
retired = mkOption {
type = listOf str;
default = [ ];
description = ''
List of retired domains, they will we redirected to `retired.dgnum.eu/$host`.
'';
};
retiredHost = mkOption {
type = str;
default = "retired.dgnum.eu";
description = ''
Host used for the redirections of retired services.
'';
};
};
config = {
services.nginx.virtualHosts =
(builtins.mapAttrs mkPermanent cfg.permanent // builtins.mapAttrs mkTemporary cfg.temporary)
// (mkRetired cfg.retired);
};
}

65
modules/nixos/dgn-ssh.nix Normal file
View file

@ -0,0 +1,65 @@
# Copyright :
# - Maurice Debray <maurice.debray@dgnum.eu> 2023
# - Tom Hubrecht <tom.hubrecht@dgnum.eu> 2023
#
# Ce logiciel est un programme informatique servant à déployer des
# configurations de serveurs via NixOS.
#
# Ce logiciel est régi par la licence CeCILL soumise au droit français et
# respectant les principes de diffusion des logiciels libres. Vous pouvez
# utiliser, modifier et/ou redistribuer ce programme sous les conditions
# de la licence CeCILL telle que diffusée par le CEA, le CNRS et l'INRIA
# sur le site "http://www.cecill.info".
#
# En contrepartie de l'accessibilité au code source et des droits de copie,
# de modification et de redistribution accordés par cette licence, il n'est
# offert aux utilisateurs qu'une garantie limitée. Pour les mêmes raisons,
# seule une responsabilité restreinte pèse sur l'auteur du programme, le
# titulaire des droits patrimoniaux et les concédants successifs.
#
# A cet égard l'attention de l'utilisateur est attirée sur les risques
# associés au chargement, à l'utilisation, à la modification et/ou au
# développement et à la reproduction du logiciel par l'utilisateur étant
# donné sa spécificité de logiciel libre, qui peut le rendre complexe à
# manipuler et qui le réserve donc à des développeurs et des professionnels
# avertis possédant des connaissances informatiques approfondies. Les
# utilisateurs sont donc invités à charger et tester l'adéquation du
# logiciel à leurs besoins dans des conditions permettant d'assurer la
# sécurité de leurs systèmes et ou de leurs données et, plus généralement,
# à l'utiliser et l'exploiter dans les mêmes conditions de sécurité.
#
# Le fait que vous puissiez accéder à cet en-tête signifie que vous avez
# pris connaissance de la licence CeCILL, et que vous en avez accepté les
# termes.
{ config, lib, ... }:
let
inherit (lib) mkEnableOption mkIf;
cfg = config.dgn-ssh;
in
{
options.dgn-ssh = {
enable = mkEnableOption "ssh default configuration." // {
default = true;
};
};
config = mkIf cfg.enable {
services.openssh = {
enable = true;
settings = {
KbdInteractiveAuthentication = false;
LoginGraceTime = "30";
MaxSessions = "64";
MaxStartups = "64";
PasswordAuthentication = false;
};
};
programs.mosh.enable = true;
};
}

View file

@ -0,0 +1,18 @@
{ config, lib, ... }:
let
inherit (lib) mkEnableOption mkIf;
cfg = config.dgn-vmVariant;
in
{
options.dgn-vmVariant.enable = mkEnableOption "ACME settings." // {
default = true;
};
config = mkIf cfg.enable {
virtualisation.vmVariant = {
services.resolved.dnssec = "false";
};
};
}

150
modules/nixos/dgn-web.nix Normal file
View file

@ -0,0 +1,150 @@
{ config, lib, ... }:
let
inherit (lib)
attrsToList
concatStringsSep
filterAttrs
getAttr
mapAttrs
mapAttrs'
mkEnableOption
mkIf
mkOption
nameValuePair
recursiveUpdate
;
inherit (lib.types)
attrs
attrsOf
bool
port
str
submodule
;
cfg = config.dgn-web;
in
{
options.dgn-web = {
enable = mkEnableOption "sane defaults for web services.";
internalPorts = mkOption {
type = attrsOf port;
default = { };
description = ''
Map from the web services to their internal ports, it should avoid port clashes.
'';
};
simpleProxies = mkOption {
type = attrsOf (submodule {
options = {
port = mkOption {
type = port;
description = ''
Port where the service will listen.
'';
};
host = mkOption {
type = str;
description = ''
Hostname of the service.
'';
};
proxyWebsockets = mkOption {
type = bool;
default = false;
description = ''
Whether to support proxying websocket connections with HTTP/1.1.
'';
};
vhostConfig = mkOption {
type = attrs;
default = { };
description = ''
Additional virtualHost settings.
'';
};
};
});
default = { };
description = ''
A set of simple localhost redirections.
'';
};
};
config = mkIf cfg.enable {
assertions = [
(
let
duplicates = builtins.attrValues (
builtins.mapAttrs (p: serv: "${p}: ${concatStringsSep ", " serv}") (
filterAttrs (_: ls: builtins.length ls != 1) (
builtins.foldl' (
rev:
{ name, value }:
let
str = builtins.toString value;
in
rev // { ${str} = (rev.${str} or [ ]) ++ [ name ]; }
) { } (attrsToList cfg.internalPorts)
)
)
);
in
{
assertion = duplicates == [ ];
message = ''
Internal ports cannot be used for multiple services, the clashes are:
${concatStringsSep "\n " duplicates}
'';
}
)
];
dgn-web.internalPorts = mapAttrs (_: getAttr "port") cfg.simpleProxies;
services.nginx = {
enable = true;
virtualHosts = mapAttrs' (
_:
{
host,
port,
proxyWebsockets,
vhostConfig,
}:
nameValuePair host (
recursiveUpdate {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:${builtins.toString port}";
inherit proxyWebsockets;
};
} vhostConfig
)
) cfg.simpleProxies;
recommendedBrotliSettings = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
recommendedZstdSettings = true;
};
networking.firewall.allowedTCPPorts = [
80
443
];
};
}

View file

@ -0,0 +1,67 @@
diff --git a/internal/hook/hook.go b/internal/hook/hook.go
index 0510095..0347f26 100644
--- a/internal/hook/hook.go
+++ b/internal/hook/hook.go
@@ -13,12 +13,12 @@ import (
"errors"
"fmt"
"hash"
- "io/ioutil"
"log"
"math"
"net"
"net/textproto"
"os"
+ "path"
"reflect"
"regexp"
"strconv"
@@ -750,14 +750,18 @@ func (h *Hooks) LoadFromFile(path string, asTemplate bool) error {
}
// parse hook file for hooks
- file, e := ioutil.ReadFile(path)
+ file, e := os.ReadFile(path)
if e != nil {
return e
}
if asTemplate {
- funcMap := template.FuncMap{"getenv": getenv}
+ funcMap := template.FuncMap{
+ "cat": cat,
+ "credential": credential,
+ "getenv": getenv,
+ }
tmpl, err := template.New("hooks").Funcs(funcMap).Parse(string(file))
if err != nil {
@@ -956,3 +960,27 @@ func compare(a, b string) bool {
func getenv(s string) string {
return os.Getenv(s)
}
+
+// cat provides a template function to retrieve content of files
+// Similarly to getenv, if no file is found, it returns the empty string
+func cat(s string) string {
+ data, e := os.ReadFile(s)
+
+ if e != nil {
+ return ""
+ }
+
+ return strings.TrimSuffix(string(data), "\n")
+}
+
+// credential provides a template function to retreive secrets using systemd's LoadCredential mechanism
+func credential(s string) string {
+ dir := getenv("CREDENTIALS_DIRECTORY")
+
+ // If no credential directory is found, fallback to the env variable
+ if dir == "" {
+ return getenv(s)
+ }
+
+ return cat(path.Join(dir, s))
+}

View file

@ -0,0 +1,710 @@
{
config,
lib,
options,
pkgs,
utils,
...
}:
let
inherit (lib)
attrNames
concatLists
concatMapAttrs
filterAttrs
getExe
getExe'
literalExpression
mapAttrs
mapAttrs'
mapAttrsToList
mkEnableOption
mkIf
mkMerge
mkOption
mkPackageOption
nameValuePair
optional
optionals
recursiveUpdate
toUpper
;
inherit (lib.types)
attrs
attrsOf
enum
functionTo
ints
listOf
nullOr
package
path
str
submodule
;
inherit (utils) escapeSystemdExecArgs;
cfg = config.services.django-apps;
# Alias the global config to allow its use when the identifier is shadowed
config' = config;
systemctl = getExe' config.systemd.package "systemctl";
in
{
options.services.django-apps = {
enable = mkEnableOption "automatic django apps management";
webhook = {
domain = mkOption {
type = str;
description = ''
The domain where the webhook service will listen.
'';
};
nginx = mkOption {
type = nullOr options.services.nginx.virtualHosts.type.nestedTypes.elemType;
default = null;
description = ''
With this option, you can customize the nginx virtualHost settings.
'';
example = literalExpression ''
{
# To enable encryption and let Let's Encrypt take care of certificate
forceSSL = true;
enableACME = true;
}
'';
};
};
sites = mkOption {
type = attrsOf (
submodule (
{ name, config, ... }:
{
options = {
source = mkOption {
type = str;
description = ''
The URI where the source of the app can be publicly fetched via git.
'';
};
branch = mkOption {
type = str;
default = "production";
description = ''
Branch to follow for updates to the source.
'';
};
domain = mkOption {
type = str;
description = ''
The domain where the web app will be served.
'';
};
nginx = mkOption {
type = nullOr options.services.nginx.virtualHosts.type.nestedTypes.elemType;
default = null;
description = ''
With this option, you can customize the nginx virtualHost settings.
'';
example = literalExpression ''
{
# To enable encryption and let Let's Encrypt take care of certificate
forceSSL = true;
enableACME = true;
}
'';
};
env_prefix = mkOption {
type = str;
default = toUpper name;
description = ''
The prefix to use for environment settings declaration.
'';
};
application = {
type = mkOption {
type = enum [
"asgi"
"wsgi"
"daphne"
];
default = "wsgi";
description = ''
Specification for the django application.
'';
};
module = mkOption {
type = str;
default = "app";
description = ''
Name of the module containing the application interface.
'';
};
settingsModule = mkOption {
type = str;
default = "${config.application.module}.settings";
description = ''
The django settings module, will be passed as an environment variable to the app.
'';
};
workers = mkOption {
type = ints.positive;
default = 4;
description = ''
Number of workers processes to use.
'';
};
channelLayer = mkOption {
type = str;
default = "channel_layer";
description = ''
Channel layer to use when running the application with daphne.
'';
};
};
python = mkPackageOption pkgs "python3" { };
django = mkOption {
type = functionTo package;
default = ps: ps.django;
defaultText = literalExpression "ps: ps.django";
description = ''
The django version to use to run the app.
'';
};
djangoEnv = mkOption {
type = package;
default = config.python.withPackages (
ps:
[ (config.django ps) ]
++ (optional (config.application.type != "daphne") ps.gunicorn)
++ (optional (config.application.type == "asgi") ps.uvicorn)
++ (optional (config.dbType == "postgresql") ps.psycopg)
++ (config.dependencies ps)
);
description = ''
The python version used to run the app, with the correct dependencies.
'';
};
dependencies = mkOption {
type = functionTo (listOf package);
default = _: [ ];
example = literalExpression "ps: [ ps.requests ]";
description = ''
Python dependencies of the app.
'';
};
extraPackages = mkOption {
type = listOf package;
default = [ ];
description = ''
Packages that will be added to the path of the app.
'';
};
credentials = mkOption {
type = attrsOf path;
default = { };
description = ''
The files containing credentials to pass through `LoadCredential` to the application.
'';
};
environment = mkOption {
type = attrsOf (pkgs.formats.json { }).type;
default = { };
description = ''
Environment variables to pass to the app.
'';
};
managePath = mkOption {
type = str;
default = "manage.py";
description = ''
Path to the manage.py file inside the source
'';
};
extraServices = mkOption {
type = attrs;
default = { };
description = ''
Extra services to run in parallel of the application.
May be used to run background tasks and/or workers.
'';
};
manageScript = mkOption {
type = package;
default = pkgs.writeShellApplication {
name = "${name}-manage";
runtimeInputs = [
pkgs.util-linux
config'.systemd.package
config.djangoEnv
] ++ config.extraPackages;
text = ''
MainPID=$(systemctl show -p MainPID --value dj-${name}.service)
nsenter -e -a -t "$MainPID" -G follow -S follow python /var/lib/django-apps/${name}/source/${config.managePath} "$@"
'';
};
description = ''
Script to run manage.py related tasks.
'';
};
updateScript = mkOption {
type = package;
default = pkgs.writeShellApplication {
name = "dj-${name}-update-source";
runtimeInputs = [
config.djangoEnv
pkgs.git
];
text = ''
git pull
python3 ${config.managePath} migrate
python3 ${config.managePath} collectstatic --no-input
'';
};
description = ''
Script to run when updating the app source.
'';
};
webHookSecret = mkOption {
type = path;
description = ''
Path to the webhook secret.
'';
};
dbType = mkOption {
type = enum [
"manual"
"postgresql"
"sqlite"
];
default = "postgresql";
description = ''
Which database backend to use, set to `manual` for custom declaration.
'';
};
baseDirectory = mkOption {
type = str;
readOnly = true;
default = "/var/lib/django-apps/${name}";
};
sourceDirectory = mkOption {
type = str;
readOnly = true;
default = "${config.baseDirectory}/source";
};
staticDirectory = mkOption {
type = str;
default = "static";
description = ''
Path to the staticfiles directory.
This is relative to the base directory, e.g. the parent of the source directory.
'';
};
mediaDirectory = mkOption {
type = str;
default = "media";
description = ''
Path to the media files directory.
This is relative to the base directory, e.g. the parent of the source directory.
'';
};
};
}
)
);
};
};
config = mkIf cfg.enable {
security.sudo.extraRules = [
{
users = [ "webhook" ];
commands = builtins.map (name: {
command = "${systemctl} start dj-${name}-update.service";
options = [ "NOPASSWD" ];
}) (attrNames cfg.sites);
}
];
environment.systemPackages = mapAttrsToList (_: { manageScript, ... }: manageScript) cfg.sites;
services = {
webhook = {
enable = true;
package = pkgs.webhook.overrideAttrs (old: {
patches = (old.patches or [ ]) ++ [ ./01-webhook.patch ];
});
# extraArgs = [ "-debug" ];
# Only listen on localhost
ip = "127.0.0.1";
hooksTemplated = mapAttrs' (
name:
{ branch, ... }:
nameValuePair "dj-${name}" (
# Avoid issues when quoting "dj-name" through builtins.toJSON
builtins.replaceStrings [ "\\" ] [ "" ] (
builtins.toJSON {
id = "dj-${name}";
execute-command = "/run/wrappers/bin/sudo";
pass-arguments-to-command =
builtins.map
(name: {
inherit name;
source = "string";
})
[
systemctl
"start"
"dj-${name}-update.service"
];
# command-working-directory = "/var/lib/django-apps/${name}";
trigger-rule = {
and = [
{
or = [
{
match = {
type = "payload-hmac-sha256";
secret = ''{{ credential "dj-${name}" | js }}'';
parameter = {
source = "header";
name = "X-Hub-Signature-256";
};
};
}
{
match = {
type = "value";
value = ''{{ credential "dj-${name}" | js }}'';
parameter = {
source = "header";
name = "X-Gitlab-Token";
};
};
}
];
}
{
match = {
type = "value";
value = "refs/heads/${branch}";
parameter = {
source = "payload";
name = "ref";
};
};
}
];
};
}
)
)
) cfg.sites;
};
nginx = mkMerge [
(mkIf (cfg.webhook.nginx != null) {
enable = true;
virtualHosts = {
${cfg.webhook.domain} = mkMerge [
{ locations."/".proxyPass = "http://127.0.0.1:${builtins.toString config.services.webhook.port}"; }
cfg.webhook.nginx
];
};
})
{
virtualHosts = mapAttrs' (
name:
{ domain, nginx, ... }:
nameValuePair domain (
recursiveUpdate {
locations = {
"/".proxyPass = "http://unix:/run/django-apps/${name}.sock";
"/static/".root = "/run/django-apps/${name}";
"/media/".root = "/run/django-apps/${name}";
};
} nginx
)
) cfg.sites;
}
];
postgresql =
let
apps = builtins.map (name: "dj-${name}") (
attrNames (filterAttrs (_: { dbType, ... }: dbType == "postgresql") cfg.sites)
);
in
mkIf (apps != [ ]) {
enable = true;
ensureDatabases = apps;
ensureUsers = builtins.map (name: {
inherit name;
ensureDBOwnership = true;
}) apps;
};
};
users = {
users.nginx.extraGroups = [ "django-apps" ];
groups.django-apps = { };
};
systemd = {
sockets = mapAttrs' (
name: _:
nameValuePair "dj-${name}" {
description = "Socket for the ${name} Django Application";
wantedBy = [ "sockets.target" ];
socketConfig = {
ListenStream = "/run/django-apps/${name}.sock";
SocketMode = "600";
SocketUser = config'.services.nginx.user;
};
}
) cfg.sites;
mounts = concatLists (
mapAttrsToList (
name:
{ mediaDirectory, staticDirectory, ... }:
[
{
where = "/run/django-apps/${name}/static";
what = "/var/lib/django-apps/${name}/${staticDirectory}";
options = "bind";
after = [ "dj-${name}.service" ];
partOf = [ "dj-${name}.service" ];
upheldBy = [ "dj-${name}.service" ];
}
{
where = "/run/django-apps/${name}/media";
what = "/var/lib/django-apps/${name}/${mediaDirectory}";
options = "bind";
after = [ "dj-${name}.service" ];
partOf = [ "dj-${name}.service" ];
upheldBy = [ "dj-${name}.service" ];
}
]
) cfg.sites
);
services =
{
webhook.serviceConfig.LoadCredential = mapAttrsToList (
name: { webHookSecret, ... }: "dj-${name}:${webHookSecret}"
) cfg.sites;
}
// (concatMapAttrs (
name: config:
let
mkDatabase =
name: type:
if type == "postgresql" then
{
ENGINE = "django.db.backends.postgresql";
NAME = "dj-${name}";
}
else if type == "sqlite" then
{
ENGINE = "django.db.backends.sqlite3";
NAME = "/var/lib/django-apps/${name}/db.sqlite3";
}
else
throw "Invalid database type !";
# Systemd Service Configuration
Group = "django-apps";
LoadCredential = mapAttrsToList (credential: path: "${credential}:${path}") config.credentials;
RuntimeDirectory = "django-apps/${name}";
StateDirectory = "django-apps/${name}";
UMask = "0027";
User = "dj-${name}";
WorkingDirectory = "/var/lib/django-apps/${name}";
environment =
let
mkValue = v: if builtins.isString v then v else builtins.toJSON v;
in
(mapAttrs' (key: value: nameValuePair "${config.env_prefix}_${key}" (mkValue value)) {
DATABASES =
if (config.dbType != "manual") then { default = mkDatabase name config.dbType; } else null;
STATIC_ROOT = "/var/lib/django-apps/${name}/${config.staticDirectory}";
MEDIA_ROOT = "/var/lib/django-apps/${name}/${config.mediaDirectory}";
ALLOWED_HOSTS = [ config.domain ];
})
// {
DJANGO_SETTINGS_MODULE = config.application.settingsModule;
}
// (mapAttrs (_: mkValue) config.environment);
path = config.extraPackages ++ [ config.djangoEnv ];
after = [ "network.target" ] ++ (optional (config.dbType == "postgresql") "postgresql.service");
in
{
"dj-${name}" = {
inherit after environment path;
preStart = ''
if [ ! -f .initialized ]; then
# The previous initialization might have failed, so restart from the beginning
rm -rf source
# We need to download the application source and run the migrations first
${lib.getExe pkgs.git} clone --single-branch --branch ${config.branch} ${config.source} source
(cd source && python ${config.managePath} migrate --no-input && python ${config.managePath} collectstatic --no-input)
touch .initialized
fi
# Create the necessary directory with the correct user/group
mkdir -p ${config.mediaDirectory} ${config.staticDirectory}
'';
requires = [ "dj-${name}.socket" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
inherit
Group
LoadCredential
RuntimeDirectory
StateDirectory
User
UMask
WorkingDirectory
;
DynamicUser = true;
ExecStart = escapeSystemdExecArgs (
if (config.application.type == "daphne") then
[
(getExe' config.djangoEnv "daphne")
"-u"
"/run/django-apps/${name}.sock"
"${config.application.module}.asgi:${config.application.channelLayer}"
]
else
(
[
(getExe' config.djangoEnv "gunicorn")
"--workers"
config.application.workers
"--bind"
"unix:/run/django-apps/${name}.sock"
"--pythonpath"
"source"
]
++ (optionals (config.application.type == "asgi") [
"--worker-class"
"uvicorn.workers.UvicornWorker"
])
++ [ "${config.application.module}.${config.application.type}" ]
)
);
ExecReload = "${getExe' pkgs.coreutils "kill"} -s HUP $MAINPID";
KillMode = "mixed";
Type = mkIf (config.application.type != "daphne") "notify";
};
};
"dj-${name}-update" = {
inherit environment path;
serviceConfig = {
inherit
Group
LoadCredential
StateDirectory
UMask
User
;
DynamicUser = true;
ExecStart = "${getExe config.updateScript}";
Type = "oneshot";
WorkingDirectory = "/var/lib/django-apps/${name}/source";
};
unitConfig = {
After = "dj-${name}.service";
Conflicts = "dj-${name}.service";
};
};
}
// (mapAttrs' (
serviceName: serviceContent:
nameValuePair "dj-${name}_${serviceName}" (
recursiveUpdate {
inherit after environment path;
partOf = [ "dj-${name}.service" ];
wantedBy = [ "multi-user.target" ];
upheldBy = [ "dj-${name}.service" ];
serviceConfig = {
inherit
Group
LoadCredential
RuntimeDirectory
StateDirectory
UMask
User
;
DynamicUser = true;
};
} serviceContent
)
) config.extraServices)
) cfg.sites);
};
};
}