feat: initial amzebs config

This commit is contained in:
2025-11-14 09:30:19 +01:00
parent 9fab06795a
commit 865311bf49
13 changed files with 741 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
{ config, lib, pkgs, ... }: {
imports = [
./utils/bento.nix
./utils/modules/sops.nix
./utils/modules/nginx.nix
./modules/mysql.nix
./modules/web/stack.nix
./utils/modules/autoupgrade.nix
./utils/modules/promtail
./utils/modules/borgbackup.nix
./hardware-configuration.nix
./sites
];
environment.systemPackages = with pkgs; [
vim
screen
php82
];
time.timeZone = "Europe/Vienna";
sops.age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
sops.defaultSopsFile = ./secrets.yaml;
nix.gc = {
automatic = true;
options = "--delete-older-than 60d";
};
boot.tmp.cleanOnBoot = true;
zramSwap.enable = true;
networking.hostName = "amzebs-01";
networking.domain = "cloonar.com";
services.openssh.enable = true;
users.users.root.openssh.authorizedKeys.keys = [
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDN/2SAFm50kraB1fepAizox/QRXxB7WbqVbH+5OPalDT47VIJGNKOKhixQoqhABHxEoLxdf/C83wxlCVlPV9poLfDgVkA3Lyt5r3tSFQ6QjjOJAgchWamMsxxyGBedhKvhiEzcr/Lxytnoz3kjDG8fqQJwEpdqMmJoMUfyL2Rqp16u+FQ7d5aJtwO8EUqovhMaNO7rggjPpV/uMOg+tBxxmscliN7DLuP4EMTA/FwXVzcFNbOx3K9BdpMRAaSJt4SWcJO2cS2KHA5n/H+PQI7nz5KN3Yr/upJN5fROhi/SHvK39QOx12Pv7FCuWlc+oR68vLaoCKYhnkl3DnCfc7A7"
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFshMhXwS0FQFPlITipshvNKrV8sA52ZFlnaoHd1thKg"
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIRQuPqH5fdX3KEw7DXzWEdO3AlUn1oSmtJtHB71ICoH Generated By Termius"
];
programs.ssh = {
knownHosts = {
"git.cloonar.com" = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDlUj7eEfS/4+z/3IhFhOTXAfpGEpNv6UWuYSL5OAhus";
};
};
};
# backups - adjust repo for this host
borgbackup.repo = "u149513-sub10@u149513-sub10.your-backup.de:borg";
# Use HTTP-01 challenge for Let's Encrypt (not DNS)
security.acme.acceptTerms = true;
security.acme.defaults.email = "admin+acme@cloonar.com";
networking.firewall = {
enable = true;
allowedTCPPorts = [ 22 80 443 ];
# Allow MariaDB access only from specific IP
extraCommands = ''
iptables -A nixos-fw -p tcp --dport 3306 -s 77.119.230.30 -j nixos-fw-accept
'';
};
system.stateVersion = "23.11";
}

View File

@@ -0,0 +1,27 @@
# Hardware configuration for amzebs-01
# This is a template - update with actual hardware configuration after installation
{ modulesPath, ... }:
{
imports = [ (modulesPath + "/profiles/qemu-guest.nix") ];
boot.loader.grub = {
efiSupport = true;
efiInstallAsRemovable = true;
device = "nodev";
configurationLimit = 2;
};
# Update these with actual device UUIDs and paths after installation
fileSystems."/boot" = {
device = "/dev/disk/by-uuid/CHANGEME";
fsType = "vfat";
};
fileSystems."/" = {
device = "/dev/sda1";
fsType = "ext4";
};
boot.initrd.availableKernelModules = [ "ata_piix" "uhci_hcd" "xen_blkfront" ];
boot.initrd.kernelModules = [ "nvme" ];
}

View File

@@ -0,0 +1,29 @@
{ pkgs, config, ... }:
{
services.mysql = {
enable = true;
package = pkgs.mariadb;
settings = {
mysqld = {
max_allowed_packet = "64M";
transaction_isolation = "READ-COMMITTED";
binlog_format = "ROW";
# Allow remote connections
bind-address = "0.0.0.0";
};
};
# Create read-only user for remote access on initial MySQL setup
initialScript = pkgs.writeShellScript "mysql-init.sql" ''
PASSWORD=$(cat ${config.sops.secrets.mysql-readonly-password.path})
${pkgs.mariadb}/bin/mysql -u root <<EOF
CREATE USER IF NOT EXISTS 'api_ebs_amz_at_ro'@'%' IDENTIFIED BY '$PASSWORD';
GRANT SELECT ON api_ebs_amz_at.* TO 'api_ebs_amz_at_ro'@'%';
FLUSH PRIVILEGES;
EOF
'';
};
services.mysqlBackup.enable = true;
}

View File

@@ -0,0 +1,321 @@
{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.webstack;
instanceOpts = { name, ... }:
{
options = {
user = mkOption {
type = types.nullOr types.str;
default = null;
description = lib.mdDoc ''
User of the typo3 instance. Defaults to attribute name in instances.
'';
example = "example.org";
};
domain = mkOption {
type = types.nullOr types.str;
default = null;
description = lib.mdDoc ''
Domain of the typo3 instance. Defaults to attribute name in instances.
'';
example = "example.org";
};
domainAliases = mkOption {
type = types.listOf types.str;
default = [];
example = [ "www.example.org" "example.org" ];
description = lib.mdDoc ''
Additional domains served by this typo3 instance.
'';
};
phpPackage = mkOption {
type = types.package;
example = literalExpression "pkgs.php";
description = lib.mdDoc ''
Which PHP package to use in this typo3 instance.
'';
};
phpOptions = mkOption {
type = types.lines;
default = "";
description = ''
"Options appended to the PHP configuration file {file}`php.ini` used for this PHP-FPM pool."
'';
};
enableMysql = mkEnableOption (lib.mdDoc "MySQL Database");
enableDefaultLocations = mkEnableOption (lib.mdDoc "Create default nginx location directives") // { default = true; };
enablePhp = mkEnableOption (lib.mdDoc "PHP-FPM support") // { default = true; };
authorizedKeys = mkOption {
type = types.listOf types.str;
default = null;
description = lib.mdDoc ''
Authorized keys for the typo3 instance ssh user.
'';
};
extraConfig = mkOption {
type = types.lines;
default = ''
if (!-e $request_filename) {
rewrite ^/(.+)\.(\d+)\.(php|js|css|png|jpg|gif|gzip)$ /$1.$3 last;
}
'';
description = lib.mdDoc ''
These lines go to the end of the vhost verbatim.
'';
};
locations = mkOption {
type = types.attrsOf (types.submodule (import <nixpkgs/nixos/modules/services/web-servers/nginx/location-options.nix> {
inherit lib config;
}));
default = {};
example = literalExpression ''
{
"/" = {
proxyPass = "http://localhost:3000";
};
};
'';
description = lib.mdDoc "Declarative location config";
};
};
};
in
{
options.services.webstack = {
dataDir = mkOption {
type = types.path;
default = "/var/www";
description = lib.mdDoc ''
The data directory for MySQL.
::: {.note}
If left as the default value of `/var/www` this directory will automatically be created before the web
server starts, otherwise you are responsible for ensuring the directory exists with appropriate ownership and permissions.
:::
'';
};
instances = mkOption {
type = types.attrsOf (types.submodule instanceOpts);
default = {};
description = lib.mdDoc "Create vhosts for typo3";
example = literalExpression ''
{
"typo3.example.com" = {
domain = "example.com";
domainAliases = [ "www.example.com" ];
phpPackage = pkgs.php81;
authorizedKeys = [
"ssh-rsa AZA=="
];
};
};
'';
};
};
config = {
systemd.services = mapAttrs' (instance: instanceOpts:
let
domain = if instanceOpts.domain != null then instanceOpts.domain else instance;
in
nameValuePair "phpfpm-${domain}" {
serviceConfig = {
ProtectHome = lib.mkForce "tmpfs";
BindPaths = "BindPaths=/var/www/${domain}:/var/www/${domain}";
};
}
) (lib.filterAttrs (name: opts: opts.enablePhp) cfg.instances);
services.phpfpm.pools = mapAttrs' (instance: instanceOpts:
let
domain = if instanceOpts.domain != null then instanceOpts.domain else instance;
user = if instanceOpts.user != null
then instanceOps.user
else builtins.replaceStrings ["." "-"] ["_" "_"] domain;
in
nameValuePair domain {
user = user;
settings = {
"listen.owner" = config.services.nginx.user;
"pm" = "dynamic";
"pm.max_children" = 32;
"pm.max_requests" = 500;
"pm.start_servers" = 2;
"pm.min_spare_servers" = 2;
"pm.max_spare_servers" = 5;
"php_admin_value[error_log]" = "syslog";
"php_admin_value[max_execution_time]" = 240;
"php_admin_value[max_input_vars]" = 1500;
"access.log" = "/var/log/$pool.access.log";
};
phpOptions = instanceOpts.phpOptions;
phpPackage = instanceOpts.phpPackage;
phpEnv."PATH" = pkgs.lib.makeBinPath [ instanceOpts.phpPackage ];
}
) (lib.filterAttrs (name: opts: opts.enablePhp) cfg.instances);
};
config.services.nginx.virtualHosts = mapAttrs' (instance: instanceOpts:
let
domain = if instanceOpts.domain != null then instanceOpts.domain else instance;
user = if instanceOpts.user != null
then instanceOps.user
else builtins.replaceStrings ["." "-"] ["_" "_"] domain;
in
nameValuePair domain {
forceSSL = true;
enableACME = true;
acmeRoot = null;
root = cfg.dataDir + "/" + domain + "/public";
locations = lib.mkMerge [
instanceOpts.locations
(mkIf instanceOpts.enableDefaultLocations {
"/favicon.ico".extraConfig = ''
log_not_found off;
access_log off;
'';
# Cache.appcache, your document html and data
"~* \\.(?:manifest|appcache|html?|xml|json)$".extraConfig = ''
expires -1;
# access_log logs/static.log; # I don't usually include a static log
'';
"~* \\.(jpe?g|png)$".extraConfig = ''
set $red Z;
if ($http_accept ~* "webp") {
set $red A;
}
if (-f $document_root/webp/$request_uri.webp) {
set $red "''${red}B";
}
if ($red = "AB") {
add_header Vary Accept;
rewrite ^ /webp/$request_uri.webp;
}
'';
# Cache Media: images, icons, video, audio, HTC
"~* \\.(?:css|js|jpg|jpeg|gif|png|webp|avif|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|woff2)$".extraConfig = ''
expires 1y;
access_log off;
add_header Cache-Control "public";
'';
# Feed
"~* \\.(?:rss|atom)$".extraConfig = ''
expires 1h;
add_header Cache-Control "public";
'';
"/".extraConfig = ''
index index.php index.html;
try_files $uri $uri/ /index.php$is_args$args;
'';
})
(mkIf instanceOpts.enablePhp {
"~ [^/]\\.php(/|$)".extraConfig = ''
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
if (!-f $document_root$fastcgi_script_name) {
return 404;
}
include ${pkgs.nginx}/conf/fastcgi_params;
include ${pkgs.nginx}/conf/fastcgi.conf;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 16k;
fastcgi_connect_timeout 240s;
fastcgi_read_timeout 240s;
fastcgi_send_timeout 240s;
fastcgi_pass unix:${config.services.phpfpm.pools."${domain}".socket};
fastcgi_index index.php;
'';
})
];
extraConfig = instanceOpts.extraConfig;
# locations = mapAttrs' (location: locationOpts:
# nameValuePair location locationOpts) instanceOpts.locations;
}
) cfg.instances;
config.users.users = mapAttrs' (instance: instanceOpts:
let
domain = if instanceOpts.domain != null then instanceOpts.domain else instance;
user = if instanceOpts.user != null
then instanceOps.user
else builtins.replaceStrings ["." "-"] ["_" "_"] domain;
in
nameValuePair user {
isNormalUser = true;
createHome = true;
home = "/var/www/" + domain;
homeMode= "770";
group = config.services.nginx.group;
openssh.authorizedKeys.keys = instanceOpts.authorizedKeys;
}
) cfg.instances;
config.users.groups = mapAttrs' (instance: instanceOpts:
let
domain = if instanceOpts.domain != null then instanceOpts.domain else instance;
user = if instanceOpts.user != null
then instanceOps.user
else builtins.replaceStrings ["." "-"] ["_" "_"] domain;
in nameValuePair user {}) cfg.instances;
config.services.mysql.ensureUsers = mapAttrsToList (instance: instanceOpts:
let
domain = if instanceOpts.domain != null then instanceOpts.domain else instance;
user = if instanceOpts.user != null
then instanceOps.user
else builtins.replaceStrings ["." "-"] ["_" "_"] domain;
in
mkIf instanceOpts.enableMysql {
name = user;
ensurePermissions = {
"${user}.*" = "ALL PRIVILEGES";
};
}) cfg.instances;
config.services.mysql.ensureDatabases = mapAttrsToList (instance: instanceOpts:
let
domain = if instanceOpts.domain != null then instanceOpts.domain else instance;
user = if instanceOpts.user != null
then instanceOps.user
else builtins.replaceStrings ["." "-"] ["_" "_"] domain;
in
mkIf instanceOpts.enableMysql user
) cfg.instances;
config.services.mysqlBackup.databases = mapAttrsToList (instance: instanceOpts:
let
domain = if instanceOpts.domain != null then instanceOpts.domain else instance;
user = if instanceOpts.user != null
then instanceOps.user
else builtins.replaceStrings ["." "-"] ["_" "_"] domain;
in
mkIf instanceOpts.enableMysql user
) cfg.instances;
}

View File

@@ -0,0 +1,18 @@
# SOPS encrypted secrets for amzebs-01
# Edit with: nix-shell -p sops --run 'sops hosts/amzebs-01/secrets.yaml'
#
# Required secrets:
# - borg-passphrase: Backup encryption passphrase
# - borg-ssh-key: SSH private key for backup server access
# - mysql-readonly-password: Password for read-only MySQL user (api_ebs_amz_at_ro)
#
# To initialize this file, first ensure the host SSH key exists, then run:
# sops hosts/amzebs-01/secrets.yaml
# Placeholder structure (will be encrypted after initialization):
borg-passphrase: CHANGEME
borg-ssh-key: |
-----BEGIN OPENSSH PRIVATE KEY-----
CHANGEME
-----END OPENSSH PRIVATE KEY-----
mysql-readonly-password: CHANGEME

View File

@@ -0,0 +1,37 @@
{ pkgs, lib, config, ... }:
{
services.webstack.instances."api.ebs.amz.at" = {
enableDefaultLocations = false;
enableMysql = true;
authorizedKeys = [
# Add deployment SSH key here
];
extraConfig = ''
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
error_page 404 /index.php;
'';
locations."/favicon.ico".extraConfig = ''
log_not_found off;
access_log off;
'';
locations."/robots.txt".extraConfig = ''
access_log off;
log_not_found off;
'';
locations."/".extraConfig = ''
try_files $uri $uri/ /index.php$is_args$args;
'';
phpPackage = pkgs.php82.withExtensions ({ enabled, all }:
enabled ++ [ all.imagick ]);
};
# Use HTTP-01 challenge for Let's Encrypt
services.nginx.virtualHosts."api.ebs.amz.at".acmeRoot = lib.mkForce "/var/lib/acme/acme-challenge";
}

View File

@@ -0,0 +1,37 @@
{ pkgs, lib, config, ... }:
{
services.webstack.instances."api.ebs.cloonar.dev" = {
enableDefaultLocations = false;
enableMysql = true;
authorizedKeys = [
# Add deployment SSH key here
];
extraConfig = ''
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
error_page 404 /index.php;
'';
locations."/favicon.ico".extraConfig = ''
log_not_found off;
access_log off;
'';
locations."/robots.txt".extraConfig = ''
access_log off;
log_not_found off;
'';
locations."/".extraConfig = ''
try_files $uri $uri/ /index.php$is_args$args;
'';
phpPackage = pkgs.php82.withExtensions ({ enabled, all }:
enabled ++ [ all.imagick ]);
};
# Use HTTP-01 challenge for Let's Encrypt
services.nginx.virtualHosts."api.ebs.cloonar.dev".acmeRoot = lib.mkForce "/var/lib/acme/acme-challenge";
}

View File

@@ -0,0 +1,37 @@
{ pkgs, lib, config, ... }:
{
services.webstack.instances."api.stage.ebs.amz.at" = {
enableDefaultLocations = false;
enableMysql = true;
authorizedKeys = [
# Add deployment SSH key here
];
extraConfig = ''
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
error_page 404 /index.php;
'';
locations."/favicon.ico".extraConfig = ''
log_not_found off;
access_log off;
'';
locations."/robots.txt".extraConfig = ''
access_log off;
log_not_found off;
'';
locations."/".extraConfig = ''
try_files $uri $uri/ /index.php$is_args$args;
'';
phpPackage = pkgs.php82.withExtensions ({ enabled, all }:
enabled ++ [ all.imagick ]);
};
# Use HTTP-01 challenge for Let's Encrypt
services.nginx.virtualHosts."api.stage.ebs.amz.at".acmeRoot = lib.mkForce "/var/lib/acme/acme-challenge";
}

View File

@@ -0,0 +1,13 @@
{ ... }: {
imports = [
# Enabled vhosts (cloonar.dev)
./api.ebs.cloonar.dev.nix
./ebs.cloonar.dev.nix
# Disabled vhosts (amz.at) - uncomment to enable
# ./api.ebs.amz.at.nix
# ./api.stage.ebs.amz.at.nix
# ./ebs.amz.at.nix
# ./stage.ebs.amz.at.nix
];
}

View File

@@ -0,0 +1,49 @@
{ pkgs, lib, config, ... }:
let
domain = "ebs.amz.at";
dataDir = "/var/www/${domain}";
in {
services.nginx.virtualHosts."${domain}" = {
forceSSL = true;
enableACME = true;
# Use HTTP-01 challenge for Let's Encrypt
acmeRoot = lib.mkForce "/var/lib/acme/acme-challenge";
root = "${dataDir}";
locations."/favicon.ico".extraConfig = ''
log_not_found off;
access_log off;
'';
# React client-side routing support
locations."/".extraConfig = ''
index index.html;
try_files $uri $uri/ /index.html;
'';
# Cache static assets
locations."~* \\.(js|jpg|gif|png|webp|css|woff2|svg|ico)$".extraConfig = ''
expires 365d;
add_header Pragma "public";
add_header Cache-Control "public";
'';
# Deny PHP execution
locations."~ [^/]\\.php(/|$)".extraConfig = ''
deny all;
'';
};
users.users."${domain}" = {
isNormalUser = true;
createHome = true;
home = dataDir;
homeMode = "770";
group = "nginx";
openssh.authorizedKeys.keys = [
# Add deployment SSH key here
];
};
users.groups.${domain} = {};
}

View File

@@ -0,0 +1,49 @@
{ pkgs, lib, config, ... }:
let
domain = "ebs.cloonar.dev";
dataDir = "/var/www/${domain}";
in {
services.nginx.virtualHosts."${domain}" = {
forceSSL = true;
enableACME = true;
# Use HTTP-01 challenge for Let's Encrypt
acmeRoot = lib.mkForce "/var/lib/acme/acme-challenge";
root = "${dataDir}";
locations."/favicon.ico".extraConfig = ''
log_not_found off;
access_log off;
'';
# React client-side routing support
locations."/".extraConfig = ''
index index.html;
try_files $uri $uri/ /index.html;
'';
# Cache static assets
locations."~* \\.(js|jpg|gif|png|webp|css|woff2|svg|ico)$".extraConfig = ''
expires 365d;
add_header Pragma "public";
add_header Cache-Control "public";
'';
# Deny PHP execution
locations."~ [^/]\\.php(/|$)".extraConfig = ''
deny all;
'';
};
users.users."${domain}" = {
isNormalUser = true;
createHome = true;
home = dataDir;
homeMode = "770";
group = "nginx";
openssh.authorizedKeys.keys = [
# Add deployment SSH key here
];
};
users.groups.${domain} = {};
}

View File

@@ -0,0 +1,49 @@
{ pkgs, lib, config, ... }:
let
domain = "stage.ebs.amz.at";
dataDir = "/var/www/${domain}";
in {
services.nginx.virtualHosts."${domain}" = {
forceSSL = true;
enableACME = true;
# Use HTTP-01 challenge for Let's Encrypt
acmeRoot = lib.mkForce "/var/lib/acme/acme-challenge";
root = "${dataDir}";
locations."/favicon.ico".extraConfig = ''
log_not_found off;
access_log off;
'';
# React client-side routing support
locations."/".extraConfig = ''
index index.html;
try_files $uri $uri/ /index.html;
'';
# Cache static assets
locations."~* \\.(js|jpg|gif|png|webp|css|woff2|svg|ico)$".extraConfig = ''
expires 365d;
add_header Pragma "public";
add_header Cache-Control "public";
'';
# Deny PHP execution
locations."~ [^/]\\.php(/|$)".extraConfig = ''
deny all;
'';
};
users.users."${domain}" = {
isNormalUser = true;
createHome = true;
home = dataDir;
homeMode = "770";
group = "nginx";
openssh.authorizedKeys.keys = [
# Add deployment SSH key here
];
};
users.groups.${domain} = {};
}

1
hosts/amzebs-01/utils Symbolic link
View File

@@ -0,0 +1 @@
../../utils