Compare commits

..

12 commits

Author SHA1 Message Date
Chris Kruining
9aaf0f0a2b
asdffasdfa 2025-08-13 07:41:30 +02:00
Chris Kruining
5d8c897b4d
update the path to system secrets, still need to fix the home secrets 2025-08-11 16:18:53 +02:00
Chris Kruining
3a6672cad9
get going with sops agian, not that hard, just need to set up my keys properly... 2025-08-11 15:22:58 +02:00
Chris Kruining
69c6d85754
resolve merge artifacts 2025-08-11 15:22:17 +02:00
Chris Kruining
de1bc287d5
reorder inputs 2025-08-07 11:59:22 +02:00
Chris Kruining
4bd4327a6d
Merge branch 'feature/nix-anywhere' of https://github.com/chris-kruining/sneeuwvlok into feature/nix-anywhere 2025-08-07 11:48:27 +02:00
Chris Kruining
7e6beb208d
kaas 2025-08-07 11:48:23 +02:00
Chris Kruining
cfb9d086b8
yep yep, justfiles are cooooool 2025-08-07 11:48:23 +02:00
Chris Kruining
a1316fdf0e
update deps 2025-08-07 11:48:23 +02:00
Chris Kruining
98362802d5
kaas 2025-08-07 11:02:45 +02:00
Chris Kruining
3921693f84
yep yep, justfiles are cooooool 2025-08-04 16:21:29 +02:00
Chris Kruining
8228418b7f
update deps 2025-08-04 16:21:17 +02:00
135 changed files with 950 additions and 10354 deletions

View file

@ -1,6 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8

2
.envrc
View file

@ -1,2 +0,0 @@
# shellcheck shell=bash
use flake

View file

@ -1,15 +0,0 @@
name: Test action
on:
workflow_dispatch:
push:
branches:
- main
jobs:
kaas:
runs-on: nix
steps:
- name: Echo
run: |
nix --version

1
.gitattributes vendored
View file

@ -1 +0,0 @@
* text=auto eol=lf

8
.gitignore vendored
View file

@ -1,8 +1,2 @@
# ---> Nix
# Ignore build outputs from performing a nix-build or `nix build` command
result
result-*
# Ignore automatically generated direnv output
.direnv
*.qcow2

View file

@ -1,34 +0,0 @@
def RESET: "0";
def BOLD: "1";
def DIM: "2";
def ITALIC: "3";
def UNDERLINE: "4";
def BLINKING: "5";
def INVERSE: "7";
def HIDDEN: "8";
def STRIKETHROUGH: "9";
def RESET_FONT: "22";
def BLACK: 0;
def RED: 1;
def GREEN: 2;
def YELLOW: 3;
def BLUE: 4;
def MAGENTA: 5;
def CYAN: 6;
def WHITE: 7;
def DEFAULT: 9;
def foreground(color): 30 + color;
def background(color): 40 + color;
def bright(color): 60 + color;
def escape(options):
(if ((options|type) == "array") then options else [options] end) as $o
| "\u001b[\($o | map(tostring) | join(";"))m";
def style(options): escape(options) + . + escape([RESET]);
def to_title:
(.|ascii_upcase) as $str
| escape([BOLD, foreground(BLACK), background(WHITE)]) + " " + $str + " " + escape([RESET]);

View file

@ -1,58 +0,0 @@
import "format" as _ {search:"./"};
def n_max(limit):
if . > limit then limit else . end;
def n_min(limit):
if . < limit then limit else . end;
def pad_right(width):
(. | tostring) as $s
| ($s | length) as $l
| ((width - $l) | n_min(0)) as $w
| ($s + (" " * $w));
def to_cells(sizes; fn):
to_entries
| map(
(sizes[.key]) as $size
| (" " + .value)
| pad_right($size + 2)
| fn // .
);
def to_cells(sizes): to_cells(sizes; null);
def to_line(left; joiner; right):
[left, .[0], (.[1:] | map([joiner, .]) ), right] | flatten | join("");
def create(data; header_callback; cell_callback):
(data[0] | keys_unsorted) as $keys
| (data | map(to_entries | map(.value))) as $rows
| ([$keys] + $rows) as $cells
| (
$keys # Use keys so that we have an array of the correct size
| to_entries
| map(
(.key) as $i
| $cells
| map(.[$i] | length)
| max
)
) as $column_sizes
| (
[
($column_sizes | map("═" * (. + 2)) | to_line("╔"; "╤"; "╗")),
($keys | to_cells($column_sizes; header_callback) | to_line("║"; "│"; "║")),
($rows | map([
($column_sizes | map("─" * (. + 2)) | to_line("╟"; "┼"; "╢")),
(. | to_cells($column_sizes; cell_callback) | to_line("║"; "│"; "║"))
])),
($column_sizes | map("═" * (. + 2)) | to_line("╚"; "╧"; "╝"))
]
| flatten
| join("\n")
);
def create(data; header_callback): create(data; header_callback; null);
def create(data): create(data; _::style(_::BOLD); null);

View file

@ -1,20 +0,0 @@
@_default: list
[doc('List machines')]
@list:
ls -1 ../systems/x86_64-linux/
[doc('Update target machine')]
[no-exit-message]
@update machine:
echo "Checking vars"
cd .. && just vars _check {{ machine }}
echo ""
just assert '-d "../systems/x86_64-linux/{{ machine }}"' "Machine {{ machine }} does not exist, must be one of: $(ls ../systems/x86_64-linux/ | sed ':a;N;$!ba;s/\n/, /g')"
nixos-rebuild switch -L --sudo --target-host {{ machine }} --flake ..#{{ machine }} --log-format internal-json -v |& nom --json
[doc('Check if target machine builds')]
[no-exit-message]
@check machine:
just assert '-d "../systems/x86_64-linux/{{ machine }}"' "Machine {{ machine }} does not exist, must be one of: $(ls ../systems/x86_64-linux/ | sed ':a;N;$!ba;s/\n/, /g')"
nix build ..#nixosConfigurations.{{ machine }}.config.system.build.toplevel

View file

@ -1,101 +0,0 @@
set unstable := true
set quiet := true
_default:
just --list users
[doc('List available users')]
[script]
list:
cd .. && just vars get ulmo zitadel/users | jq -r -C '
import ".jq/table" as table;
import ".jq/format" as f;
fromjson
| to_entries
| sort_by(.key)
| map(
(.key|f::to_title) + ":\n"
+ table::create(
.value
| to_entries
| sort_by(.key)
| map({username:.key} + .value)
)
)
| join("\n\n┄┄┄\n\n")
';
[doc('Add a new user')]
[script]
add:
exec 5>&1
pad () { [ "$#" -gt 1 ] && [ -n "$2" ] && printf "%$2.${2#-}s" "$1"; }
input() {
local label=$1
local value=$2
local res=$(gum input --header "$label" --value "$value")
echo -e "\e[2m$(pad "$label" -11)\e[0m$res" >&5
echo $res
}
data=`cd .. && just vars get ulmo zitadel/users | jq 'fromjson'`
# Gather inputs
org=`
jq -r 'to_entries | map(.key)[]' <<< "$data" \
| gum choose --header 'Which organisation to save to?' --select-if-one
`
username=`input 'user name' ''`
email=`input 'email' ''`
first_name=`input 'first name' ''`
last_name=`input 'last name' ''`
user_exists=`jq --arg 'org' "$org" --arg 'username' "$username" '.[$org][$username]? | . != null' <<< "$data"`
if [ "$user_exists" == "true" ]; then
gum confirm 'User already exists, overwrite it?' --padding="1 1" || exit 0
fi
next=`
jq \
--arg 'org' "$org" \
--arg 'username' "$username" \
--arg 'email' "$email" \
--arg 'first_name' "$first_name" \
--arg 'last_name' "$last_name" \
--compact-output \
'.[$org] += { $username: { email: $email, firstName: $first_name, lastName: $last_name } }' \
<<< $data
`
gum spin --title "saving..." -- echo "$(cd .. && just vars set ulmo 'zitadel/users' "$next")"
[doc('Remove a new user')]
[script]
remove:
data=`cd .. && just vars get ulmo zitadel/users | jq fromjson`
# Gather inputs
org=`
jq -r 'to_entries | map(.key)[]' <<< "$data" \
| gum choose --header 'Which organisation?' --select-if-one
`
user=`
jq -r --arg org "$org" '.[$org] | to_entries | map(.key)[]' <<< "$data" \
| gum choose --header 'Which user?' --select-if-one
`
next=`
jq \
--arg 'org' "$org" \
--arg 'user' "$user" \
--compact-output \
'del(.[$org][$user])' \
<<< $data
`
gum spin --title "saving..." -- echo "$(cd .. && just vars set ulmo 'zitadel/users' "$next")"

View file

@ -1,108 +0,0 @@
set unstable := true
set quiet := true
base_path := justfile_directory() + "/systems/x86_64-linux"
_default:
just --list vars
[doc('List all vars of {machine}')]
list machine:
sops decrypt {{ base_path }}/{{ machine }}/secrets.yml
[doc('Edit all vars of {machine} in your editor')]
edit machine:
sops edit {{ base_path }}/{{ machine }}/secrets.yml
[doc('Set var {value} by {key} for {machine}')]
@set machine key value:
sops set {{ base_path }}/{{ machine }}/secrets.yml "$(printf '%s\n' '["{{ key }}"]' | sed -E 's#/#"]["#g; s/\["([0-9]+)"\]/[\1]/g')" "\"$(echo '{{ value }}' | sed 's/\"/\\\"/g')\""
git add {{ base_path }}/{{ machine }}/secrets.yml
git commit -m 'chore(secrets): set secret "{{ key }}" for machine "{{ machine }}"' -- {{ base_path }}/{{ machine }}/secrets.yml > /dev/null
echo "Done"
[doc('Get var by {key} from {machine}')]
get machine key:
sops decrypt {{ base_path }}/{{ machine }}/secrets.yml | yq ".$(echo "{{ key }}" | sed -E 's/\//./g') // \"\""
[doc('Remove var by {key} for {machine}')]
remove machine key:
sops unset {{ base_path }}/{{ machine }}/secrets.yml "$(printf '%s\n' '["{{ key }}"]' | sed -E 's#/#"]["#g; s/\["([0-9]+)"\]/[\1]/g')"
git add {{ base_path }}/{{ machine }}/secrets.yml
git commit -m 'chore(secrets): removed secret "{{ key }}" from machine "{{ machine }}"' -- {{ base_path }}/{{ machine }}/secrets.yml > /dev/null
echo "Done"
[doc('Generate var values for {machine}')]
[script]
generate machine:
for key in $(nix eval --apply 'builtins.attrNames' --json ..#nixosConfigurations.{{ machine }}.config.sops.secrets | jq -r '.[]'); do
# Skip if we already have a value
[ $(just vars get "{{ machine }}" "$key" | jq -r) ] && continue
just vars _rotate "{{ machine }}" "$key"
done
[doc('Regenerate var values for {machine}')]
[script]
_rotate machine key:
# Exit if there's no script
[ -f "{{ justfile_directory() }}/script/{{ key }}" ] || exit 0
echo "Executing script for {{ key }}"
just vars set "{{ machine }}" "{{ key }}" "$(cd -- "$(dirname "{{ justfile_directory() }}/script/{{ key }}")" && source "./$(basename "{{ key }}")")"
[script]
check:
cd ..
for machine in $(ls {{ base_path }}); do
just vars _check "$machine"
done
[no-exit-message]
[script]
_check machine:
# If the default nix file is missing,
# we can skip this folder as we are
# missing the files used to compare
# the defined vs the configured secrets
if [ ! -f "{{ base_path }}/{{ machine }}/default.nix" ]; then
printf "\r• %-8sskipped\n" "{{ machine }}"
exit 0
fi
exec 3< <(jq -nr \
--rawfile defined <(nix eval --json ..#nixosConfigurations.{{ machine }}.config.sops.secrets 2>/dev/null) \
--rawfile configured <([ -f "{{ base_path }}/{{ machine }}/secrets.yml" ] && sops decrypt {{ base_path }}/{{ machine }}/secrets.yml | yq '.' || echo "{}") \
'
[ $configured | fromjson | paths(scalars) | join("/") ] as $conf
| $defined
| fromjson
| map(.key | select(. | IN($conf[]) | not))
| unique
| .[]
')
pid=$! # Process Id of the previous running command
spin='⠇⠋⠙⠸⢰⣠⣄⡆'
i=0
while kill -0 $pid 2>/dev/null
do
i=$(( (i+1) %${#spin} ))
printf "\r${spin:$i:1} %s" "{{ machine }}"
sleep .1
done
mapfile -t missing <&3
if (( ${#missing[@]} > 0 )); then
printf '\r✗ %-8smissing %d secret(s):\n%s\n' "{{ machine }}" "${#missing[@]}" "$(printf -- ' %s\n' "${missing[@]}")"
exit 1
else
printf "\r✓ %-8sup to date\n" "{{ machine }}"
fi

View file

@ -1,36 +0,0 @@
@_default:
just --list --list-submodules
[doc('Manage vars')]
mod vars '.just/vars.just'
[doc('Manage users')]
mod users '.just/users.just'
[doc('Manage machines')]
mod machine '.just/machine.just'
[doc('Show information about project')]
@show:
echo "show"
[doc('update the flake dependencies')]
@update:
nix flake update
git commit -m 'chore: update dependencies' -- ./flake.lock > /dev/null
echo "Done"
[doc('Introspection on flake output')]
@select key:
nix eval --show-trace --json .#{{ key }} | jq .
#===============================================================================================
# Utils
#===============================================================================================
[no-exit-message]
[no-cd]
[private]
@assert condition message:
[ {{ condition }} ] || { echo -e 1>&2 "\n\x1b[1;41m Error \x1b[0m {{ message }}\n"; exit 1; }

View file

@ -1,13 +0,0 @@
keys:
- &ulmo_1 age19qfpf980tadguqq44zf6xwvjvl428dyrj46ha3n6aeqddwhtnuqqml7etq
- &ulmo_2 age1ewes0f5snqx3sh5ul6fa6qtxzhd25829v6mf5rx2wnheat6fefps5rme2x
- &manwe_1 age1jmrmdw4kmjeu9d6z74r2unqt7wpgsx24vqejmdjretsnsn8g4drsl3m98w
creation_rules:
# All Machine secrets
- path_regex: systems/[^/]+/[^/]+/[^/]+\.(yml|yaml)$
key_groups:
- age:
- *ulmo_1
- *ulmo_2
- *manwe_1

57
.sops.yml Normal file
View file

@ -0,0 +1,57 @@
keys:
- home:
- &chris age1ewes0f5snqx3sh5ul6fa6qtxzhd25829v6mf5rx2wnheat6fefps5rme2x
- system:
- &aule age
- &mandos age
- &manwe age10c5hmykkduvy75yvqfnchm5lcesr5puarhkwp4l7xdwpykdm397q6xdxuy
- &melkor age
- &orome age
- &tulkas age
- &varda age
- &yavanna age1ewes0f5snqx3sh5ul6fa6qtxzhd25829v6mf5rx2wnheat6fefps5rme2x
creation_rules:
#===================================================================
# HOSTS
#===================================================================
- path_regex: systems/x86_64-linux/aule/secrets.yaml$
age: *aule
- path_regex: systems/x86_64-linux/mandos/secrets.yaml$
age: *mandos
- path_regex: systems/x86_64-linux/manwe/secrets.yaml$
key_groups:
- age:
- *manwe
- *yavanna
- path_regex: systems/x86_64-linux/melkor/secrets.yaml$
age: *melkor
- path_regex: systems/x86_64-linux/orome/secrets.yaml$
age: *orome
- path_regex: systems/x86_64-linux/tulkas/secrets.yaml$
age: *tulkas
- path_regex: systems/x86_64-linux/varda/secrets.yaml$
age: *varda
- path_regex: systems/x86_64-linux/yavanna/secrets.yaml$
age: *yavanna
#===================================================================
# USERS
#===================================================================
- path_regex: homes/x86_64-linux/chris@\w+/secrets.yaml$
age: *chris

View file

@ -18,4 +18,5 @@ nix build .#install-isoConfigurations.minimal
- [dafitt/dotfiles](https://github.com/dafitt/dotfiles/)
- [khaneliman/khanelinix](https://github.com/khaneliman/khanelinix)
- [alex007sirois/nix-config](https://github.com/alex007sirois/nix-config) (justfile)
- [hmajid2301/nixicle](https://gitlab.com/hmajid2301/nixicle) (the GOAT, he did what I am aiming for!)

748
flake.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,6 @@
{
description = "Nixos config flake";
nixConfig = {
warn-dirty = false;
};
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
@ -13,6 +9,11 @@
inputs.nixpkgs.follows = "nixpkgs";
};
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
@ -29,13 +30,13 @@
inputs.nixpkgs.follows = "nixpkgs";
};
# neovim
nvf.url = "github:notashelf/nvf";
# plymouth theme
nixos-boot.url = "github:Melkor333/nixos-boot";
firefox.url = "github:nix-community/flake-firefox-nightly";
nixos-wsl = {
url = "github:nix-community/nixos-wsl";
inputs = {
nixpkgs.follows = "nixpkgs";
flake-compat.follows = "";
};
};
stylix.url = "github:nix-community/stylix";
@ -45,10 +46,13 @@
inputs.nixpkgs.follows = "nixpkgs";
};
zen-browser = {
url = "github:0xc000022070/zen-browser-flake";
inputs.nixpkgs.follows = "nixpkgs";
};
# neovim
nvf.url = "github:notashelf/nvf";
# plymouth theme
nixos-boot.url = "github:Melkor333/nixos-boot";
zen-browser.url = "github:MarceColl/zen-browser-flake";
nix-minecraft.url = "github:Infinidoge/nix-minecraft";
@ -74,33 +78,9 @@
grub2-themes = {
url = "github:vinceliuice/grub2-themes";
};
nixos-wsl = {
url = "github:nix-community/nixos-wsl";
inputs = {
nixpkgs.follows = "nixpkgs";
flake-compat.follows = "";
};
};
terranix = {
url = "github:terranix/terranix";
inputs.nixpkgs.follows = "nixpkgs";
};
clan-core = {
url = "https://git.clan.lol/clan/clan-core/archive/main.tar.gz";
inputs.nixpkgs.follows = "nixpkgs";
};
mydia = {
url = "github:chris-kruining/mydia";
# url = "github:getmydia/mydia";
};
};
outputs = inputs:
inputs.snowfall-lib.mkFlake {
outputs = inputs: inputs.snowfall-lib.mkFlake {
inherit inputs;
src = ./.;
@ -116,15 +96,8 @@
channels-config = {
allowUnfree = true;
permittedInsecurePackages = [
# Due to *arr stack
"dotnet-sdk-6.0.428"
"aspnetcore-runtime-6.0.36"
# I think this is because of zen
"qtwebengine-5.15.19"
# For Nheko, the matrix client
"olm-3.2.16"
];
};
@ -134,13 +107,9 @@
flux.overlays.default
];
systems.modules = with inputs; [
clan-core.nixosModules.default
];
homes.modules = with inputs; [
stylix.homeModules.stylix
plasma-manager.homeModules.plasma-manager
plasma-manager.homeManagerModules.plasma-manager
];
};
}

View file

@ -1,11 +1,10 @@
{osConfig, ...}: {
{ osConfig, ... }:
{
home.stateVersion = osConfig.system.stateVersion;
programs.git = {
settings.user = {
name = "Chris Kruining";
email = "chris@kruining.eu";
};
userName = "Chris Kruining";
userEmail = "chris@kruining.eu";
};
sneeuwvlok = {

View file

@ -1,11 +1,10 @@
{osConfig, ...}: {
{ osConfig, ... }:
{
home.stateVersion = osConfig.system.stateVersion;
programs.git = {
settings.user = {
name = "Chris Kruining";
email = "chris@kruining.eu";
};
userName = "Chris Kruining";
userEmail = "chris@kruining.eu";
};
sneeuwvlok = {
@ -36,7 +35,6 @@
bitwarden.enable = true;
discord.enable = true;
ladybird.enable = true;
matrix.enable = true;
obs.enable = true;
onlyoffice.enable = true;
signal.enable = true;

View file

@ -0,0 +1,21 @@
user_level_secrets: ENC[AES256_GCM,data:TNT+via+r4bpgROz,iv:cVO6/r4Aovr5uJFhU87mE5XwRJ518y4OJdHo4m92ahM=,tag:jYInD+euh7k1zSnMRppI5Q==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1ewes0f5snqx3sh5ul6fa6qtxzhd25829v6mf5rx2wnheat6fefps5rme2x
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBTYVRQTEVSMWM3WXY3eTdW
ZkUwSnNidlJwWGVETURpNUJRRUllYXo4WjNvCmxmN21qVzNFV3N4UVR6WEV1am1W
eW1KTk9HVDluek1BUnBmSGI3Y2ZqaDQKLS0tIHlMYldYMTVORVNWbEgrWlBSanRM
bUZiMHlOU3pxYUhQSTREb0l4TmFlOEkKiasV2H481aJzAvEAvyeWqGYDOW+WKRFX
yyocZDo0o1lHz/gNXoC0/ujU+O3rSXdsy6Qdz6Rm+xeFUfe4KoD4bg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2025-08-11T13:21:38Z"
mac: ENC[AES256_GCM,data:kfMcZuYuQqxxfqtyfH7DltSkq8YNz+vroB+ZQKTIpCNC/W6vJP1o23/xLRzdnEgnnH5GfgZQFAK8Am00/bUD2BgEPyXxXNf1lG70ocFbRM9htii92BFfHgfi25zlEqCO7yrudm1HEJyYrFbZnT63H6u1OgWSC38CzEZTBsCE0kU=,iv:feWGBau48s2GSvZjnKPfP2z46SBuHbh//4zzcLv+MTY=,tag:D86akwawLxobhEu2AvBFKg==,type:str]
pgp: []
unencrypted_suffix: _unencrypted
version: 3.9.4

View file

@ -1,11 +1,10 @@
{osConfig, ...}: {
{ osConfig, ... }:
{
home.stateVersion = osConfig.system.stateVersion;
programs.git = {
settings.user = {
name = "Chris Kruining";
email = "chris@kruining.eu";
};
userName = "Chris Kruining";
userEmail = "chris@kruining.eu";
};
sneeuwvlok = {

View file

@ -1,11 +1,10 @@
{osConfig, ...}: {
{ osConfig, ... }:
{
home.stateVersion = osConfig.system.stateVersion;
programs.git = {
settings.user = {
name = "Chris Kruining";
email = "chris@kruining.eu";
};
userName = "Chris Kruining";
userEmail = "chris@kruining.eu";
};
sneeuwvlok = {

24
justfile Normal file
View file

@ -0,0 +1,24 @@
[private]
default:
@just -l
[doc('Update flake dependencies')]
update:
nix flake update
[doc('install nixos on a system (uses nix-anywhere)
> profile: Which profile to use
> host: How to reach the target system in the standard format of `user@host`
')]
install profile host:
nix run nixpkgs#nixos-anywhere -- \
--flake .#{{profile}} \
--generate-hardware-config nixos-generate-config ./hardware-configuration.nix \
{{host}}
[doc('builds the configuration for the host')]
build host:
nh os build . -H {{host}}
edit-secrets target:
sops --config "{{justfile_directory()}}/.sops.yml" edit "{{justfile_directory()}}/{{ if target =~ ".+@.+" { "homes" } else { "systems" } }}/x86_64-linux/{{target}}/secrets.yaml"

View file

@ -1,38 +0,0 @@
{ lib, ...}:
let
inherit (builtins) isString typeOf;
inherit (lib) mkOption types throwIfNot concatStringsSep splitStringBy toLower map;
in
{
options = {
mkUrlOptions =
defaults:
{
host = mkOption {
type = types.str;
example = "host.tld";
description = ''
Hostname
'';
} // (defaults.host or {});
port = mkOption {
type = types.port;
default = 1234;
example = "1234";
description = ''
Port
'';
} // (defaults.port or {});
protocol = mkOption {
type = types.str;
default = "https";
example = "https";
description = ''
Which protocol to use when creating a url string
'';
} // (defaults.protocol or {});
};
};
}

View file

@ -1,39 +0,0 @@
{ lib, ...}:
let
inherit (builtins) isString typeOf match toString head;
inherit (lib) throwIfNot concatStringsSep splitStringBy toLower map concatMapAttrsStringSep;
in
{
strings = {
#========================================================================================
# Converts a string to snake case
#
# simply replaces any uppercase letter to its lowercase variant preceeded by an underscore
#========================================================================================
toSnakeCase =
str:
throwIfNot (isString str) "toSnakeCase only accepts string values, but got ${typeOf str}" (
str
|> splitStringBy (prev: curr: builtins.match "[a-z]" prev != null && builtins.match "[A-Z]" curr != null) true
|> map (p: toLower p)
|> concatStringsSep "_"
);
#========================================================================================
# Converts a set of url parts to a string
#========================================================================================
toUrl =
{ protocol ? null, host, port ? null, path ? null, query ? null, hash ? null }:
let
trim_slashes = str: str |> match "^\/*(.+?)\/*$" |> head;
encode_to_str = set: concatMapAttrsStringSep "&" (n: v: "${n}=${v}") set;
_protocol = if protocol != null then "${protocol}://" else "";
_port = if port != null then ":${toString port}" else "";
_path = if path != null then "/${path |> trim_slashes}" else "";
_query = if query != null then "?${query |> encode_to_str}" else "";
_hash = if hash != null then "#${hash |> encode_to_str}" else "";
in
"${_protocol}${host}${_port}${_path}${_query}${_hash}";
};
}

View file

@ -1,2 +0,0 @@
{"level":"fatal","error":"homeserver.address not configured","time":"2026-04-15T09:10:06.949460064Z","message":"Configuration error"}
{"level":"info","time":"2026-04-15T09:10:06.949840013Z","message":"See https://docs.mau.fi/faq/field-unconfigured for more info"}

View file

@ -1,2 +0,0 @@
{"level":"fatal","error":"appservice.as_token not configured. Did you forget to generate the registration? ","time":"2026-04-15T09:11:43.617908298Z","message":"Configuration error"}
{"level":"info","time":"2026-04-15T09:11:43.618232253Z","message":"See https://docs.mau.fi/faq/field-unconfigured for more info"}

View file

@ -1,19 +0,0 @@
{ config, lib, pkgs, namespace, osConfig ? {}, ... }:
let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.application.matrix;
in
{
options.${namespace}.application.matrix = {
enable = mkEnableOption "enable Matrix client (Fractal)";
};
config = mkIf cfg.enable {
home.packages = with pkgs; [ fractal element-desktop ];
programs.element-desktop = {
enable = true;
};
};
}

View file

@ -1,20 +1,16 @@
{
inputs,
config,
lib,
pkgs,
namespace,
...
}: let
{ inputs, config, lib, pkgs, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.application.onlyoffice;
in {
in
{
options.${namespace}.application.onlyoffice = {
enable = mkEnableOption "enable onlyoffice";
};
config = mkIf cfg.enable {
home.packages = with pkgs; [onlyoffice-desktopeditors];
home.packages = with pkgs; [ onlyoffice-bin ];
# fonts.packages = with pkgs; [ corefonts ];
};
}

View file

@ -10,7 +10,7 @@ in
};
config = mkIf cfg.enable {
home.packages = with pkgs; [ protonup-ng ];
home.packages = with pkgs; [ protonup ];
home.sessionVariables = {
STEAM_EXTRA_COMPAT_TOOLS_PATHS = "\${HOME}/.steam/root/compatibilitytools.d";

View file

@ -10,9 +10,6 @@ in
};
config = mkIf cfg.enable {
home.packages = with pkgs; [
# teamspeak3
teamspeak6-client
];
home.packages = with pkgs; [ teamspeak_client ];
};
}

View file

@ -5,61 +5,35 @@ let
cfg = config.${namespace}.application.zen;
in
{
imports = [
inputs.zen-browser.homeModules.default
];
options.${namespace}.application.zen = {
enable = mkEnableOption "enable zen";
};
config = mkIf cfg.enable {
home.packages = [ inputs.zen-browser.packages.${pkgs.system}.specific ];
home.sessionVariables = {
MOZ_ENABLE_WAYLAND = "1";
};
programs.zen-browser = {
enable = true;
policies = {
AutofillAddressEnabled = true;
AutofillCreditCardEnabled = false;
AppAutoUpdate = false;
DisableAppUpdate = true;
ManualAppUpdateOnly = true;
DisableFeedbackCommands = true;
DisableFirefoxStudies = true;
DisablePocket = true;
DisableTelemetry = true;
DontCheckDefaultBrowser = false;
# DontCheckDefaultBrowser = false;
NoDefaultBookmarks = true;
OfferToSaveLogins = false;
# OfferToSaveLogins = false;
EnableTrackingProtection = {
Value = true;
Locked = true;
Cryptomining = true;
Fingerprinting = true;
};
HttpAllowlist = [
"http://ulmo"
];
};
policies.ExtensionSettings = let
mkExtension = id: {
install_url = "https://addons.mozilla.org/firefox/downloads/latest/${builtins.toString id}/latest.xpi";
installation_mode = "force_installed";
};
in
{
ublock_origin = 4531307;
ghostry = 4562168;
bitwarden = 4562769;
sponsorblock = 4541835;
};
};
};

View file

@ -64,7 +64,7 @@ in
};
kwalletrc = {
Wallet.Enabled = true;
Wallet.Enabled = false;
};
plasmarc = {

View file

@ -95,7 +95,7 @@
digitalClock = {
date = {
enable = true;
format.custom = "dd-MM-yyyy";
format = "shortDate";
position = "belowTime";
};
time = {

View file

@ -15,7 +15,7 @@ in {
programs.zed-editor = {
enable = true;
extensions = [ "nix" "toml" "html" "just-ls" ];
extensions = [ "nix" "toml" "html" ];
userSettings = {
assistant.enabled = false;

View file

@ -4,9 +4,7 @@ let
in
{
systemd.user.startServices = "sd-switch";
programs.home-manager = {
enable = true;
};
programs.home-manager.enable = true;
home.stateVersion = mkDefault (osConfig.system.stateVersion or "25.05");
}

View file

@ -17,7 +17,6 @@ in
eza.enable = true;
fzf.enable = true;
git.enable = true;
just.enable = true;
starship.enable = true;
tmux.enable = true;
yazi.enable = true;

View file

@ -1,14 +1,10 @@
{
config,
lib,
pkgs,
namespace,
...
}: let
{ config, lib, pkgs, namespace, ... }:
let
inherit (lib) mkEnableOption mkIf;
cfg = config.${namespace}.shell.toolset.git;
in {
in
{
options.${namespace}.shell.toolset.git = {
enable = mkEnableOption "version-control system";
};
@ -33,6 +29,12 @@ in {
git = {
enable = true;
package = pkgs.gitFull;
difftastic = {
enable = true;
background = "dark";
color = "always";
display = "inline";
};
ignores = [
# General:
@ -65,7 +67,7 @@ in {
"*.elc"
];
settings = {
extraConfig = {
init.defaultBranch = "main";
core = {
editor = "nvim";
@ -102,16 +104,6 @@ in {
};
};
};
difftastic = {
enable = true;
git.enable = true;
options = {
background = "dark";
color = "always";
display = "inline";
};
};
};
};
}

View file

@ -1,15 +0,0 @@
{ config, lib, pkgs, namespace, ... }:
let
inherit (lib) mkEnableOption mkIf;
cfg = config.${namespace}.shell.toolset.just;
in
{
options.${namespace}.shell.toolset.just = {
enable = mkEnableOption "version-control system";
};
config = mkIf cfg.enable {
home.packages = with pkgs; [ just gum ];
};
}

View file

@ -31,9 +31,7 @@ in {
base16Scheme = "${pkgs.base16-schemes}/share/themes/${cfg.theme}.yaml";
image = ./${cfg.theme}.jpg;
polarity = cfg.polarity;
targets.qt.platform = mkDefault "kde";
targets.zen-browser.profileNames = [ "Chris" ];
targets.qt.platform = mkDefault "kde6";
fonts = {
serif = {
@ -52,7 +50,7 @@ in {
};
emoji = {
package = pkgs.noto-fonts-color-emoji;
package = pkgs.noto-fonts-emoji;
name = "Noto Color Emoji";
};
};

View file

@ -1,52 +1,37 @@
{
inputs,
config,
lib,
pkgs,
namespace,
...
}: let
{ inputs, config, lib, pkgs, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.application.steam;
in {
in
{
options.${namespace}.application.steam = {
enable = mkEnableOption "enable steam";
};
config = mkIf cfg.enable {
# environment.systemPackages = with pkgs; [ steam ];
programs = {
steam = {
enable = true;
remotePlay.openFirewall = true;
dedicatedServer.openFirewall = true;
localNetworkGameTransfers.openFirewall = true;
package = pkgs.steam-small.override {
extraEnv = {
DXVK_HUD = "compiler";
MANGOHUD = true;
};
};
extraCompatPackages = with pkgs; [
proton-ge-bin
];
# package = pkgs.steam.override {
# extraEnv = {
# DXVK_HUD = "compiler";
# MANGOHUD = true;
# };
# };
# gamescopeSession = {
# enable = true;
# args = ["--immediate-flips"];
# };
gamescopeSession = {
enable = true;
args = ["--immediate-flips"];
};
};
# https://github.com/FeralInteractive/gamemode
# gamemode = {
# enable = true;
# enableRenice = true;
# settings = {};
# };
gamemode = {
enable = true;
enableRenice = true;
settings = {};
};
# gamescope = {
# enable = true;

View file

@ -1,26 +0,0 @@
{
lib,
config,
namespace,
inputs,
...
}: let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.desktop.cosmic;
in {
options.${namespace}.desktop.cosmic = {
enable =
mkEnableOption "Enable Cosmic desktop"
// {
default = config.${namespace}.desktop.use == "cosmic";
};
};
config = mkIf cfg.enable {
services = {
displayManager.cosmic-greeter.enable = true;
desktopManager.cosmic.enable = true;
};
};
}

View file

@ -1,22 +1,18 @@
{
lib,
config,
namespace,
inputs,
...
}: let
{ lib, config, namespace, inputs, ... }:
let
inherit (lib) mkIf mkOption mkEnableOption mkMerge;
inherit (lib.types) nullOr enum;
cfg = config.${namespace}.desktop;
in {
in
{
imports = [
inputs.grub2-themes.nixosModules.default
];
options.${namespace}.desktop = {
use = mkOption {
type = nullOr (enum ["plasma" "gamescope" "gnome" "cosmic"]);
type = nullOr (enum [ "plasma" "gamescope" "gnome" ]);
default = null;
example = "plasma";
description = "Which desktop to enable";
@ -24,11 +20,11 @@ in {
};
config = mkMerge [
{
({
services.displayManager = {
enable = true;
};
}
})
# (mkIf (cfg.use != null) {
# ${namespace}.desktop.${cfg.use}.enable = true;

View file

@ -12,18 +12,7 @@ in
};
config = mkIf cfg.enable {
environment.plasma6.excludePackages = with pkgs.kdePackages; [
elisa
kmahjongg
kmines
konversation
kpat
ksudoku
konsole
kate
ghostwriter
# oxygen
];
environment.plasma6.excludePackages = with pkgs.kdePackages; [ konsole kate ghostwriter oxygen ];
environment.sessionVariables.NIXOS_OZONE_WL = "1";
services = {

View file

@ -17,6 +17,11 @@ in
};
amdgpu = {
amdvlk = {
enable = true;
support32Bit.enable = true;
};
initrd.enable = true;
};
};

View file

@ -1,6 +0,0 @@
{ ... }:
{
config = {
home-manager.backupFileExtension = "homeManagerBackup";
};
}

View file

@ -1,11 +1,15 @@
{ pkgs, lib, namespace, config, ... }:
let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.nix;
in
{
options.${namespace}.nix = {};
options.${namespace}.nix = {
enable = mkEnableOption "Enable nix command";
};
config = {
config = mkIf cfg.enable {
programs.git.enable = true;
nix = {

View file

@ -1,36 +1,16 @@
{
config,
lib,
pkgs,
namespace,
...
}: let
{ config, lib, pkgs, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption;
user = "authelia-testing";
cfg = config.${namespace}.services.authentication.authelia;
in {
in
{
options.${namespace}.services.authentication.authelia = {
enable = mkEnableOption "Authelia";
};
config = mkIf cfg.enable {
${namespace}.services.networking.caddy = {
hosts = {
"auth.kruining.eu".extraConfig = ''
reverse_proxy http://127.0.0.1:9091
'';
};
extraConfig = ''
(auth) {
forward_auth http://127.0.0.1:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
}
'';
};
environment.systemPackages = with pkgs; [
authelia
];
@ -150,23 +130,6 @@ in {
scopes = [ "offline_access" "openid" "email" "picture" "profile" "groups" ];
redirect_uris = [ "http://localhost:3000/api/auth/oauth2/callback/authelia" ];
}
{
client_id = "forgejo";
client_name = "forgejo";
# ZPuiW2gpVV6MGXIJFk5P3EeSW8V_ICgqduF.hJVCKkrnVmRqIQXRk0o~HSA8ZdCf8joA4m_F
client_secret = "$pbkdf2-sha512$310000$CzZjvJT75bz5z7MjwxsEtg$JtOiIgaY5/HcLLxJgyX4zvsQV9jIoow0e4JdlFsk/LWRDOJ0kc.PzstlYfw7QERTXtJILoWsDqPzmvpneK5Leg";
public = false;
require_pkce = true;
pkce_challenge_method = "S256";
token_endpoint_auth_method = "client_secret_post";
authorization_policy = "one_factor";
userinfo_signed_response_alg = "none";
consent_mode = "implicit";
scopes = ["offline_access" "openid" "email" "picture" "profile" "groups"];
response_types = ["code"];
grant_types = ["authorization_code"];
redirect_uris = ["http://localhost:5002/user/oauth2/authelia/callback"];
}
];
};
};
@ -215,8 +178,48 @@ in {
- jellyfin-users
- admin
- dev
jacqueline:
disabled: false
displayname: Jacqueline Bevers
password: $argon2id$v=19$m=65536,t=3,p=4$XgN8yEJV+syAE5yeos3HsA$SlN+j/lJfxJ5VxLu2CdrwowlCiWQNNGhIrSyDpohq18
groups:
- jellyfin-users
martijn:
disabled: false
displayname: Martijn Kruining
password: $argon2id$v=19$m=65536,t=3,p=4$XgN8yEJV+syAE5yeos3HsA$SlN+j/lJfxJ5VxLu2CdrwowlCiWQNNGhIrSyDpohq18
groups:
- jellyfin-users
andrea:
disabled: false
displayname: Andrea Kruining
password: $argon2id$v=19$m=65536,t=3,p=4$XgN8yEJV+syAE5yeos3HsA$SlN+j/lJfxJ5VxLu2CdrwowlCiWQNNGhIrSyDpohq18
groups:
- jellyfin-users
'';
};
};
services.caddy = {
enable = true;
virtualHosts = {
"auth.kruining.eu".extraConfig = ''
reverse_proxy http://127.0.0.1:9091
'';
};
extraConfig = ''
(auth) {
forward_auth http://127.0.0.1:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
}
'';
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
};
}

View file

@ -0,0 +1 @@
{ ... }: {}

View file

@ -1,14 +1,9 @@
{
inputs,
lib,
config,
namespace,
...
}: let
{ inputs, lib, config, namespace, ... }: let
inherit (lib) mkEnableOption mkIf;
cfg = config.${namespace}.services.authentication.himmelblau;
in {
in
{
imports = [ inputs.himmelblau.nixosModules.himmelblau ];
options.${namespace}.services.authentication.himmelblau = {
@ -19,7 +14,7 @@ in {
services.himmelblau = {
enable = true;
settings = {
domain = "";
domains = [];
pam_allow_groups = [];
local_groups = [];
};

View file

@ -0,0 +1,86 @@
{ config, lib, pkgs, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.authentication.zitadel;
db_name = "zitadel";
db_user = "zitadel";
in
{
options.${namespace}.services.authentication.zitadel = {
enable = mkEnableOption "Zitadel";
};
config = mkIf cfg.enable {
environment.systemPackages = with pkgs; [
zitadel
];
services = {
zitadel = {
enable = true;
openFirewall = true;
masterKeyFile = config.sops.secrets."zitadel/masterKey".path;
tlsMode = "external";
settings = {
Port = 9092;
Database = {
Host = "/run/postgresql";
# Zitadel will report error if port is not set
Port = 5432;
Database = db_name;
User.Username = db_user;
};
};
steps = {
TestInstance = {
InstanceName = "Zitadel test";
Org = {
Name = "Kruining.eu";
Human = {
UserName = "admin";
Password = "kaas";
};
};
};
};
};
postgresql = {
enable = true;
ensureDatabases = [ db_name ];
ensureUsers = [
{
name = db_user;
ensureDBOwnership = true;
}
];
};
caddy = {
enable = true;
virtualHosts = {
"auth-z.kruining.eu".extraConfig = ''
reverse_proxy h2c://127.0.0.1:9092
'';
};
# extraConfig = ''
# (auth) {
# forward_auth h2c://127.0.0.1:9092 {
# uri /api/authz/forward-auth
# copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
# }
# }
# '';
};
};
# Secrets
sops.secrets."zitadel/masterKey" = {
owner = "zitadel";
group = "zitadel";
restartUnits = [ "zitadel.service" ];
};
};
}

View file

@ -1,732 +0,0 @@
{ config, lib, pkgs, namespace, system, inputs, ... }:
let
inherit (lib) mkIf mkEnableOption mkOption toString types toUpper toSentenceCase nameValuePair mapAttrs mapAttrs' concatMapAttrs concatMapStringsSep filterAttrsRecursive listToAttrs imap0 head drop length literalExpression attrNames;
inherit (lib.${namespace}.strings) toSnakeCase;
cfg = config.${namespace}.services.authentication.zitadel;
port = 3010;
database = "zitadel";
in
{
options.${namespace}.services.authentication.zitadel = {
enable = mkEnableOption "Zitadel";
organization = mkOption {
type = types.attrsOf (types.submodule ({ name, ... }: {
options =
let
org = name;
in
{
isDefault = mkOption {
type = types.bool;
default = false;
example = "true";
description = ''
True sets the '${org}' org as default org for the instance. Only one org can be default org.
Nothing happens if you set it to false until you set another org as default org.
'';
};
project = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
hasProjectCheck = mkOption {
type = types.bool;
default = false;
example = "true";
description = ''
ZITADEL checks if the org of the user has permission to this project.
'';
};
privateLabelingSetting = mkOption {
type = types.nullOr (types.enum [ "unspecified" "enforceProjectResourceOwnerPolicy" "allowLoginUserResourceOwnerPolicy" ]);
default = null;
example = "enforceProjectResourceOwnerPolicy";
description = ''
Defines from where the private labeling should be triggered,
supported values:
- unspecified
- enforceProjectResourceOwnerPolicy
- allowLoginUserResourceOwnerPolicy
'';
};
projectRoleAssertion = mkOption {
type = types.bool;
default = false;
example = "true";
description = ''
Describes if roles of user should be added in token.
'';
};
projectRoleCheck = mkOption {
type = types.bool;
default = false;
example = "true";
description = ''
ZITADEL checks if the user has at least one on this project.
'';
};
role = mkOption {
default = {};
type = types.attrsOf (types.submodule ({ name, ... }: {
options =
let
roleName = name;
in
{
displayName = mkOption {
type = types.str;
default = toSentenceCase name;
example = "RoleName";
description = ''
Name used for project role.
'';
};
group = mkOption {
type = types.nullOr types.str;
default = null;
example = "some_group";
description = ''
Group used for project role.
'';
};
};
}));
};
assign = mkOption {
default = {};
type = types.attrsOf (types.listOf types.str);
};
application = mkOption {
default = {};
type = types.attrsOf (types.submodule {
options = {
redirectUris = mkOption {
type = types.nonEmptyListOf types.str;
example = ''
[ "https://example.com/redirect/url" ]
'';
description = ''
.
'';
};
grantTypes = mkOption {
type = types.nonEmptyListOf (types.enum [ "authorizationCode" "implicit" "refreshToken" "deviceCode" "tokenExchange" ]);
example = ''
[ "authorizationCode" ]
'';
description = ''
.
'';
};
responseTypes = mkOption {
type = types.nonEmptyListOf (types.enum [ "code" "idToken" "idTokenToken" ]);
example = ''
[ "code" ]
'';
description = ''
.
'';
};
exportMap =
let
strOpt = mkOption { type = types.nullOr types.str; default = null; };
in
mkOption {
type = types.submodule { options = { client_id = strOpt; client_secret = strOpt; }; };
default = {};
example = literalExpression ''
{
client_id = "SSO_CLIENT_ID";
client_secret = "SSO_CLIENT_SECRET";
}
'';
description = ''
Remap the outputted variables to another key.
'';
};
};
});
};
};
});
};
user = mkOption {
default = {};
type = types.attrsOf (types.submodule ({ name, ... }: {
options =
let
username = name;
in
{
email = mkOption {
type = types.str;
example = "someone@some.domain";
description = ''
Username.
'';
};
userName = mkOption {
type = types.nullOr types.str;
default = username;
example = "some_user_name";
description = ''
Username. Default value is the key of the config object you created, you can overwrite that by setting this option
'';
};
firstName = mkOption {
type = types.str;
example = "John";
description = ''
First name of the user.
'';
};
lastName = mkOption {
type = types.str;
example = "Doe";
description = ''
Last name of the user.
'';
};
roles = mkOption {
type = types.listOf types.str;
default = [];
example = "[ \"ORG_OWNER\" ]";
description = ''
List of roles granted to organisation.
'';
};
instanceRoles = mkOption {
type = types.listOf types.str;
default = [];
example = "[ \"IAM_OWNER\" ]";
description = ''
List of roles granted to instance.
'';
};
};
}));
};
action = mkOption {
default = {};
type = types.attrsOf (types.submodule ({ name, ... }: {
options = {
script = mkOption {
type = types.str;
example = ''
(ctx, api) => {
api.v1.claims.setClaim('some_claim', 'some_value');
};
'';
description = ''
The script to run. This must be a function that receives 2 parameters, and returns void. During the creation of the action's script this module simly does `const {{name}} = {{script}}`.
'';
};
timeout = mkOption {
type = (types.ints.between 0 20);
default = 10;
example = "10";
description = ''
After which time the action will be terminated if not finished.
'';
};
allowedToFail = mkOption {
type = types.bool;
default = true;
example = "true";
description = ''
Allowed to fail.
'';
};
};
}));
};
triggers = mkOption {
default = [];
type = types.listOf (types.submodule {
options = {
flowType = mkOption {
type = types.enum [ "authentication" "customiseToken" "internalAuthentication" "samlResponse" ];
example = "customiseToken";
description = ''
Type of the flow to which the action triggers belong.
'';
};
triggerType = mkOption {
type = types.enum [ "postAuthentication" "preCreation" "postCreation" "preUserinfoCreation" "preAccessTokenCreation" "preSamlResponse" ];
example = "postAuthentication";
description = ''
Trigger type on when the actions get triggered.
'';
};
actions = mkOption {
type = types.nonEmptyListOf types.str;
example = ''[ "action_name" ]'';
description = ''
Names of actions to trigger
'';
};
};
});
};
};
}));
};
};
config = let
_refTypeMap = {
org = { type = "org"; };
project = { type = "project"; };
user = { type = "user"; tfType = "human_user"; };
};
mapRef' = { type, tfType ? type }: name: { "${type}Id" = "\${ resource.zitadel_${tfType}.${toSnakeCase name}.id }"; };
mapRef = type: name: mapRef' (_refTypeMap.${type}) name;
mapEnum = prefix: value: "${prefix}_${value |> toSnakeCase |> toUpper}";
mapValue = type: value: ({
appType = mapEnum "OIDC_APP_TYPE" value;
grantTypes = map (t: mapEnum "OIDC_GRANT_TYPE" t) value;
responseTypes = map (t: mapEnum "OIDC_RESPONSE_TYPE" t) value;
authMethodType = mapEnum "OIDC_AUTH_METHOD_TYPE" value;
flowType = mapEnum "FLOW_TYPE" value;
triggerType = mapEnum "TRIGGER_TYPE" value;
accessTokenType = mapEnum "OIDC_TOKEN_TYPE" value;
}."${type}" or value);
toResource = name: value: nameValuePair
(toSnakeCase name)
(lib.mapAttrs' (k: v: nameValuePair (toSnakeCase k) (mapValue k v)) value);
withRef = type: name: attrs: attrs // (mapRef type name);
select = keys: callback: set:
if (length keys) == 0 then
mapAttrs' callback set
else let key = head keys; in
concatMapAttrs (k: v: select (drop 1 keys) (callback k) (v.${key} or {})) set
;
append = attrList: set: set // (listToAttrs attrList);
config' = config;
# this is a nix package, the generated json file to be exact
terraformConfiguration = inputs.terranix.lib.terranixConfiguration {
inherit system;
modules = [
({ config, lib, ... }: {
config =
let
forEach = src: key: set:
let
_key = concatMapStringsSep "_" (k: "\${item.${k}}") key;
in
{
forEach = lib.tfRef ''{
for item in ${src} :
"''${item.org}_''${item.name}" => item
}'';
}
// set;
in
{
terraform.required_providers.zitadel = {
source = "zitadel/zitadel";
version = "2.2.0";
};
provider.zitadel = {
domain = "auth.kruining.eu";
insecure = "false";
jwt_profile_file = "/var/lib/zitadel/machine-key.json";
};
locals = {
extra_users = lib.tfRef "
flatten([ for org, users in jsondecode(file(\"${config'.sops.secrets."zitadel/users".path}\")): [
for name, details in users: {
org = org
name = name
email = details.email
firstName = details.firstName
lastName = details.lastName
}
] ])
";
orgs = cfg.organization |> mapAttrs (org: _: lib.tfRef "resource.zitadel_org.${org}.id");
};
resource = {
# Organizations
zitadel_org = cfg.organization |> select [] (name: { isDefault, ... }:
{ inherit name isDefault; }
|> toResource name
);
# Projects per organization
zitadel_project = cfg.organization |> select [ "project" ] (org: name: { hasProjectCheck, privateLabelingSetting, projectRoleAssertion, projectRoleCheck, ... }:
{
inherit name hasProjectCheck privateLabelingSetting projectRoleAssertion projectRoleCheck;
}
|> withRef "org" org
|> toResource "${org}_${name}"
);
# Each OIDC app per project
zitadel_application_oidc = cfg.organization |> select [ "project" "application" ] (org: project: name: { redirectUris, grantTypes, responseTypes, ...}:
{
inherit name redirectUris grantTypes responseTypes;
accessTokenRoleAssertion = true;
idTokenRoleAssertion = true;
accessTokenType = "JWT";
}
|> withRef "org" org
|> withRef "project" "${org}_${project}"
|> toResource "${org}_${project}_${name}"
);
# Each project role
zitadel_project_role = cfg.organization |> select [ "project" "role" ] (org: project: name: value:
{ inherit (value) displayName group; roleKey = name; }
|> withRef "org" org
|> withRef "project" "${org}_${project}"
|> toResource "${org}_${project}_${name}"
);
# Each project role assignment
zitadel_user_grant = cfg.organization |> select [ "project" "assign" ] (org: project: user: roles:
{ roleKeys = roles; }
|> withRef "org" org
|> withRef "project" "${org}_${project}"
|> withRef "user" "${org}_${user}"
|> toResource "${org}_${project}_${user}"
);
# Users
zitadel_human_user =
cfg.organization
|> select [ "user" ] (org: name: { email, userName, firstName, lastName, ... }:
{
inherit email userName firstName lastName;
isEmailVerified = true;
}
|> withRef "org" org
|> toResource "${org}_${name}"
)
|> append [
(forEach "local.extra_users" [ "org" "name" ] {
orgId = lib.tfRef "local.orgs[each.value.org]";
userName = lib.tfRef "each.value.name";
email = lib.tfRef "each.value.email";
firstName = lib.tfRef "each.value.firstName";
lastName = lib.tfRef "each.value.lastName";
isEmailVerified = true;
}
|> toResource "extraUsers")
]
;
# Global user roles
zitadel_instance_member =
cfg.organization
|> filterAttrsRecursive (n: v: !(v ? "instanceRoles" && (length v.instanceRoles) == 0))
|> select [ "user" ] (org: name: { instanceRoles, ... }:
{ roles = instanceRoles; }
|> withRef "user" "${org}_${name}"
|> toResource "${org}_${name}"
);
# Organazation specific roles
zitadel_org_member =
cfg.organization
|> filterAttrsRecursive (n: v: !(v ? "roles" && (length v.roles) == 0))
|> select [ "user" ] (org: name: { roles, ... }:
{ inherit roles; }
|> withRef "org" org
|> withRef "user" "${org}_${name}"
|> toResource "${org}_${name}"
);
# Organazation's actions
zitadel_action = cfg.organization |> select [ "action" ] (org: name: { timeout, allowedToFail, script, ...}:
{
inherit allowedToFail name;
timeout = "${toString timeout}s";
script = "const ${name} = ${script}";
}
|> withRef "org" org
|> toResource "${org}_${name}"
);
# Organazation's action assignments
zitadel_trigger_actions =
cfg.organization
|> concatMapAttrs (org: { triggers, ... }:
triggers
|> imap0 (i: { flowType, triggerType, actions, ... }: (let name = "trigger_${toString i}"; in
{
inherit flowType triggerType;
actionIds =
actions
|> map (action: (lib.tfRef "zitadel_action.${org}_${toSnakeCase action}.id"));
}
|> withRef "org" org
|> toResource "${org}_${name}"
))
|> listToAttrs
);
# SMTP config
zitadel_smtp_config.default = {
sender_address = "chris@kruining.eu";
sender_name = "no-reply (Zitadel)";
tls = true;
host = "black-mail.nl:587";
user = "chris@kruining.eu";
password = lib.tfRef "file(\"${config'.sops.secrets."zitadel/email".path}\")";
set_active = true;
};
# Client credentials per app
local_sensitive_file = cfg.organization |> select [ "project" "application" ] (org: project: name: { exportMap, ... }:
nameValuePair "${org}_${project}_${name}" {
content = ''
${if exportMap.client_id != null then exportMap.client_id else "CLIENT_ID"}=${lib.tfRef "resource.zitadel_application_oidc.${org}_${project}_${name}.client_id"}
${if exportMap.client_secret != null then exportMap.client_secret else "CLIENT_SECRET"}=${lib.tfRef "resource.zitadel_application_oidc.${org}_${project}_${name}.client_secret"}
'';
filename = "/var/lib/zitadel/clients/${org}_${project}_${name}";
}
);
};
};
})
];
};
in
mkIf cfg.enable {
${namespace}.services = {
persistance.postgresql.enable = true;
networking.caddy = {
hosts = {
"auth.kruining.eu" = ''
reverse_proxy h2c://[::1]:${toString port}
'';
};
extraConfig = ''
(auth) {
forward_auth h2c://[::1]:${toString port} {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
}
'';
};
};
environment.systemPackages = with pkgs; [
zitadel
];
systemd.tmpfiles.rules = [
"d /tmp/zitadelApplyTerraform 0755 zitadel zitadel -"
"d /var/lib/zitadel/clients 0755 zitadel zitadel -"
];
systemd.services.zitadelApplyTerraform = {
description = "Zitadel terraform apply";
wantedBy = [ "multi-user.target" ];
wants = [ "zitadel.service" ];
script =
let
tofu = lib.getExe pkgs.opentofu;
in
''
if [ "$(systemctl is-active zitadel)" != "active" ]; then
echo "Zitadel is not running"
exit 1
fi
# Print the path to the source for easier debugging
echo "config location: ${terraformConfiguration}"
# Copy infra code into workspace
cp -f ${terraformConfiguration} config.tf.json
# Initialize OpenTofu
${tofu} init
# Run the infrastructure code
${tofu} plan -refresh=false -out=tfplan
${tofu} apply -auto-approve tfplan
'';
serviceConfig = {
Type = "oneshot";
User = "zitadel";
Group = "zitadel";
WorkingDirectory = "/tmp/zitadelApplyTerraform";
};
};
services = {
zitadel = {
enable = true;
openFirewall = true;
masterKeyFile = config.sops.secrets."zitadel/masterKey".path;
tlsMode = "external";
settings = {
Port = port;
ExternalDomain = "auth.kruining.eu";
ExternalPort = 443;
ExternalSecure = true;
Metrics.Type = "otel";
Tracing.Type = "otel";
Telemetry.Enabled = true;
SystemDefaults = {
PasswordHasher.Hasher.Algorithm = "argon2id";
SecretHasher.Hasher.Algorithm = "argon2id";
};
Database.postgres = {
Host = "localhost";
# Zitadel will report error if port is not set
Port = 5432;
Database = database;
User = {
Username = database;
SSL.Mode = "disable";
};
Admin = {
Username = "postgres";
SSL.Mode = "disable";
};
};
};
steps = {
FirstInstance = {
# Not sure, this option seems to be mostly irrelevant
InstanceName = "eu";
MachineKeyPath = "/var/lib/zitadel/machine-key.json";
# PatPath = "/var/lib/zitadel/machine-key.pat";
# LoginClientPatPath = "/var/lib/zitadel/machine-key.json";
Org = {
Name = "kruining";
Human = {
UserName = "chris";
FirstName = "Chris";
LastName = "Kruining";
Email = {
Address = "chris@kruining.eu";
Verified = true;
};
Password = "KaasIsAwesome1!";
};
Machine = {
Machine = {
Username = "terraform-service-user";
Name = "Terraform";
};
MachineKey = { ExpirationDate = "2026-01-01T00:00:00Z"; Type = 1; };
# Pat = { ExpirationDate = "2026-01-01T00:00:00Z"; };
};
# LoginClient.Machine = {
# Username = "terraform-service-user";
# Name = "Terraform";
# };
};
};
};
# extraStepsPaths = [
# config.sops.templates."secrets.yaml".path
# ];
};
postgresql = {
enable = true;
ensureDatabases = [ database ];
ensureUsers = [
{
name = database;
ensureDBOwnership = true;
}
];
};
};
# Secrets
sops = {
secrets = {
"zitadel/masterKey" = {
owner = "zitadel";
group = "zitadel";
restartUnits = [ "zitadel.service" ]; #EMGDB#6O$8qpGoLI1XjhUhnng1san@0
};
"zitadel/email" = {
owner = "zitadel";
group = "zitadel";
key = "email/chris_kruining_eu";
restartUnits = [ "zitadel.service" ];
};
"zitadel/users" = {
owner = "zitadel";
group = "zitadel";
restartUnits = [ "zitadelApplyTerraform.service" ];
};
};
templates = {
"users.yml" = {
};
};
};
};
}

View file

@ -1,35 +0,0 @@
{ config, lib, pkgs, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.backup.borg;
in
{
options.${namespace}.services.backup.borg = {
enable = mkEnableOption "Borg Backup";
};
config = mkIf cfg.enable {
programs.ssh.extraConfig = ''
Host beheer.hazelhof.nl
Port 222
User chris
AddressFamily inet
IdentityFile /home/chris/.ssh/id_ed25519
'';
services = {
borgbackup.jobs = {
media = {
paths = "/var/media/test";
encryption.mode = "none";
# environment.BORG_SSH = "ssh -4 -i /home/chris/.ssh/id_ed25519";
environment.BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK = "yes";
repo = "ssh://beheer.hazelhof.nl//media";
compression = "auto,zstd";
startAt = "daily";
};
};
};
};
}

View file

@ -1,399 +0,0 @@
{
config,
lib,
pkgs,
namespace,
...
}: let
inherit (builtins) toString toJSON;
inherit (lib) mkIf mkEnableOption mkMerge;
cfg = config.${namespace}.services.communication.matrix;
domain = "kruining.eu";
fqn = "matrix.${domain}";
port = 4001;
database = "synapse";
keyFile = "/var/lib/element-call/key";
mkMautrix = bridge: i: conf: {
${bridge} = mkMerge [
{
enable = true;
registerToSynapse = true;
settings = {
appservice = {
# hostname = "[::]";
# port = 40010 + i;
# address = "http://${config.services.${bridge}.settings.appservice.hostname}:${toString config.services.${bridge}.settings.appservice.port}";
provisioning.enabled = false;
};
homeserver = {
inherit domain;
address = "http://[::1]:${toString port}";
};
bridge = {
permissions = {
"@chris:${domain}" = "admin";
};
};
};
}
conf
];
};
in {
options.${namespace}.services.communication.matrix = {
enable = mkEnableOption "Matrix server (Synapse)";
};
config = mkIf cfg.enable {
${namespace}.services = {
persistance.postgresql.enable = true;
networking.caddy = {
hosts = let
server = {
"m.server" = "${fqn}:443";
};
client = {
"m.homeserver".base_url = "https://${fqn}";
"m.identity_server".base_url = "https://auth.${domain}";
"org.matrix.msc3575.proxy".url = "https://${domain}";
"org.matrix.msc4143.rtc_foci" = [
{
type = "livekit";
livekit_service_url = "https://${domain}/livekit/jwt";
}
];
};
in {
"${domain}, darkch.at" = ''
# Route for lk-jwt-service
handle /livekit/jwt* {
uri strip_prefix /livekit/jwt
reverse_proxy http://[::1]:${toString config.services.lk-jwt-service.port} {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
}
}
handle_path /livekit/sfu* {
reverse_proxy http://[::1]:${toString config.services.livekit.settings.port} {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
}
}
header /.well-known/matrix/* Content-Type application/json
header /.well-known/matrix/* Access-Control-Allow-Origin *
respond /.well-known/matrix/server `${toJSON server}`
respond /.well-known/matrix/client `${toJSON client}`
'';
"${fqn}" = ''
reverse_proxy /_matrix/* http://[::1]:${toString port}
reverse_proxy /_synapse/client/* http://[::1]:${toString port}
'';
};
};
};
services = mkMerge [
(mkMautrix "mautrix-signal" 1 {})
(mkMautrix "mautrix-telegram" 2 {})
(mkMautrix "mautrix-whatsapp" 3 {})
(mkMautrix "arrtrix" 4 {
environmentFile = config.sops.templates."arrtrix/secrets".path;
settings = {
observability = {
otlp_grpc_endpoint = "http://[::1]:9071";
service_name = "arrtrix";
};
network.content = {
movies = {
url = "http://[::1]:${toString config.services.radarr.settings.server.port}";
api_key = "$RADARR_APIKEY";
root_folder_path = "/var/media/movies";
quality_profile_id = 5;
};
series = {
url = "http://[::1]:${toString config.services.sonarr.settings.server.port}";
api_key = "$SONARR_APIKEY";
root_folder_path = "/var/media/series";
quality_profile_id = 5;
language_profile_id = 1;
};
};
};
})
{
matrix-synapse = {
enable = true;
extras = ["oidc"];
extraConfigFiles = [
config.sops.templates."synapse.yaml".path
config.sops.templates."synapse-oidc.yaml".path
];
settings = {
server_name = domain;
public_baseurl = "https://${fqn}";
enable_metrics = true;
url_preview_enabled = true;
precence.enabled = true;
# Since we'll be using OIDC for auth disable all local options
enable_registration = false;
enable_registration_without_verification = false;
password_config.enabled = true;
backchannel_logout_enabled = true;
# Element Call options
max_event_delay_duration = "24h";
rc_message = {
per_second = 0.5;
burst_count = 30;
};
rc_delayed_event_mgmt = {
per_second = 1;
burst_count = 20;
};
turn_uris = ["turn:turn.${domain}:4004?transport=udp" "turn:turn.${domain}:4004?transport=tcp"];
experimental_features = {
# MSC2965: OAuth 2.0 Authorization Server Metadata discovery
msc2965_enabled = true;
# MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled = true;
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled = true;
};
sso = {
client_whitelist = ["http://[::1]:${toString config.services.zitadel.settings.Port}/" "https://auth.kruining.eu/"];
update_profile_information = true;
};
database = {
# this is postgresql (also the default, but I prefer to be explicit)
name = "psycopg2";
args = {
database = database;
user = database;
};
};
listeners = [
{
bind_addresses = ["::"];
port = port;
type = "http";
tls = false;
x_forwarded = true;
resources = [
{
names = ["client" "federation" "openid" "metrics" "media" "health"];
compress = true;
}
];
}
];
};
};
postgresql = {
ensureDatabases = [database];
ensureUsers = [
{
name = database;
ensureDBOwnership = true;
}
];
};
livekit = {
enable = true;
openFirewall = true;
inherit keyFile;
settings = {
port = 4002;
room.auto_create = false;
};
};
lk-jwt-service = {
enable = true;
port = 4003;
# can be on the same virtualHost as synapse
livekitUrl = "wss://${domain}/livekit/sfu";
inherit keyFile;
};
coturn = rec {
enable = true;
listening-port = 4004;
tls-listening-port = 40004;
no-cli = true;
no-tcp-relay = true;
min-port = 50000;
max-port = 50100;
use-auth-secret = true;
static-auth-secret-file = config.sops.secrets."coturn/secret".path;
realm = "turn.${domain}";
# cert = "${config.security.acme.certs.${realm}.directory}/full.pem";
# pkey = "${config.security.acme.certs.${realm}.directory}/key.pem";
extraConfig = ''
# for debugging
verbose
# ban private IP ranges
no-multicast-peers
denied-peer-ip=0.0.0.0-0.255.255.255
denied-peer-ip=10.0.0.0-10.255.255.255
denied-peer-ip=100.64.0.0-100.127.255.255
denied-peer-ip=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.0.0.0-192.0.0.255
denied-peer-ip=192.0.2.0-192.0.2.255
denied-peer-ip=192.88.99.0-192.88.99.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=198.18.0.0-198.19.255.255
denied-peer-ip=198.51.100.0-198.51.100.255
denied-peer-ip=203.0.113.0-203.0.113.255
denied-peer-ip=240.0.0.0-255.255.255.255
denied-peer-ip=::1
denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff
denied-peer-ip=::ffff:0.0.0.0-::ffff:255.255.255.255
denied-peer-ip=100::-100::ffff:ffff:ffff:ffff
denied-peer-ip=2001::-2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=2002::-2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
'';
};
}
];
networking.firewall = {
allowedTCPPortRanges = [];
allowedTCPPorts = [
# Synapse
port
# coTURN ports
config.services.coturn.listening-port
config.services.coturn.alt-listening-port
config.services.coturn.tls-listening-port
config.services.coturn.alt-tls-listening-port
];
allowedUDPPortRanges = with config.services.coturn;
lib.singleton {
from = min-port;
to = max-port;
};
allowedUDPPorts = [
# coTURN ports
config.services.coturn.listening-port
config.services.coturn.alt-listening-port
];
};
systemd = {
services.livekit-key = {
before = ["lk-jwt-service.service" "livekit.service"];
wantedBy = ["multi-user.target"];
path = with pkgs; [livekit coreutils gawk];
script = ''
echo "Key missing, generating key"
echo "lk-jwt-service: $(livekit-server generate-keys | tail -1 | awk '{print $3}')" > "${keyFile}"
'';
serviceConfig.Type = "oneshot";
unitConfig.ConditionPathExists = "!${keyFile}";
};
services.lk-jwt-service.environment.LIVEKIT_FULL_ACCESS_HOMESERVERS = "${domain}";
};
sops = {
secrets = {
"synapse/oidc_id" = {
restartUnits = ["synapse-matrix.service"];
};
"synapse/oidc_secret" = {
restartUnits = ["synapse-matrix.service"];
};
"synapse/shared_secret" = {
restartUnits = ["synapse-matrix.service"];
};
"coturn/secret" = {
owner = config.systemd.services.coturn.serviceConfig.User;
group = config.systemd.services.coturn.serviceConfig.Group;
restartUnits = ["coturn.service"];
};
};
templates = {
"synapse.yaml" = {
owner = "matrix-synapse";
content = ''
registration_shared_secret: ${config.sops.placeholder."synapse/shared_secret"}
'';
restartUnits = ["matrix-synapse.service"];
};
"synapse-oidc.yaml" = {
owner = "matrix-synapse";
content = ''
oidc_providers:
- discover: true
idp_id: zitadel
idp_name: Zitadel
issuer: "https://auth.kruining.eu"
scopes:
- openid
- profile
- email
- offline_access
client_id: '${config.sops.placeholder."synapse/oidc_id"}'
client_secret: '${config.sops.placeholder."synapse/oidc_secret"}'
backchannel_logout_enabled: true
user_profile_method: userinfo_endpoint
allow_existing_users: true
enable_registration: true
user_mapping_provider:
config:
localpart_template: "{{ user.preferred_username }}"
display_name_template: "{{ user.name }}"
email_template: "{{ user.email }}"
'';
restartUnits = ["matrix-synapse.service"];
};
"arrtrix/secrets" = {
owner = "arrtrix";
content = ''
RADARR_APIKEY=${config.sops.placeholder."radarr/apikey"}
SONARR_APIKEY=${config.sops.placeholder."sonarr/apikey"}
'';
restartUnits = ["arrtrix.service"];
};
};
};
};
}

View file

@ -1,210 +0,0 @@
{
config,
lib,
pkgs,
namespace,
...
}: let
inherit (builtins) toString;
inherit (lib) mkIf mkEnableOption mkOption;
cfg = config.${namespace}.services.development.forgejo;
domain = "git.amarth.cloud";
in {
options.${namespace}.services.development.forgejo = {
enable = mkEnableOption "Forgejo";
port = mkOption {
type = lib.types.port;
default = 5002;
example = "1234";
description = ''
Which port to bind forgejo to
'';
};
};
config = mkIf cfg.enable {
${namespace}.services = {
persistance.postgresql.enable = true;
virtualisation.podman.enable = true;
networking.caddy = {
hosts = {
"${domain}" = ''
# import auth
# stupid dumb way to prevent the login page and go to zitadel instead
# be aware that this does not disable local login at all!
# rewrite /user/login /user/oauth2/Zitadel
reverse_proxy http://127.0.0.1:${toString cfg.port}
'';
};
};
};
environment.systemPackages = with pkgs; [forgejo];
services = {
forgejo = {
enable = true;
lfs.enable = true;
useWizard = false;
database.type = "postgres";
settings = {
DEFAULT = {
APP_NAME = "Tamin Amarth";
APP_SLOGAN = "Where code is forged";
};
server = {
DOMAIN = domain;
ROOT_URL = "https://${domain}/";
HTTP_PORT = cfg.port;
LANDING_PAGE = "explore";
};
cors = {
ENABLED = true;
ALLOW_DOMAIN = "https://*.amarth.cloud";
};
security = {
INSTALL_LOCK = true;
PASSWORD_HASH_ALGO = "argon2";
DISABLE_WEBHOOKS = true;
};
ui = {
EXPLORE_PAGING_NUM = 50;
ISSUE_PAGING_NUM = 50;
MEMBERS_PAGING_NUM = 50;
};
"ui.meta" = {
AUTHOR = "Where code is forged!";
DESCRIPTION = "Self-hosted solution for git, because FOSS is the anvil of the future";
};
admin = {
USER_DISABLED_FEATURES = "manage_gpg_keys";
EXTERNAL_USER_DISABLE_FEATURES = "manage_gpg_keys";
};
service = {
# Auth
ENABLE_BASIC_AUTHENTICATION = false;
DISABLE_REGISTRATION = false;
ALLOW_ONLY_EXTERNAL_REGISTRATION = true;
SHOW_REGISTRATION_BUTTON = false;
# Privacy
DEFAULT_KEEP_EMAIL_PRIVATE = true;
DEFAULT_USER_VISIBILITY = "private";
DEFAULT_ORG_VISIBILITY = "private";
# Common sense
VALID_SITE_URL_SCHEMES = "https";
};
openid = {
ENABLE_OPENID_SIGNIN = true;
ENABLE_OPENID_SIGNUP = true;
WHITELISTED_URIS = "https://auth.kruining.eu";
};
oauth2_client = {
ENABLE_AUTO_REGISTRATION = true;
UPDATE_AVATAR = true;
ACCOUNT_LINKING = "auto";
};
actions = {
ENABLED = true;
# DEFAULT_ACTIONS_URL = "https://data.forgejo.org";
};
other = {
SHOW_FOOTER_VERSION = false;
SHOW_FOOTER_TEMPLATE_LOAD_TIME = false;
};
metrics = {
ENABLED = true;
};
api = {
ENABLE_SWAGGER = false;
};
mirror = {
ENABLED = true;
};
session = {
PROVIDER = "db";
COOKIE_SECURE = true;
};
mailer = {
ENABLED = true;
PROTOCOL = "smtp+starttls";
SMTP_ADDR = "black-mail.nl";
SMTP_PORT = 587;
FROM = "chris@kruining.eu";
USER = "chris@kruining.eu";
PASSWD_URI = "file:${config.sops.secrets."forgejo/email".path}";
};
};
};
openssh.settings.AllowUsers = ["forgejo"];
gitea-actions-runner = {
package = pkgs.forgejo-runner;
instances.default = {
enable = true;
name = "default";
url = "https://git.amarth.cloud";
# Obtaining the path to the runner token file may differ
# tokenFile should be in format TOKEN=<secret>, since it's EnvironmentFile for systemd
tokenFile = config.sops.secrets."forgejo/action_runner_token".path;
# token = "ZBetud1F0IQ9VjVFpZ9bu0FXgx9zcsy1x25yvjhw";
labels = [
"default:docker://nixos/nix:latest"
"ubuntu:docker://ubuntu:24-bookworm"
"nix:docker://git.amarth.cloud/amarth/runners/default:latest"
];
settings = {
log.level = "info";
};
};
};
};
users = {
users."gitea-runner" = {
isSystemUser = true;
group = "gitea-runner";
};
groups."gitea-runner" = {};
};
sops.secrets = {
"forgejo/action_runner_token" = {
owner = "gitea-runner";
group = "gitea-runner";
restartUnits = ["gitea-runner-default.service"];
};
"forgejo/email" = {
owner = "forgejo";
group = "forgejo";
key = "email/chris_kruining_eu";
restartUnits = ["forgejo.service"];
};
};
};
}

View file

@ -1,16 +1,11 @@
{
inputs,
config,
lib,
pkgs,
namespace,
...
}: let
{ inputs, config, lib, pkgs, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption mkOption;
inherit (lib.types) str;
cfg = config.${namespace}.services.games.minecraft;
in {
in
{
imports = [
inputs.nix-minecraft.nixosModules.minecraft-servers
];
@ -30,7 +25,7 @@ in {
};
config = mkIf cfg.enable {
users.users.${cfg.user} = {
user.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
};
@ -108,14 +103,8 @@ in {
inherit (pkgs) linkFarmFromDrvs fetchurl;
in {
mods = linkFarmFromDrvs "mods" (attrValues {
FabricApi = fetchurl {
url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/ZNwYCTsk/fabric-api-0.118.0%2B1.21.4.jar";
sha512 = "1e0d31b6663dc2c7be648f3a5a9cf7b698b9a0fd0f7ae16d1d3f32d943d7c5205ff63a4f81b0c4e94a8997482cce026b7ca486e99d9ce35ac069aeb29b02a30d";
};
Terralith = fetchurl {
url = "https://cdn.modrinth.com/data/8oi3bsk5/versions/MuJMtPGQ/Terralith_1.21.x_v2.5.8.jar";
sha512 = "f862ed5435ce4c11a97d2ea5c40eee9f817c908f3223b5fd3e3fff0562a55111d7429dc73a2f1ca0b1af7b1ff6fa0470ed6efebb5de13336c40bb70fb357dd60";
};
FabricApi = fetchurl { url = "https://cdn.modrinth.com/data/P7dR8mSH/versions/ZNwYCTsk/fabric-api-0.118.0%2B1.21.4.jar"; sha512 = "1e0d31b6663dc2c7be648f3a5a9cf7b698b9a0fd0f7ae16d1d3f32d943d7c5205ff63a4f81b0c4e94a8997482cce026b7ca486e99d9ce35ac069aeb29b02a30d"; };
Terralith = fetchurl { url = "https://cdn.modrinth.com/data/8oi3bsk5/versions/MuJMtPGQ/Terralith_1.21.x_v2.5.8.jar"; sha512 = "f862ed5435ce4c11a97d2ea5c40eee9f817c908f3223b5fd3e3fff0562a55111d7429dc73a2f1ca0b1af7b1ff6fa0470ed6efebb5de13336c40bb70fb357dd60"; };
# DistantHorizons = fetchurl { url = "https://cdn.modrinth.com/data/uCdwusMi/versions/jptcCdp2/DistantHorizons-2.2.1-a-1.20.4-forge-fabric.jar"; sha512 = "47368d91099d0b5f364339a69f4e425f8fb1e3a7c3250a8b649da76135e68a22f1a76b191c87e15a5cdc0a1d36bc57f2fa825490d96711d09d96807be97d575d"; };
});
};
@ -158,16 +147,13 @@ in {
inherit (lib) concatMapAttrs;
readDirRec = src: dir: fn:
concatMapAttrs (
name: type:
if type == "directory"
concatMapAttrs (name: type: if type == "directory"
then (readDirRec src "${dir}/${name}" fn)
else {"${dir}/${name}" = fn "${dir}/${name}";}
else { "${dir}/${name}" = (fn "${dir}/${name}"); }
) (readDir "${src}/${dir}");
copyDir = dir: readDirRec src dir (x: "${src}/${x}");
in
{
in {
"ops.json" = {
value = ops;
};
@ -178,11 +164,7 @@ in {
inherit (builtins) attrNames readDir map;
inherit (pkgs) linkFarm;
linkFarmFromDir = name: dir:
linkFarm name (map (x: {
name = x;
path = "${src}/${dir}/${x}";
}) (attrNames (readDir "${src}/${dir}")));
linkFarmFromDir = name: dir: linkFarm name (map (x: { name = x; path = "${src}/${dir}/${x}"; }) (attrNames (readDir "${src}/${dir}")));
in {
Deftu = linkFarmFromDir "tekxit-deftu" "Deftu";
TKXAddons = linkFarmFromDir "tekxit-TKXAddons" "TKXAddons";

View file

@ -1,27 +0,0 @@
{ config, lib, pkgs, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.games.openrct;
in
{
options.${namespace}.services.games.openrct = {
enable = mkEnableOption "OpenRCT2";
};
config = mkIf cfg.enable {
environment.systemPackages = with pkgs; [
openrct2
];
systemd.services.openrct = {
enable = true;
after = [ "network.target"];
description = "OpenRCT2 Server";
serviceConfig = {
Type = "";
ExecStart = lib.getExe pkgs.openrct2;
};
};
};
}

View file

@ -0,0 +1,25 @@
{ config, lib, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.games.palworld;
in
{
options.${namespace}.services.games.palworld = {
enable = mkEnableOption "Palworld";
};
config = mkIf cfg.enable {
# kaas = (pkgs.mkSteamServer rec {
# name = "Palworld";
# src = pkgs.fetchSteam {
# inherit name;
# appId = "2394010";
# hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
# };
#
# sartCmd = "PalServer.sh";
# hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
# });
};
}

View file

@ -1,30 +0,0 @@
{
config,
lib,
namespace,
...
}: let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.games.palworld;
in {
options.${namespace}.services.games.palworld = {
enable = mkEnableOption "Palworld";
};
config = mkIf cfg.enable {
# kaas = (pkgs.mkSteamServer rec {
# name = "Palworld";
# src = pkgs.fetchSteam {
# inherit name;
# appId = "2394010";
# hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
# };
#
# sartCmd = "PalServer.sh";
# hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
# });
sops.secrets."palworld/password" = {};
};
}

View file

@ -1,15 +1,11 @@
{
pkgs,
lib,
namespace,
config,
...
}: let
{ pkgs, lib, namespace, config, ... }:
let
inherit (lib) mkIf mkEnableOption mkOption;
inherit (lib.types) str;
cfg = config.${namespace}.services.media;
in {
in
{
options.${namespace}.services.media = {
enable = mkEnableOption "Enable media services";
@ -35,6 +31,13 @@ in {
#=========================================================================
environment.systemPackages = with pkgs; [
podman-tui
jellyfin
jellyfin-web
jellyfin-ffmpeg
jellyseerr
mediainfo
id3v2
yt-dlp
];
#=========================================================================
@ -49,27 +52,105 @@ in {
};
systemd.tmpfiles.rules = [
"d '${cfg.path}/qbittorrent' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/sabnzbd' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/downloads/incomplete' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/downloads/done' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/series' 0700 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/movies' 0700 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/music' 0700 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/qbittorrent' 0700 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/sabnzbd' 0700 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/reiverr/config' 0700 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/downloads/incomplete' 0700 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/downloads/done' 0700 ${cfg.user} ${cfg.group} - -"
];
#=========================================================================
# Services
#=========================================================================
services = {
bazarr = {
services = let
serviceConf = {
enable = true;
openFirewall = true;
user = cfg.user;
group = cfg.group;
listenPort = 2050;
};
in {
jellyfin = serviceConf;
radarr = serviceConf;
sonarr = serviceConf;
bazarr = serviceConf;
lidarr = serviceConf;
jellyseerr = {
enable = true;
openFirewall = true;
};
postgresql = {
prowlarr = {
enable = true;
openFirewall = true;
};
qbittorrent = {
enable = true;
openFirewall = true;
webuiPort = 5000;
serverConfig = {
LegalNotice.Accepted = true;
};
user = cfg.user;
group = cfg.group;
};
sabnzbd = {
enable = true;
openFirewall = true;
configFile = "${cfg.path}/sabnzbd/config.ini";
user = cfg.user;
group = cfg.group;
};
caddy = {
enable = true;
virtualHosts = {
"media.kruining.eu".extraConfig = ''
import auth
reverse_proxy http://127.0.0.1:9494
'';
"jellyfin.kruining.eu".extraConfig = ''
reverse_proxy http://127.0.0.1:8096
'';
};
};
};
systemd.services.jellyfin.serviceConfig.killSignal = lib.mkForce "SIGKILL";
${namespace}.services.virtualisation.podman.enable = true;
virtualisation = {
oci-containers = {
backend = "podman";
containers = {
flaresolverr = {
image = "flaresolverr/flaresolverr";
autoStart = true;
ports = [ "127.0.0.1:8191:8191" ];
};
reiverr = {
image = "ghcr.io/aleksilassila/reiverr:v2.2.0";
autoStart = true;
ports = [ "127.0.0.1:9494:9494" ];
volumes = [ "${cfg.path}/reiverr/config:/config" ];
};
};
};
};
networking.firewall.allowedTCPPorts = [ 80 443 6969 ];
};
}

View file

@ -1,179 +0,0 @@
{
config,
lib,
namespace,
...
}: let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.media.glance;
in {
options.${namespace}.services.media.glance = {
enable = mkEnableOption "Enable Glance";
};
config = mkIf cfg.enable {
# ${namespace}.services.networking.caddy.hosts = {
# "https://${config.networking.hostName}.arda:443" = ''
# reverse_proxy http://[::1]:2000
# '';
# };
services.glance = {
enable = true;
openFirewall = true;
environmentFile = config.sops.templates."glance/secrets.env".path;
settings = {
server = {
host = "0.0.0.0";
port = 2000;
};
theme = {
# Teal city predefined theme (https://github.com/glanceapp/glance/blob/main/docs/themes.md#teal-city)
background-color = "225 14 15";
primary-color = "157 47 65";
contrast-multiplier = 1.1;
};
pages = [
{
name = "Home";
columns = [
{
size = "small";
widgets = [
{
type = "calendar";
first-day-of-the-week = "monday";
}
];
}
{
size = "full";
widgets = [
{
type = "monitor";
cache = "1m";
title = "Services";
sites = [
{
title = "Zitadel";
url = "https://auth.kruining.eu";
icon = "sh:zitadel";
}
{
title = "Forgejo";
url = "https://git.amarth.cloud/chris";
icon = "sh:forgejo";
}
{
title = "Vaultwarden";
url = "https://vault.kruining.eu";
icon = "sh:vaultwarden";
}
];
}
{
type = "monitor";
cache = "1m";
title = "Observability";
sites = [
{
title = "Grafana";
url = "http://${config.networking.hostName}:${builtins.toString config.services.grafana.settings.server.http_port}";
icon = "sh:grafana";
}
{
title = "Prometheus";
url = "http://${config.networking.hostName}:${builtins.toString config.services.prometheus.port}";
icon = "sh:prometheus";
}
];
}
{
type = "monitor";
cache = "1m";
title = "Media";
sites = [
{
title = "Jellyfin";
url = "http://${config.networking.hostName}:8096";
icon = "sh:jellyfin";
}
{
title = "Radarr";
url = "http://${config.networking.hostName}:${builtins.toString config.services.radarr.settings.server.port}";
icon = "sh:radarr";
}
{
title = "Sonarr";
url = "http://${config.networking.hostName}:${builtins.toString config.services.sonarr.settings.server.port}";
icon = "sh:sonarr";
}
{
title = "Lidarr";
url = "http://${config.networking.hostName}:${builtins.toString config.services.lidarr.settings.server.port}";
icon = "sh:lidarr";
}
{
title = "Prowlarr";
url = "http://${config.networking.hostName}:${builtins.toString config.services.prowlarr.settings.server.port}";
icon = "sh:prowlarr";
}
{
title = "qBittorrent";
url = "http://${config.networking.hostName}:${builtins.toString config.services.qbittorrent.webuiPort}";
icon = "sh:qbittorrent";
}
{
title = "SABnzbd";
url = "http://${config.networking.hostName}:${builtins.toString config.services.sabnzbd.settings.misc.port}";
icon = "sh:sabnzbd";
}
];
}
];
}
{
size = "small";
widgets = [
{
type = "weather";
location = "Amsterdam, The Netherlands";
units = "metric";
hour-format = "24h";
}
{
type = "server-stats";
servers = [
{
type = "local";
name = "Ulmo";
}
];
}
];
}
];
}
];
};
};
sops.templates."glance/secrets.env" = {
# owner = config.services.glance.user;
# group = config.services.glance.group;
content = ''
RADARR_KEY="${config.sops.placeholder."radarr/apikey"}"
SONARR_KEY="${config.sops.placeholder."sonarr/apikey"}"
LIDARR_KEY="${config.sops.placeholder."lidarr/apikey"}"
'';
};
};
}

View file

@ -1,49 +0,0 @@
{
pkgs,
config,
lib,
namespace,
inputs,
system,
...
}: let
inherit (builtins) toString;
inherit (lib) mkIf mkEnableOption mkOption types;
cfg = config.${namespace}.services.media.jellyfin;
in {
options.${namespace}.services.media.jellyfin = {
enable = mkEnableOption "Enable jellyfin server";
};
config = mkIf cfg.enable {
${namespace}.services.networking.caddy = {
hosts = {
"jellyfin.kruining.eu" = ''
reverse_proxy http://[::1]:8096
'';
};
};
environment.systemPackages = with pkgs; [
jellyfin
jellyfin-web
jellyfin-ffmpeg
mediainfo
id3v2
yt-dlp
];
services = {
# port is harcoded in nixpkgs module
jellyfin = {
enable = true;
openFirewall = true;
user = "media";
group = "media";
};
};
systemd.services.jellyfin.serviceConfig.killSignal = lib.mkForce "SIGKILL";
};
}

View file

@ -1,95 +0,0 @@
{
config,
lib,
namespace,
inputs,
system,
...
}: let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.media.mydia;
in {
imports = [
inputs.mydia.nixosModules.default
];
options.${namespace}.services.media.mydia = {
enable = mkEnableOption "Enable Mydia";
};
config = mkIf cfg.enable {
services.mydia = {
enable = true;
port = 2100;
listenAddress = "0.0.0.0";
openFirewall = true;
mediaLibraries = [
"/var/mydia/movies"
"/var/mydia/series"
];
database = {
# type = "sqlite";
# uri = "file:///var/lib/mydia/mydia.db";
type = "postgres";
uri = "postgres://mydia@localhost:5432/mydia?sslmode=disable";
passwordFile = config.sops.templates."mydia/database_password".path;
};
secretKeyBaseFile = config.sops.secrets."mydia/secret_key_base".path;
guardianSecretKeyFile = config.sops.secrets."mydia/guardian_secret".path;
oidc = {
enable = true;
issuer = "https://auth.kruining.eu";
clientIdFile = config.sops.secrets."mydia/oidc_id".path;
clientSecretFile = config.sops.secrets."mydia/oidc_secret".path;
scopes = ["openid" "profile" "email"];
};
downloadClients = {
qbittorrent = {
type = "qbittorrent";
host = "localhost";
port = 2080;
username = "admin";
passwordFile = config.sops.secrets."mydia/qbittorrent_password".path;
useSsl = false;
};
};
};
sops.secrets = let
base =
["secret_key_base" "guardian_secret" "oidc_id" "oidc_secret"]
|> lib.map (name:
lib.nameValuePair "mydia/${name}" {
owner = config.services.mydia.user;
group = config.services.mydia.group;
restartUnits = ["mydia.service"];
})
|> lib.listToAttrs;
in
base
// {
"mydia/qbittorrent_password" = {
owner = config.services.mydia.user;
group = config.services.mydia.group;
restartUnits = ["mydia.service"];
key = "qbittorrent/password";
};
};
sops.templates."mydia/database_password" = {
owner = config.services.mydia.user;
group = config.services.mydia.group;
restartUnits = ["mydia.service"];
content = ''
DATABASE_PASSWORD=""
'';
};
};
}

View file

@ -1,16 +1,12 @@
{
config,
lib,
pkgs,
namespace,
...
}: let
{ config, lib, pkgs, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption mkOption;
inherit (lib.types) str;
cfg = config.${namespace}.services.media.nextcloud;
in {
options.${namespace}.services.media.nextcloud = {
in
{
options.modules.services.nextcloud = {
enable = mkEnableOption "Nextcloud";
user = mkOption {
@ -25,14 +21,6 @@ in {
};
config = mkIf cfg.enable {
${namespace}.services.networking.caddy = {
hosts."cloud.kruining.eu" = ''
php_fastcgi unix//run/phpfpm/nextcloud.sock {
env front_controller_active true
}
'';
};
users = {
users.${cfg.user} = {
isSystemUser = true;
@ -52,7 +40,7 @@ in {
services.nextcloud = {
enable = true;
# webserver = "caddy";
webserver = "caddy";
package = pkgs.nextcloud31;
hostName = "localhost";
@ -87,5 +75,14 @@ in {
# startServices = true;
# };
services.caddy = {
enable = true;
virtualHosts."cloud.kruining.eu".extraConfig = ''
php_fastcgi unix//run/phpfpm/nextcloud.sock {
env front_controller_active true
}
'';
};
};
}

View file

@ -2,10 +2,10 @@
let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.media.nfs;
cfg = config.${namespace}.media.nfs;
in
{
options.${namespace}.services.media.nfs = {
options.${namespace}.media.nfs = {
enable = mkEnableOption "Enable NFS";
};

View file

@ -1,533 +0,0 @@
{
pkgs,
config,
lib,
namespace,
inputs,
system,
...
}: let
inherit (builtins) toString;
inherit (lib) mkIf mkEnableOption mkOption types;
cfg = config.${namespace}.services.media.servarr;
servarr = import ./lib.nix {inherit lib;};
anyEnabled = cfg |> lib.attrNames |> lib.length |> (l: l > 0);
in {
options.${namespace}.services.media = {
servarr = mkOption {
type = types.attrsOf (types.submodule ({name, ...}: {
options = {
enable = mkEnableOption "Enable ${name}";
debug = mkEnableOption "Use tofu plan instead of tofu apply for ${name} ";
port = mkOption {
type = types.port;
};
rootFolders = mkOption {
type = types.listOf types.str;
default = [];
};
};
}));
default = {};
};
};
config = mkIf anyEnabled {
services =
cfg
|> lib.mapAttrsToList (service: {
enable,
port,
...
}: (mkIf enable {
"${service}" =
{
enable = true;
openFirewall = true;
environmentFiles = [
config.sops.templates."${service}/config.env".path
];
settings = {
auth.authenticationMethod = "External";
server = {
# bindaddress = "0.0.0.0";
bindaddress = "[::]";
port = port;
};
postgres = {
host = "localhost";
port = "5432";
user = service;
maindb = service;
logdb = service;
};
};
}
// (lib.optionalAttrs (lib.elem service ["radarr" "sonarr" "lidarr" "whisparr"]) {
user = service;
group = "media";
});
}))
|> lib.concat [
{
qbittorrent = {
enable = true;
openFirewall = true;
webuiPort = 2080;
serverConfig = lib.mkForce {};
user = "qbittorrent";
group = "media";
};
sabnzbd = {
enable = true;
openFirewall = true;
allowConfigWrite = false;
configFile = lib.mkForce null;
secretFiles = [
config.sops.templates."sabnzbd/config.ini".path
];
settings = {
misc = {
host = "0.0.0.0";
port = 2090;
host_whitelist = "${config.networking.hostName}";
permissions = "770";
download_dir = "/var/media/downloads/incomplete";
complete_dir = "/var/media/downloads/done";
};
servers = {
"news.sunnyusenet.com" = {
name = "news.sunnyusenet.com";
displayname = "news.sunnyusenet.com";
host = "news.sunnyusenet.com";
port = 563;
timeout = 60;
};
};
};
user = "sabnzbd";
group = "media";
};
flaresolverr = {
enable = true;
openFirewall = true;
port = 2070;
};
postgresql = let
databases = [] ++ (cfg |> lib.attrNames);
in {
ensureDatabases = databases;
ensureUsers =
databases
|> lib.map (service: {
name = service;
ensureDBOwnership = true;
});
};
}
]
|> lib.mkMerge;
systemd.services =
cfg
|> lib.mapAttrsToList (service: {
enable,
debug,
port,
rootFolders,
...
}: (mkIf enable {
"${service}ApplyTerraform" = let
config' = config;
lib' = lib;
terraformConfiguration = inputs.terranix.lib.terranixConfiguration {
inherit system;
modules = [
({
config,
lib,
...
}: {
config = {
variable =
cfg
|> lib'.mapAttrsToList (s: _: {
"${s}_api_key" = {
type = "string";
description = "${s} API key";
};
})
|> lib'.concat [
{
qbittorrent_api_key = {
type = "string";
description = "qbittorrent api key";
};
sabnzbd_api_key = {
type = "string";
description = "sabnzbd api key";
};
}
]
|> lib'.mkMerge;
terraform.required_providers.${service} = {
source = "devopsarr/${service}";
version =
{
radarr = "2.3.5";
sonarr = "3.4.0";
prowlarr = "3.1.0";
lidarr = "1.13.0";
readarr = "2.1.0";
whisparr = "1.2.0";
}.${
service
};
};
provider.${service} = {
url = "http://127.0.0.1:${toString port}";
api_key = lib.tfRef "var.${service}_api_key";
};
resource =
{
"${service}_notification_webhook" = mkIf (lib.elem service ["radarr" "sonarr" "whisparr" "lidarr" "readarr"]) {
"arrtrix" =
{
method = 1; # HTTP METHOD 1=POST, 2=PUT
name = "Arrtrix";
url = "http://localhost:${toString config'.services.arrtrix.settings.appservice.port}/_arrtrix/webhook";
on_grab = true;
on_download = true;
on_rename = true;
on_upgrade = true;
}
// (lib.optionalAttrs (lib.elem service ["radarr" "whisparr"]) {
on_movie_delete = true;
});
};
"${service}_root_folder" = mkIf (lib.elem service ["radarr" "sonarr" "whisparr"]) (
rootFolders
|> lib.imap (i: f: lib.nameValuePair "local${toString i}" {path = f;})
|> lib.listToAttrs
);
"${service}_download_client_qbittorrent" = mkIf (lib.elem service ["radarr" "sonarr" "lidarr" "whisparr"]) {
"main" = {
name = "qBittorrent";
enable = true;
priority = 1;
host = "localhost";
username = "admin";
password = lib.tfRef "var.qbittorrent_api_key";
url_base = "/";
port = 2080;
};
};
"${service}_download_client_sabnzbd" = mkIf (lib.elem service ["radarr" "sonarr" "lidarr" "whisparr"]) {
"main" =
{
name = "SABnzbd";
enable = true;
priority = 1;
host = "localhost";
api_key = lib.tfRef "var.sabnzbd_api_key";
url_base = "/";
port = 2090;
}
// ({
radarr = {movie_category = "movies";};
sonarr = {tv_category = "tv";};
lidarr = {music_category = "audio";};
whisparr = {movie_category = "movies";};
readarr = {book_category = "Default";};
}.${
service
});
};
}
// (lib.optionalAttrs (service == "prowlarr") (
cfg
|> lib'.filterAttrs (s: _: lib'.elem s ["radarr" "sonarr" "lidarr" "whisparr"])
|> lib'.mapAttrsToList (s: {port, ...}: {
"prowlarr_application_${s}"."main" = let
p = cfg.prowlarr.port or config'.services.prowlarr.settings.server.port or 9696;
in {
name = s;
sync_level = "addOnly";
base_url = "http://localhost:${toString port}";
prowlarr_url = "http://localhost:${toString p}";
api_key = lib.tfRef "var.${s}_api_key";
# sync_categories = [3000 3010 3030];
};
})
|> lib'.concat [
{
"prowlarr_indexer" = {
"nyaa" = {
enable = true;
app_profile_id = 1;
priority = 1;
name = "Nyaa";
implementation = "Cardigann";
config_contract = "CardigannSettings";
protocol = "torrent";
fields = [
{
name = "definitionFile";
text_value = "nyaasi";
}
{
name = "baseSettings.limitsUnit";
number_value = 0;
}
{
name = "torrentBaseSettings.preferMagnetUrl";
bool_value = false;
}
{
name = "prefer_magnet_links";
bool_value = true;
}
{
name = "sonarr_compatibility";
bool_value = false;
}
{
name = "strip_s01";
bool_value = false;
}
{
name = "radarr_compatibility";
bool_value = false;
}
{
name = "filter-id";
number_value = 0;
}
{
name = "cat-id";
number_value = 0;
}
{
name = "sort";
number_value = 0;
}
{
name = "type";
number_value = 1;
}
];
};
};
}
]
|> lib'.mkMerge
));
};
})
];
};
in {
description = "${service} terraform apply";
wantedBy = ["multi-user.target"];
wants = ["${service}.service"];
preStart = ''
install -d -m 0770 -o ${service} -g media /var/lib/${service}ApplyTerraform
${
rootFolders
|> lib.map (folder: "install -d -m 0770 -o media -g media ${folder}")
|> lib.join "\n"
}
'';
script = ''
# Sleep for a bit to give the service a chance to start up
sleep 5s
if [ "$(systemctl is-active "${service}")" != "active" ]; then
echo "${service} is not running"
exit 1
fi
# Print the path to the source for easier debugging
echo "config location: ${terraformConfiguration}"
# Copy infra code into workspace
cp -f ${terraformConfiguration} config.tf.json
# Initialize OpenTofu
${lib.getExe pkgs.opentofu} init
# Run the infrastructure code
${lib.getExe pkgs.opentofu} \
${
if debug
then "plan"
else "apply -auto-approve"
} \
-var-file='${config.sops.templates."servarr/config.tfvars".path}'
'';
serviceConfig = {
Type = "oneshot";
User = service;
Group = "media";
WorkingDirectory = "/var/lib/${service}ApplyTerraform";
EnvironmentFile = [
config.sops.templates."${service}/config.env".path
];
};
};
}))
|> lib.mkMerge;
system.activationScripts.qbittorrent-config = {
deps = lib.optional (!config.sops.useSystemdActivation) "setupSecrets";
# TODO: If sops-nix is switched to systemd activation, add a systemd unit
# for this install step that runs after sops-install-secrets.service,
# because this activation-script dependency only orders against setupSecrets.
text = ''
install -Dm0600 -o ${config.services.qbittorrent.user} -g ${config.services.qbittorrent.group} \
${config.sops.templates."qbittorrent/qBittorrent.conf".path} \
${config.services.qbittorrent.profileDir}/qBittorrent/config/qBittorrent.conf
'';
};
users =
cfg
|> lib.mapAttrsToList (service: {enable, ...}: (mkIf enable {
users.${service} = {
isSystemUser = true;
group = lib.mkDefault service;
extraGroups = ["media"];
};
groups.${service} = {};
}))
|> lib.concat [
{
groups.media = {};
}
]
|> lib.mkMerge;
sops =
cfg
|> lib.mapAttrsToList (service: {enable, ...}: (mkIf enable {
secrets."${service}/apikey" = {
owner = service;
group = "media";
restartUnits = ["${service}.service"];
};
templates = {
"${service}/config.env" = {
owner = service;
group = "media";
restartUnits = ["${service}.service"];
content = ''
${lib.toUpper service}__AUTH__APIKEY="${config.sops.placeholder."${service}/apikey"}"
'';
};
};
}))
|> lib.concat [
{
secrets = {
"qbittorrent/password" = {};
"qbittorrent/password_hash" = {
owner = "qbittorrent";
group = "media";
};
"sabnzbd/apikey" = {};
"sabnzbd/nzbkey" = {};
"sabnzbd/sunnyweb/username" = {};
"sabnzbd/sunnyweb/password" = {};
};
templates = {
"servarr/config.tfvars" = {
owner = "media";
group = "media";
mode = "0440";
restartUnits = cfg |> lib.attrNames |> lib.map (s: "${s}.service");
content = ''
${
cfg
|> lib.attrNames
|> lib.map (s: "${s}_api_key = \"${config.sops.placeholder."${s}/apikey"}\"")
|> lib.join "\n"
}
qbittorrent_api_key = "${config.sops.placeholder."qbittorrent/password"}"
sabnzbd_api_key = "${config.sops.placeholder."sabnzbd/apikey"}"
'';
};
"qbittorrent/qBittorrent.conf" = {
owner = "qbittorrent";
group = "media";
mode = "0600";
restartUnits = ["qbittorrent.service"];
content = ''
[LegalNotice]
Accepted=true
[Preferences]
WebUI\AlternativeUIEnabled=true
WebUI\RootFolder=${pkgs.vuetorrent}/share/vuetorrent
WebUI\Username=admin
WebUI\Password_PBKDF2=${config.sops.placeholder."qbittorrent/password_hash"}
'';
};
"sabnzbd/config.ini" = {
owner = "sabnzbd";
group = "media";
mode = "0660";
content = ''
[misc]
api_key = ${config.sops.placeholder."sabnzbd/apikey"}
nzb_key = ${config.sops.placeholder."sabnzbd/nzbkey"}
[servers]
[[news.sunnyusenet.com]]
username = ${config.sops.placeholder."sabnzbd/sunnyweb/username"}
password = ${config.sops.placeholder."sabnzbd/sunnyweb/password"}
'';
};
};
}
]
|> lib.mkMerge;
};
}

View file

@ -1,2 +0,0 @@
{lib, ...}: {
}

View file

@ -1,42 +0,0 @@
{
config,
pkgs,
lib,
namespace,
...
}: let
inherit (builtins) length;
inherit (lib) mkIf mkEnableOption mkOption types attrNames mapAttrs;
cfg = config.${namespace}.services.networking.caddy;
hasHosts = (cfg.hosts |> attrNames |> length) > 0;
in {
options.${namespace}.services.networking.caddy = {
enable = mkEnableOption "enable caddy" // {default = true;};
hosts = mkOption {
type = types.attrsOf types.str;
};
extraConfig = mkOption {
type = types.str;
};
};
config = mkIf hasHosts {
networking.firewall.allowedTCPPorts = [80 443];
services.caddy = {
enable = cfg.enable;
package = pkgs.caddy.withPlugins {
plugins = ["github.com/corazawaf/coraza-caddy/v2@v2.1.0"];
hash = "sha256-pSXjLaZoRtKV3eFl2ySRSjl3yxi514G1Cb7pfrpxxtE=";
};
virtualHosts =
cfg.hosts
|> mapAttrs (host: extraConfig: {inherit extraConfig;});
};
};
}

View file

@ -1,47 +0,0 @@
{
config,
pkgs,
lib,
namespace,
...
}: let
inherit (builtins) length;
inherit (lib) mkIf mkEnableOption mkOption types attrNames attrsToList listToAttrs;
cfg = config.${namespace}.services.networking.wireguard;
hasPeers = (cfg.peer |> attrNames |> length) > 0;
in {
options.${namespace}.services.networking.wireguard = {
# enable = mkEnableOption "enable wireguard" // {default = true;};
peer = mkOption {
type = types.attrsOf (types.submodule {
options = {
port = mkOption {
type = types.port;
description = '''';
};
address = mkOption {
type = types.listOf types.str;
default = [];
description = '''';
};
};
});
};
};
config = mkIf hasPeers {
# networking.firewall.allowedUDPPorts = cfg.peer |> lib.attrValues |> lib.map (p: p.port);
# networking.wq-quick = {
# # enable = cfg.enable;
# interfaces =
# cfg.peer
# |> attrsToList
# |> imap0 (i: { name, value }: (namevaluepair "wg${i}" (value // {})))
# |> listToAttrs;
# };
};
}

View file

@ -1,83 +0,0 @@
{
config,
lib,
namespace,
...
}: let
inherit (builtins) toString;
inherit (lib) mkEnableOption mkIf;
cfg = config.${namespace}.services.observability.alloy;
httpPort = 9070;
otlpGrpcPort = 9071;
otlpHttpPort = 9072;
tempoOtlpGrpcPort = 9062;
in {
options.${namespace}.services.observability.alloy = {
enable = mkEnableOption "enable Grafana Alloy";
};
config = mkIf cfg.enable {
services.alloy = {
enable = true;
configPath = "/etc/alloy";
extraFlags = [
"--disable-reporting"
"--server.http.listen-addr=[::]:${toString httpPort}"
"--storage.path=/var/lib/alloy"
];
};
environment.etc."alloy/config.alloy".text = ''
otelcol.receiver.otlp "default" {
grpc {
endpoint = "[::1]:${toString otlpGrpcPort}"
}
http {
endpoint = "[::1]:${toString otlpHttpPort}"
}
output {
metrics = [otelcol.processor.batch.metrics.input]
traces = [otelcol.processor.batch.traces.input]
}
}
otelcol.processor.batch "metrics" {
output {
metrics = [otelcol.exporter.prometheus.default.input]
}
}
otelcol.processor.batch "traces" {
output {
traces = [otelcol.exporter.otlp.tempo.input]
}
}
otelcol.exporter.prometheus "default" {
forward_to = [prometheus.remote_write.local.receiver]
}
prometheus.remote_write "local" {
endpoint {
url = "http://[::1]:${toString config.services.prometheus.port}/api/v1/write"
}
}
otelcol.exporter.otlp "tempo" {
client {
endpoint = "[::1]:${toString tempoOtlpGrpcPort}"
tls {
insecure = true
}
}
}
'';
networking.firewall.allowedTCPPorts = [httpPort];
};
}

View file

@ -1,7 +0,0 @@
{
"title": "Default Dash",
"description": "The default dashboard",
"timezone": "browser",
"editable": false,
"panels": []
}

View file

@ -1,174 +0,0 @@
{
pkgs,
config,
lib,
namespace,
...
}: let
inherit (lib.modules) mkIf;
inherit (lib.options) mkEnableOption;
cfg = config.${namespace}.services.observability.grafana;
db_user = "grafana";
db_name = "grafana";
in {
options.${namespace}.services.observability.grafana = {
enable = mkEnableOption "enable Grafana";
};
config = mkIf cfg.enable {
services = {
grafana = {
enable = true;
openFirewall = true;
settings = {
server = {
http_port = 9010;
http_addr = "::";
domain = "ulmo";
};
security = {
secret_key = "$__file{${config.sops.secrets."grafana/secret_key".path}}";
};
auth = {
disable_login_form = false;
};
"auth.basic".enable = false;
"auth.generic_oauth" = {
enable = true;
name = "Zitadel";
client_id = "$__file{${config.sops.secrets."grafana/oidc_id".path}}";
client_secret = "$__file{${config.sops.secrets."grafana/oidc_secret".path}}";
scopes = "openid email profile offline_access urn:zitadel:iam:org:project:roles";
email_attribute_path = "email";
login_attribute_path = "username";
name_attribute_path = "full_name";
role_attribute_path = "contains(urn:zitadel:iam:org:project:roles[*], 'owner') && 'GrafanaAdmin' || contains(urn:zitadel:iam:org:project:roles[*], 'contributer') && 'Editor' || 'Viewer'";
auth_url = "https://auth.kruining.eu/oauth/v2/authorize";
token_url = "https://auth.kruining.eu/oauth/v2/token";
api_url = "https://auth.kruining.eu/oidc/v1/userinfo";
allow_sign_up = true;
auto_login = true;
use_pkce = true;
usr_refresh_token = true;
allow_assign_grafana_admin = true;
};
database = {
type = "postgres";
host = "/var/run/postgresql:5432";
name = db_name;
user = db_user;
ssl_mode = "disable";
};
users = {
allow_sign_up = false;
allow_org_create = false;
viewers_can_edit = false;
default_theme = "system";
};
analytics = {
reporting_enabled = false;
check_for_updates = false;
check_for_plugin_updates = false;
feedback_links_enabled = false;
};
};
provision = {
enable = true;
dashboards.settings = {
apiVersion = 1;
providers = [
{
name = "Default Dashboard";
disableDeletion = true;
allowUiUpdates = false;
options = {
path = "/etc/grafana/dashboards";
foldersFromFilesStructure = true;
};
}
];
};
datasources.settings.datasources = [
{
name = "Prometheus";
uid = "prometheus";
type = "prometheus";
url = "http://[::1]:9020";
isDefault = true;
editable = false;
}
{
name = "Loki";
uid = "loki";
type = "loki";
url = "http://[::1]:9030";
editable = false;
}
{
name = "Tempo";
uid = "tempo";
type = "tempo";
url = "http://localhost:9060";
editable = false;
jsonData = {
nodeGraph.enabled = true;
serviceMap.datasourceUid = "prometheus";
tracesToLogsV2 = {
datasourceUid = "loki";
filterByTraceID = true;
spanStartTimeShift = "-1h";
spanEndTimeShift = "1h";
};
};
}
];
};
};
postgresql = {
enable = true;
ensureDatabases = [db_name];
ensureUsers = [
{
name = db_user;
ensureDBOwnership = true;
}
];
};
};
environment.etc."/grafana/dashboards/default.json".source = ./dashboards/default.json;
sops = {
secrets = {
"grafana/secret_key" = {
owner = "grafana";
group = "grafana";
};
"grafana/oidc_id" = {
owner = "grafana";
group = "grafana";
};
"grafana/oidc_secret" = {
owner = "grafana";
group = "grafana";
};
};
};
};
}

View file

@ -1,49 +0,0 @@
{ pkgs, config, lib, namespace, ... }:
let
inherit (lib.modules) mkIf;
inherit (lib.options) mkEnableOption;
cfg = config.${namespace}.services.observability.loki;
in
{
options.${namespace}.services.observability.loki = {
enable = mkEnableOption "enable Grafana Loki";
};
config = mkIf cfg.enable {
services.loki = {
enable = true;
configuration = {
auth_enabled = false;
server = {
http_listen_port = 9030;
};
common = {
ring = {
instance_addr = "127.0.0.1";
kvstore.store = "inmemory";
};
replication_factor = 1;
path_prefix = "/tmp/loki";
};
schema_config.configs = [
{
from = "2025-01-01";
store = "tsdb";
object_store = "filesystem";
schema = "v13";
index = {
prefix = "index_";
period = "24h";
};
}
];
};
};
networking.firewall.allowedTCPPorts = [ 9030 ];
};
}

View file

@ -1,67 +0,0 @@
{ pkgs, config, lib, namespace, ... }:
let
inherit (builtins) toString;
inherit (lib) mkEnableOption mkIf optionals;
cfg = config.${namespace}.services.observability.prometheus;
in
{
options.${namespace}.services.observability.prometheus = {
enable = mkEnableOption "enable Prometheus";
};
config = mkIf cfg.enable {
services.prometheus = {
enable = true;
port = 9020;
extraFlags = optionals config.${namespace}.services.observability.alloy.enable [
"--web.enable-remote-write-receiver"
];
globalConfig.scrape_interval = "15s";
scrapeConfigs = [
{
job_name = "prometheus";
static_configs = [
{ targets = [ "localhost:9020" ]; }
];
}
{
job_name = "node";
static_configs = [
{ targets = [ "localhost:${toString config.services.prometheus.exporters.node.port}" ]; }
];
}
]
++ optionals config.${namespace}.services.observability.alloy.enable [
{
job_name = "alloy";
static_configs = [
{ targets = [ "localhost:9070" ]; }
];
}
]
++ optionals config.${namespace}.services.observability.tempo.enable [
{
job_name = "tempo";
static_configs = [
{ targets = [ "localhost:9060" ]; }
];
}
];
exporters = {
node = {
enable = true;
port = 9021;
enabledCollectors = [ "systemd" ];
openFirewall = true;
};
};
};
networking.firewall.allowedTCPPorts = [ 9020 ];
};
}

View file

@ -1,65 +0,0 @@
{
pkgs,
config,
lib,
namespace,
...
}: let
inherit (lib.modules) mkIf;
inherit (lib.options) mkEnableOption;
cfg = config.${namespace}.services.observability.promtail;
in {
options.${namespace}.services.observability.promtail = {
enable = mkEnableOption "enable Grafana Promtail";
};
config = mkIf cfg.enable {
services.promtail = {
enable = true;
# Ensures proper permissions
extraFlags = [
"-config.expand-env=true"
];
configuration = {
server = {
http_listen_port = 9040;
grpc_listen_port = 0;
};
positions = {
filename = "filename";
};
clients = [
{
url = "http://[::1]:9030/loki/api/v1/push";
}
];
scrape_configs = [
{
job_name = "journal";
journal = {
max_age = "12h";
labels = {
job = "systemd-journal";
host = "ulmo";
};
};
relabel_configs = [
{
source_labels = ["__journal__systemd_unit"];
target_label = "unit";
}
];
}
];
};
};
networking.firewall.allowedTCPPorts = [9040];
};
}

View file

@ -1,51 +0,0 @@
{
config,
lib,
namespace,
...
}: let
inherit (lib) mkEnableOption mkIf;
cfg = config.${namespace}.services.observability.tempo;
httpPort = 9060;
grpcPort = 9061;
otlpGrpcPort = 9062;
otlpHttpPort = 9063;
in {
options.${namespace}.services.observability.tempo = {
enable = mkEnableOption "enable Grafana Tempo";
};
config = mkIf cfg.enable {
services.tempo = {
enable = true;
settings = {
auth_enabled = false;
search_enabled = true;
server = {
http_listen_address = "[::]";
http_listen_port = httpPort;
grpc_listen_address = "[::1]";
grpc_listen_port = grpcPort;
};
distributor.receivers.otlp.protocols = {
grpc.endpoint = "[::1]:${builtins.toString otlpGrpcPort}";
http.endpoint = "[::1]:${builtins.toString otlpHttpPort}";
};
storage.trace = {
backend = "local";
wal.path = "/var/lib/tempo/wal";
local.path = "/var/lib/tempo/traces";
};
compactor.compaction.block_retention = "168h";
};
};
networking.firewall.allowedTCPPorts = [httpPort];
};
}

View file

@ -1,25 +0,0 @@
{ pkgs, config, lib, namespace, ... }:
let
inherit (builtins) toString;
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.observability.uptime-kuma;
in
{
options.${namespace}.services.observability.uptime-kuma = {
enable = mkEnableOption "enable uptime kuma";
};
config = mkIf cfg.enable {
services.uptime-kuma = {
enable = true;
settings = {
PORT = toString 9050;
HOST = "0.0.0.0";
};
};
networking.firewall.allowedTCPPorts = [ 9050 ];
};
}

View file

@ -1,31 +0,0 @@
{
config,
lib,
pkgs,
namespace,
...
}: let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.persistance.postgresql;
in {
options.${namespace}.services.persistance.postgresql = {
enable = mkEnableOption "Postgresql";
};
# Access db with `psql -U postgres`
config = mkIf cfg.enable {
services = {
postgresql = {
enable = true;
authentication = ''
# Generated file, do not edit!
# TYPE DATABASE USER ADDRESS METHOD
local all all trust
host all all 127.0.0.1/32 trust
host all all ::1/128 trust
'';
};
};
};
}

View file

@ -1,216 +1,28 @@
{
pkgs,
config,
lib,
namespace,
...
}: let
inherit (builtins) toString;
inherit (lib) mkIf mkEnableOption mkOption types getAttrs toUpper concatMapAttrsStringSep;
{ pkgs, config, lib, namespace, ... }:
let
inherit (lib.modules) mkIf;
inherit (lib.options) mkEnableOption;
cfg = config.${namespace}.services.security.vaultwarden;
databaseProviderSqlite = types.submodule ({...}: {
options = {
type = mkOption {
type = types.enum ["sqlite"];
};
file = mkOption {
type = types.path;
description = ''
Path to sqlite database file.
'';
};
};
});
databaseProviderPostgresql = types.submodule ({...}: let
urlOptions = lib.${namespace}.options.mkUrlOptions {
host = {
description = ''
Hostname of the postgresql server
'';
};
port = {
default = 5432;
example = "5432";
description = ''
Port of the postgresql server
'';
};
protocol = mkOption {
default = "postgres";
example = "postgres";
};
};
in {
options =
in
{
type = mkOption {
type = types.enum ["postgresql"];
};
sslMode = mkOption {
type = types.enum ["verify-ca" "verify-full" "require" "prefer" "allow" "disabled"];
default = "verify-full";
example = "verify-ca";
description = ''
How to verify the server's ssl
| mode | eavesdropping protection | MITM protection | Statement |
|-------------|--------------------------|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| disable | No | No | I don't care about security, and I don't want to pay the overhead of encryption. |
| allow | Maybe | No | I don't care about security, but I will pay the overhead of encryption if the server insists on it. |
| prefer | Maybe | No | I don't care about encryption, but I wish to pay the overhead of encryption if the server supports it. |
| require | Yes | No | I want my data to be encrypted, and I accept the overhead. I trust that the network will make sure I always connect to the server I want. |
| verify-ca | Yes | Depends on CA policy | I want my data encrypted, and I accept the overhead. I want to be sure that I connect to a server that I trust. |
| verify-full | Yes | Yes | I want my data encrypted, and I accept the overhead. I want to be sure that I connect to a server I trust, and that it's the one I specify. |
[Source](https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-SSLMODE-STATEMENTS)
'';
};
}
// (urlOptions |> getAttrs ["protocol" "host" "port"]);
});
in {
options.${namespace}.services.security.vaultwarden = {
enable = mkEnableOption "enable vaultwarden";
database = mkOption {
type = types.oneOf [
(types.addCheck databaseProviderSqlite (x: x ? type && x.type == "sqlite"))
(types.addCheck databaseProviderPostgresql (x: x ? type && x.type == "postgresql"))
null
];
default = null;
description = '''';
};
};
config = mkIf cfg.enable {
${namespace}.services.networking.caddy.hosts = {
"vault.kruining.eu" = ''
encode zstd gzip
handle_path /admin {
respond 401 {
close
}
}
reverse_proxy http://localhost:${toString config.services.vaultwarden.config.ROCKET_PORT} {
header_up X-Real-IP {remote_host}
}
'';
};
systemd.tmpfiles.rules = [
"d '/var/lib/vaultwarden' 0700 vaultwarden vaultwarden - -"
environment.systemPackages = with pkgs; [
vaultwarden
vaultwarden-postgresql
];
# systemd.services.vaultwarden.wants = [ "zitadelApplyTerraform.service" ];
services = {
vaultwarden = {
services.vaultwarden = {
enable = true;
dbBackend = "postgresql";
package = pkgs.vaultwarden-postgresql;
config = {
SIGNUPS_ALLOWED = false;
DOMAIN = "https://vault.kruining.eu";
DATABASE_URL = "postgres://localhost:5432/vaultwarden?sslmode=disable";
WEB_VAULT_ENABLED = true;
SSO_ENABLED = true;
SSO_ONLY = true;
SSO_PKCE = true;
SSO_AUTH_ONLY_NOT_SESSION = false;
SSO_ROLES_ENABLED = true;
SSO_ORGANIZATIONS_ENABLED = true;
SSO_ORGANIZATIONS_REVOCATION = true;
SSO_AUTHORITY = "https://auth.kruining.eu";
SSO_SCOPES = "email profile offline_access";
ROCKET_ADDRESS = "::1";
ROCKET_PORT = 8222;
ROCKET_LOG = "critical";
SMTP_HOST = "black-mail.nl";
SMTP_PORT = 587;
SMTP_SECURITY = "starttls";
SMTP_USERNAME = "chris@kruining.eu";
SMTP_FROM = "chris@kruining.eu";
SMTP_FROM_NAME = "Chris' Vaultwarden";
};
environmentFile = [
"/var/lib/zitadel/clients/nix_ulmo_vaultwarden"
config.sops.templates."vaultwarden/config.env".path
];
};
postgresql = {
enable = true;
ensureDatabases = ["vaultwarden"];
ensureUsers = [
{
name = "vaultwarden";
ensureDBOwnership = true;
}
];
};
};
sops = {
secrets = {
"vaultwarden/email" = {
owner = config.users.users.vaultwarden.name;
group = config.users.users.vaultwarden.name;
key = "email/chris_kruining_eu";
restartUnits = ["vaultwarden.service"];
};
};
templates = {
"vaultwarden/config.env" = {
content = ''
SMTP_PASSWORD='${config.sops.placeholder."vaultwarden/email"}';
'';
owner = config.users.users.vaultwarden.name;
group = config.users.groups.vaultwarden.name;
};
temp-db-output.content = let
config =
cfg.database
|> (
{type, ...} @ db:
if type == "sqlite"
then {inherit (db) type file;}
else if type == "postgresql"
then {
inherit (db) type;
url = lib.${namespace}.strings.toUrl {
inherit (db) protocol host port;
path = "vaultwarden";
query = {
sslmode = db.sslMode;
};
};
}
else {}
)
|> concatMapAttrsStringSep "\n" (n: v: "${toUpper n}=${v}");
in ''
# GENERATED VALUES
${config}
'';
DOMAIN = "https://passwords.kruining.eu";
};
};
};

View file

@ -12,7 +12,6 @@ in
config = mkIf cfg.enable {
virtualisation = {
containers.enable = true;
oci-containers.backend = "podman";
podman = {
enable = true;

View file

@ -1,2 +1,2 @@
{...}: {
}
{ ... }:
{}

View file

@ -1,4 +1,4 @@
{ pkgs, config, namespace, inputs, system, ... }:
{ pkgs, config, namespace, inputs, ... }:
let
cfg = config.${namespace}.system.security.sops;
in
@ -13,14 +13,10 @@ in
environment.systemPackages = with pkgs; [ sops ];
sops = {
defaultSopsFormat = "yaml";
defaultSopsFile = inputs.self + "/systems/${system}/${config.networking.hostName}/secrets.yml";
age.keyFile = "/home/.sops-key.age";
age = {
# keyFile = "~/.config/sops/age/keys.txt";
# sshKeyPaths = [ "~/.ssh/id_ed25519" ];
# generateKey = true;
};
defaultSopsFile = ../../../../systems/x86_64-linux/${config.networking.hostName}/secrets.yaml;
defaultSopsFormat = "yaml";
};
};
}

View file

@ -14,8 +14,9 @@ in
sudo-rs = {
enable = true;
execWheelOnly = true;
extraConfig = ''Defaults env_keep += "EDITOR PATH DISPLAY"'';
extraConfig = ''
Defaults env_keep += "EDITOR PATH DISPLAY"
'';
};
};
};

View file

@ -1,214 +0,0 @@
{
config,
lib,
pkgs,
namespace,
...
}: let
inherit (lib) mkEnableOption mkPackageOption mkIf mkOption optionalAttrs recursiveUpdate types baseNameOf;
cfg = config.services.arrtrix;
dataDir = "/var/lib/arrtrix";
registrationFile = "${dataDir}/arrtrix-registration.yaml";
settingsFile = "${dataDir}/config.yaml";
settingsFileUnsubstituted = settingsFormat.generate "arrtrix-config-unsubstituted.json" cfg.settings;
settingsFormat = pkgs.formats.json {};
defaultConfig = {
bridge = {
command_prefix = "!arr";
relay.enabled = true;
permissions."*" = "relay";
};
database = {
type = "sqlite3-fk-wal";
uri = "file:${dataDir}/arrtrix.db?_txlock=immediate";
};
homeserver = {
address = "http://localhost:8448";
domain = config.services.matrix-synapse.settings.server_name or "example.com";
};
appservice = {
hostname = "[::]";
port = 29329;
id = "arrtrix";
bot = {
username = "arrtrixbot";
displayname = "arrtrix Bot";
};
as_token = "";
hs_token = "";
username_template = "arrtrix_{{.}}";
};
logging = {
min_level = "info";
writers = lib.singleton {
type = "stdout";
format = "pretty-colored";
time_format = " ";
};
};
observability = {
otlp_grpc_endpoint = "";
service_name = "arrtrix";
resource_attributes = {};
};
network.content = {
movies = {
url = "";
api_key = "";
root_folder_path = "";
quality_profile_id = 0;
minimum_availability = "released";
search_on_add = true;
};
series = {
url = "";
api_key = "";
root_folder_path = "";
quality_profile_id = 0;
language_profile_id = 0;
season_folder = true;
series_type = "standard";
search_on_add = true;
};
};
};
in {
options.services.arrtrix = {
enable = mkEnableOption "Arr-focused Matrix appservice foundation";
package = mkPackageOption pkgs.${namespace} "arrtrix" {};
registerToSynapse = mkOption {
type = types.bool;
default = config.services.matrix-synapse.enable;
defaultText = lib.literalExpression ''
config.services.matrix-synapse.enable
'';
description = ''
Whether to add the bridge's app service registration file to
`services.matrix-synapse.settings.app_service_config_files`.
'';
};
settings = mkOption {
apply = lib.recursiveUpdate defaultConfig;
type = settingsFormat.type;
default = defaultConfig;
description = ''
{file}`config.yaml` configuration as a Nix attribute set.
Configuration options should match those described in the example configuration.
Get an example configuration by executing `arrtrix -c example.yaml --generate-example-config`
Secret tokens should be specified using {option}`environmentFile`
instead of this world-readable attribute set.
'';
example = {};
};
environmentFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
File containing environment variables to be passed to the arrtrix service.
If an environment variable `ARRTRIX_BRIDGE_LOGIN_SHARED_SECRET` is set,
then its value will be used in the configuration file for the option
`double_puppet.secrets` without leaking it to the store, using the configured
`homeserver.domain` as key.
'';
};
serviceDependencies = lib.mkOption {
type = with lib.types; listOf str;
default =
(lib.optional config.services.matrix-synapse.enable config.services.matrix-synapse.serviceUnit)
++ (lib.optional config.services.matrix-conduit.enable "conduit.service");
defaultText = lib.literalExpression ''
(optional config.services.matrix-synapse.enable config.services.matrix-synapse.serviceUnit)
++ (optional config.services.matrix-conduit.enable "conduit.service")
'';
description = ''
List of systemd units to require and wait for when starting the application service.
'';
};
};
config = mkIf cfg.enable {
users = {
users."arrtrix" = {
isSystemUser = true;
group = "arrtrix";
};
groups."arrtrix" = {};
};
services.matrix-synapse = lib.mkIf cfg.registerToSynapse {
settings.app_service_config_files = [registrationFile];
};
systemd.services.matrix-synapse = lib.mkIf cfg.registerToSynapse {
serviceConfig.SupplementaryGroups = ["arrtrix"];
};
systemd.services.arrtrix = {
description = "arrtrix, A *arr stack to matrix bridge for *arr-notifications";
wantedBy = ["multi-user.target"];
after = ["network-online.target"];
wants = ["network-online.target"];
restartTriggers = [settingsFileUnsubstituted];
preStart = ''
# substitute the settings file by environment variables
# in this case read from EnvironmentFile
test -f '${settingsFile}' && rm -f '${settingsFile}'
old_umask=$(umask)
umask 0177
${lib.getExe pkgs.envsubst} -o '${settingsFile}' -i '${settingsFileUnsubstituted}'
umask $old_umask
if [ ! -f '${registrationFile}' ]; then
${lib.getExe cfg.package} --generate-registration --config='${settingsFile}' --registration='${registrationFile}'
fi
chmod 640 ${registrationFile}
'';
serviceConfig = {
Type = "simple";
User = "arrtrix";
Group = "arrtrix";
StateDirectory = baseNameOf dataDir;
WorkingDirectory = dataDir;
EnvironmentFile = cfg.environmentFile;
ExecStart = ''
${lib.getExe cfg.package} --config='${settingsFile}' --registration='${registrationFile}'
'';
Restart = "on-failure";
RestartSec = "30s";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
ProtectClock = true;
ProtectControlGroups = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
SystemCallArchitectures = "native";
SystemCallErrorNumber = "EPERM";
SystemCallFilter = ["@system-service"];
UMask = "0027";
};
};
};
}

View file

@ -1,25 +0,0 @@
package main
import (
"sneeuwvlok/packages/arrtrix/pkg/connector"
"sneeuwvlok/packages/arrtrix/pkg/runtime"
)
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
var m = runtime.Main{
Name: "arrtrix",
URL: "https://github.com/chris-kruining/sneeuwvlok",
Description: "An Arr-focused Matrix appservice bridge.",
Version: "0.1.0",
Connector: &connector.ArrtrixConnector{},
}
func main() {
m.InitVersion(Tag, Commit, BuildTime)
m.Run()
}

View file

@ -1,33 +0,0 @@
{
buildGoModule,
lib,
olm,
versionCheckHook,
}:
buildGoModule rec {
pname = "arrtrix";
version = "0.1.0";
tag = "v0.1.0";
src = lib.cleanSource ./.;
vendorHash = "sha256-UYRit+v41djnCx+GFdEl/8WQsp2DzF4ywT9iv3m1pSc=";
subPackages = ["cmd/arrtrix"];
buildInputs = [olm];
ldflags = [
"-X main.Tag=${tag}"
];
doInstallCheck = true;
nativeInstallCheckInputs = [versionCheckHook];
meta = {
description = "*arr-stack Matrix bridge";
homepage = "https://github.com/chris-kruining/sneeuwvlok";
license = lib.licenses.mit;
maintainers = [];
mainProgram = "arrtrix";
};
}

View file

@ -1,60 +0,0 @@
module sneeuwvlok/packages/arrtrix
go 1.25.0
require (
github.com/rs/zerolog v1.34.0
go.mau.fi/util v0.9.7
go.mau.fi/zeroconfig v0.2.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/log v0.19.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.19.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.26.4
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/lib/pq v1.11.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.7.16 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)

View file

@ -1,142 +0,0 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 h1:rh2lKw/P/EqHa724vYH2+VVQ1YnW4u6EOXl0PMAovZE=
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=

View file

@ -1,76 +0,0 @@
package arr
import (
"fmt"
"slices"
"strings"
)
type ContentType string
const (
ContentTypeMovies ContentType = "movies"
ContentTypeSeries ContentType = "series"
)
var supportedContentTypes = []ContentType{
ContentTypeMovies,
ContentTypeSeries,
}
var supportedEvents = map[ContentType][]string{
ContentTypeMovies: {"Test", "Grab", "Download", "Rename", "MovieFileDelete", "MovieDelete"},
ContentTypeSeries: {"Test", "Grab", "Download", "Rename", "EpisodeFileDelete", "SeriesDelete"},
}
func SupportedContentTypes() []ContentType {
return append([]ContentType(nil), supportedContentTypes...)
}
func SupportedEventTypes(contentType ContentType) []string {
return append([]string(nil), supportedEvents[contentType]...)
}
func ParseContentType(value string) (ContentType, error) {
contentType := ContentType(strings.ToLower(strings.TrimSpace(value)))
if slices.Contains(supportedContentTypes, contentType) {
return contentType, nil
}
return "", fmt.Errorf("unsupported content type %q (expected one of: %s)", value, Strings())
}
func ParseEventType(contentType ContentType, value string) (string, error) {
value = strings.TrimSpace(value)
if strings.EqualFold(value, "all") {
return "all", nil
}
for _, eventType := range supportedEvents[contentType] {
if strings.EqualFold(eventType, value) {
return eventType, nil
}
}
return "", fmt.Errorf("unsupported event type %q for %s", value, contentType)
}
func SupportsEventType(contentType ContentType, eventType string) bool {
return slices.Contains(supportedEvents[contentType], strings.TrimSpace(eventType))
}
func (c ContentType) Label() string {
switch c {
case ContentTypeMovies:
return "movies"
case ContentTypeSeries:
return "series"
default:
return string(c)
}
}
func Strings() string {
values := make([]string, 0, len(supportedContentTypes))
for _, contentType := range supportedContentTypes {
values = append(values, string(contentType))
}
return strings.Join(values, ", ")
}

View file

@ -1,23 +0,0 @@
package arr
import "testing"
func TestParseContentType(t *testing.T) {
contentType, err := ParseContentType("Movies")
if err != nil {
t.Fatalf("ParseContentType returned error: %v", err)
}
if contentType != ContentTypeMovies {
t.Fatalf("expected movies content type, got %q", contentType)
}
}
func TestParseEventType(t *testing.T) {
eventType, err := ParseEventType(ContentTypeSeries, "download")
if err != nil {
t.Fatalf("ParseEventType returned error: %v", err)
}
if eventType != "Download" {
t.Fatalf("expected Download event type, got %q", eventType)
}
}

View file

@ -1,363 +0,0 @@
package arrclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"io"
"mime"
"net/http"
"net/url"
"path"
"path/filepath"
"strings"
"sneeuwvlok/packages/arrtrix/pkg/arr"
)
type Client interface {
ContentType() arr.ContentType
Search(context.Context, string) ([]SearchResult, error)
List(context.Context, string) ([]ManagedItem, error)
Add(context.Context, SearchResult) (*ManagedItem, error)
SetMonitored(context.Context, int64, bool) (*ManagedItem, error)
Delete(context.Context, int64) error
FetchImage(context.Context, ManagedItem) (*MediaAsset, error)
}
type SearchResult struct {
LookupID int64
Title string
Year int
Overview string
}
type ManagedItem struct {
ID int64
LookupID int64
Title string
Year int
Monitored bool
Path string
ImageURL string
}
type MediaAsset struct {
Data []byte
FileName string
MimeType string
}
type RadarrConfig struct {
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
RootFolderPath string `yaml:"root_folder_path"`
QualityProfileID int64 `yaml:"quality_profile_id"`
MinimumAvailability string `yaml:"minimum_availability"`
SearchOnAdd *bool `yaml:"search_on_add"`
}
type SonarrConfig struct {
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
RootFolderPath string `yaml:"root_folder_path"`
QualityProfileID int64 `yaml:"quality_profile_id"`
LanguageProfileID int64 `yaml:"language_profile_id"`
SeasonFolder *bool `yaml:"season_folder"`
SeriesType string `yaml:"series_type"`
SearchOnAdd *bool `yaml:"search_on_add"`
}
type httpClient struct {
baseURL *url.URL
apiKey string
httpClient *http.Client
}
type mediaImage struct {
CoverType string `json:"coverType"`
URL string `json:"url"`
RemoteURL string `json:"remoteUrl"`
}
func (c *RadarrConfig) ApplyDefaults() {
if c.MinimumAvailability == "" {
c.MinimumAvailability = "released"
}
}
func (c RadarrConfig) Enabled() bool {
return strings.TrimSpace(c.URL) != "" || strings.TrimSpace(c.APIKey) != ""
}
func (c RadarrConfig) Validate() error {
if !c.Enabled() {
return nil
}
switch {
case strings.TrimSpace(c.URL) == "":
return fmt.Errorf("network.content.movies.url must be set when movies content is configured")
case strings.TrimSpace(c.APIKey) == "":
return fmt.Errorf("network.content.movies.api_key must be set when movies content is configured")
case strings.TrimSpace(c.RootFolderPath) == "":
return fmt.Errorf("network.content.movies.root_folder_path must be set when movies content is configured")
case c.QualityProfileID <= 0:
return fmt.Errorf("network.content.movies.quality_profile_id must be set when movies content is configured")
case strings.TrimSpace(c.MinimumAvailability) == "":
return fmt.Errorf("network.content.movies.minimum_availability must not be empty")
default:
return nil
}
}
func (c RadarrConfig) SearchOnAddValue() bool {
return boolValue(c.SearchOnAdd, true)
}
func (c *SonarrConfig) ApplyDefaults() {
if c.SeriesType == "" {
c.SeriesType = "standard"
}
}
func (c SonarrConfig) Enabled() bool {
return strings.TrimSpace(c.URL) != "" || strings.TrimSpace(c.APIKey) != ""
}
func (c SonarrConfig) Validate() error {
if !c.Enabled() {
return nil
}
switch {
case strings.TrimSpace(c.URL) == "":
return fmt.Errorf("network.content.series.url must be set when series content is configured")
case strings.TrimSpace(c.APIKey) == "":
return fmt.Errorf("network.content.series.api_key must be set when series content is configured")
case strings.TrimSpace(c.RootFolderPath) == "":
return fmt.Errorf("network.content.series.root_folder_path must be set when series content is configured")
case c.QualityProfileID <= 0:
return fmt.Errorf("network.content.series.quality_profile_id must be set when series content is configured")
case c.LanguageProfileID <= 0:
return fmt.Errorf("network.content.series.language_profile_id must be set when series content is configured")
case strings.TrimSpace(c.SeriesType) == "":
return fmt.Errorf("network.content.series.series_type must not be empty")
default:
return nil
}
}
func (c SonarrConfig) SeasonFolderValue() bool {
return boolValue(c.SeasonFolder, true)
}
func (c SonarrConfig) SearchOnAddValue() bool {
return boolValue(c.SearchOnAdd, true)
}
func newHTTPClient(rawURL, apiKey string) (*httpClient, error) {
parsedURL, err := url.Parse(strings.TrimRight(strings.TrimSpace(rawURL), "/"))
if err != nil {
return nil, err
}
return &httpClient{
baseURL: parsedURL,
apiKey: apiKey,
httpClient: http.DefaultClient,
}, nil
}
func (c *httpClient) do(ctx context.Context, method, requestPath string, query url.Values, body any, dest any) error {
endpoint := *c.baseURL
endpoint.Path = path.Join(endpoint.Path, requestPath)
if len(query) > 0 {
endpoint.RawQuery = query.Encode()
}
var payload io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return err
}
payload = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), payload)
if err != nil {
return err
}
req.Header.Set("X-Api-Key", c.apiKey)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("%s %s returned %d: %s", method, endpoint.String(), resp.StatusCode, strings.TrimSpace(string(data)))
}
if dest == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(dest)
}
func boolValue(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
func containsFold(haystack, needle string) bool {
return strings.Contains(strings.ToLower(haystack), strings.ToLower(strings.TrimSpace(needle)))
}
func FormatSearchResult(result SearchResult) string {
if result.Year != 0 {
return fmt.Sprintf("%s (%d)", result.Title, result.Year)
}
return result.Title
}
func FormatManagedItem(item ManagedItem) string {
if item.Year != 0 {
return fmt.Sprintf("%s (%d)", item.Title, item.Year)
}
return item.Title
}
func EscapeText(text string) string {
return html.EscapeString(text)
}
func (c *httpClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) {
imageURL := strings.TrimSpace(item.ImageURL)
if imageURL == "" {
return nil, nil
}
endpoint, err := url.Parse(imageURL)
if err != nil {
return nil, fmt.Errorf("parse image URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL.ResolveReference(endpoint).String(), nil)
if err != nil {
return nil, err
}
if sameHost(req.URL, c.baseURL) {
req.Header.Set("X-Api-Key", c.apiKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("GET %s returned %d: %s", req.URL.String(), resp.StatusCode, strings.TrimSpace(string(data)))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, err
}
mimeType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if idx := strings.Index(mimeType, ";"); idx >= 0 {
mimeType = strings.TrimSpace(mimeType[:idx])
}
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
return &MediaAsset{
Data: data,
FileName: imageFileName(item, endpoint, mimeType),
MimeType: mimeType,
}, nil
}
func (c *httpClient) imageURL(images []mediaImage) string {
for _, coverType := range []string{"poster", "cover", "fanart"} {
for _, image := range images {
if !strings.EqualFold(image.CoverType, coverType) {
continue
}
if resolved := c.resolveMediaURL(image); resolved != "" {
return resolved
}
}
}
return ""
}
func (c *httpClient) resolveMediaURL(image mediaImage) string {
switch {
case strings.TrimSpace(image.URL) != "":
ref, err := url.Parse(strings.TrimSpace(image.URL))
if err != nil {
return ""
}
return c.baseURL.ResolveReference(ref).String()
case strings.TrimSpace(image.RemoteURL) != "":
return strings.TrimSpace(image.RemoteURL)
default:
return ""
}
}
func imageFileName(item ManagedItem, endpoint *url.URL, mimeType string) string {
baseName := sanitizeFileName(strings.TrimSpace(item.Title))
if baseName == "" {
baseName = fmt.Sprintf("arrtrix-%d", item.ID)
}
ext := strings.TrimSpace(filepath.Ext(endpoint.Path))
if ext == "" && mimeType != "" {
if extensions, err := mime.ExtensionsByType(mimeType); err == nil && len(extensions) > 0 {
ext = extensions[0]
}
}
if ext == "" {
ext = ".jpg"
}
if item.ID != 0 {
return fmt.Sprintf("%s-%d%s", baseName, item.ID, ext)
}
return baseName + ext
}
func sanitizeFileName(value string) string {
replacer := strings.NewReplacer(
"<", "",
">", "",
":", "",
"\"", "",
"/", "-",
"\\", "-",
"|", "-",
"?", "",
"*", "",
)
value = replacer.Replace(value)
value = strings.Join(strings.Fields(value), "-")
return strings.Trim(value, ".- ")
}
func sameHost(left, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) && strings.EqualFold(left.Host, right.Host)
}

View file

@ -1,80 +0,0 @@
package arrclient
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestImageURLPrefersPosterAndResolvesRelativePath(t *testing.T) {
baseURL, err := url.Parse("https://radarr.example")
if err != nil {
t.Fatalf("failed to parse base URL: %v", err)
}
client := &httpClient{baseURL: baseURL}
imageURL := client.imageURL([]mediaImage{
{CoverType: "fanart", URL: "/MediaCover/1/fanart.jpg"},
{CoverType: "poster", URL: "/MediaCover/1/poster.jpg"},
})
if imageURL != "https://radarr.example/MediaCover/1/poster.jpg" {
t.Fatalf("unexpected image URL %q", imageURL)
}
}
func TestImageURLFallsBackToRemoteURL(t *testing.T) {
baseURL, err := url.Parse("https://sonarr.example")
if err != nil {
t.Fatalf("failed to parse base URL: %v", err)
}
client := &httpClient{baseURL: baseURL}
imageURL := client.imageURL([]mediaImage{
{CoverType: "poster", RemoteURL: "https://images.example/poster.jpg"},
})
if imageURL != "https://images.example/poster.jpg" {
t.Fatalf("unexpected remote image URL %q", imageURL)
}
}
func TestFetchImageUsesAPIKeyForSameHost(t *testing.T) {
headers := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
headers <- r.Header.Get("X-Api-Key")
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("jpeg-bytes"))
}))
defer server.Close()
client, err := newHTTPClient(server.URL, "secret")
if err != nil {
t.Fatalf("failed to create client: %v", err)
}
asset, err := client.FetchImage(context.Background(), ManagedItem{
ID: 42,
Title: "Dune Part Two",
ImageURL: server.URL + "/MediaCover/42/poster.jpg",
})
if err != nil {
t.Fatalf("failed to fetch image: %v", err)
}
if asset == nil {
t.Fatal("expected media asset")
}
if got := <-headers; got != "secret" {
t.Fatalf("expected API key header, got %q", got)
}
if got := string(asset.Data); got != "jpeg-bytes" {
t.Fatalf("unexpected media bytes %q", got)
}
if asset.MimeType != "image/jpeg" {
t.Fatalf("unexpected mime type %q", asset.MimeType)
}
if !strings.HasPrefix(asset.FileName, "Dune-Part-Two-42") || !strings.HasSuffix(asset.FileName, ".jpg") {
t.Fatalf("unexpected filename %q", asset.FileName)
}
}

View file

@ -1,172 +0,0 @@
package arrclient
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sneeuwvlok/packages/arrtrix/pkg/arr"
)
type RadarrClient struct {
http *httpClient
config RadarrConfig
}
type radarrMovie struct {
ID int64 `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
TMDBID int64 `json:"tmdbId"`
Overview string `json:"overview"`
Monitored bool `json:"monitored"`
Path string `json:"path"`
Images []mediaImage `json:"images"`
}
func NewRadarrClient(config RadarrConfig) (*RadarrClient, error) {
config.ApplyDefaults()
if err := config.Validate(); err != nil {
return nil, err
}
httpClient, err := newHTTPClient(config.URL, config.APIKey)
if err != nil {
return nil, err
}
return &RadarrClient{http: httpClient, config: config}, nil
}
func (c *RadarrClient) ContentType() arr.ContentType {
return arr.ContentTypeMovies
}
func (c *RadarrClient) Search(ctx context.Context, query string) ([]SearchResult, error) {
var response []radarrMovie
if err := c.http.do(ctx, http.MethodGet, "/api/v3/movie/lookup", url.Values{"term": {strings.TrimSpace(query)}}, nil, &response); err != nil {
return nil, err
}
results := make([]SearchResult, 0, len(response))
for _, movie := range response {
if movie.TMDBID == 0 {
continue
}
results = append(results, SearchResult{
LookupID: movie.TMDBID,
Title: movie.Title,
Year: movie.Year,
Overview: movie.Overview,
})
}
return results, nil
}
func (c *RadarrClient) List(ctx context.Context, query string) ([]ManagedItem, error) {
var response []radarrMovie
if err := c.http.do(ctx, http.MethodGet, "/api/v3/movie", nil, nil, &response); err != nil {
return nil, err
}
items := make([]ManagedItem, 0, len(response))
for _, movie := range response {
if query != "" && !containsFold(movie.Title, query) && !containsFold(strconv.Itoa(movie.Year), query) {
continue
}
items = append(items, ManagedItem{
ID: movie.ID,
LookupID: movie.TMDBID,
Title: movie.Title,
Year: movie.Year,
Monitored: movie.Monitored,
Path: movie.Path,
ImageURL: c.http.imageURL(movie.Images),
})
}
return items, nil
}
func (c *RadarrClient) Add(ctx context.Context, result SearchResult) (*ManagedItem, error) {
payload := map[string]any{
"title": result.Title,
"tmdbId": result.LookupID,
"year": result.Year,
"qualityProfileId": c.config.QualityProfileID,
"rootFolderPath": c.config.RootFolderPath,
"minimumAvailability": c.config.MinimumAvailability,
"monitored": true,
"addOptions": map[string]any{
"searchForMovie": c.config.SearchOnAddValue(),
},
}
var response radarrMovie
if err := c.http.do(ctx, http.MethodPost, "/api/v3/movie", nil, payload, &response); err != nil {
return nil, err
}
item := ManagedItem{
ID: response.ID,
LookupID: response.TMDBID,
Title: response.Title,
Year: response.Year,
Monitored: response.Monitored,
Path: response.Path,
ImageURL: c.http.imageURL(response.Images),
}
return &item, nil
}
func (c *RadarrClient) SetMonitored(ctx context.Context, id int64, monitored bool) (*ManagedItem, error) {
var movie map[string]any
endpoint := "/api/v3/movie/" + strconv.FormatInt(id, 10)
if err := c.http.do(ctx, http.MethodGet, endpoint, nil, nil, &movie); err != nil {
return nil, err
}
movie["monitored"] = monitored
var response radarrMovie
if err := c.http.do(ctx, http.MethodPut, endpoint, nil, movie, &response); err != nil {
return nil, err
}
item := ManagedItem{
ID: response.ID,
LookupID: response.TMDBID,
Title: response.Title,
Year: response.Year,
Monitored: response.Monitored,
Path: response.Path,
ImageURL: c.http.imageURL(response.Images),
}
return &item, nil
}
func (c *RadarrClient) Delete(ctx context.Context, id int64) error {
return c.http.do(ctx, http.MethodDelete, "/api/v3/movie/"+strconv.FormatInt(id, 10), url.Values{
"deleteFiles": {"false"},
"addImportExclusion": {"false"},
}, nil, nil)
}
func (c *RadarrClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) {
return c.http.FetchImage(ctx, item)
}
func PickSingleResult(results []SearchResult, query string) (SearchResult, error) {
switch len(results) {
case 0:
return SearchResult{}, fmt.Errorf("no matching result found for %q", query)
case 1:
return results[0], nil
default:
normalized := strings.TrimSpace(strings.ToLower(query))
for _, result := range results {
title := strings.ToLower(FormatSearchResult(result))
if title == normalized {
return result, nil
}
}
return SearchResult{}, fmt.Errorf("multiple results matched %q", query)
}
}

View file

@ -1,157 +0,0 @@
package arrclient
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"sneeuwvlok/packages/arrtrix/pkg/arr"
)
type SonarrClient struct {
http *httpClient
config SonarrConfig
}
type sonarrSeries struct {
ID int64 `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
TVDBID int64 `json:"tvdbId"`
Overview string `json:"overview"`
Monitored bool `json:"monitored"`
Path string `json:"path"`
Images []mediaImage `json:"images"`
}
func NewSonarrClient(config SonarrConfig) (*SonarrClient, error) {
config.ApplyDefaults()
if err := config.Validate(); err != nil {
return nil, err
}
httpClient, err := newHTTPClient(config.URL, config.APIKey)
if err != nil {
return nil, err
}
return &SonarrClient{http: httpClient, config: config}, nil
}
func (c *SonarrClient) ContentType() arr.ContentType {
return arr.ContentTypeSeries
}
func (c *SonarrClient) Search(ctx context.Context, query string) ([]SearchResult, error) {
var response []sonarrSeries
if err := c.http.do(ctx, http.MethodGet, "/api/v3/series/lookup", url.Values{"term": {strings.TrimSpace(query)}}, nil, &response); err != nil {
return nil, err
}
results := make([]SearchResult, 0, len(response))
for _, series := range response {
if series.TVDBID == 0 {
continue
}
results = append(results, SearchResult{
LookupID: series.TVDBID,
Title: series.Title,
Year: series.Year,
Overview: series.Overview,
})
}
return results, nil
}
func (c *SonarrClient) List(ctx context.Context, query string) ([]ManagedItem, error) {
var response []sonarrSeries
if err := c.http.do(ctx, http.MethodGet, "/api/v3/series", nil, nil, &response); err != nil {
return nil, err
}
items := make([]ManagedItem, 0, len(response))
for _, series := range response {
if query != "" && !containsFold(series.Title, query) && !containsFold(strconv.Itoa(series.Year), query) {
continue
}
items = append(items, ManagedItem{
ID: series.ID,
LookupID: series.TVDBID,
Title: series.Title,
Year: series.Year,
Monitored: series.Monitored,
Path: series.Path,
ImageURL: c.http.imageURL(series.Images),
})
}
return items, nil
}
func (c *SonarrClient) Add(ctx context.Context, result SearchResult) (*ManagedItem, error) {
payload := map[string]any{
"title": result.Title,
"tvdbId": result.LookupID,
"qualityProfileId": c.config.QualityProfileID,
"languageProfileId": c.config.LanguageProfileID,
"rootFolderPath": c.config.RootFolderPath,
"seasonFolder": c.config.SeasonFolderValue(),
"monitored": true,
"seriesType": c.config.SeriesType,
"addOptions": map[string]any{
"searchForMissingEpisodes": c.config.SearchOnAddValue(),
},
}
if result.Year != 0 {
payload["year"] = result.Year
}
var response sonarrSeries
if err := c.http.do(ctx, http.MethodPost, "/api/v3/series", nil, payload, &response); err != nil {
return nil, err
}
item := ManagedItem{
ID: response.ID,
LookupID: response.TVDBID,
Title: response.Title,
Year: response.Year,
Monitored: response.Monitored,
Path: response.Path,
ImageURL: c.http.imageURL(response.Images),
}
return &item, nil
}
func (c *SonarrClient) SetMonitored(ctx context.Context, id int64, monitored bool) (*ManagedItem, error) {
var series map[string]any
endpoint := "/api/v3/series/" + strconv.FormatInt(id, 10)
if err := c.http.do(ctx, http.MethodGet, endpoint, nil, nil, &series); err != nil {
return nil, err
}
series["monitored"] = monitored
var response sonarrSeries
if err := c.http.do(ctx, http.MethodPut, endpoint, nil, series, &response); err != nil {
return nil, err
}
item := ManagedItem{
ID: response.ID,
LookupID: response.TVDBID,
Title: response.Title,
Year: response.Year,
Monitored: response.Monitored,
Path: response.Path,
ImageURL: c.http.imageURL(response.Images),
}
return &item, nil
}
func (c *SonarrClient) Delete(ctx context.Context, id int64) error {
return c.http.do(ctx, http.MethodDelete, "/api/v3/series/"+strconv.FormatInt(id, 10), url.Values{
"deleteFiles": {"false"},
"addImportListExclusion": {"false"},
}, nil, nil)
}
func (c *SonarrClient) FetchImage(ctx context.Context, item ManagedItem) (*MediaAsset, error) {
return c.http.FetchImage(ctx, item)
}

View file

@ -1,65 +0,0 @@
package config
import (
"go.mau.fi/util/dbutil"
"go.mau.fi/zeroconfig"
"gopkg.in/yaml.v3"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"sneeuwvlok/packages/arrtrix/pkg/observability"
)
type Config struct {
Network yaml.Node `yaml:"network"`
Bridge bridgeconfig.BridgeConfig `yaml:"bridge"`
Database dbutil.Config `yaml:"database"`
Homeserver bridgeconfig.HomeserverConfig `yaml:"homeserver"`
AppService bridgeconfig.AppserviceConfig `yaml:"appservice"`
Logging zeroconfig.Config `yaml:"logging"`
Observability observability.Config `yaml:"observability"`
EnvConfigPrefix string `yaml:"env_config_prefix"`
ManagementTexts bridgeconfig.ManagementRoomTexts `yaml:"management_room_texts"`
}
func Load(data []byte) (*Config, error) {
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
cfg.applyDefaults()
return &cfg, nil
}
func (c *Config) applyDefaults() {
if c.Homeserver.Software == "" {
c.Homeserver.Software = bridgeconfig.SoftwareStandard
}
c.Observability.ApplyDefaults()
}
func (c *Config) Compile() bridgeconfig.Config {
return bridgeconfig.Config{
Network: c.Network,
Bridge: c.Bridge,
Database: c.Database,
Homeserver: c.Homeserver,
AppService: c.AppService,
Logging: c.Logging,
EnvConfigPrefix: c.EnvConfigPrefix,
ManagementRoomTexts: c.ManagementTexts,
Matrix: bridgeconfig.MatrixConfig{
MessageStatusEvents: false,
DeliveryReceipts: false,
MessageErrorNotices: true,
SyncDirectChatList: false,
FederateRooms: true,
},
DoublePuppet: bridgeconfig.DoublePuppetConfig{
Servers: map[string]string{},
Secrets: map[string]string{},
},
}
}

View file

@ -1,159 +0,0 @@
package config
import (
"testing"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
)
func TestLoadDefaultsHomeserverSoftware(t *testing.T) {
cfg, err := Load([]byte(`
bridge:
command_prefix: "!arr"
homeserver:
address: http://127.0.0.1:8008
domain: test.local
appservice:
id: arrtrix
bot:
username: arrtrixbot
displayname: Arrtrix Bot
username_template: arrtrix_{{.}}
database:
type: sqlite3-fk-wal
uri: file:arrtrix.db?_txlock=immediate
logging:
min_level: info
writers:
- type: stdout
format: pretty-colored
`))
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.Homeserver.Software != bridgeconfig.SoftwareStandard {
t.Fatalf("expected homeserver software default %q, got %q", bridgeconfig.SoftwareStandard, cfg.Homeserver.Software)
}
}
func TestCompileSetsInternalDefaultsForHiddenSections(t *testing.T) {
cfg, err := Load([]byte(`
bridge:
command_prefix: "!arr"
permissions:
"*": relay
homeserver:
address: http://127.0.0.1:8008
domain: test.local
appservice:
id: arrtrix
bot:
username: arrtrixbot
displayname: Arrtrix Bot
username_template: arrtrix_{{.}}
database:
type: sqlite3-fk-wal
uri: file:arrtrix.db?_txlock=immediate
logging:
min_level: info
writers:
- type: stdout
format: pretty-colored
`))
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
runtimeCfg := cfg.Compile()
if !runtimeCfg.Matrix.MessageErrorNotices {
t.Fatalf("expected message error notices to stay enabled")
}
if !runtimeCfg.Matrix.FederateRooms {
t.Fatalf("expected federated rooms to stay enabled")
}
if runtimeCfg.DoublePuppet.Servers == nil || runtimeCfg.DoublePuppet.Secrets == nil {
t.Fatalf("expected hidden double puppet maps to be initialized")
}
}
func TestLoadIgnoresLegacyHiddenSections(t *testing.T) {
cfg, err := Load([]byte(`
bridge:
command_prefix: "!arr"
homeserver:
address: http://127.0.0.1:8008
domain: test.local
appservice:
id: arrtrix
bot:
username: arrtrixbot
displayname: Arrtrix Bot
username_template: arrtrix_{{.}}
database:
type: sqlite3-fk-wal
uri: file:arrtrix.db?_txlock=immediate
logging:
min_level: info
writers:
- type: stdout
format: pretty-colored
matrix:
federate_rooms: false
provisioning:
shared_secret: ignored
double_puppet:
secrets:
test.local: secret
encryption:
allow: true
`))
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
runtimeCfg := cfg.Compile()
if !runtimeCfg.Matrix.FederateRooms {
t.Fatalf("expected runtime defaults to win for hidden legacy sections")
}
if len(runtimeCfg.DoublePuppet.Secrets) != 0 {
t.Fatalf("expected hidden double puppet secrets to stay internal-only")
}
}
func TestLoadIgnoresLegacyWebhookSettings(t *testing.T) {
cfg, err := Load([]byte(`
network:
webhooks:
radarr:
enabled: true
path: /_arrtrix/webhooks/radarr
secret: legacy-secret
bridge:
command_prefix: "!arr"
homeserver:
address: http://127.0.0.1:8008
domain: test.local
appservice:
id: arrtrix
bot:
username: arrtrixbot
displayname: Arrtrix Bot
username_template: arrtrix_{{.}}
database:
type: sqlite3-fk-wal
uri: file:arrtrix.db?_txlock=immediate
logging:
min_level: info
writers:
- type: stdout
format: pretty-colored
`))
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg == nil {
t.Fatal("expected config to load")
}
}

View file

@ -1,74 +0,0 @@
package connector
import (
_ "embed"
"fmt"
"net/http"
up "go.mau.fi/util/configupgrade"
"maunium.net/go/mautrix/bridgev2"
"sneeuwvlok/packages/arrtrix/pkg/arr"
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
"sneeuwvlok/packages/arrtrix/pkg/webhook"
)
//go:embed example-config.yaml
var ExampleConfig string
type Config struct {
Content ContentConfig `yaml:"content"`
}
type ContentConfig struct {
Movies arrclient.RadarrConfig `yaml:"movies"`
Series arrclient.SonarrConfig `yaml:"series"`
}
func upgradeConfig(helper up.Helper) {}
func (s *ArrtrixConnector) GetConfig() (string, any, up.Upgrader) {
return ExampleConfig, &s.Config, up.SimpleUpgrader(upgradeConfig)
}
func (s *ArrtrixConnector) ValidateConfig() error {
s.Config.Content.Movies.ApplyDefaults()
s.Config.Content.Series.ApplyDefaults()
if err := s.Config.Content.Movies.Validate(); err != nil {
return err
}
if err := s.Config.Content.Series.Validate(); err != nil {
return err
}
return nil
}
func (s *ArrtrixConnector) MountRoutes(router *http.ServeMux) error {
if s.Bridge == nil {
return fmt.Errorf("bridge is not initialized")
}
return webhook.MountArr(router, s.Bridge, s.Subscriptions())
}
var _ bridgev2.ConfigValidatingNetwork = (*ArrtrixConnector)(nil)
var _ webhook.SubscriptionFilter = (*subscriptions.Repository)(nil)
func (c ContentConfig) Client(contentType arr.ContentType) (arrclient.Client, bool, error) {
switch contentType {
case arr.ContentTypeMovies:
if !c.Movies.Enabled() {
return nil, false, nil
}
client, err := arrclient.NewRadarrClient(c.Movies)
return client, true, err
case arr.ContentTypeSeries:
if !c.Series.Enabled() {
return nil, false, nil
}
client, err := arrclient.NewSonarrClient(c.Series)
return client, true, err
default:
return nil, false, fmt.Errorf("unsupported content type %q", contentType)
}
}

View file

@ -1,23 +0,0 @@
package connector
import "testing"
func TestValidateConfigRejectsPartialMoviesConfig(t *testing.T) {
conn := &ArrtrixConnector{
Config: Config{
Content: ContentConfig{},
},
}
conn.Config.Content.Movies.URL = "http://radarr.test"
if err := conn.ValidateConfig(); err == nil {
t.Fatal("expected partial movies config to fail validation")
}
}
func TestValidateConfigAllowsEmptyContentConfig(t *testing.T) {
conn := &ArrtrixConnector{}
if err := conn.ValidateConfig(); err != nil {
t.Fatalf("ValidateConfig returned error: %v", err)
}
}

View file

@ -1,135 +0,0 @@
package connector
import (
"context"
"fmt"
"net/http"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
)
type ArrtrixConnector struct {
Bridge *bridgev2.Bridge
Config Config
clients map[arr.ContentType]arrclient.Client
subscriptions *subscriptions.Repository
}
var _ bridgev2.NetworkConnector = (*ArrtrixConnector)(nil)
var _ interface{ MountRoutes(*http.ServeMux) error } = (*ArrtrixConnector)(nil)
func (s *ArrtrixConnector) GetName() bridgev2.BridgeName {
return bridgev2.BridgeName{
DisplayName: "Arrtrix",
NetworkURL: "https://wiki.servarr.com/",
NetworkID: "arrtrix",
BeeperBridgeType: "arrtrix",
DefaultPort: 29329,
DefaultCommandPrefix: "!arr",
}
}
func (s *ArrtrixConnector) Init(bridge *bridgev2.Bridge) {
s.Bridge = bridge
s.subscriptions = subscriptions.NewRepository(bridge.DB.Database, string(bridge.ID))
s.clients = make(map[arr.ContentType]arrclient.Client)
for _, contentType := range arr.SupportedContentTypes() {
client, ok, err := s.Config.Content.Client(contentType)
if err != nil {
panic(err)
}
if ok {
s.clients[contentType] = client
}
}
}
func (s *ArrtrixConnector) Start(context.Context) error {
return nil
}
func (s *ArrtrixConnector) GetDBMetaTypes() database.MetaTypes {
return database.MetaTypes{}
}
func (s *ArrtrixConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities {
return &bridgev2.NetworkGeneralCapabilities{}
}
func (s *ArrtrixConnector) LoadUserLogin(_ context.Context, login *bridgev2.UserLogin) error {
login.Client = &ArrtrixClient{
Main: s,
UserLogin: login,
}
return nil
}
func (s *ArrtrixConnector) GetLoginFlows() []bridgev2.LoginFlow {
return nil
}
func (s *ArrtrixConnector) CreateLogin(_ context.Context, _ *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
return nil, fmt.Errorf("login flow %q is not implemented", flowID)
}
func (s *ArrtrixConnector) GetBridgeInfoVersion() (info, capabilities int) {
return 1, 1
}
type ArrtrixClient struct {
Main *ArrtrixConnector
UserLogin *bridgev2.UserLogin
}
var _ bridgev2.NetworkAPI = (*ArrtrixClient)(nil)
func (c *ArrtrixClient) Connect(context.Context) {}
func (c *ArrtrixClient) Disconnect() {}
func (c *ArrtrixClient) IsLoggedIn() bool {
return false
}
func (c *ArrtrixClient) LogoutRemote(context.Context) {}
func (c *ArrtrixClient) IsThisUser(context.Context, networkid.UserID) bool {
return false
}
func (c *ArrtrixClient) GetChatInfo(context.Context, *bridgev2.Portal) (*bridgev2.ChatInfo, error) {
return &bridgev2.ChatInfo{}, nil
}
func (c *ArrtrixClient) GetUserInfo(context.Context, *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
return &bridgev2.UserInfo{}, nil
}
func (c *ArrtrixClient) GetCapabilities(context.Context, *bridgev2.Portal) *event.RoomFeatures {
return &event.RoomFeatures{}
}
func (c *ArrtrixClient) HandleMatrixMessage(context.Context, *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) {
return nil, fmt.Errorf("bridging Matrix messages is not implemented")
}
func (c *ArrtrixClient) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID {
return networkid.RawTransactionID("")
}
func (s *ArrtrixConnector) ContentClient(contentType arr.ContentType) (arrclient.Client, bool) {
client, ok := s.clients[contentType]
return client, ok
}
func (s *ArrtrixConnector) Subscriptions() *subscriptions.Repository {
return s.subscriptions
}

View file

@ -1,23 +0,0 @@
content:
movies:
# Radarr connection for movie management commands.
url: ""
api_key: ""
root_folder_path: ""
quality_profile_id: 0
minimum_availability: released
search_on_add: true
series:
# Sonarr connection for series management commands.
url: ""
api_key: ""
root_folder_path: ""
quality_profile_id: 0
language_profile_id: 0
season_folder: true
series_type: standard
search_on_add: true
# Arr-stack webhooks are exposed automatically on the fixed built-in path:
# POST /_arrtrix/webhook

View file

@ -1,260 +0,0 @@
package matrixcmd
import (
"fmt"
"strconv"
"strings"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
)
type commandServiceProvider interface {
ContentClient(arr.ContentType) (arrclient.Client, bool)
Subscriptions() *subscriptions.Repository
}
func NewDownloadHandler() Handler {
return NewHandler(Meta{
Name: "download",
Description: "Manage monitored movies and series in Arr.",
Usage: "<list|search|add|monitor|remove> <movies|series> [...]",
}, func(ctx *Context) {
if len(ctx.Args) < 2 {
ctx.Reply("Usage: `download <list|search|add|monitor|remove> <movies|series> [...]`")
return
}
contentType, err := arr.ParseContentType(ctx.Args[1])
if err != nil {
ctx.Reply(err.Error())
return
}
client, ok := contentClient(ctx, contentType)
if !ok {
ctx.Reply("No %s client is configured yet.", contentType.Label())
return
}
switch strings.ToLower(ctx.Args[0]) {
case "list":
handleDownloadList(ctx, client, contentType)
case "search":
handleDownloadSearch(ctx, client, contentType)
case "add":
handleDownloadAdd(ctx, client, contentType)
case "monitor":
handleDownloadMonitor(ctx, client, contentType)
case "remove":
handleDownloadRemove(ctx, client, contentType)
default:
ctx.Reply("Unknown download subcommand `%s`.", ctx.Args[0])
}
})
}
func handleDownloadList(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
items, err := client.List(ctx.Ctx, query)
if err != nil {
ctx.Reply("Failed to list %s: %v", contentType.Label(), err)
return
}
if len(items) == 0 {
if query == "" {
ctx.Reply("No monitored %s are currently tracked.", contentType.Label())
} else {
ctx.Reply("No %s matched `%s`.", contentType.Label(), query)
}
return
}
count := len(items)
if count > 12 {
count = 12
}
ctx.Reply("Tracked %s (showing %d of %d):", contentType.Label(), count, len(items))
for i, item := range items {
if i == 12 {
break
}
if err := replyWithManagedItem(ctx, client, item); err != nil {
ctx.Log.Err(err).Int64("item_id", item.ID).Str("content_type", contentType.Label()).Msg("Failed to send Matrix-native image for download listing")
}
}
if len(items) > 12 {
ctx.Reply("…and %d more.", len(items)-12)
}
}
func handleDownloadSearch(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
if query == "" {
ctx.Reply("Usage: `download search %s <query>`", contentType.Label())
return
}
results, err := client.Search(ctx.Ctx, query)
if err != nil {
ctx.Reply("Failed to search %s: %v", contentType.Label(), err)
return
}
replyWithSearchResults(ctx, contentType, query, results)
}
func handleDownloadAdd(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
query := strings.TrimSpace(strings.Join(ctx.Args[2:], " "))
if query == "" {
ctx.Reply("Usage: `download add %s <query>`", contentType.Label())
return
}
results, err := client.Search(ctx.Ctx, query)
if err != nil {
ctx.Reply("Failed to search %s: %v", contentType.Label(), err)
return
}
result, err := arrclient.PickSingleResult(results, query)
if err != nil {
replyWithSearchResults(ctx, contentType, query, results)
return
}
item, err := client.Add(ctx.Ctx, result)
if err != nil {
ctx.Reply("Failed to add %s: %v", contentType.Label(), err)
return
}
ctx.Reply("Added %s to %s with id `%d`.", formatManagedItem(*item), contentType.Label(), item.ID)
}
func handleDownloadMonitor(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
if len(ctx.Args) < 4 {
ctx.Reply("Usage: `download monitor %s <id> <on|off>`", contentType.Label())
return
}
itemID, err := strconv.ParseInt(ctx.Args[2], 10, 64)
if err != nil {
ctx.Reply("Invalid %s id `%s`.", contentType.Label(), ctx.Args[2])
return
}
state, err := parseEnabled(ctx.Args[3])
if err != nil {
ctx.Reply(err.Error())
return
}
item, err := client.SetMonitored(ctx.Ctx, itemID, state)
if err != nil {
ctx.Reply("Failed to update %s monitoring: %v", contentType.Label(), err)
return
}
ctx.Reply("%s is now monitored=%t.", formatManagedItem(*item), item.Monitored)
}
func handleDownloadRemove(ctx *Context, client arrclient.Client, contentType arr.ContentType) {
if len(ctx.Args) < 3 {
ctx.Reply("Usage: `download remove %s <id>`", contentType.Label())
return
}
itemID, err := strconv.ParseInt(ctx.Args[2], 10, 64)
if err != nil {
ctx.Reply("Invalid %s id `%s`.", contentType.Label(), ctx.Args[2])
return
}
if err = client.Delete(ctx.Ctx, itemID); err != nil {
ctx.Reply("Failed to remove %s: %v", contentType.Label(), err)
return
}
ctx.Reply("Removed `%d` from %s.", itemID, contentType.Label())
}
func contentClient(ctx *Context, contentType arr.ContentType) (arrclient.Client, bool) {
provider, ok := ctx.Bridge.Network.(commandServiceProvider)
if !ok {
return nil, false
}
return provider.ContentClient(contentType)
}
func contentSubscriptions(ctx *Context) *subscriptions.Repository {
provider, ok := ctx.Bridge.Network.(commandServiceProvider)
if !ok {
return nil
}
return provider.Subscriptions()
}
func replyWithSearchResults(ctx *Context, contentType arr.ContentType, query string, results []arrclient.SearchResult) {
if len(results) == 0 {
ctx.Reply("No %s matched `%s`.", contentType.Label(), query)
return
}
var builder strings.Builder
builder.WriteString(fmt.Sprintf("Search results for `%s` in %s:\n", query, contentType.Label()))
for i, result := range results {
if i == 8 {
builder.WriteString("…\n")
break
}
builder.WriteString(fmt.Sprintf("- `%d` %s\n", result.LookupID, arrclient.FormatSearchResult(result)))
}
builder.WriteString(fmt.Sprintf("\nRefine the query and rerun `download add %s <query>` until only one match remains.", contentType.Label()))
ctx.Reply(builder.String())
}
func formatManagedItem(item arrclient.ManagedItem) string {
return arrclient.FormatManagedItem(item)
}
func parseEnabled(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "on", "true", "yes", "enabled":
return true, nil
case "off", "false", "no", "disabled":
return false, nil
default:
return false, fmt.Errorf("expected `on` or `off`, got `%s`", value)
}
}
func userIDString(userID id.UserID) string {
return userID.String()
}
func replyWithManagedItem(ctx *Context, client arrclient.Client, item arrclient.ManagedItem) error {
details := formatDownloadListCaption(item)
if item.ImageURL != "" {
asset, err := client.FetchImage(ctx.Ctx, item)
if err != nil {
ctx.Log.Err(err).Int64("item_id", item.ID).Msg("Failed to fetch poster for Matrix listing")
} else if asset != nil {
if err := ctx.SendImage(asset, details); err != nil {
ctx.Log.Err(err).Int64("item_id", item.ID).Msg("Failed to upload poster for Matrix listing")
} else {
return nil
}
} else {
ctx.Log.Debug().Int64("item_id", item.ID).Msg("Poster was empty for Matrix listing")
}
}
ctx.Reply(details)
return nil
}
func formatDownloadListCaption(item arrclient.ManagedItem) string {
return fmt.Sprintf("%s %s", monitoredIcon(item.Monitored), arrclient.FormatManagedItem(item))
}
func formatDownloadListFallbackCard(item arrclient.ManagedItem) string {
return formatDownloadListCaption(item)
}
func monitoredIcon(monitored bool) string {
if monitored {
return "👁"
}
return "🚫"
}

Some files were not shown because too many files have changed in this diff Show more