Compare commits

..

3 commits

Author SHA1 Message Date
Chris Kruining
e849826de6
chore: update dependencies 2025-12-08 16:32:45 +01:00
Chris Kruining
3730ab856b
feat: improve justfiles 2025-12-08 16:31:52 +01:00
Chris Kruining
eab9e8b58d trying some stuff 2025-12-08 15:30:25 +00:00
99 changed files with 1241 additions and 7378 deletions

View file

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

5
.gitattributes vendored
View file

@ -1 +1,4 @@
* text=auto eol=lf * text=auto
core.autocrlf=false
core.eol=lf
core.filemode=false

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 +1,14 @@
@_default: list set unstable := true
set quiet := true
_default: list
[doc('List machines')] [doc('List machines')]
@list: list:
ls -1 ../systems/x86_64-linux/ ls -1 ../systems/x86_64-linux/
[doc('Update target machine')] [doc('Update the target machine')]
[no-exit-message] [no-exit-message]
@update machine: update machine:
echo "Checking vars" just assert '-d "../systems/x86_64-linux/{{ machine }}"' "Machine {{ machine }} does not exist, must be one of: $(ls ../systems/x86_64-linux/ | tr '\n' ' ')"
cd .. && just vars _check {{ machine }} nixos-rebuild switch --use-remote-sudo --target-host {{ machine }} --flake ..#{{ 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,20 +1,18 @@
set unstable := true set unstable := true
set quiet := true set quiet := true
base_path := justfile_directory() + "/systems/x86_64-linux" base_path := invocation_directory() / "systems/x86_64-linux"
_default: _default:
just --list vars just --list
[doc('List all vars of {machine}')] [doc('list all vars of the target machine')]
list machine: list machine:
sops decrypt {{ base_path }}/{{ machine }}/secrets.yml sops decrypt {{ base_path }}/{{ machine }}/secrets.yml
[doc('Edit all vars of {machine} in your editor')]
edit machine: edit machine:
sops edit {{ base_path }}/{{ machine }}/secrets.yml sops edit {{ base_path }}/{{ machine }}/secrets.yml
[doc('Set var {value} by {key} for {machine}')]
@set machine key value: @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')\"" 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')\""
@ -23,11 +21,9 @@ edit machine:
echo "Done" echo "Done"
[doc('Get var by {key} from {machine}')]
get machine key: get machine key:
sops decrypt {{ base_path }}/{{ machine }}/secrets.yml | yq ".$(echo "{{ key }}" | sed -E 's/\//./g') // \"\"" sops decrypt {{ base_path }}/{{ machine }}/secrets.yml | yq ".$(echo "{{ key }}" | sed -E 's/\//./g')"
[doc('Remove var by {key} for {machine}')]
remove machine key: remove machine key:
sops unset {{ base_path }}/{{ machine }}/secrets.yml "$(printf '%s\n' '["{{ key }}"]' | sed -E 's#/#"]["#g; s/\["([0-9]+)"\]/[\1]/g')" sops unset {{ base_path }}/{{ machine }}/secrets.yml "$(printf '%s\n' '["{{ key }}"]' | sed -E 's#/#"]["#g; s/\["([0-9]+)"\]/[\1]/g')"
@ -35,74 +31,3 @@ remove machine key:
git commit -m 'chore(secrets): removed secret "{{ key }}" from machine "{{ machine }}"' -- {{ base_path }}/{{ machine }}/secrets.yml > /dev/null git commit -m 'chore(secrets): removed secret "{{ key }}" from machine "{{ machine }}"' -- {{ base_path }}/{{ machine }}/secrets.yml > /dev/null
echo "Done" 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 +1,40 @@
@_default: _default:
just --list --list-submodules just --list --list-submodules
[doc('Manage vars')] set unstable
set quiet
mod vars '.just/vars.just' mod vars '.just/vars.just'
[doc('Manage users')]
mod users '.just/users.just'
[doc('Manage machines')]
mod machine '.just/machine.just' mod machine '.just/machine.just'
[doc('Show information about project')] [doc('Show information about project')]
@show: show:
echo "show" echo "show"
[doc('update the flake dependencies')] [doc('update the flake dependencies')]
@update: update:
nix flake update nix flake update
git commit -m 'chore: update dependencies' -- ./flake.lock > /dev/null git commit -m 'chore: update dependencies' -- ./flake.lock > /dev/null
echo "Done" echo "Done"
[doc('Rebase branch on main')]
rebase:
git stash -q \
&& git fetch \
&& git rebase origin/main \
&& git stash pop -q
echo "Done"
[doc('Introspection on flake output')] [doc('Introspection on flake output')]
@select key: select key:
nix eval --show-trace --json .#{{ key }} | jq . nix eval --json .#{{ key }} | jq .
#=============================================================================================== #===============================================================================================
# Utils # Utils
# =============================================================================================== # ===============================================================================================
[no-exit-message]
[no-cd] [no-cd]
[no-exit-message]
[private] [private]
@assert condition message: assert condition message:
[ {{ condition }} ] || { echo -e 1>&2 "\n\x1b[1;41m Error \x1b[0m {{ message }}\n"; exit 1; } [ {{ condition }} ] || { echo -e 1>&2 "\n\x1b[1;41m Error \x1b[0m {{ message }}\n"; exit 1; }

View file

@ -1,7 +1,6 @@
keys: keys:
- &ulmo_1 age19qfpf980tadguqq44zf6xwvjvl428dyrj46ha3n6aeqddwhtnuqqml7etq - &ulmo_1 age19qfpf980tadguqq44zf6xwvjvl428dyrj46ha3n6aeqddwhtnuqqml7etq
- &ulmo_2 age1ewes0f5snqx3sh5ul6fa6qtxzhd25829v6mf5rx2wnheat6fefps5rme2x - &ulmo_2 age1ewes0f5snqx3sh5ul6fa6qtxzhd25829v6mf5rx2wnheat6fefps5rme2x
- &manwe_1 age1jmrmdw4kmjeu9d6z74r2unqt7wpgsx24vqejmdjretsnsn8g4drsl3m98w
creation_rules: creation_rules:
# All Machine secrets # All Machine secrets
@ -10,4 +9,3 @@ creation_rules:
- age: - age:
- *ulmo_1 - *ulmo_1
- *ulmo_2 - *ulmo_2
- *manwe_1

601
flake.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,6 @@
{ {
description = "Nixos config flake"; description = "Nixos config flake";
nixConfig = {
warn-dirty = false;
};
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";

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

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

View file

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

View file

@ -32,7 +32,7 @@ in {
image = ./${cfg.theme}.jpg; image = ./${cfg.theme}.jpg;
polarity = cfg.polarity; polarity = cfg.polarity;
targets.qt.platform = mkDefault "kde"; # targets.qt.platform = mkDefault "kde";
targets.zen-browser.profileNames = [ "Chris" ]; targets.zen-browser.profileNames = [ "Chris" ];
fonts = { fonts = {

View file

@ -15,38 +15,28 @@ in {
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
# environment.systemPackages = with pkgs; [ steam ];
programs = { programs = {
steam = { steam = {
enable = true; enable = true;
remotePlay.openFirewall = true; package = pkgs.steam.override {
dedicatedServer.openFirewall = true; extraEnv = {
localNetworkGameTransfers.openFirewall = true; DXVK_HUD = "compiler";
MANGOHUD = true;
};
};
extraCompatPackages = with pkgs; [ gamescopeSession = {
proton-ge-bin enable = true;
]; args = ["--immediate-flips"];
};
# package = pkgs.steam.override {
# extraEnv = {
# DXVK_HUD = "compiler";
# MANGOHUD = true;
# };
# };
# gamescopeSession = {
# enable = true;
# args = ["--immediate-flips"];
# };
}; };
# https://github.com/FeralInteractive/gamemode # https://github.com/FeralInteractive/gamemode
# gamemode = { gamemode = {
# enable = true; enable = true;
# enableRenice = true; enableRenice = true;
# settings = {}; settings = {};
# }; };
# gamescope = { # gamescope = {
# enable = true; # 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, ... }:
lib, let
config,
namespace,
inputs,
...
}: let
inherit (lib) mkIf mkOption mkEnableOption mkMerge; inherit (lib) mkIf mkOption mkEnableOption mkMerge;
inherit (lib.types) nullOr enum; inherit (lib.types) nullOr enum;
cfg = config.${namespace}.desktop; cfg = config.${namespace}.desktop;
in { in
{
imports = [ imports = [
inputs.grub2-themes.nixosModules.default inputs.grub2-themes.nixosModules.default
]; ];
options.${namespace}.desktop = { options.${namespace}.desktop = {
use = mkOption { use = mkOption {
type = nullOr (enum ["plasma" "gamescope" "gnome" "cosmic"]); type = nullOr (enum [ "plasma" "gamescope" "gnome" ]);
default = null; default = null;
example = "plasma"; example = "plasma";
description = "Which desktop to enable"; description = "Which desktop to enable";
@ -24,11 +20,11 @@ in {
}; };
config = mkMerge [ config = mkMerge [
{ ({
services.displayManager = { services.displayManager = {
enable = true; enable = true;
}; };
} })
# (mkIf (cfg.use != null) { # (mkIf (cfg.use != null) {
# ${namespace}.desktop.${cfg.use}.enable = true; # ${namespace}.desktop.${cfg.use}.enable = true;

View file

@ -22,7 +22,7 @@ in
konsole konsole
kate kate
ghostwriter ghostwriter
# oxygen oxygen
]; ];
environment.sessionVariables.NIXOS_OZONE_WL = "1"; environment.sessionVariables.NIXOS_OZONE_WL = "1";

View file

@ -1,36 +1,16 @@
{ { config, lib, pkgs, namespace, ... }:
config, let
lib,
pkgs,
namespace,
...
}: let
inherit (lib) mkIf mkEnableOption; inherit (lib) mkIf mkEnableOption;
user = "authelia-testing"; user = "authelia-testing";
cfg = config.${namespace}.services.authentication.authelia; cfg = config.${namespace}.services.authentication.authelia;
in { in
{
options.${namespace}.services.authentication.authelia = { options.${namespace}.services.authentication.authelia = {
enable = mkEnableOption "Authelia"; enable = mkEnableOption "Authelia";
}; };
config = mkIf cfg.enable { 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; [ environment.systemPackages = with pkgs; [
authelia authelia
]; ];
@ -215,8 +195,48 @@ in {
- jellyfin-users - jellyfin-users
- admin - admin
- dev - 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

@ -1,10 +1,9 @@
{ config, lib, pkgs, namespace, system, inputs, ... }: { config, lib, pkgs, namespace, system, inputs, ... }:
let 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) mkIf mkEnableOption mkOption types toUpper toSentenceCase nameValuePair mapAttrs mapAttrs' concatMapAttrs concatMapStringsSep filterAttrsRecursive listToAttrs imap0 head drop length literalExpression attrNames;
inherit (lib.${namespace}.strings) toSnakeCase; inherit (lib.${namespace}.strings) toSnakeCase;
cfg = config.${namespace}.services.authentication.zitadel; cfg = config.${namespace}.services.authentication.zitadel;
port = 3010;
database = "zitadel"; database = "zitadel";
in in
@ -445,7 +444,8 @@ in
|> withRef "org" org |> withRef "org" org
|> toResource "${org}_${name}" |> toResource "${org}_${name}"
) )
|> append [ |> append
[
(forEach "local.extra_users" [ "org" "name" ] { (forEach "local.extra_users" [ "org" "name" ] {
orgId = lib.tfRef "local.orgs[each.value.org]"; orgId = lib.tfRef "local.orgs[each.value.org]";
userName = lib.tfRef "each.value.name"; userName = lib.tfRef "each.value.name";
@ -538,25 +538,7 @@ in
}; };
in in
mkIf cfg.enable { mkIf cfg.enable {
${namespace}.services = { ${namespace}.services.persistance.postgresql.enable = true;
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; [ environment.systemPackages = with pkgs; [
zitadel zitadel
@ -613,7 +595,7 @@ in
masterKeyFile = config.sops.secrets."zitadel/masterKey".path; masterKeyFile = config.sops.secrets."zitadel/masterKey".path;
tlsMode = "external"; tlsMode = "external";
settings = { settings = {
Port = port; Port = 9092;
ExternalDomain = "auth.kruining.eu"; ExternalDomain = "auth.kruining.eu";
ExternalPort = 443; ExternalPort = 443;
@ -697,7 +679,26 @@ in
} }
]; ];
}; };
caddy = {
enable = true;
virtualHosts = {
"auth.kruining.eu".extraConfig = ''
reverse_proxy h2c://::1:9092
'';
}; };
extraConfig = ''
(auth) {
forward_auth h2c://::1:9092 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
}
'';
};
};
networking.firewall.allowedTCPPorts = [ 80 443 ];
# Secrets # Secrets
sops = { sops = {

View file

@ -6,7 +6,7 @@
... ...
}: let }: let
inherit (builtins) toString toJSON; inherit (builtins) toString toJSON;
inherit (lib) mkIf mkEnableOption mkMerge; inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.communication.matrix; cfg = config.${namespace}.services.communication.matrix;
@ -15,37 +15,6 @@
port = 4001; port = 4001;
database = "synapse"; 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 { in {
options.${namespace}.services.communication.matrix = { options.${namespace}.services.communication.matrix = {
enable = mkEnableOption "Matrix server (Synapse)"; enable = mkEnableOption "Matrix server (Synapse)";
@ -54,97 +23,18 @@ in {
config = mkIf cfg.enable { config = mkIf cfg.enable {
${namespace}.services = { ${namespace}.services = {
persistance.postgresql.enable = true; persistance.postgresql.enable = true;
# virtualisation.podman.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 [ networking.firewall.allowedTCPPorts = [4001];
(mkMautrix "mautrix-signal" 1 {})
(mkMautrix "mautrix-telegram" 2 {})
(mkMautrix "mautrix-whatsapp" 3 {})
(mkMautrix "arrtrix" 4 {
environmentFile = config.sops.templates."arrtrix/secrets".path;
settings = { services = {
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 = { matrix-synapse = {
enable = true; enable = true;
extras = ["oidc"]; extras = ["oidc"];
extraConfigFiles = [ extraConfigFiles = [
config.sops.templates."synapse.yaml".path
config.sops.templates."synapse-oidc.yaml".path config.sops.templates."synapse-oidc.yaml".path
]; ];
@ -154,40 +44,19 @@ in {
enable_metrics = true; enable_metrics = true;
registration_shared_secret = "tZtBnlhEmLbMwF0lQ112VH1Rl5MkZzYH9suI4pEoPXzk6nWUB8FJF4eEnwLkbstz";
url_preview_enabled = true; url_preview_enabled = true;
precence.enabled = true; precence.enabled = true;
# Since we'll be using OIDC for auth disable all local options # Since we'll be using OIDC for auth disable all local options
enable_registration = false; enable_registration = false;
enable_registration_without_verification = false; enable_registration_without_verification = false;
password_config.enabled = true; password_config.enabled = false;
backchannel_logout_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 = { sso = {
client_whitelist = ["http://[::1]:${toString config.services.zitadel.settings.Port}/" "https://auth.kruining.eu/"]; client_whitelist = ["http://[::1]:9092"];
update_profile_information = true; update_profile_information = true;
}; };
@ -219,7 +88,54 @@ in {
}; };
}; };
mautrix-signal = {
enable = true;
registerToSynapse = true;
settings = {
appservice = {
provisioning.enabled = false;
# port = 40011;
};
homeserver = {
address = "http://[::1]:${toString port}";
domain = domain;
};
bridge = {
permissions = {
"@chris:${domain}" = "admin";
};
};
};
};
mautrix-whatsapp = {
enable = true;
registerToSynapse = true;
settings = {
appservice = {
provisioning.enabled = false;
# port = 40012;
};
homeserver = {
address = "http://[::1]:${toString port}";
domain = domain;
};
bridge = {
permissions = {
"@chris:${domain}" = "admin";
};
};
};
};
postgresql = { postgresql = {
enable = true;
ensureDatabases = [database]; ensureDatabases = [database];
ensureUsers = [ ensureUsers = [
{ {
@ -229,135 +145,38 @@ in {
]; ];
}; };
livekit = { caddy = {
enable = true; enable = true;
openFirewall = true; virtualHosts = let
inherit keyFile; server = {
"m.server" = "${fqn}:443";
settings = {
port = 4002;
room.auto_create = false;
}; };
client = {
"m.homeserver".base_url = "https://${fqn}";
"m.identity_server".base_url = "https://auth.kruining.eu";
}; };
in {
lk-jwt-service = { "${domain}".extraConfig = ''
enable = true; header /.well-known/matrix/* Content-Type application/json
port = 4003; header /.well-known/matrix/* Access-Control-Allow-Origin *
# can be on the same virtualHost as synapse respond /.well-known/matrix/server `${toJSON server}`
livekitUrl = "wss://${domain}/livekit/sfu"; respond /.well-known/matrix/client `${toJSON client}`
inherit keyFile; '';
}; "${fqn}".extraConfig = ''
reverse_proxy /_matrix/* http://::1:4001
coturn = rec { reverse_proxy /_synapse/client/* http://::1:4001
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 = { sops = {
secrets = { secrets = {
"synapse/oidc_id" = { "synapse/oidc_id" = {};
restartUnits = ["synapse-matrix.service"]; "synapse/oidc_secret" = {};
};
"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 = { templates = {
"synapse.yaml" = {
owner = "matrix-synapse";
content = ''
registration_shared_secret: ${config.sops.placeholder."synapse/shared_secret"}
'';
restartUnits = ["matrix-synapse.service"];
};
"synapse-oidc.yaml" = { "synapse-oidc.yaml" = {
owner = "matrix-synapse"; owner = "matrix-synapse";
content = '' content = ''
@ -369,30 +188,16 @@ in {
scopes: scopes:
- openid - openid
- profile - profile
- email
- offline_access
client_id: '${config.sops.placeholder."synapse/oidc_id"}' client_id: '${config.sops.placeholder."synapse/oidc_id"}'
client_secret: '${config.sops.placeholder."synapse/oidc_secret"}' client_secret: '${config.sops.placeholder."synapse/oidc_secret"}'
backchannel_logout_enabled: true backchannel_logout_enabled: true
user_profile_method: userinfo_endpoint
allow_existing_users: true
enable_registration: true
user_mapping_provider: user_mapping_provider:
config: config:
localpart_template: "{{ user.preferred_username }}" localpart_template: "{{ user.preferred_username }}"
display_name_template: "{{ user.name }}" display_name_template: "{{ user.name }}"
email_template: "{{ user.email }}"
''; '';
restartUnits = ["matrix-synapse.service"]; 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

@ -28,20 +28,6 @@ in {
${namespace}.services = { ${namespace}.services = {
persistance.postgresql.enable = true; persistance.postgresql.enable = true;
virtualisation.podman.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]; environment.systemPackages = with pkgs; [forgejo];
@ -49,7 +35,6 @@ in {
services = { services = {
forgejo = { forgejo = {
enable = true; enable = true;
lfs.enable = true;
useWizard = false; useWizard = false;
database.type = "postgres"; database.type = "postgres";
@ -182,6 +167,21 @@ in {
}; };
}; };
}; };
caddy = {
enable = true;
virtualHosts = {
"${domain}".extraConfig = ''
# 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}
'';
};
};
}; };
users = { users = {

View file

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

@ -35,6 +35,13 @@ in {
#========================================================================= #=========================================================================
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
podman-tui podman-tui
jellyfin
jellyfin-web
jellyfin-ffmpeg
jellyseerr
mediainfo
id3v2
yt-dlp
]; ];
#========================================================================= #=========================================================================
@ -49,6 +56,9 @@ in {
}; };
systemd.tmpfiles.rules = [ systemd.tmpfiles.rules = [
# "d '${cfg.path}/series' 0770 ${cfg.user} ${cfg.group} - -"
# "d '${cfg.path}/movies' 0770 ${cfg.user} ${cfg.group} - -"
# "d '${cfg.path}/music' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/qbittorrent' 0770 ${cfg.user} ${cfg.group} - -" "d '${cfg.path}/qbittorrent' 0770 ${cfg.user} ${cfg.group} - -"
"d '${cfg.path}/sabnzbd' 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/incomplete' 0770 ${cfg.user} ${cfg.group} - -"
@ -64,12 +74,57 @@ in {
openFirewall = true; openFirewall = true;
user = cfg.user; user = cfg.user;
group = cfg.group; group = cfg.group;
listenPort = 2050; listenPort = 2005;
};
flaresolverr = {
enable = true;
openFirewall = true;
port = 2007;
};
# port is harcoded in nixpkgs module
jellyfin = {
enable = true;
openFirewall = true;
user = cfg.user;
group = cfg.group;
}; };
postgresql = { postgresql = {
enable = true; enable = true;
}; };
caddy = {
enable = true;
virtualHosts = {
"jellyfin.kruining.eu".extraConfig = ''
reverse_proxy http://[::1]:8096
'';
};
};
};
systemd.services.jellyfin.serviceConfig.killSignal = lib.mkForce "SIGKILL";
sops = {
secrets = {
# "qbittorrent/password" = {};
"qbittorrent/password_hash" = {};
};
templates = {
"qbittorrent/password.conf" = {
owner = cfg.user;
group = cfg.group;
restartUnits = ["qbittorrent.service"];
path = "${config.services.qbittorrent.profileDir}/qBittorrent/config/password.conf";
content = ''
[Preferences]
WebUI\Password_PBKDF2="${config.sops.placeholder."qbittorrent/password_hash"}"
'';
};
};
}; };
}; };
} }

View file

@ -13,12 +13,6 @@ in {
}; };
config = mkIf cfg.enable { config = mkIf cfg.enable {
# ${namespace}.services.networking.caddy.hosts = {
# "https://${config.networking.hostName}.arda:443" = ''
# reverse_proxy http://[::1]:2000
# '';
# };
services.glance = { services.glance = {
enable = true; enable = true;
openFirewall = true; openFirewall = true;
@ -106,22 +100,22 @@ in {
} }
{ {
title = "Radarr"; title = "Radarr";
url = "http://${config.networking.hostName}:${builtins.toString config.services.radarr.settings.server.port}"; url = "http://${config.networking.hostName}:2001";
icon = "sh:radarr"; icon = "sh:radarr";
} }
{ {
title = "Sonarr"; title = "Sonarr";
url = "http://${config.networking.hostName}:${builtins.toString config.services.sonarr.settings.server.port}"; url = "http://${config.networking.hostName}:2002";
icon = "sh:sonarr"; icon = "sh:sonarr";
} }
{ {
title = "Lidarr"; title = "Lidarr";
url = "http://${config.networking.hostName}:${builtins.toString config.services.lidarr.settings.server.port}"; url = "http://${config.networking.hostName}:2003";
icon = "sh:lidarr"; icon = "sh:lidarr";
} }
{ {
title = "Prowlarr"; title = "Prowlarr";
url = "http://${config.networking.hostName}:${builtins.toString config.services.prowlarr.settings.server.port}"; url = "http://${config.networking.hostName}:2004";
icon = "sh:prowlarr"; icon = "sh:prowlarr";
} }
{ {
@ -131,11 +125,21 @@ in {
} }
{ {
title = "SABnzbd"; title = "SABnzbd";
url = "http://${config.networking.hostName}:${builtins.toString config.services.sabnzbd.settings.misc.port}"; url = "http://${config.networking.hostName}:8080";
icon = "sh:sabnzbd"; icon = "sh:sabnzbd";
} }
]; ];
} }
{
type = "videos";
channels = [
"UCXuqSBlHAE6Xw-yeJA0Tunw" # Linus Tech Tips
"UCR-DXc1voovS8nhAvccRZhg" # Jeff Geerling
"UCsBjURrPoezykLs9EqgamOA" # Fireship
"UCBJycsmduvYEL83R_U4JriQ" # Marques Brownlee
"UCHnyfMqiRRG1u-2MsSQLbXA" # Veritasium
];
}
]; ];
} }

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

@ -22,7 +22,7 @@ in {
services.mydia = { services.mydia = {
enable = true; enable = true;
port = 2100; port = 2010;
listenAddress = "0.0.0.0"; listenAddress = "0.0.0.0";
openFirewall = true; openFirewall = true;
@ -36,7 +36,7 @@ in {
# uri = "file:///var/lib/mydia/mydia.db"; # uri = "file:///var/lib/mydia/mydia.db";
type = "postgres"; type = "postgres";
uri = "postgres://mydia@localhost:5432/mydia?sslmode=disable"; uri = "postgres://mydia@localhost:5432/mydia?sslmode=disable";
passwordFile = config.sops.templates."mydia/database_password".path; passwordFile = config.sops.secrets."mydia/qbittorrent_password".path;
}; };
secretKeyBaseFile = config.sops.secrets."mydia/secret_key_base".path; secretKeyBaseFile = config.sops.secrets."mydia/secret_key_base".path;
@ -54,7 +54,7 @@ in {
qbittorrent = { qbittorrent = {
type = "qbittorrent"; type = "qbittorrent";
host = "localhost"; host = "localhost";
port = 2080; port = 2008;
username = "admin"; username = "admin";
passwordFile = config.sops.secrets."mydia/qbittorrent_password".path; passwordFile = config.sops.secrets."mydia/qbittorrent_password".path;
useSsl = false; useSsl = false;
@ -82,14 +82,5 @@ in {
key = "qbittorrent/password"; 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,15 +1,11 @@
{ { config, lib, pkgs, namespace, ... }:
config, let
lib,
pkgs,
namespace,
...
}: let
inherit (lib) mkIf mkEnableOption mkOption; inherit (lib) mkIf mkEnableOption mkOption;
inherit (lib.types) str; inherit (lib.types) str;
cfg = config.${namespace}.services.media.nextcloud; cfg = config.${namespace}.services.media.nextcloud;
in { in
{
options.${namespace}.services.media.nextcloud = { options.${namespace}.services.media.nextcloud = {
enable = mkEnableOption "Nextcloud"; enable = mkEnableOption "Nextcloud";
@ -25,14 +21,6 @@ in {
}; };
config = mkIf cfg.enable { 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 = {
users.${cfg.user} = { users.${cfg.user} = {
isSystemUser = true; isSystemUser = true;
@ -87,5 +75,14 @@ in {
# startServices = true; # 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

@ -11,8 +11,6 @@
inherit (lib) mkIf mkEnableOption mkOption types; inherit (lib) mkIf mkEnableOption mkOption types;
cfg = config.${namespace}.services.media.servarr; cfg = config.${namespace}.services.media.servarr;
servarr = import ./lib.nix {inherit lib;};
anyEnabled = cfg |> lib.attrNames |> lib.length |> (l: l > 0);
in { in {
options.${namespace}.services.media = { options.${namespace}.services.media = {
servarr = mkOption { servarr = mkOption {
@ -35,7 +33,7 @@ in {
}; };
}; };
config = mkIf anyEnabled { config = {
services = services =
cfg cfg
|> lib.mapAttrsToList (service: { |> lib.mapAttrsToList (service: {
@ -56,8 +54,7 @@ in {
auth.authenticationMethod = "External"; auth.authenticationMethod = "External";
server = { server = {
# bindaddress = "0.0.0.0"; bindaddress = "0.0.0.0";
bindaddress = "[::]";
port = port; port = port;
}; };
@ -70,80 +67,53 @@ in {
}; };
}; };
} }
// (lib.optionalAttrs (lib.elem service ["radarr" "sonarr" "lidarr" "whisparr"]) { // (lib.optionalAttrs (service != "prowlarr") {
user = service; user = service;
group = "media"; group = "media";
}); });
})) }))
|> lib.concat [ |> lib.mkMerge
{ |> (set:
set
// {
qbittorrent = { qbittorrent = {
enable = true; enable = true;
openFirewall = true; openFirewall = true;
webuiPort = 2080; webuiPort = 2008;
serverConfig = lib.mkForce {};
serverConfig = {
LegalNotice.Accepted = true;
Prefecences.WebUI = {
Username = "admin";
};
};
user = "qbittorrent"; user = "qbittorrent";
group = "media"; group = "media";
}; };
# port is harcoded in nixpkgs module
sabnzbd = { sabnzbd = {
enable = true; enable = true;
openFirewall = true; openFirewall = true;
configFile = "${cfg.path}/sabnzbd/config.ini";
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"; user = "sabnzbd";
group = "media"; group = "media";
}; };
flaresolverr = { postgresql = {
enable = true; ensureDatabases = cfg |> lib.attrNames;
openFirewall = true;
port = 2070;
};
postgresql = let
databases = [] ++ (cfg |> lib.attrNames);
in {
ensureDatabases = databases;
ensureUsers = ensureUsers =
databases cfg
|> lib.attrNames
|> lib.map (service: { |> lib.map (service: {
name = service; name = service;
ensureDBOwnership = true; ensureDBOwnership = true;
}); });
}; };
} });
]
|> lib.mkMerge;
systemd.services = systemd.services =
cfg cfg
@ -155,9 +125,6 @@ in {
... ...
}: (mkIf enable { }: (mkIf enable {
"${service}ApplyTerraform" = let "${service}ApplyTerraform" = let
config' = config;
lib' = lib;
terraformConfiguration = inputs.terranix.lib.terranixConfiguration { terraformConfiguration = inputs.terranix.lib.terranixConfiguration {
inherit system; inherit system;
@ -168,34 +135,18 @@ in {
... ...
}: { }: {
config = { config = {
variable = variable = {
cfg api_key = {
|> lib'.mapAttrsToList (s: _: {
"${s}_api_key" = {
type = "string"; type = "string";
description = "${s} API key"; description = "${service} 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} = { terraform.required_providers.${service} = {
source = "devopsarr/${service}"; source = "devopsarr/${service}";
version = version =
{ {
radarr = "2.3.5"; radarr = "2.3.3";
sonarr = "3.4.0"; sonarr = "3.4.0";
prowlarr = "3.1.0"; prowlarr = "3.1.0";
lidarr = "1.13.0"; lidarr = "1.13.0";
@ -208,151 +159,17 @@ in {
provider.${service} = { provider.${service} = {
url = "http://127.0.0.1:${toString port}"; url = "http://127.0.0.1:${toString port}";
api_key = lib.tfRef "var.${service}_api_key"; api_key = lib.tfRef "var.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;
});
}; };
resource = {
"${service}_root_folder" = mkIf (lib.elem service ["radarr" "sonarr" "whisparr"]) ( "${service}_root_folder" = mkIf (lib.elem service ["radarr" "sonarr" "whisparr"]) (
rootFolders rootFolders
|> lib.imap (i: f: lib.nameValuePair "local${toString i}" {path = f;}) |> lib.imap (i: f: lib.nameValuePair "local${toString i}" {path = f;})
|> lib.listToAttrs |> 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
));
};
}) })
]; ];
}; };
@ -375,7 +192,7 @@ in {
# Sleep for a bit to give the service a chance to start up # Sleep for a bit to give the service a chance to start up
sleep 5s sleep 5s
if [ "$(systemctl is-active "${service}")" != "active" ]; then if [ "$(systemctl is-active ${service})" != "active" ]; then
echo "${service} is not running" echo "${service} is not running"
exit 1 exit 1
fi fi
@ -387,7 +204,7 @@ in {
cp -f ${terraformConfiguration} config.tf.json cp -f ${terraformConfiguration} config.tf.json
# Initialize OpenTofu # Initialize OpenTofu
${lib.getExe pkgs.opentofu} init ${lib.getExe pkgs.opentofu} init -upgrade
# Run the infrastructure code # Run the infrastructure code
${lib.getExe pkgs.opentofu} \ ${lib.getExe pkgs.opentofu} \
@ -396,7 +213,7 @@ in {
then "plan" then "plan"
else "apply -auto-approve" else "apply -auto-approve"
} \ } \
-var-file='${config.sops.templates."servarr/config.tfvars".path}' -var-file='${config.sops.templates."${service}/config.tfvars".path}'
''; '';
serviceConfig = { serviceConfig = {
@ -414,18 +231,6 @@ in {
})) }))
|> lib.mkMerge; |> 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 = users =
cfg cfg
|> lib.mapAttrsToList (service: {enable, ...}: (mkIf enable { |> lib.mapAttrsToList (service: {enable, ...}: (mkIf enable {
@ -436,11 +241,6 @@ in {
}; };
groups.${service} = {}; groups.${service} = {};
})) }))
|> lib.concat [
{
groups.media = {};
}
]
|> lib.mkMerge; |> lib.mkMerge;
sops = sops =
@ -461,73 +261,17 @@ in {
${lib.toUpper service}__AUTH__APIKEY="${config.sops.placeholder."${service}/apikey"}" ${lib.toUpper service}__AUTH__APIKEY="${config.sops.placeholder."${service}/apikey"}"
''; '';
}; };
"${service}/config.tfvars" = {
owner = service;
group = "media";
restartUnits = ["${service}.service"];
content = ''
api_key = "${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; |> 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,10 +1,5 @@
{ { pkgs, config, lib, namespace, ... }:
pkgs, let
config,
lib,
namespace,
...
}: let
inherit (lib.modules) mkIf; inherit (lib.modules) mkIf;
inherit (lib.options) mkEnableOption; inherit (lib.options) mkEnableOption;
@ -12,7 +7,8 @@
db_user = "grafana"; db_user = "grafana";
db_name = "grafana"; db_name = "grafana";
in { in
{
options.${namespace}.services.observability.grafana = { options.${namespace}.services.observability.grafana = {
enable = mkEnableOption "enable Grafana"; enable = mkEnableOption "enable Grafana";
}; };
@ -25,25 +21,22 @@ in {
settings = { settings = {
server = { server = {
http_port = 9010; http_port = 9001;
http_addr = "::"; http_addr = "0.0.0.0";
domain = "ulmo"; domain = "ulmo";
}; };
security = {
secret_key = "$__file{${config.sops.secrets."grafana/secret_key".path}}";
};
auth = { auth = {
disable_login_form = false; disable_login_form = false;
oauth_auto_login = true;
}; };
"auth.basic".enable = false; "auth.basic".enable = false;
"auth.generic_oauth" = { "auth.generic_oauth" = {
enable = true; enable = true;
name = "Zitadel"; name = "Zitadel";
client_id = "$__file{${config.sops.secrets."grafana/oidc_id".path}}"; client_id = "334170712283611395";
client_secret = "$__file{${config.sops.secrets."grafana/oidc_secret".path}}"; client_secret = "AFjypmURdladmQn1gz2Ke0Ta5LQXapnuKkALVZ43riCL4qWicgV2Z6RlwpoWBZg1";
scopes = "openid email profile offline_access urn:zitadel:iam:org:project:roles"; scopes = "openid email profile offline_access urn:zitadel:iam:org:project:roles";
email_attribute_path = "email"; email_attribute_path = "email";
login_attribute_path = "username"; login_attribute_path = "username";
@ -104,38 +97,18 @@ in {
datasources.settings.datasources = [ datasources.settings.datasources = [
{ {
name = "Prometheus"; name = "Prometheus";
uid = "prometheus";
type = "prometheus"; type = "prometheus";
url = "http://[::1]:9020"; url = "http://localhost:9005";
isDefault = true; isDefault = true;
editable = false; editable = false;
} }
{ {
name = "Loki"; name = "Loki";
uid = "loki";
type = "loki"; type = "loki";
url = "http://[::1]:9030"; url = "http://localhost:9003";
editable = false; 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";
};
};
}
]; ];
}; };
}; };
@ -153,22 +126,5 @@ in {
}; };
environment.etc."/grafana/dashboards/default.json".source = ./dashboards/default.json; 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

@ -17,7 +17,7 @@ in
auth_enabled = false; auth_enabled = false;
server = { server = {
http_listen_port = 9030; http_listen_port = 9003;
}; };
common = { common = {
@ -44,6 +44,6 @@ in
}; };
}; };
networking.firewall.allowedTCPPorts = [ 9030 ]; networking.firewall.allowedTCPPorts = [ 9003 ];
}; };
} }

View file

@ -1,7 +1,7 @@
{ pkgs, config, lib, namespace, ... }: { pkgs, config, lib, namespace, ... }:
let let
inherit (builtins) toString; inherit (builtins) toString;
inherit (lib) mkEnableOption mkIf optionals; inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.observability.prometheus; cfg = config.${namespace}.services.observability.prometheus;
in in
@ -13,10 +13,7 @@ in
config = mkIf cfg.enable { config = mkIf cfg.enable {
services.prometheus = { services.prometheus = {
enable = true; enable = true;
port = 9020; port = 9002;
extraFlags = optionals config.${namespace}.services.observability.alloy.enable [
"--web.enable-remote-write-receiver"
];
globalConfig.scrape_interval = "15s"; globalConfig.scrape_interval = "15s";
@ -24,7 +21,7 @@ in
{ {
job_name = "prometheus"; job_name = "prometheus";
static_configs = [ static_configs = [
{ targets = [ "localhost:9020" ]; } { targets = [ "localhost:9002" ]; }
]; ];
} }
@ -34,34 +31,18 @@ in
{ targets = [ "localhost:${toString config.services.prometheus.exporters.node.port}" ]; } { 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 = { exporters = {
node = { node = {
enable = true; enable = true;
port = 9021; port = 9005;
enabledCollectors = [ "systemd" ]; enabledCollectors = [ "systemd" ];
openFirewall = true; openFirewall = true;
}; };
}; };
}; };
networking.firewall.allowedTCPPorts = [ 9020 ]; networking.firewall.allowedTCPPorts = [ 9002 ];
}; };
} }

View file

@ -1,15 +1,11 @@
{ { pkgs, config, lib, namespace, ... }:
pkgs, let
config,
lib,
namespace,
...
}: let
inherit (lib.modules) mkIf; inherit (lib.modules) mkIf;
inherit (lib.options) mkEnableOption; inherit (lib.options) mkEnableOption;
cfg = config.${namespace}.services.observability.promtail; cfg = config.${namespace}.services.observability.promtail;
in { in
{
options.${namespace}.services.observability.promtail = { options.${namespace}.services.observability.promtail = {
enable = mkEnableOption "enable Grafana Promtail"; enable = mkEnableOption "enable Grafana Promtail";
}; };
@ -25,7 +21,7 @@ in {
configuration = { configuration = {
server = { server = {
http_listen_port = 9040; http_listen_port = 9004;
grpc_listen_port = 0; grpc_listen_port = 0;
}; };
@ -35,7 +31,7 @@ in {
clients = [ clients = [
{ {
url = "http://[::1]:9030/loki/api/v1/push"; url = "http://::1:9003/loki/api/v1/push";
} }
]; ];
@ -50,16 +46,13 @@ in {
}; };
}; };
relabel_configs = [ relabel_configs = [
{ { source_labels = [ "__journal__systemd_unit" ]; target_label = "unit"; }
source_labels = ["__journal__systemd_unit"];
target_label = "unit";
}
]; ];
} }
]; ];
}; };
}; };
networking.firewall.allowedTCPPorts = [9040]; networking.firewall.allowedTCPPorts = [ 9004 ];
}; };
} }

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

@ -15,11 +15,11 @@ in
enable = true; enable = true;
settings = { settings = {
PORT = toString 9050; PORT = toString 9006;
HOST = "0.0.0.0"; HOST = "0.0.0.0";
}; };
}; };
networking.firewall.allowedTCPPorts = [ 9050 ]; networking.firewall.allowedTCPPorts = [ 9006 ];
}; };
} }

View file

@ -0,0 +1,21 @@
{ config, pkgs, lib, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.persistance.convex;
in
{
imports = [ ./source.nix ];
options.${namespace}.services.persistance.convex = {
enable = mkEnableOption "enable Convex";
};
config = mkIf cfg.enable {
services.convex = {
enable = true;
package = pkgs.${namespace}.convex;
secret = "ThisIsMyAwesomeSecret";
};
};
}

View file

@ -0,0 +1,149 @@
{ config, pkgs, lib, namespace, ... }:
let
inherit (lib) mkIf mkEnableOption mkPackageOption mkOption optional types;
cfg = config.services.convex;
default_user = "convex";
default_group = "convex";
in
{
options.services.convex = {
enable = mkEnableOption "enable Convex (backend only for now)";
package = mkPackageOption pkgs "convex" {};
name = lib.mkOption {
type = types.str;
default = "convex";
description = ''
Name for the instance.
'';
};
secret = lib.mkOption {
type = types.str;
default = "";
description = ''
Secret for the instance.
'';
};
apiPort = mkOption {
type = types.port;
default = 3210;
description = ''
The TCP port to use for the API.
'';
};
actionsPort = mkOption {
type = types.port;
default = 3211;
description = ''
The TCP port to use for the HTTP actions.
'';
};
dashboardPort = mkOption {
type = types.port;
default = 6791;
description = ''
The TCP port to use for the Dashboard.
'';
};
openFirewall = lib.mkOption {
type = types.bool;
default = false;
description = ''
Whether to open ports in the firewall for the server.
'';
};
user = lib.mkOption {
type = types.str;
default = default_user;
description = ''
As which user to run the service.
'';
};
group = lib.mkOption {
type = types.str;
default = default_group;
description = ''
As which group to run the service.
'';
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.secret != "";
message = ''
No secret provided for convex
'';
}
];
users = {
users.${cfg.user} = {
description = "System user for convex service";
isSystemUser = true;
group = cfg.group;
};
groups.${cfg.group} = {};
};
networking.firewall.allowedTCPPorts = optional cfg.openFirewall [ cfg.apiPort cfg.actionsPort cfg.dashboardPort ];
environment.systemPackages = [ cfg.package ];
systemd.services.convex = {
description = "Convex Backend server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = "${cfg.package}/bin --instance-name ${cfg.name} --instance-secret ${cfg.secret}";
Type = "notify";
User = cfg.user;
Group = cfg.group;
RuntimeDirectory = "convex";
RuntimeDirectoryMode = "0775";
StateDirectory = "convex";
StateDirectoryMode = "0775";
Umask = "0077";
CapabilityBoundingSet = "";
NoNewPrivileges = true;
# Sandboxing
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
PrivateUsers = true;
ProtectClock = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true;
LockPersonality = true;
};
};
};
}

View file

@ -1,19 +1,14 @@
{ { config, lib, pkgs, namespace, ... }:
config, let
lib,
pkgs,
namespace,
...
}: let
inherit (lib) mkIf mkEnableOption; inherit (lib) mkIf mkEnableOption;
cfg = config.${namespace}.services.persistance.postgresql; cfg = config.${namespace}.services.persistance.postgresql;
in { in
{
options.${namespace}.services.persistance.postgresql = { options.${namespace}.services.persistance.postgresql = {
enable = mkEnableOption "Postgresql"; enable = mkEnableOption "Postgresql";
}; };
# Access db with `psql -U postgres`
config = mkIf cfg.enable { config = mkIf cfg.enable {
services = { services = {
postgresql = { postgresql = {

View file

@ -91,22 +91,6 @@ in {
}; };
config = mkIf cfg.enable { 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 = [ systemd.tmpfiles.rules = [
"d '/var/lib/vaultwarden' 0700 vaultwarden vaultwarden - -" "d '/var/lib/vaultwarden' 0700 vaultwarden vaultwarden - -"
]; ];
@ -118,7 +102,7 @@ in {
enable = true; enable = true;
dbBackend = "postgresql"; dbBackend = "postgresql";
package = pkgs.vaultwarden-postgresql; package = pkgs.${namespace}.vaultwarden;
config = { config = {
SIGNUPS_ALLOWED = false; SIGNUPS_ALLOWED = false;
@ -135,7 +119,7 @@ in {
SSO_ROLES_ENABLED = true; SSO_ROLES_ENABLED = true;
SSO_ORGANIZATIONS_ENABLED = true; SSO_ORGANIZATIONS_ENABLED = true;
SSO_ORGANIZATIONS_REVOCATION = true; SSO_ORGANIZATIONS_REVOCATION = true;
SSO_AUTHORITY = "https://auth.kruining.eu"; SSO_AUTHORITY = "https://auth.kruining.eu/";
SSO_SCOPES = "email profile offline_access"; SSO_SCOPES = "email profile offline_access";
ROCKET_ADDRESS = "::1"; ROCKET_ADDRESS = "::1";
@ -166,6 +150,25 @@ in {
} }
]; ];
}; };
caddy = {
enable = true;
virtualHosts = {
"vault.kruining.eu".extraConfig = ''
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}
}
'';
};
};
}; };
sops = { sops = {

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 "🚫"
}

View file

@ -1,44 +0,0 @@
package matrixcmd
import (
"testing"
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
)
func TestFormatDownloadListFallbackCardUsesMonitoredIcon(t *testing.T) {
item := arrclient.ManagedItem{
ID: 1,
Title: "Severance",
Year: 2022,
Monitored: true,
}
fallback := formatDownloadListFallbackCard(item)
if fallback != "👁 Severance (2022)" {
t.Fatalf("unexpected monitored fallback %q", fallback)
}
}
func TestFormatDownloadListFallbackCardUsesUnmonitoredIcon(t *testing.T) {
item := arrclient.ManagedItem{
ID: 7,
Title: "Andor",
Year: 2022,
Monitored: false,
}
fallback := formatDownloadListFallbackCard(item)
if fallback != "🚫 Andor (2022)" {
t.Fatalf("unexpected unmonitored fallback %q", fallback)
}
}
func TestMonitoredIcon(t *testing.T) {
if monitoredIcon(true) != "👁" {
t.Fatalf("expected monitored icon, got %q", monitoredIcon(true))
}
if monitoredIcon(false) != "🚫" {
t.Fatalf("expected unmonitored icon, got %q", monitoredIcon(false))
}
}

View file

@ -1,60 +0,0 @@
package matrixcmd
import (
"fmt"
"sort"
"strings"
)
func NewHelpHandler(proc *Processor) Handler {
return NewHandler(Meta{
Name: "help",
Description: "Show this help message.",
}, func(ctx *Context) {
ctx.Reply(formatHelp(proc, ctx))
})
}
func formatHelp(proc *Processor, ctx *Context) string {
var builder strings.Builder
switch {
case ctx.RoomID == ctx.User.ManagementRoom:
builder.WriteString(fmt.Sprintf("This is your management room: prefixing commands with `%s` is not required.\n", ctx.Bridge.Config.CommandPrefix))
case ctx.Portal != nil:
builder.WriteString(fmt.Sprintf("**This is a portal room**: you must always prefix commands with `%s`. Management commands will not be bridged.\n", ctx.Bridge.Config.CommandPrefix))
default:
builder.WriteString(fmt.Sprintf("This is not your management room: prefixing commands with `%s` is required.\n", ctx.Bridge.Config.CommandPrefix))
}
builder.WriteString("Parameters in [square brackets] are optional, while parameters in <angle brackets> are required.\n\n")
builder.WriteString("#### General\n")
handlers := proc.Handlers()
sort.SliceStable(handlers, func(i, j int) bool {
return handlers[i].Meta().Name < handlers[j].Meta().Name
})
for _, handler := range handlers {
meta := handler.Meta()
builder.WriteString("**")
builder.WriteString(meta.Name)
builder.WriteString("**")
if meta.Usage != "" {
builder.WriteByte(' ')
builder.WriteString(meta.Usage)
}
if meta.Description != "" {
builder.WriteString(" - ")
builder.WriteString(meta.Description)
}
builder.WriteByte('\n')
}
if extra := strings.TrimSpace(ctx.Processor.texts.AdditionalHelp); extra != "" {
builder.WriteByte('\n')
builder.WriteString(extra)
builder.WriteByte('\n')
}
return builder.String()
}

View file

@ -1,46 +0,0 @@
package matrixcmd
import (
"strings"
"testing"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/id"
)
func TestFormatHelpManagementRoom(t *testing.T) {
roomID := id.RoomID("!arrtrix:test")
proc := &Processor{
texts: bridgeconfig.ManagementRoomTexts{AdditionalHelp: "Extra help text."},
command: make(map[string]Handler),
alias: make(map[string]string),
}
proc.Add(NewHelpHandler(proc))
proc.Add(NewDownloadHandler())
proc.Add(NewSubscriptionsHandler())
out := formatHelp(proc, &Context{
Bridge: &bridgev2.Bridge{
Config: &bridgeconfig.BridgeConfig{
CommandPrefix: "!arr",
},
},
RoomID: roomID,
User: &bridgev2.User{User: &database.User{ManagementRoom: roomID}},
Processor: proc,
})
for _, fragment := range []string{
"prefixing commands with `!arr` is not required",
"**download** <list|search|add|monitor|remove> <movies|series> [...] - Manage monitored movies and series in Arr.",
"**help** - Show this help message.",
"**subscriptions** <list|enable|disable> [movies|series] [event-type|all] - Manage notification subscriptions by content type and event type.",
"Extra help text.",
} {
if !strings.Contains(out, fragment) {
t.Fatalf("expected help output to contain %q, got:\n%s", fragment, out)
}
}
}

View file

@ -1,270 +0,0 @@
package matrixcmd
import (
"context"
"fmt"
"runtime/debug"
"sort"
"strings"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arrclient"
"sneeuwvlok/packages/arrtrix/pkg/observability"
)
type Handler interface {
Meta() Meta
Run(*Context)
}
type Meta struct {
Name string
Description string
Usage string
Aliases []string
}
type HandlerFunc struct {
meta Meta
run func(*Context)
}
func NewHandler(meta Meta, run func(*Context)) Handler {
return HandlerFunc{meta: meta, run: run}
}
func (h HandlerFunc) Meta() Meta {
return h.meta
}
func (h HandlerFunc) Run(ctx *Context) {
h.run(ctx)
}
type Processor struct {
bridge *bridgev2.Bridge
bot bridgev2.MatrixAPI
texts bridgeconfig.ManagementRoomTexts
command map[string]Handler
alias map[string]string
order []string
}
type Context struct {
Bridge *bridgev2.Bridge
Bot bridgev2.MatrixAPI
RoomID id.RoomID
OrigRoomID id.RoomID
EventID id.EventID
ReplyTo id.EventID
User *bridgev2.User
Portal *bridgev2.Portal
Command string
Args []string
RawArgs string
Ctx context.Context
Log *zerolog.Logger
Processor *Processor
}
var _ bridgev2.CommandProcessor = (*Processor)(nil)
func NewProcessor(bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomTexts) *Processor {
proc := &Processor{
bridge: bridge,
bot: bridge.Bot,
texts: texts,
command: make(map[string]Handler),
alias: make(map[string]string),
}
proc.Add(NewHelpHandler(proc))
proc.Add(NewDownloadHandler())
proc.Add(NewSubscriptionsHandler())
return proc
}
func (p *Processor) Add(handler Handler) {
meta := handler.Meta()
p.command[meta.Name] = handler
p.order = append(p.order, meta.Name)
for _, alias := range meta.Aliases {
p.alias[alias] = meta.Name
}
}
func (p *Processor) Handlers() []Handler {
names := append([]string(nil), p.order...)
sort.Strings(names)
handlers := make([]Handler, 0, len(names))
for _, name := range names {
handler, ok := p.command[name]
if ok {
handlers = append(handlers, handler)
}
}
return handlers
}
func (p *Processor) Handle(ctx context.Context, roomID id.RoomID, eventID id.EventID, user *bridgev2.User, message string, replyTo id.EventID) {
ctx, span := observability.StartSpan(ctx, "arrtrix.matrix.command")
defer span.End()
ms := &bridgev2.MessageStatus{
Step: status.MsgStepCommand,
Status: event.MessageStatusSuccess,
}
logCopy := zerolog.Ctx(ctx).With().Logger()
log := &logCopy
outcome := "success"
commandName := "unknown-command"
defer func() {
statusInfo := &bridgev2.MessageStatusEventInfo{
RoomID: roomID,
SourceEventID: eventID,
EventType: event.EventMessage,
Sender: user.MXID,
}
if recovered := recover(); recovered != nil {
logEvt := log.Error().Bytes(zerolog.ErrorStackFieldName, debug.Stack())
if err, ok := recovered.(error); ok {
logEvt = logEvt.Err(err)
ms.InternalError = err
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else {
logEvt = logEvt.Any(zerolog.ErrorFieldName, recovered)
ms.InternalError = fmt.Errorf("%v", recovered)
span.SetStatus(codes.Error, "panic")
}
logEvt.Msg("Panic in arrtrix Matrix command handler")
ms.Status = event.MessageStatusFail
ms.IsCertain = true
ms.ErrorAsMessage = true
outcome = "panic"
}
observability.RecordCommand(ctx, commandName, outcome)
p.bridge.Matrix.SendMessageStatus(ctx, ms, statusInfo)
}()
args := strings.Fields(message)
if len(args) == 0 {
args = []string{"unknown-command"}
}
commandName = strings.ToLower(args[0])
if actual, ok := p.alias[commandName]; ok {
commandName = actual
}
span.SetAttributes(
attribute.String("arrtrix.matrix.command.name", commandName),
attribute.String("matrix.room_id", roomID.String()),
)
portal, err := p.bridge.GetPortalByMXID(ctx, roomID)
if err != nil {
log.Err(err).Msg("Failed to get portal")
}
commandCtx := &Context{
Bridge: p.bridge,
Bot: p.bot,
RoomID: roomID,
OrigRoomID: roomID,
EventID: eventID,
ReplyTo: replyTo,
User: user,
Portal: portal,
Command: commandName,
Args: args[1:],
RawArgs: strings.TrimSpace(strings.TrimPrefix(message, args[0])),
Ctx: ctx,
Log: log,
Processor: p,
}
handler, ok := p.command[commandName]
if !ok {
log.Debug().Str("mx_command", commandName).Msg("Received unknown Matrix room command")
span.SetStatus(codes.Error, "unknown command")
outcome = "unknown"
commandCtx.Reply("Unknown command, use the `help` command for help.")
return
}
log.UpdateContext(func(c zerolog.Context) zerolog.Context {
return c.Str("mx_command", commandName)
})
log.Debug().Msg("Received Matrix room command")
handler.Run(commandCtx)
span.SetStatus(codes.Ok, "")
}
func (c *Context) Reply(message string, args ...any) {
message = strings.ReplaceAll(message, "$cmdprefix ", c.Bridge.Config.CommandPrefix+" ")
if len(args) > 0 {
message = fmt.Sprintf(message, args...)
}
content := format.RenderMarkdown(message, true, false)
content.MsgType = event.MsgNotice
if err := c.sendNotice(&content); err != nil {
c.Log.Err(err).Msg("Failed to reply to Matrix room command")
}
}
func (c *Context) ReplyFormatted(body, formattedBody string) {
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: body,
Format: event.FormatHTML,
FormattedBody: formattedBody,
}
if err := c.sendNotice(content); err != nil {
c.Log.Err(err).Msg("Failed to reply to Matrix room command")
}
}
func (c *Context) SendImage(asset *arrclient.MediaAsset, body string) error {
if asset == nil || len(asset.Data) == 0 {
return nil
}
mxcURL, file, err := c.Bot.UploadMedia(c.Ctx, c.OrigRoomID, asset.Data, asset.FileName, asset.MimeType)
if err != nil {
return err
}
content := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: body,
FileName: asset.FileName,
URL: mxcURL,
File: file,
Info: &event.FileInfo{
MimeType: asset.MimeType,
Size: len(asset.Data),
},
}
_, err = c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: content}, nil)
return err
}
func (c *Context) sendNotice(content *event.MessageEventContent) error {
_, err := c.Bot.SendMessage(c.Ctx, c.OrigRoomID, event.EventMessage, &event.Content{Parsed: content}, nil)
return err
}

View file

@ -1,107 +0,0 @@
package matrixcmd
import (
"context"
"fmt"
"strings"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
)
func NewSubscriptionsHandler() Handler {
return NewHandler(Meta{
Name: "subscriptions",
Aliases: []string{"subscription", "notify"},
Description: "Manage notification subscriptions by content type and event type.",
Usage: "<list|enable|disable> [movies|series] [event-type|all]",
}, func(ctx *Context) {
repo := contentSubscriptions(ctx)
if repo == nil {
ctx.Reply("Subscription storage is not available.")
return
}
if len(ctx.Args) == 0 || strings.EqualFold(ctx.Args[0], "list") {
handleSubscriptionList(ctx, repo)
return
}
if len(ctx.Args) < 3 {
ctx.Reply("Usage: `subscriptions <enable|disable> <movies|series> <event-type|all>`")
return
}
contentType, err := arr.ParseContentType(ctx.Args[1])
if err != nil {
ctx.Reply(err.Error())
return
}
eventType, err := arr.ParseEventType(contentType, ctx.Args[2])
if err != nil {
ctx.Reply(err.Error())
return
}
switch strings.ToLower(ctx.Args[0]) {
case "enable":
handleSubscriptionSet(ctx, repo, contentType, eventType, true)
case "disable":
handleSubscriptionSet(ctx, repo, contentType, eventType, false)
default:
ctx.Reply("Unknown subscriptions subcommand `%s`.", ctx.Args[0])
}
})
}
func handleSubscriptionList(ctx *Context, repo subscriptionRepo) {
preferences, err := repo.List(ctx.Ctx, ctx.User.MXID)
if err != nil {
ctx.Reply("Failed to load subscriptions: %v", err)
return
}
var builder strings.Builder
builder.WriteString("Current notification subscriptions:\n")
for _, contentType := range arr.SupportedContentTypes() {
builder.WriteString(fmt.Sprintf("\n**%s**\n", strings.Title(contentType.Label())))
for _, eventType := range arr.SupportedEventTypes(contentType) {
enabled := findPreference(preferences, contentType, eventType)
builder.WriteString(fmt.Sprintf("- `%s`: %t\n", eventType, enabled))
}
}
ctx.Reply(builder.String())
}
func handleSubscriptionSet(ctx *Context, repo subscriptionRepo, contentType arr.ContentType, eventType string, enabled bool) {
var err error
if eventType == "all" {
err = repo.SetAll(ctx.Ctx, ctx.User.MXID, contentType, enabled)
} else {
err = repo.Set(ctx.Ctx, ctx.User.MXID, contentType, eventType, enabled)
}
if err != nil {
ctx.Reply("Failed to update subscriptions: %v", err)
return
}
if eventType == "all" {
ctx.Reply("Set all `%s` notifications for %s to %t.", contentType.Label(), userIDString(ctx.User.MXID), enabled)
return
}
ctx.Reply("Set `%s/%s` notifications to %t.", contentType.Label(), eventType, enabled)
}
type subscriptionRepo interface {
List(ctx context.Context, userID id.UserID) ([]subscriptions.Preference, error)
Set(ctx context.Context, userID id.UserID, contentType arr.ContentType, eventType string, enabled bool) error
SetAll(ctx context.Context, userID id.UserID, contentType arr.ContentType, enabled bool) error
}
func findPreference(preferences []subscriptions.Preference, contentType arr.ContentType, eventType string) bool {
for _, preference := range preferences {
if preference.ContentType == contentType && preference.EventType == eventType {
return preference.Enabled
}
}
return true
}

View file

@ -1,22 +0,0 @@
package observability
import "strings"
type Config struct {
OTLPGRPCEndpoint string `yaml:"otlp_grpc_endpoint"`
ServiceName string `yaml:"service_name"`
ResourceAttributes map[string]string `yaml:"resource_attributes"`
}
func (c *Config) ApplyDefaults() {
if c.ServiceName == "" {
c.ServiceName = "arrtrix"
}
if c.ResourceAttributes == nil {
c.ResourceAttributes = map[string]string{}
}
}
func (c Config) Enabled() bool {
return strings.TrimSpace(c.OTLPGRPCEndpoint) != ""
}

View file

@ -1,397 +0,0 @@
package observability
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"time"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
otellog "go.opentelemetry.io/otel/log"
logglobal "go.opentelemetry.io/otel/log/global"
otelmetric "go.opentelemetry.io/otel/metric"
sdklog "go.opentelemetry.io/otel/sdk/log"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)
const (
instrumentationScope = "sneeuwvlok/packages/arrtrix"
logScope = instrumentationScope + "/logs"
)
type Runtime struct {
traceProvider *sdktrace.TracerProvider
meterProvider *sdkmetric.MeterProvider
logProvider *sdklog.LoggerProvider
logHook zerolog.Hook
}
type exporterEndpoint struct {
raw string
insecure bool
}
type instruments struct {
webhookRequests otelCounter
webhookLatency otelHistogram
commandInvocations otelCounter
inviteEvents otelCounter
startupDuration otelHistogram
}
type otelCounter interface {
Add(context.Context, int64, ...otelmetric.AddOption)
}
type otelHistogram interface {
Record(context.Context, float64, ...otelmetric.RecordOption)
}
var (
mu sync.RWMutex
current instruments
tracer = otel.Tracer(instrumentationScope)
currentReady bool
)
func Setup(ctx context.Context, cfg Config, version string) (*Runtime, error) {
cfg.ApplyDefaults()
if !cfg.Enabled() {
resetInstruments()
return &Runtime{}, nil
}
res, err := buildResource(cfg, version)
if err != nil {
return nil, err
}
endpoint, err := parseEndpoint(cfg.OTLPGRPCEndpoint)
if err != nil {
return nil, err
}
traceExporter, err := otlptracegrpc.New(ctx, traceOptions(endpoint)...)
if err != nil {
return nil, fmt.Errorf("create trace exporter: %w", err)
}
metricExporter, err := otlpmetricgrpc.New(ctx, metricOptions(endpoint)...)
if err != nil {
return nil, fmt.Errorf("create metric exporter: %w", err)
}
logExporter, err := otlploggrpc.New(ctx, logOptions(endpoint)...)
if err != nil {
return nil, fmt.Errorf("create log exporter: %w", err)
}
traceProvider := sdktrace.NewTracerProvider(
sdktrace.WithResource(res),
sdktrace.WithBatcher(traceExporter),
)
meterProvider := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter, sdkmetric.WithInterval(30*time.Second))),
)
logProvider := sdklog.NewLoggerProvider(
sdklog.WithResource(res),
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExporter)),
)
otel.SetTracerProvider(traceProvider)
otel.SetMeterProvider(meterProvider)
logglobal.SetLoggerProvider(logProvider)
if err = setInstruments(meterProvider); err != nil {
_ = traceProvider.Shutdown(ctx)
_ = meterProvider.Shutdown(ctx)
_ = logProvider.Shutdown(ctx)
return nil, err
}
tracer = otel.Tracer(instrumentationScope)
return &Runtime{
traceProvider: traceProvider,
meterProvider: meterProvider,
logProvider: logProvider,
logHook: newLogHook(logglobal.Logger(logScope)),
}, nil
}
func (r *Runtime) Enabled() bool {
return r != nil && r.traceProvider != nil
}
func (r *Runtime) LoggerHook() zerolog.Hook {
if r == nil {
return nil
}
return r.logHook
}
func (r *Runtime) Shutdown(ctx context.Context) error {
if r == nil || !r.Enabled() {
resetInstruments()
return nil
}
var errs []error
if err := r.logProvider.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("shutdown log provider: %w", err))
}
if err := r.meterProvider.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("shutdown meter provider: %w", err))
}
if err := r.traceProvider.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("shutdown trace provider: %w", err))
}
resetInstruments()
return errors.Join(errs...)
}
func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
return tracer.Start(ctx, name, opts...)
}
func RecordWebhook(ctx context.Context, eventType, outcome string, statusCode int, duration time.Duration) {
mu.RLock()
inst := current
ready := currentReady
mu.RUnlock()
if !ready {
return
}
attrs := otelmetric.WithAttributes(
attribute.String("event_type", eventType),
attribute.String("outcome", outcome),
attribute.Int("http.status_code", statusCode),
)
inst.webhookRequests.Add(ctx, 1, attrs)
inst.webhookLatency.Record(ctx, duration.Seconds(), attrs)
}
func RecordCommand(ctx context.Context, name, outcome string) {
mu.RLock()
inst := current
ready := currentReady
mu.RUnlock()
if !ready {
return
}
inst.commandInvocations.Add(ctx, 1, otelmetric.WithAttributes(
attribute.String("command", name),
attribute.String("outcome", outcome),
))
}
func RecordInvite(ctx context.Context, outcome string) {
mu.RLock()
inst := current
ready := currentReady
mu.RUnlock()
if !ready {
return
}
inst.inviteEvents.Add(ctx, 1, otelmetric.WithAttributes(attribute.String("outcome", outcome)))
}
func RecordStartupPhase(ctx context.Context, phase, outcome string, duration time.Duration) {
mu.RLock()
inst := current
ready := currentReady
mu.RUnlock()
if !ready {
return
}
inst.startupDuration.Record(ctx, duration.Seconds(), otelmetric.WithAttributes(
attribute.String("phase", phase),
attribute.String("outcome", outcome),
))
}
func parseEndpoint(raw string) (exporterEndpoint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return exporterEndpoint{}, errors.New("observability.otlp_grpc_endpoint must not be empty when observability is enabled")
}
if strings.Contains(raw, "://") {
u, err := url.Parse(raw)
if err != nil {
return exporterEndpoint{}, fmt.Errorf("parse observability.otlp_grpc_endpoint: %w", err)
}
if u.Scheme == "" || u.Host == "" {
return exporterEndpoint{}, fmt.Errorf("invalid observability.otlp_grpc_endpoint %q", raw)
}
return exporterEndpoint{raw: raw, insecure: u.Scheme == "http"}, nil
}
return exporterEndpoint{raw: "http://" + raw, insecure: true}, nil
}
func buildResource(cfg Config, version string) (*resource.Resource, error) {
attrs := []attribute.KeyValue{
attribute.String("service.name", cfg.ServiceName),
}
if version != "" {
attrs = append(attrs, attribute.String("service.version", version))
}
for key, value := range cfg.ResourceAttributes {
attrs = append(attrs, attribute.String(key, value))
}
return resource.Merge(resource.Default(), resource.NewWithAttributes("", attrs...))
}
func setInstruments(provider *sdkmetric.MeterProvider) error {
meter := provider.Meter(instrumentationScope)
webhookRequests, err := meter.Int64Counter(
"arrtrix.webhook.requests",
otelmetric.WithDescription("Number of Arr webhook requests handled by arrtrix."),
)
if err != nil {
return fmt.Errorf("create webhook request counter: %w", err)
}
webhookLatency, err := meter.Float64Histogram(
"arrtrix.webhook.duration.seconds",
otelmetric.WithDescription("Duration of Arr webhook request handling."),
otelmetric.WithUnit("s"),
)
if err != nil {
return fmt.Errorf("create webhook duration histogram: %w", err)
}
commandInvocations, err := meter.Int64Counter(
"arrtrix.matrix.commands",
otelmetric.WithDescription("Number of Matrix management-room commands handled by arrtrix."),
)
if err != nil {
return fmt.Errorf("create command counter: %w", err)
}
inviteEvents, err := meter.Int64Counter(
"arrtrix.matrix.invites",
otelmetric.WithDescription("Number of management-room invite flows observed by arrtrix."),
)
if err != nil {
return fmt.Errorf("create invite counter: %w", err)
}
startupDuration, err := meter.Float64Histogram(
"arrtrix.runtime.phase.duration.seconds",
otelmetric.WithDescription("Duration of arrtrix runtime startup and shutdown phases."),
otelmetric.WithUnit("s"),
)
if err != nil {
return fmt.Errorf("create runtime duration histogram: %w", err)
}
mu.Lock()
current = instruments{
webhookRequests: webhookRequests,
webhookLatency: webhookLatency,
commandInvocations: commandInvocations,
inviteEvents: inviteEvents,
startupDuration: startupDuration,
}
currentReady = true
mu.Unlock()
return nil
}
func resetInstruments() {
mu.Lock()
current = instruments{}
currentReady = false
mu.Unlock()
}
func traceOptions(endpoint exporterEndpoint) []otlptracegrpc.Option {
opts := []otlptracegrpc.Option{otlptracegrpc.WithEndpointURL(endpoint.raw)}
if endpoint.insecure {
opts = append(opts, otlptracegrpc.WithInsecure())
}
return opts
}
func metricOptions(endpoint exporterEndpoint) []otlpmetricgrpc.Option {
opts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpointURL(endpoint.raw)}
if endpoint.insecure {
opts = append(opts, otlpmetricgrpc.WithInsecure())
}
return opts
}
func logOptions(endpoint exporterEndpoint) []otlploggrpc.Option {
opts := []otlploggrpc.Option{otlploggrpc.WithEndpointURL(endpoint.raw)}
if endpoint.insecure {
opts = append(opts, otlploggrpc.WithInsecure())
}
return opts
}
type otelLogHook struct {
logger otellog.Logger
}
func newLogHook(logger otellog.Logger) zerolog.Hook {
return otelLogHook{logger: logger}
}
func (h otelLogHook) Run(e *zerolog.Event, level zerolog.Level, message string) {
if h.logger == nil {
return
}
ctx := e.GetCtx()
if ctx == nil {
ctx = context.Background()
}
severity := mapSeverity(level)
if !h.logger.Enabled(ctx, otellog.EnabledParameters{Severity: severity}) {
return
}
now := time.Now()
record := otellog.Record{}
record.SetTimestamp(now)
record.SetObservedTimestamp(now)
record.SetSeverity(severity)
record.SetSeverityText(strings.ToUpper(level.String()))
record.SetBody(otellog.StringValue(message))
record.AddAttributes(otellog.String("log.scope", logScope))
if spanCtx := trace.SpanContextFromContext(ctx); spanCtx.IsValid() {
record.AddAttributes(
otellog.String("trace_id", spanCtx.TraceID().String()),
otellog.String("span_id", spanCtx.SpanID().String()),
)
}
h.logger.Emit(ctx, record)
}
func mapSeverity(level zerolog.Level) otellog.Severity {
switch level {
case zerolog.TraceLevel:
return otellog.SeverityTrace
case zerolog.DebugLevel:
return otellog.SeverityDebug
case zerolog.InfoLevel:
return otellog.SeverityInfo
case zerolog.WarnLevel:
return otellog.SeverityWarn
case zerolog.ErrorLevel:
return otellog.SeverityError
case zerolog.FatalLevel:
return otellog.SeverityFatal
case zerolog.PanicLevel:
return otellog.SeverityFatal4
default:
return otellog.SeverityUndefined
}
}

View file

@ -1,54 +0,0 @@
package observability
import "testing"
func TestConfigDefaults(t *testing.T) {
var cfg Config
cfg.ApplyDefaults()
if cfg.ServiceName != "arrtrix" {
t.Fatalf("expected default service name arrtrix, got %q", cfg.ServiceName)
}
if cfg.ResourceAttributes == nil {
t.Fatal("expected resource attributes map to be initialized")
}
if cfg.Enabled() {
t.Fatal("expected observability to be disabled by default")
}
}
func TestParseEndpointSupportsURLAndBareHost(t *testing.T) {
tests := []struct {
name string
input string
wantRaw string
insecure bool
wantError bool
}{
{name: "https url", input: "https://otel.example:4317", wantRaw: "https://otel.example:4317"},
{name: "http url", input: "http://127.0.0.1:4317", wantRaw: "http://127.0.0.1:4317", insecure: true},
{name: "bare host", input: "collector:4317", wantRaw: "http://collector:4317", insecure: true},
{name: "invalid", input: "://bad", wantError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseEndpoint(tt.input)
if tt.wantError {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("parseEndpoint returned error: %v", err)
}
if got.raw != tt.wantRaw {
t.Fatalf("expected raw endpoint %q, got %q", tt.wantRaw, got.raw)
}
if got.insecure != tt.insecure {
t.Fatalf("expected insecure=%t, got %t", tt.insecure, got.insecure)
}
})
}
}

View file

@ -1,171 +0,0 @@
package onboarding
import (
"context"
"fmt"
"strings"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/observability"
)
const handledInviteEventType = "com.arrtrix.handled_invite"
func HandleBotInvite(ctx context.Context, bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomTexts, evt *event.Event) {
if evt.Type != event.StateMember ||
evt.GetStateKey() != bridge.Bot.GetMXID().String() ||
evt.Content.AsMember().Membership != event.MembershipInvite {
return
}
ctx, span := observability.StartSpan(ctx, "arrtrix.matrix.invite")
defer span.End()
span.SetAttributes(
attribute.String("matrix.room_id", evt.RoomID.String()),
attribute.String("matrix.sender", evt.Sender.String()),
)
outcome := "ignored"
defer observability.RecordInvite(ctx, outcome)
log := zerolog.Ctx(ctx)
sender, err := bridge.GetUserByMXID(ctx, evt.Sender)
if err != nil {
outcome = "user_lookup_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Msg("Failed to load sender for bot invite")
return
}
if !sender.Permissions.Commands {
outcome = "permission_denied"
span.SetStatus(codes.Error, "sender lacks command permission")
return
}
if err = bridge.Bot.EnsureJoined(ctx, evt.RoomID); err != nil {
outcome = "join_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Msg("Failed to accept invite to room")
return
}
members, err := bridge.Matrix.GetMembers(ctx, evt.RoomID)
if err != nil {
outcome = "member_lookup_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Msg("Failed to get members of room after accepting invite")
return
}
if len(members) != 2 {
outcome = "non_management_room"
span.SetStatus(codes.Error, "invite room is not a direct management room")
return
}
assignedManagementRoom := sender.ManagementRoom == ""
if assignedManagementRoom {
sender.ManagementRoom = evt.RoomID
if err = sender.Save(ctx); err != nil {
outcome = "management_room_save_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Msg("Failed to update user's management room in database")
return
}
}
message := buildWelcomeMessage(bridge, texts, sender, assignedManagementRoom)
content := format.RenderMarkdown(message, true, false)
if _, err = bridge.Bot.SendMessage(ctx, evt.RoomID, event.EventMessage, &event.Content{Parsed: &content}, nil); err != nil {
outcome = "welcome_send_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
log.Err(err).Msg("Failed to send welcome message to room")
return
}
outcome = "welcomed"
span.SetStatus(codes.Ok, "")
evt.Type = event.Type{Type: handledInviteEventType}
}
func buildWelcomeMessage(bridge *bridgev2.Bridge, texts bridgeconfig.ManagementRoomTexts, sender *bridgev2.User, assignedManagementRoom bool) string {
return composeWelcomeMessage(
bridge.Network.GetName().DisplayName,
bridge.Config.CommandPrefix,
bridge.Bot.GetMXID(),
texts,
sender.GetDefaultLogin() != nil,
assignedManagementRoom,
)
}
func composeWelcomeMessage(
bridgeName string,
commandPrefix string,
botMXID id.UserID,
texts bridgeconfig.ManagementRoomTexts,
connected bool,
assignedManagementRoom bool,
) string {
replacer := strings.NewReplacer(
"$cmdprefix", commandPrefix,
"$bridge", bridgeName,
"$bot", string(botMXID),
)
var parts []string
base := strings.TrimSpace(texts.Welcome)
if base == "" {
base = fmt.Sprintf("Hello, I'm the %s bot.", bridgeName)
}
parts = append(parts, replacer.Replace(base))
if assignedManagementRoom {
parts = append(parts, "This room has been marked as your management room.")
} else {
parts = append(parts, fmt.Sprintf("Use `%s help` to see available commands in this room.", commandPrefix))
}
if connected {
connected := strings.TrimSpace(texts.WelcomeConnected)
if connected == "" {
connected = "You're connected. Use `help` to see the commands available right now."
}
parts = append(parts, replacer.Replace(connected))
} else {
unconnected := strings.TrimSpace(texts.WelcomeUnconnected)
if unconnected == "" {
unconnected = "Use `help` to see the commands available right now."
}
parts = append(parts, replacer.Replace(unconnected))
}
if extra := strings.TrimSpace(texts.AdditionalHelp); extra != "" {
parts = append(parts, replacer.Replace(extra))
}
return strings.Join(parts, "\n\n")
}
func IsHandledInviteEvent(evt *event.Event) bool {
return evt.Type.Type == handledInviteEventType
}
func IsBotInviteFor(roomBot id.UserID, evt *event.Event) bool {
return evt.Type == event.StateMember &&
evt.GetStateKey() == roomBot.String() &&
evt.Content.AsMember().Membership == event.MembershipInvite
}

View file

@ -1,56 +0,0 @@
package onboarding
import (
"strings"
"testing"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/id"
)
func TestComposeWelcomeMessageDefaults(t *testing.T) {
out := composeWelcomeMessage(
"Arrtrix",
"!arr",
id.UserID("@arrtrixbot:test"),
bridgeconfig.ManagementRoomTexts{},
false,
true,
)
for _, fragment := range []string{
"Hello, I'm the Arrtrix bot.",
"This room has been marked as your management room.",
"Use `help` to see the commands available right now.",
} {
if !strings.Contains(out, fragment) {
t.Fatalf("expected welcome output to contain %q, got:\n%s", fragment, out)
}
}
}
func TestComposeWelcomeMessageTemplateValues(t *testing.T) {
out := composeWelcomeMessage(
"Arrtrix",
"!arr",
id.UserID("@arrtrixbot:test"),
bridgeconfig.ManagementRoomTexts{
Welcome: "Welcome to $bridge.",
WelcomeConnected: "Talk to $bot with $cmdprefix help.",
AdditionalHelp: "Custom footer for $bridge.",
},
true,
false,
)
for _, fragment := range []string{
"Welcome to Arrtrix.",
"Use `!arr help` to see available commands in this room.",
"Talk to @arrtrixbot:test with !arr help.",
"Custom footer for Arrtrix.",
} {
if !strings.Contains(out, fragment) {
t.Fatalf("expected templated welcome output to contain %q, got:\n%s", fragment, out)
}
}
}

View file

@ -1,256 +0,0 @@
package runtime
import (
"fmt"
"os"
"reflect"
"strconv"
"strings"
)
const fileEnvPrefix = "READFILE:"
func updateConfigFromEnv(cfg, networkData any, prefix string) error {
if prefix == "" {
return nil
}
cfgVal := reflect.ValueOf(cfg)
networkVal := reflect.ValueOf(networkData)
for _, env := range os.Environ() {
if !strings.HasPrefix(env, prefix) {
continue
}
keyValue := strings.SplitN(env, "=", 2)
if len(keyValue) != 2 {
continue
}
key := strings.TrimPrefix(keyValue[0], prefix)
value := keyValue[1]
if strings.HasSuffix(key, "_FILE") {
key = strings.TrimSuffix(key, "_FILE")
value = fileEnvPrefix + value
}
key = strings.ToLower(key)
lookupKey := key
if !strings.ContainsRune(key, '.') {
key = strings.ReplaceAll(key, "__", ".")
}
path := strings.Split(key, ".")
field, ok := reflectGetFromMainOrNetwork(cfgVal, networkVal, path)
if !ok && !strings.ContainsRune(lookupKey, '.') {
field, ok = reflectGetFromMainOrNetworkTokens(cfgVal, networkVal, strings.Split(lookupKey, "_"))
}
if !ok {
return fmt.Errorf("%s not found", formatKey(path))
}
if strings.HasPrefix(value, fileEnvPrefix) {
filePath := strings.TrimPrefix(value, fileEnvPrefix)
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file %s for %s: %w", filePath, formatKey(path), err)
}
value = strings.TrimSpace(string(fileData))
}
if err := setReflectedValue(field, path, value); err != nil {
return err
}
}
return nil
}
type reflectedField struct {
value reflect.Value
valueKind reflect.Kind
remainingPath []string
}
func formatKey(path []string) string {
return strings.Join(path, "->")
}
func reflectGetFromMainOrNetwork(main, network reflect.Value, path []string) (*reflectedField, bool) {
if len(path) > 0 && path[0] == "network" {
return reflectGetYAML(network, path[1:])
}
return reflectGetYAML(main, path)
}
func reflectGetFromMainOrNetworkTokens(main, network reflect.Value, tokens []string) (*reflectedField, bool) {
if len(tokens) > 0 && normalizeKey(tokens[0]) == "network" {
return reflectGetYAMLTokens(network, tokens[1:])
}
return reflectGetYAMLTokens(main, tokens)
}
func reflectGetYAML(value reflect.Value, path []string) (*reflectedField, bool) {
if len(path) == 0 {
return &reflectedField{value: value, valueKind: value.Kind()}, true
}
if value.Kind() == reflect.Ptr {
value = value.Elem()
}
switch value.Kind() {
case reflect.Map:
return &reflectedField{
value: value,
valueKind: value.Type().Elem().Kind(),
remainingPath: path,
}, true
case reflect.Struct:
fields := reflect.VisibleFields(value.Type())
for _, field := range fields {
if yamlFieldName(field) != path[0] {
continue
}
return reflectGetYAML(value.FieldByIndex(field.Index), path[1:])
}
}
return nil, false
}
func reflectGetYAMLTokens(value reflect.Value, tokens []string) (*reflectedField, bool) {
if len(tokens) == 0 {
return &reflectedField{value: value, valueKind: value.Kind()}, true
}
if value.Kind() == reflect.Ptr {
value = value.Elem()
}
switch value.Kind() {
case reflect.Map:
return &reflectedField{
value: value,
valueKind: value.Type().Elem().Kind(),
remainingPath: []string{strings.Join(tokens, "_")},
}, true
case reflect.Struct:
fields := reflect.VisibleFields(value.Type())
for _, field := range fields {
name := yamlFieldName(field)
if name == "" {
continue
}
normalizedFieldName := normalizeKey(name)
for i := len(tokens); i >= 1; i-- {
if normalizeKey(strings.Join(tokens[:i], "_")) != normalizedFieldName {
continue
}
return reflectGetYAMLTokens(value.FieldByIndex(field.Index), tokens[i:])
}
}
}
return nil, false
}
func yamlFieldName(field reflect.StructField) string {
parts := strings.SplitN(field.Tag.Get("yaml"), ",", 2)
switch name := parts[0]; {
case name == "-" && len(parts) == 1:
return ""
case name == "":
return strings.ToLower(field.Name)
default:
return name
}
}
func normalizeKey(value string) string {
return strings.ReplaceAll(strings.ToLower(value), "_", "")
}
func setReflectedValue(field *reflectedField, path []string, raw string) error {
parsed, err := parseValue(field.valueKind, raw, path)
if err != nil {
return err
}
value := field.value
if value.Kind() == reflect.Ptr {
if value.IsNil() {
value.Set(reflect.New(value.Type().Elem()))
}
value = value.Elem()
}
if value.Kind() == reflect.Map {
if value.Type().Key().Kind() != reflect.String {
return fmt.Errorf("unsupported map key type %s in %s", value.Type().Key().Kind(), formatKey(path))
}
key := strings.Join(field.remainingPath, ".")
value.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(parsed))
return nil
}
value.Set(reflect.ValueOf(parsed))
return nil
}
func parseValue(kind reflect.Kind, raw string, path []string) (any, error) {
switch kind {
case reflect.String:
return raw, nil
case reflect.Bool:
parsed, err := strconv.ParseBool(raw)
if err != nil {
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
}
return parsed, nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
}
switch kind {
case reflect.Int8:
return int8(parsed), nil
case reflect.Int16:
return int16(parsed), nil
case reflect.Int32:
return int32(parsed), nil
case reflect.Int64:
return parsed, nil
default:
return int(parsed), nil
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
parsed, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
}
switch kind {
case reflect.Uint8:
return uint8(parsed), nil
case reflect.Uint16:
return uint16(parsed), nil
case reflect.Uint32:
return uint32(parsed), nil
case reflect.Uint64:
return parsed, nil
default:
return uint(parsed), nil
}
case reflect.Float32, reflect.Float64:
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
return nil, fmt.Errorf("invalid value for %s: %w", formatKey(path), err)
}
if kind == reflect.Float32 {
return float32(parsed), nil
}
return parsed, nil
default:
return nil, fmt.Errorf("unsupported type %s in %s", kind, formatKey(path))
}
}

View file

@ -1,57 +0,0 @@
package runtime
import (
"os"
"testing"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"sneeuwvlok/packages/arrtrix/pkg/connector"
)
func TestUpdateConfigFromEnvSupportsFlatUnderscorePaths(t *testing.T) {
t.Setenv("ARRTRIX_NETWORK_CONTENT_MOVIES_APIKEY", "radarr-secret")
cfg := &bridgeconfig.Config{}
network := &connector.Config{}
if err := updateConfigFromEnv(cfg, network, "ARRTRIX_"); err != nil {
t.Fatalf("updateConfigFromEnv returned error: %v", err)
}
if network.Content.Movies.APIKey != "radarr-secret" {
t.Fatalf("expected movies api key to be overridden, got %q", network.Content.Movies.APIKey)
}
}
func TestUpdateConfigFromEnvSupportsExplicitUnderscoredFieldNames(t *testing.T) {
t.Setenv("ARRTRIX_NETWORK_CONTENT_MOVIES_ROOT_FOLDER_PATH", "/data/movies")
cfg := &bridgeconfig.Config{}
network := &connector.Config{}
if err := updateConfigFromEnv(cfg, network, "ARRTRIX_"); err != nil {
t.Fatalf("updateConfigFromEnv returned error: %v", err)
}
if network.Content.Movies.RootFolderPath != "/data/movies" {
t.Fatalf("expected root folder path to be overridden, got %q", network.Content.Movies.RootFolderPath)
}
}
func TestUpdateConfigFromEnvSupportsDoubleUnderscorePaths(t *testing.T) {
t.Setenv("ARRTRIX_NETWORK__CONTENT__SERIES__API_KEY", "sonarr-secret")
cfg := &bridgeconfig.Config{}
network := &connector.Config{}
if err := updateConfigFromEnv(cfg, network, "ARRTRIX_"); err != nil {
t.Fatalf("updateConfigFromEnv returned error: %v", err)
}
if network.Content.Series.APIKey != "sonarr-secret" {
t.Fatalf("expected series api key to be overridden, got %q", network.Content.Series.APIKey)
}
}
func TestMain(m *testing.M) {
code := m.Run()
os.Exit(code)
}

View file

@ -1,76 +0,0 @@
package runtime
import (
"fmt"
"strings"
"maunium.net/go/mautrix/bridgev2"
)
func makeExampleConfig(networkName bridgev2.BridgeName, networkExample string) string {
var builder strings.Builder
builder.WriteString("# Network-specific config options\n")
builder.WriteString("network:\n")
for _, line := range strings.Split(strings.TrimRight(networkExample, "\n"), "\n") {
if line == "" {
builder.WriteString(" \n")
continue
}
builder.WriteString(" ")
builder.WriteString(line)
builder.WriteByte('\n')
}
builder.WriteByte('\n')
builder.WriteString(fmt.Sprintf(`bridge:
command_prefix: "%s"
permissions:
"*": relay
"@admin:example.com": admin
database:
type: sqlite3-fk-wal
uri: file:arrtrix.db?_txlock=immediate
homeserver:
address: http://example.localhost:8008
domain: example.com
software: standard
appservice:
address: http://localhost:%d
hostname: 127.0.0.1
port: %d
id: %s
bot:
username: %s
displayname: %s
as_token: This value is generated when generating the registration
hs_token: This value is generated when generating the registration
username_template: %s_{{.}}
logging:
min_level: info
writers:
- type: stdout
format: pretty-colored
observability:
# OTLP/gRPC endpoint for logs, traces, and metrics.
# Set to e.g. http://127.0.0.1:4317 to enable export.
otlp_grpc_endpoint: ""
service_name: arrtrix
resource_attributes: {}
management_room_texts:
welcome: ""
welcome_connected: ""
welcome_unconnected: ""
additional_help: ""
env_config_prefix: ""
`, networkName.DefaultCommandPrefix, networkName.DefaultPort, networkName.DefaultPort, networkName.NetworkID, "arrtrixbot", "Arrtrix Bot", networkName.NetworkID))
return builder.String()
}

View file

@ -1,486 +0,0 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"runtime"
"strings"
"syscall"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"go.mau.fi/util/exerrors"
"go.mau.fi/util/exzerolog"
"go.mau.fi/util/progver"
"go.opentelemetry.io/otel/codes"
"gopkg.in/yaml.v3"
flag "maunium.net/go/mauflag"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/commands"
"maunium.net/go/mautrix/bridgev2/matrix"
"maunium.net/go/mautrix/event"
arrconfig "sneeuwvlok/packages/arrtrix/pkg/config"
"sneeuwvlok/packages/arrtrix/pkg/matrixcmd"
"sneeuwvlok/packages/arrtrix/pkg/observability"
"sneeuwvlok/packages/arrtrix/pkg/onboarding"
"sneeuwvlok/packages/arrtrix/pkg/subscriptions"
)
var configPath = flag.MakeFull("c", "config", "The path to your config file.", "config.yaml").String()
var writeExampleConfig = flag.MakeFull("e", "generate-example-config", "Save the example config to the config path and quit.", "false").Bool()
var dontSaveConfig = flag.MakeFull("n", "no-update", "Don't save updated config to disk.", "false").Bool()
var registrationPath = flag.MakeFull("r", "registration", "The path where to save the appservice registration.", "registration.yaml").String()
var generateRegistration = flag.MakeFull("g", "generate-registration", "Generate registration and quit.", "false").Bool()
var version = flag.MakeFull("v", "version", "View bridge version and quit.", "false").Bool()
var versionJSON = flag.Make().LongKey("version-json").Usage("Print a JSON object representing the bridge version and quit.").Default("false").Bool()
var ignoreUnsupportedDatabase = flag.Make().LongKey("ignore-unsupported-database").Usage("Run even if the database schema is too new").Default("false").Bool()
var ignoreForeignTables = flag.Make().LongKey("ignore-foreign-tables").Usage("Run even if the database contains tables from other programs (like Synapse)").Default("false").Bool()
var ignoreUnsupportedServer = flag.Make().LongKey("ignore-unsupported-server").Usage("Run even if the Matrix homeserver is outdated").Default("false").Bool()
var wantHelp, _ = flag.MakeHelpFlag()
type Main struct {
Name string
Description string
URL string
Version string
Connector bridgev2.NetworkConnector
PostInit func()
PostStart func()
Log *zerolog.Logger
DB *dbutil.Database
PublicConfig *arrconfig.Config
Config *bridgeconfig.Config
Matrix *matrix.Connector
Bridge *bridgev2.Bridge
OTEL *observability.Runtime
ConfigPath string
RegistrationPath string
SaveConfig bool
ver progver.ProgramVersion
manualStop chan int
}
type versionJSONOutput struct {
progver.ProgramVersion
OS string
Arch string
Mautrix struct {
Version string
Commit string
}
}
type routeMounter interface {
MountRoutes(*http.ServeMux) error
}
func (m *Main) Run() {
m.PreInit()
m.Init()
m.Start()
exitCode := m.WaitForInterrupt()
m.Stop()
os.Exit(exitCode)
}
func (m *Main) PreInit() {
m.manualStop = make(chan int, 1)
flag.SetHelpTitles(
fmt.Sprintf("%s - %s", m.Name, m.Description),
fmt.Sprintf("%s [-hgvn] [-c <path>] [-r <path>]", m.Name),
)
err := flag.Parse()
m.ConfigPath = *configPath
m.RegistrationPath = *registrationPath
m.SaveConfig = !*dontSaveConfig
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
flag.PrintHelp()
os.Exit(1)
}
switch {
case *wantHelp:
flag.PrintHelp()
os.Exit(0)
case *version:
fmt.Println(m.ver.VersionDescription)
os.Exit(0)
case *versionJSON:
output := versionJSONOutput{
ProgramVersion: m.ver,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
output.Mautrix.Version = mautrix.Version
output.Mautrix.Commit = mautrix.Commit
_ = json.NewEncoder(os.Stdout).Encode(output)
os.Exit(0)
case *writeExampleConfig:
m.writeExampleConfig()
os.Exit(0)
}
m.LoadConfig()
if *generateRegistration {
m.GenerateRegistration()
os.Exit(0)
}
}
func (m *Main) writeExampleConfig() {
if *configPath != "-" {
if _, err := os.Stat(*configPath); !errors.Is(err, os.ErrNotExist) {
_, _ = fmt.Fprintln(os.Stderr, *configPath, "already exists, please remove it if you want to generate a new example")
os.Exit(1)
}
}
networkExample, _, _ := m.Connector.GetConfig()
example := makeExampleConfig(m.Connector.GetName(), networkExample)
if *configPath == "-" {
fmt.Print(example)
return
}
exerrors.PanicIfNotNil(os.WriteFile(*configPath, []byte(example), 0o600))
fmt.Println("Wrote example config to", *configPath)
}
func (m *Main) GenerateRegistration() {
if !m.SaveConfig {
_, _ = fmt.Fprintln(os.Stderr, "--no-update is not compatible with --generate-registration")
os.Exit(5)
}
if m.Config.Homeserver.Domain == "example.com" {
_, _ = fmt.Fprintln(os.Stderr, "Homeserver domain is not set")
os.Exit(20)
}
registration := m.Config.GenerateRegistration()
if err := registration.Save(m.RegistrationPath); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to save registration:", err)
os.Exit(21)
}
if err := m.saveConfig(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to save config:", err)
os.Exit(22)
}
fmt.Println("Registration generated. See https://docs.mau.fi/bridges/general/registering-appservices.html for instructions on installing the registration.")
}
func (m *Main) LoadConfig() {
configData, err := os.ReadFile(m.ConfigPath)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to read config:", err)
os.Exit(10)
}
publicConfig, err := arrconfig.Load(configData)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse config:", err)
os.Exit(10)
}
cfg := publicConfig.Compile()
if err = m.loadRegistrationTokens(&cfg); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse registration:", err)
os.Exit(10)
}
_, networkData, _ := m.Connector.GetConfig()
if networkData != nil {
if err = cfg.Network.Decode(networkData); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse network config:", err)
os.Exit(10)
}
}
cfg.Bridge.Backfill = cfg.Backfill
if err = updateConfigFromEnv(&cfg, networkData, cfg.EnvConfigPrefix); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to parse environment variables:", err)
os.Exit(10)
}
m.PublicConfig = publicConfig
m.Config = &cfg
}
func (m *Main) loadRegistrationTokens(cfg *bridgeconfig.Config) error {
if m.RegistrationPath == "" {
return nil
}
data, err := os.ReadFile(m.RegistrationPath)
if errors.Is(err, os.ErrNotExist) {
return nil
} else if err != nil {
return err
}
var tokens struct {
AppToken string `yaml:"as_token"`
ServerToken string `yaml:"hs_token"`
}
if err = yaml.Unmarshal(data, &tokens); err != nil {
return err
}
if tokens.AppToken != "" {
cfg.AppService.ASToken = tokens.AppToken
}
if tokens.ServerToken != "" {
cfg.AppService.HSToken = tokens.ServerToken
}
return nil
}
func (m *Main) Init() {
start := time.Now()
ctx := context.Background()
var err error
m.Log, err = m.Config.Logging.Compile()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to initialize logger:", err)
os.Exit(12)
}
exzerolog.SetupDefaults(m.Log)
if err = m.validateConfig(); err != nil {
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Configuration error")
m.Log.Info().Msg("See https://docs.mau.fi/faq/field-unconfigured for more info")
os.Exit(11)
}
otelCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
m.OTEL, err = observability.Setup(otelCtx, m.PublicConfig.Observability, m.Version)
cancel()
if err != nil {
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize observability")
os.Exit(15)
}
if hook := m.OTEL.LoggerHook(); hook != nil {
logger := m.Log.Hook(hook)
m.Log = &logger
exzerolog.SetupDefaults(m.Log)
}
ctx = m.Log.WithContext(context.Background())
ctx, span := observability.StartSpan(ctx, "arrtrix.runtime.init")
defer func() {
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
observability.RecordStartupPhase(ctx, "init", "error", time.Since(start))
return
}
span.SetStatus(codes.Ok, "")
observability.RecordStartupPhase(ctx, "init", "ok", time.Since(start))
}()
defer span.End()
m.Log.Info().
Str("name", m.Name).
Str("version", m.ver.FormattedVersion).
Time("built_at", m.ver.BuildTime).
Str("go_version", runtime.Version()).
Msg("Initializing bridge")
m.initDB()
if err = subscriptions.EnsureSchema(ctx, m.DB); err != nil {
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize subscription schema")
os.Exit(14)
}
m.Matrix = matrix.NewConnector(m.Config)
m.Matrix.OnWebsocketReplaced = func() {
m.TriggerStop(0)
}
m.Matrix.IgnoreUnsupportedServer = *ignoreUnsupportedServer
m.Bridge = bridgev2.NewBridge("", m.DB, *m.Log, &m.Config.Bridge, m.Matrix, m.Connector, commands.NewProcessor)
m.Bridge.Commands = matrixcmd.NewProcessor(m.Bridge, m.Config.ManagementRoomTexts)
if m.Matrix.EventProcessor != nil {
if m.Config.AppService.AsyncTransactions {
m.Matrix.EventProcessor.ExecMode = appservice.AsyncLoop
} else {
m.Matrix.EventProcessor.ExecMode = appservice.Sync
}
m.Matrix.EventProcessor.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) {
onboarding.HandleBotInvite(ctx, m.Bridge, m.Config.ManagementRoomTexts, evt)
})
}
m.Matrix.AS.DoublePuppetValue = m.Name
if mounter, ok := m.Connector.(routeMounter); ok {
if err = mounter.MountRoutes(m.Matrix.AS.Router); err != nil {
_, _ = fmt.Fprintln(os.Stderr, "Failed to mount HTTP routes:", err)
os.Exit(13)
}
}
if m.PostInit != nil {
m.PostInit()
}
}
func (m *Main) Start() {
start := time.Now()
ctx := m.Log.WithContext(context.Background())
ctx, span := observability.StartSpan(ctx, "arrtrix.runtime.start")
defer func() {
if r := recover(); r != nil {
span.SetStatus(codes.Error, "panic")
observability.RecordStartupPhase(ctx, "start", "panic", time.Since(start))
span.End()
panic(r)
}
span.End()
}()
if err := m.Bridge.Start(ctx); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
observability.RecordStartupPhase(ctx, "start", "error", time.Since(start))
m.Log.Fatal().Err(err).Msg("Failed to start bridge")
}
span.SetStatus(codes.Ok, "")
observability.RecordStartupPhase(ctx, "start", "ok", time.Since(start))
if m.PostStart != nil {
m.PostStart()
}
}
func (m *Main) Stop() {
start := time.Now()
ctx := m.Log.WithContext(context.Background())
ctx, span := observability.StartSpan(ctx, "arrtrix.runtime.stop")
defer span.End()
m.Bridge.StopWithTimeout(5 * time.Second)
span.SetStatus(codes.Ok, "")
observability.RecordStartupPhase(ctx, "stop", "ok", time.Since(start))
if m.OTEL != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := m.OTEL.Shutdown(shutdownCtx); err != nil && m.Log != nil {
m.Log.Error().Err(err).Msg("Failed to shut down observability")
}
}
}
func (m *Main) WaitForInterrupt() int {
interrupts := make(chan os.Signal, 1)
signal.Notify(interrupts, os.Interrupt, syscall.SIGTERM)
select {
case <-interrupts:
m.Log.Info().Msg("Interrupt signal received from OS")
return 0
case exitCode := <-m.manualStop:
m.Log.Info().Msg("Internal stop signal received")
return exitCode
}
}
func (m *Main) TriggerStop(exitCode int) {
select {
case m.manualStop <- exitCode:
default:
}
}
func (m *Main) InitVersion(tag, commit, rawBuildTime string) {
m.ver = progver.ProgramVersion{
Name: m.Name,
URL: m.URL,
BaseVersion: m.Version,
}.Init(tag, commit, rawBuildTime)
mautrix.DefaultUserAgent = fmt.Sprintf("%s/%s %s", m.Name, m.ver.FormattedVersion, mautrix.DefaultUserAgent)
m.Version = m.ver.FormattedVersion
}
func (m *Main) validateConfig() error {
switch {
case m.Config.Homeserver.Address == "http://example.localhost:8008":
return errors.New("homeserver.address not configured")
case m.Config.Homeserver.Domain == "example.com":
return errors.New("homeserver.domain not configured")
case !bridgeconfig.AllowedHomeserverSoftware[m.Config.Homeserver.Software]:
return errors.New("invalid value for homeserver.software (use `standard` if you don't know what the field is for)")
case m.Config.AppService.ASToken == "This value is generated when generating the registration":
return errors.New("appservice.as_token not configured. Did you forget to generate the registration?")
case m.Config.AppService.HSToken == "This value is generated when generating the registration":
return errors.New("appservice.hs_token not configured. Did you forget to generate the registration?")
case m.Config.Database.URI == "postgres://user:password@host/database?sslmode=disable":
return errors.New("database.uri not configured")
case !m.Config.Bridge.Permissions.IsConfigured():
return errors.New("bridge.permissions not configured")
case !strings.Contains(m.Config.AppService.FormatUsername("1234567890"), "1234567890"):
return errors.New("username template is missing user ID placeholder")
default:
if validator, ok := m.Connector.(bridgev2.ConfigValidatingNetwork); ok {
return validator.ValidateConfig()
}
return nil
}
}
func (m *Main) initDB() {
if m.Config.Database.Type == "sqlite3" {
m.Log.WithLevel(zerolog.FatalLevel).Msg("Invalid database type sqlite3. Use sqlite3-fk-wal instead.")
os.Exit(14)
}
if (m.Config.Database.Type == "sqlite3-fk-wal" || m.Config.Database.Type == "litestream") &&
m.Config.Database.MaxOpenConns != 1 &&
!strings.Contains(m.Config.Database.URI, "_txlock=immediate") {
var fixedURI string
switch {
case !strings.HasPrefix(m.Config.Database.URI, "file:"):
fixedURI = fmt.Sprintf("file:%s?_txlock=immediate", m.Config.Database.URI)
case !strings.ContainsRune(m.Config.Database.URI, '?'):
fixedURI = fmt.Sprintf("%s?_txlock=immediate", m.Config.Database.URI)
default:
fixedURI = fmt.Sprintf("%s&_txlock=immediate", m.Config.Database.URI)
}
m.Log.Warn().Str("fixed_uri_example", fixedURI).Msg("Using SQLite without _txlock=immediate is not recommended")
}
var err error
m.DB, err = dbutil.NewFromConfig("megabridge/"+m.Name, m.Config.Database, dbutil.ZeroLogger(m.Log.With().Str("db_section", "main").Logger()))
if err != nil {
m.Log.WithLevel(zerolog.FatalLevel).Err(err).Msg("Failed to initialize database connection")
os.Exit(14)
}
m.DB.IgnoreUnsupportedDatabase = *ignoreUnsupportedDatabase
m.DB.IgnoreForeignTables = *ignoreForeignTables
}
func (m *Main) saveConfig() error {
publicConfig := *m.PublicConfig
publicConfig.AppService.ASToken = m.Config.AppService.ASToken
publicConfig.AppService.HSToken = m.Config.AppService.HSToken
configData, err := yaml.Marshal(&publicConfig)
if err != nil {
return err
}
return os.WriteFile(m.ConfigPath, configData, 0o600)
}

View file

@ -1,30 +0,0 @@
package runtime
import (
"os"
"path/filepath"
"testing"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
)
func TestLoadRegistrationTokens(t *testing.T) {
tempDir := t.TempDir()
registrationPath := filepath.Join(tempDir, "registration.yaml")
if err := os.WriteFile(registrationPath, []byte("as_token: app-token\nhs_token: hs-token\n"), 0o600); err != nil {
t.Fatalf("failed to write registration file: %v", err)
}
cfg := &bridgeconfig.Config{}
main := &Main{RegistrationPath: registrationPath}
if err := main.loadRegistrationTokens(cfg); err != nil {
t.Fatalf("loadRegistrationTokens returned error: %v", err)
}
if cfg.AppService.ASToken != "app-token" {
t.Fatalf("expected as token to be loaded, got %q", cfg.AppService.ASToken)
}
if cfg.AppService.HSToken != "hs-token" {
t.Fatalf("expected hs token to be loaded, got %q", cfg.AppService.HSToken)
}
}

View file

@ -1,141 +0,0 @@
package subscriptions
import (
"context"
"fmt"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
)
type Preference struct {
ContentType arr.ContentType
EventType string
Enabled bool
}
type Repository struct {
db *dbutil.Database
bridgeID string
}
func EnsureSchema(ctx context.Context, db *dbutil.Database) error {
_, err := db.Exec(ctx, `
CREATE TABLE IF NOT EXISTS arrtrix_subscription (
bridge_id TEXT NOT NULL,
user_mxid TEXT NOT NULL,
content_type TEXT NOT NULL,
event_type TEXT NOT NULL,
enabled BOOLEAN NOT NULL,
PRIMARY KEY (bridge_id, user_mxid, content_type, event_type)
)
`)
return err
}
func NewRepository(db *dbutil.Database, bridgeID string) *Repository {
return &Repository{db: db, bridgeID: bridgeID}
}
func (r *Repository) EnsureDefaults(ctx context.Context, userID id.UserID) error {
var existing int
if err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM arrtrix_subscription WHERE bridge_id=$1 AND user_mxid=$2`, r.bridgeID, userID.String()).Scan(&existing); err != nil {
return err
}
if existing > 0 {
return nil
}
for _, contentType := range arr.SupportedContentTypes() {
for _, eventType := range arr.SupportedEventTypes(contentType) {
if _, err := r.db.Exec(ctx, `
INSERT INTO arrtrix_subscription (bridge_id, user_mxid, content_type, event_type, enabled)
VALUES ($1, $2, $3, $4, TRUE)
`, r.bridgeID, userID.String(), string(contentType), eventType); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) List(ctx context.Context, userID id.UserID) ([]Preference, error) {
if err := r.EnsureDefaults(ctx, userID); err != nil {
return nil, err
}
rows, err := r.db.Query(ctx, `
SELECT content_type, event_type, enabled
FROM arrtrix_subscription
WHERE bridge_id=$1 AND user_mxid=$2
ORDER BY content_type, event_type
`, r.bridgeID, userID.String())
if err != nil {
return nil, err
}
defer rows.Close()
var preferences []Preference
for rows.Next() {
var contentType string
var preference Preference
if err = rows.Scan(&contentType, &preference.EventType, &preference.Enabled); err != nil {
return nil, err
}
preference.ContentType = arr.ContentType(contentType)
preferences = append(preferences, preference)
}
if err = rows.Err(); err != nil {
return nil, err
}
return preferences, nil
}
func (r *Repository) Set(ctx context.Context, userID id.UserID, contentType arr.ContentType, eventType string, enabled bool) error {
if err := r.EnsureDefaults(ctx, userID); err != nil {
return err
}
if _, err := r.db.Exec(ctx, `
INSERT INTO arrtrix_subscription (bridge_id, user_mxid, content_type, event_type, enabled)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (bridge_id, user_mxid, content_type, event_type)
DO UPDATE SET enabled=excluded.enabled
`, r.bridgeID, userID.String(), string(contentType), eventType, enabled); err != nil {
return err
}
return nil
}
func (r *Repository) SetAll(ctx context.Context, userID id.UserID, contentType arr.ContentType, enabled bool) error {
if err := r.EnsureDefaults(ctx, userID); err != nil {
return err
}
for _, eventType := range arr.SupportedEventTypes(contentType) {
if err := r.Set(ctx, userID, contentType, eventType, enabled); err != nil {
return err
}
}
return nil
}
func (r *Repository) Allows(ctx context.Context, userID id.UserID, contentType arr.ContentType, eventType string) (bool, error) {
if !arr.SupportsEventType(contentType, eventType) {
return true, nil
}
if err := r.EnsureDefaults(ctx, userID); err != nil {
return false, err
}
var enabled bool
err := r.db.QueryRow(ctx, `
SELECT enabled
FROM arrtrix_subscription
WHERE bridge_id=$1 AND user_mxid=$2 AND content_type=$3 AND event_type=$4
`, r.bridgeID, userID.String(), string(contentType), eventType).Scan(&enabled)
if err != nil {
return false, fmt.Errorf("query subscription: %w", err)
}
return enabled, nil
}

View file

@ -1,349 +0,0 @@
package webhook
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/format"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
"sneeuwvlok/packages/arrtrix/pkg/observability"
)
const ArrWebhookPath = "/_arrtrix/webhook"
var (
ErrNoManagementRoom = errors.New("no management room configured")
ErrAmbiguousManagementRoom = errors.New("multiple management rooms configured")
)
type payload struct {
EventType string `json:"eventType"`
Movie *movie `json:"movie"`
MovieFile *movieFile `json:"movieFile"`
Series *series `json:"series"`
Episodes []episode `json:"episodes"`
EpisodeFile *episodeFile `json:"episodeFile"`
IsUpgrade bool `json:"isUpgrade"`
}
type movie struct {
Title string `json:"title"`
Year int `json:"year"`
ImdbID string `json:"imdbId"`
TmdbID int `json:"tmdbId"`
Path string `json:"path"`
}
type movieFile struct {
Quality string `json:"quality"`
RelativePath string `json:"relativePath"`
SceneName string `json:"sceneName"`
ReleaseGroup string `json:"releaseGroup"`
}
type series struct {
Title string `json:"title"`
Year int `json:"year"`
Path string `json:"path"`
}
type episode struct {
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Title string `json:"title"`
}
type episodeFile struct {
Quality string `json:"quality"`
RelativePath string `json:"relativePath"`
SceneName string `json:"sceneName"`
}
type managementTarget struct {
UserID id.UserID
RoomID id.RoomID
}
type roomResolver interface {
ResolveManagementRoom(context.Context) (managementTarget, error)
}
type noticeSender interface {
SendNotice(context.Context, id.RoomID, string) error
}
type SubscriptionFilter interface {
Allows(context.Context, id.UserID, arr.ContentType, string) (bool, error)
}
type ArrHandler struct {
resolver roomResolver
sender noticeSender
subscriptions SubscriptionFilter
}
func MountArr(router *http.ServeMux, bridge *bridgev2.Bridge, subscriptions SubscriptionFilter) error {
if bridge == nil {
return fmt.Errorf("bridge is not initialized")
}
handler := &ArrHandler{
resolver: bridgeRoomResolver{bridge: bridge},
sender: bridgeNoticeSender{bridge: bridge},
subscriptions: subscriptions,
}
router.Handle(fmt.Sprintf("POST %s", ArrWebhookPath), handler)
return nil
}
func (h *ArrHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ctx, span := observability.StartSpan(r.Context(), "arrtrix.webhook.handle", trace.WithSpanKind(trace.SpanKindServer))
defer span.End()
statusCode := http.StatusAccepted
outcome := "ok"
eventType := ""
defer func() {
observability.RecordWebhook(ctx, eventType, outcome, statusCode, time.Since(start))
}()
var body payload
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
statusCode = http.StatusBadRequest
outcome = "invalid_payload"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
http.Error(w, "invalid webhook payload", http.StatusBadRequest)
return
}
if strings.TrimSpace(body.EventType) == "" {
statusCode = http.StatusBadRequest
outcome = "missing_event_type"
span.SetStatus(codes.Error, "missing eventType")
http.Error(w, "missing eventType", http.StatusBadRequest)
return
}
eventType = body.EventType
span.SetAttributes(
attribute.String("arrtrix.webhook.event_type", body.EventType),
attribute.String("http.method", r.Method),
attribute.String("http.route", ArrWebhookPath),
)
target, err := h.resolver.ResolveManagementRoom(ctx)
if err != nil {
statusCode = http.StatusInternalServerError
outcome = "resolve_failed"
if errors.Is(err, ErrNoManagementRoom) || errors.Is(err, ErrAmbiguousManagementRoom) {
statusCode = http.StatusConflict
outcome = "routing_conflict"
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
http.Error(w, err.Error(), statusCode)
return
}
contentType, ok := body.ContentType()
if ok && h.subscriptions != nil {
allowed, filterErr := h.subscriptions.Allows(ctx, target.UserID, contentType, body.EventType)
if filterErr != nil {
statusCode = http.StatusInternalServerError
outcome = "subscription_check_failed"
span.RecordError(filterErr)
span.SetStatus(codes.Error, filterErr.Error())
http.Error(w, "failed to evaluate subscriptions", statusCode)
return
}
if !allowed {
outcome = "filtered"
span.SetStatus(codes.Ok, "filtered")
w.WriteHeader(statusCode)
return
}
}
if err = h.sender.SendNotice(ctx, target.RoomID, renderNotice(body)); err != nil {
statusCode = http.StatusBadGateway
outcome = "delivery_failed"
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
http.Error(w, "failed to deliver webhook", http.StatusBadGateway)
return
}
span.SetStatus(codes.Ok, "")
w.WriteHeader(statusCode)
}
type bridgeRoomResolver struct {
bridge *bridgev2.Bridge
}
func (r bridgeRoomResolver) ResolveManagementRoom(ctx context.Context) (managementTarget, error) {
ctx, span := observability.StartSpan(ctx, "arrtrix.webhook.resolve_management_room")
defer span.End()
rows, err := r.bridge.DB.Query(ctx, `SELECT mxid, management_room FROM "user" WHERE bridge_id=$1 AND management_room IS NOT NULL AND management_room <> ''`, r.bridge.ID)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return managementTarget{}, fmt.Errorf("failed to query management rooms: %w", err)
}
defer rows.Close()
var target managementTarget
var owners []id.UserID
for rows.Next() {
var mxid, managementRoom string
if err = rows.Scan(&mxid, &managementRoom); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return managementTarget{}, fmt.Errorf("failed to scan management room: %w", err)
}
owners = append(owners, id.UserID(mxid))
if target.RoomID == "" {
target = managementTarget{
UserID: id.UserID(mxid),
RoomID: id.RoomID(managementRoom),
}
}
}
if err = rows.Err(); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return managementTarget{}, fmt.Errorf("failed to iterate management rooms: %w", err)
}
switch len(owners) {
case 0:
span.SetStatus(codes.Error, ErrNoManagementRoom.Error())
return managementTarget{}, ErrNoManagementRoom
case 1:
span.SetAttributes(attribute.Int("arrtrix.management_room.count", 1))
span.SetStatus(codes.Ok, "")
return target, nil
default:
span.SetAttributes(attribute.Int("arrtrix.management_room.count", len(owners)))
span.SetStatus(codes.Error, ErrAmbiguousManagementRoom.Error())
return managementTarget{}, fmt.Errorf("%w: %s", ErrAmbiguousManagementRoom, strings.Join(convertUserIDs(owners), ", "))
}
}
type bridgeNoticeSender struct {
bridge *bridgev2.Bridge
}
func (s bridgeNoticeSender) SendNotice(ctx context.Context, roomID id.RoomID, markdown string) error {
ctx, span := observability.StartSpan(ctx, "arrtrix.webhook.send_notice")
defer span.End()
span.SetAttributes(attribute.String("matrix.room_id", roomID.String()))
if err := s.bridge.Bot.EnsureJoined(ctx, roomID); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
content := format.RenderMarkdown(markdown, true, false)
_, err := s.bridge.Bot.SendMessage(ctx, roomID, event.EventMessage, &event.Content{Parsed: &content}, nil)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return err
}
func renderNotice(body payload) string {
lines := []string{fmt.Sprintf("**Arr %s**", body.EventType)}
switch contentType, ok := body.ContentType(); {
case ok && contentType == arr.ContentTypeMovies:
title := body.Movie.Title
if body.Movie.Year != 0 {
title = fmt.Sprintf("%s (%d)", title, body.Movie.Year)
}
lines = append(lines, fmt.Sprintf("Movie: %s", title))
if body.MovieFile != nil && body.MovieFile.Quality != "" {
lines = append(lines, fmt.Sprintf("Quality: %s", body.MovieFile.Quality))
}
if body.MovieFile != nil && body.MovieFile.RelativePath != "" {
lines = append(lines, fmt.Sprintf("File: `%s`", body.MovieFile.RelativePath))
}
if body.EventType == "Download" {
lines = append(lines, fmt.Sprintf("Upgrade: %t", body.IsUpgrade))
}
if body.Movie.ImdbID != "" {
lines = append(lines, fmt.Sprintf("IMDb: `%s`", body.Movie.ImdbID))
}
case ok && contentType == arr.ContentTypeSeries:
title := body.Series.Title
if body.Series.Year != 0 {
title = fmt.Sprintf("%s (%d)", title, body.Series.Year)
}
lines = append(lines, fmt.Sprintf("Series: %s", title))
if len(body.Episodes) > 0 {
lines = append(lines, fmt.Sprintf("Episodes: %s", renderEpisodes(body.Episodes)))
}
if body.EpisodeFile != nil && body.EpisodeFile.Quality != "" {
lines = append(lines, fmt.Sprintf("Quality: %s", body.EpisodeFile.Quality))
}
if body.EpisodeFile != nil && body.EpisodeFile.RelativePath != "" {
lines = append(lines, fmt.Sprintf("File: `%s`", body.EpisodeFile.RelativePath))
}
default:
if body.EventType != "Test" {
lines = append(lines, "Payload received.")
}
}
return strings.Join(lines, "\n")
}
func convertUserIDs(users []id.UserID) []string {
out := make([]string, len(users))
for i, user := range users {
out[i] = string(user)
}
return out
}
var _ roomResolver = bridgeRoomResolver{}
var _ noticeSender = bridgeNoticeSender{}
var _ http.Handler = (*ArrHandler)(nil)
func (p payload) ContentType() (arr.ContentType, bool) {
switch {
case p.Movie != nil:
return arr.ContentTypeMovies, true
case p.Series != nil:
return arr.ContentTypeSeries, true
default:
return "", false
}
}
func renderEpisodes(episodes []episode) string {
parts := make([]string, 0, len(episodes))
for _, item := range episodes {
if item.Title != "" {
parts = append(parts, fmt.Sprintf("S%02dE%02d %s", item.SeasonNumber, item.EpisodeNumber, item.Title))
continue
}
parts = append(parts, fmt.Sprintf("S%02dE%02d", item.SeasonNumber, item.EpisodeNumber))
}
return strings.Join(parts, ", ")
}

View file

@ -1,145 +0,0 @@
package webhook
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"maunium.net/go/mautrix/id"
"sneeuwvlok/packages/arrtrix/pkg/arr"
)
type stubRoomResolver struct {
target managementTarget
err error
}
func (s stubRoomResolver) ResolveManagementRoom(context.Context) (managementTarget, error) {
return s.target, s.err
}
type stubNoticeSender struct {
roomID id.RoomID
message string
err error
}
type stubSubscriptionFilter struct {
allowed bool
err error
}
func (s *stubNoticeSender) SendNotice(_ context.Context, roomID id.RoomID, message string) error {
s.roomID = roomID
s.message = message
return s.err
}
func (s stubSubscriptionFilter) Allows(context.Context, id.UserID, arr.ContentType, string) (bool, error) {
return s.allowed, s.err
}
func TestMountArrRequiresBridge(t *testing.T) {
router := http.NewServeMux()
if err := MountArr(router, nil, nil); err == nil {
t.Fatal("expected nil bridge to fail")
}
}
func TestArrHandlerDeliversNotice(t *testing.T) {
sender := &stubNoticeSender{}
handler := &ArrHandler{
resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
sender: sender,
}
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Download","movie":{"title":"Dune","year":2021,"imdbId":"tt1160419"},"movieFile":{"quality":"1080p","relativePath":"Dune (2021)/Dune.mkv"},"isUpgrade":false}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected accepted status, got %d", rec.Code)
}
if sender.roomID != "!room:test" {
t.Fatalf("expected notice sent to management room, got %q", sender.roomID)
}
if !strings.Contains(sender.message, "**Arr Download**") || !strings.Contains(sender.message, "Dune (2021)") {
t.Fatalf("unexpected message: %s", sender.message)
}
}
func TestArrHandlerReportsAmbiguousManagementRoom(t *testing.T) {
handler := &ArrHandler{
resolver: stubRoomResolver{err: ErrAmbiguousManagementRoom},
sender: &stubNoticeSender{},
}
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Test"}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected conflict status, got %d", rec.Code)
}
}
func TestRenderNoticeForTestEvent(t *testing.T) {
msg := renderNotice(payload{EventType: "Test"})
if strings.TrimSpace(msg) != "**Arr Test**" {
t.Fatalf("unexpected test-event message: %q", msg)
}
}
func TestArrHandlerReturnsBadGatewayOnSendFailure(t *testing.T) {
handler := &ArrHandler{
resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
sender: &stubNoticeSender{err: errors.New("send failed")},
}
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Test"}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("expected bad gateway status, got %d", rec.Code)
}
}
func TestArrHandlerRejectsMissingEventType(t *testing.T) {
handler := &ArrHandler{
resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
sender: &stubNoticeSender{},
}
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"movie":{"title":"Dune"}}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected bad request status, got %d", rec.Code)
}
}
func TestArrHandlerFiltersDisabledSubscriptions(t *testing.T) {
sender := &stubNoticeSender{}
handler := &ArrHandler{
resolver: stubRoomResolver{target: managementTarget{UserID: "@user:test", RoomID: "!room:test"}},
sender: sender,
subscriptions: stubSubscriptionFilter{allowed: false},
}
req := httptest.NewRequest(http.MethodPost, ArrWebhookPath, strings.NewReader(`{"eventType":"Download","movie":{"title":"Dune","year":2021}}`))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("expected accepted status, got %d", rec.Code)
}
if sender.roomID != "" {
t.Fatalf("expected no notice to be sent, got room %q", sender.roomID)
}
}

View file

@ -0,0 +1,59 @@
{
lib,
stdenv,
rustPlatform,
fetchFromGitHub,
# dependencies
openssl,
pkg-config,
cmake,
llvmPackages,
postgresql,
sqlite,
#options
dbBackend ? "postgresql",
...
}:
rustPlatform.buildRustPackage rec {
pname = "convex";
version = "2025-08-20-c9b561e";
src = fetchFromGitHub {
owner = "get-convex";
repo = "convex-backend";
rev = "c9b561e1b365c85ef28af35d742cb7dd174b5555";
hash = "sha256-4h4AQt+rQ+nTw6eTbbB5vqFt9MFjKYw3Z7bGXdXijJ0=";
};
cargoHash = "sha256-pcDNWGrk9D0qcF479QAglPLFDZp27f8RueP5/lq9jho=";
cargoBuildFlags = [
"-p" "local_backend"
"--bin" "convex-local-backend"
];
env = {
LIBCLANG_PATH = "${llvmPackages.libclang}/lib";
};
strictDeps = true;
# Build-time dependencies
nativeBuildInputs = [ pkg-config cmake rustPlatform.bindgenHook ];
# Run-time dependencies
buildInputs =
[ openssl ]
++ lib.optional (dbBackend == "sqlite") sqlite
++ lib.optional (dbBackend == "postgresql") postgresql;
buildFeatures = "";
meta = with lib; {
license = licenses.fsl11Asl20;
mainProgram = "convex";
};
}

View file

@ -1,22 +1,18 @@
{ { pkgs, inputs }: let
pkgs,
inputs,
}: let
inherit (builtins) fetchurl; inherit (builtins) fetchurl;
inherit (pkgs) makeDesktopItem copyDesktopItems wineWow64Packages; inherit (pkgs) makeDesktopItem copyDesktopItems wineWowPackages;
inherit (inputs.erosanix.lib.x86_64-linux) mkWindowsAppNoCC makeDesktopIcon copyDesktopIcons; inherit (inputs.erosanix.lib.x86_64-linux) mkWindowsAppNoCC makeDesktopIcon copyDesktopIcons;
wine = wineWow64Packages.base; wine = wineWowPackages.base;
in in mkWindowsAppNoCC rec {
mkWindowsAppNoCC rec {
inherit wine; inherit wine;
pname = "studio"; pname = "studio";
version = "2.25.12"; version = "2.25.4";
src = fetchurl { src = fetchurl {
url = "https://studio.download.bricklink.info/Studio2.0+EarlyAccess/Archive/2.25.12_1/Studio+2.0+EarlyAccess.exe"; url = "https://studio.download.bricklink.info/Studio2.0+EarlyAccess/Archive/2.25.4_1/Studio+2.0+EarlyAccess.exe";
sha256 = "sha256:1xl3zvzkzr64zphk7rnpfx3whhbaykzw06m3nd5dc12r2p4sdh3v"; sha256 = "sha256:1gw6pyvfr7zr42g21hqgiwkjs88nvhq2c2v40y21frvwv17hja92";
}; };
enableMonoBootPrompt = false; enableMonoBootPrompt = false;
@ -63,10 +59,7 @@ in
wine64 reg add 'HKEY_CURRENT_USER\Software\Wine\X11 Driver' /t REG_SZ /v UseTakeFocus /d N /f wine64 reg add 'HKEY_CURRENT_USER\Software\Wine\X11 Driver' /t REG_SZ /v UseTakeFocus /d N /f
''; '';
winAppPreRun = '' winAppPreRun = '''';
wineserver -W
wine64 reg add 'HKEY_CURRENT_USER\Software\Wine\X11 Driver' /t REG_SZ /v UseTakeFocus /d N /f
'';
winAppRun = '' winAppRun = ''
wine64 "$WINEPREFIX/drive_c/Program Files/Studio 2.0/Studio.exe" "$ARGS" wine64 "$WINEPREFIX/drive_c/Program Files/Studio 2.0/Studio.exe" "$ARGS"

View file

@ -0,0 +1,29 @@
{ lib, stdenv, rustPlatform, fetchFromGitHub, openssl, pkg-config, postgresql, dbBackend ? "postgresql", ... }:
rustPlatform.buildRustPackage rec {
pname = "vaultwarden";
version = "1.34.3";
src = fetchFromGitHub {
owner = "Timshel";
repo = "vaultwarden";
rev = "1.34.3";
hash = "sha256-Dj0ySVRvBZ/57+UHas3VI8bi/0JBRqn0IW1Dq+405J0=";
};
cargoHash = "sha256-4sDagd2XGamBz1XvDj4ycRVJ0F+4iwHOPlj/RglNDqE=";
# used for "Server Installed" version in admin panel
env.VW_VERSION = version;
nativeBuildInputs = [ pkg-config ];
buildInputs =
[ openssl ]
++ lib.optional (dbBackend == "postgresql") postgresql;
buildFeatures = dbBackend;
meta = with lib; {
license = licenses.agpl3Only;
mainProgram = "vaultwarden";
};
}

View file

@ -1,395 +0,0 @@
__version__ = 19
__encoding__ = utf-8
[misc]
helpful_warnings = 1
queue_complete = hibernate_pc
queue_complete_pers = 0
bandwidth_perc = 100
refresh_rate = 1
interface_settings = '{"dateFormat":"YYYY-MM-DD HH:mm","extraQueueColumns":[],"extraHistoryColumns":[],"displayCompact":false,"displayFullWidth":false,"confirmDeleteQueue":true,"confirmDeleteHistory":true,"keyboardShortcuts":true}'
queue_limit = 20
config_lock = 0
fixed_ports = 1
notified_new_skin = 2
direct_unpack_tested = 1
sorters_converted = 1
check_new_rel = 1
auto_browser = 0
language = en
enable_https_verification = 0
host = 0.0.0.0
port = 8080
https_port = ""
username = ""
password = ""
bandwidth_max = ""
cache_limit = 1G
web_dir = Glitter
web_color = Auto
https_cert = server.cert
https_key = server.key
https_chain = ""
enable_https = 0
inet_exposure = 0
api_key = 0052eba0db9d4b4f93a8a96f0cb85198
nzb_key = 171ebeb3e0044c379dc7719bef6b3144
socks5_proxy_url = ""
permissions = ""
download_dir = /var/media/downloads/incomplete
download_free = ""
complete_dir = /var/media/downloads/done
complete_free = ""
fulldisk_autoresume = 0
script_dir = ""
nzb_backup_dir = ""
admin_dir = admin
backup_dir = ""
dirscan_dir = ""
dirscan_speed = 5
password_file = ""
log_dir = logs
max_art_tries = 3
top_only = 0
sfv_check = 1
script_can_fail = 0
enable_recursive = 1
flat_unpack = 0
par_option = ""
pre_check = 0
nice = ""
win_process_prio = 3
ionice = ""
fail_hopeless_jobs = 1
fast_fail = 1
auto_disconnect = 1
pre_script = None
end_queue_script = None
no_dupes = 0
no_series_dupes = 0
no_smart_dupes = 0
dupes_propercheck = 1
pause_on_pwrar = 1
ignore_samples = 0
deobfuscate_final_filenames = 1
auto_sort = ""
direct_unpack = 0
propagation_delay = 0
folder_rename = 1
replace_spaces = 0
replace_underscores = 0
replace_dots = 0
safe_postproc = 1
pause_on_post_processing = 0
enable_all_par = 0
sanitize_safe = 0
cleanup_list = ,
unwanted_extensions = ,
action_on_unwanted_extensions = 0
unwanted_extensions_mode = 0
new_nzb_on_failure = 0
history_retention = ""
history_retention_option = all
history_retention_number = 1
quota_size = ""
quota_day = ""
quota_resume = 0
quota_period = m
enable_tv_sorting = 0
tv_sort_string = ""
tv_categories = tv,
enable_movie_sorting = 0
movie_sort_string = ""
movie_sort_extra = -cd%1
movie_categories = movies,
enable_date_sorting = 0
date_sort_string = ""
date_categories = tv,
schedlines = ,
rss_rate = 60
ampm = 0
start_paused = 0
preserve_paused_state = 0
enable_par_cleanup = 1
process_unpacked_par2 = 1
enable_multipar = 1
enable_unrar = 1
enable_7zip = 1
enable_filejoin = 1
enable_tsjoin = 1
overwrite_files = 0
ignore_unrar_dates = 0
backup_for_duplicates = 0
empty_postproc = 0
wait_for_dfolder = 0
rss_filenames = 0
api_logging = 1
html_login = 1
warn_dupl_jobs = 0
keep_awake = 1
tray_icon = 1
allow_incomplete_nzb = 0
enable_broadcast = 1
ipv6_hosting = 0
ipv6_staging = 0
api_warnings = 1
no_penalties = 0
x_frame_options = 1
allow_old_ssl_tls = 0
enable_season_sorting = 1
verify_xff_header = 0
rss_odd_titles = nzbindex.nl/, nzbindex.com/, nzbclub.com/
quick_check_ext_ignore = nfo, sfv, srr
req_completion_rate = 100.2
selftest_host = self-test.sabnzbd.org
movie_rename_limit = 100M
episode_rename_limit = 20M
size_limit = 0
direct_unpack_threads = 3
history_limit = 5
wait_ext_drive = 5
max_foldername_length = 246
nomedia_marker = ""
ipv6_servers = 1
url_base = /sabnzbd
host_whitelist = usenet.kruining.eu, ulmo
local_ranges = ,
max_url_retries = 10
downloader_sleep_time = 10
receive_threads = 2
switchinterval = 0.005
ssdp_broadcast_interval = 15
ext_rename_ignore = ,
email_server = ""
email_to = ,
email_from = ""
email_account = ""
email_pwd = ""
email_endjob = 0
email_full = 0
email_dir = ""
email_rss = 0
email_cats = *,
config_conversion_version = 4
disable_par2cmdline = 0
disable_archive = 0
unrar_parameters = ""
outgoing_nntp_ip = ""
[logging]
log_level = 1
max_log_size = 5242880
log_backups = 5
[ncenter]
ncenter_enable = 0
ncenter_cats = *,
ncenter_prio_startup = 0
ncenter_prio_download = 0
ncenter_prio_pause_resume = 0
ncenter_prio_pp = 0
ncenter_prio_complete = 1
ncenter_prio_failed = 1
ncenter_prio_disk_full = 1
ncenter_prio_new_login = 0
ncenter_prio_warning = 0
ncenter_prio_error = 0
ncenter_prio_queue_done = 0
ncenter_prio_other = 1
ncenter_prio_quota = 1
[acenter]
acenter_enable = 0
acenter_cats = *,
acenter_prio_startup = 0
acenter_prio_download = 0
acenter_prio_pause_resume = 0
acenter_prio_pp = 0
acenter_prio_complete = 1
acenter_prio_failed = 1
acenter_prio_disk_full = 1
acenter_prio_new_login = 0
acenter_prio_warning = 0
acenter_prio_error = 0
acenter_prio_queue_done = 0
acenter_prio_other = 1
acenter_prio_quota = 1
[ntfosd]
ntfosd_enable = 1
ntfosd_cats = *,
ntfosd_prio_startup = 0
ntfosd_prio_download = 0
ntfosd_prio_pause_resume = 0
ntfosd_prio_pp = 0
ntfosd_prio_complete = 1
ntfosd_prio_failed = 1
ntfosd_prio_disk_full = 1
ntfosd_prio_new_login = 0
ntfosd_prio_warning = 0
ntfosd_prio_error = 0
ntfosd_prio_queue_done = 0
ntfosd_prio_other = 1
ntfosd_prio_quota = 1
[prowl]
prowl_enable = 0
prowl_cats = *,
prowl_apikey = ""
prowl_prio_startup = -3
prowl_prio_download = -3
prowl_prio_pause_resume = -3
prowl_prio_pp = -3
prowl_prio_complete = 0
prowl_prio_failed = 1
prowl_prio_disk_full = 1
prowl_prio_new_login = -3
prowl_prio_warning = -3
prowl_prio_error = -3
prowl_prio_queue_done = -3
prowl_prio_other = 0
prowl_prio_quota = 0
[pushover]
pushover_token = ""
pushover_userkey = ""
pushover_device = ""
pushover_emergency_expire = 3600
pushover_emergency_retry = 60
pushover_enable = 0
pushover_cats = *,
pushover_prio_startup = -3
pushover_prio_download = -2
pushover_prio_pause_resume = -2
pushover_prio_pp = -3
pushover_prio_complete = -1
pushover_prio_failed = -1
pushover_prio_disk_full = 1
pushover_prio_new_login = -3
pushover_prio_warning = 1
pushover_prio_error = 1
pushover_prio_queue_done = -3
pushover_prio_other = -1
pushover_prio_quota = -1
[pushbullet]
pushbullet_enable = 0
pushbullet_cats = *,
pushbullet_apikey = ""
pushbullet_device = ""
pushbullet_prio_startup = 0
pushbullet_prio_download = 0
pushbullet_prio_pause_resume = 0
pushbullet_prio_pp = 0
pushbullet_prio_complete = 1
pushbullet_prio_failed = 1
pushbullet_prio_disk_full = 1
pushbullet_prio_new_login = 0
pushbullet_prio_warning = 0
pushbullet_prio_error = 0
pushbullet_prio_queue_done = 0
pushbullet_prio_other = 1
pushbullet_prio_quota = 1
[apprise]
apprise_enable = 0
apprise_cats = *,
apprise_urls = ""
apprise_target_startup = ""
apprise_target_startup_enable = 0
apprise_target_download = ""
apprise_target_download_enable = 0
apprise_target_pause_resume = ""
apprise_target_pause_resume_enable = 0
apprise_target_pp = ""
apprise_target_pp_enable = 0
apprise_target_complete = ""
apprise_target_complete_enable = 1
apprise_target_failed = ""
apprise_target_failed_enable = 1
apprise_target_disk_full = ""
apprise_target_disk_full_enable = 0
apprise_target_new_login = ""
apprise_target_new_login_enable = 1
apprise_target_warning = ""
apprise_target_warning_enable = 0
apprise_target_error = ""
apprise_target_error_enable = 0
apprise_target_queue_done = ""
apprise_target_queue_done_enable = 0
apprise_target_other = ""
apprise_target_other_enable = 1
apprise_target_quota = ""
apprise_target_quota_enable = 1
[nscript]
nscript_enable = 0
nscript_cats = *,
nscript_script = ""
nscript_parameters = ""
nscript_prio_startup = 0
nscript_prio_download = 0
nscript_prio_pause_resume = 0
nscript_prio_pp = 0
nscript_prio_complete = 1
nscript_prio_failed = 1
nscript_prio_disk_full = 1
nscript_prio_new_login = 0
nscript_prio_warning = 0
nscript_prio_error = 0
nscript_prio_queue_done = 0
nscript_prio_other = 1
nscript_prio_quota = 1
[categories]
[[*]]
name = *
order = 0
pp = 3
script = None
dir = ""
newzbin = ""
priority = 0
[[movies]]
name = movies
order = 1
pp = ""
script = Default
dir = ""
newzbin = ""
priority = -100
[[tv]]
name = tv
order = 2
pp = ""
script = Default
dir = ""
newzbin = ""
priority = -100
[[audio]]
name = audio
order = 3
pp = ""
script = Default
dir = ""
newzbin = ""
priority = -100
[[software]]
name = software
order = 4
pp = ""
script = Default
dir = ""
newzbin = ""
priority = -100
[servers]
[[news.sunnyusenet.com]]
name = news.sunnyusenet.com
displayname = news.sunnyusenet.com
host = news.sunnyusenet.com
port = 563
timeout = 60
username = michiel@hazelhof.nl
password = dasusenet
connections = 8
ssl = 1
ssl_verify = 3
ssl_ciphers = ""
enable = 1
required = 0
optional = 0
retention = 0
expire_date = ""
quota = ""
usage_at_start = 0
priority = 1
notes = ""

View file

@ -1,3 +0,0 @@
#!/bin/bash
pwgen -s 128 1

View file

@ -1,19 +0,0 @@
#!/usr/bin/bash
import base64
import hashlib
import sys
import uuid
password = sys.argv[1]
salt = uuid.uuid4()
salt_bytes = salt.bytes
password = str.encode(password)
hashed_password = hashlib.pbkdf2_hmac("sha512", password, salt_bytes, 100000, dklen=64)
b64_salt = base64.b64encode(salt_bytes).decode("utf-8")
b64_password = base64.b64encode(hashed_password).decode("utf-8")
password_string = "@ByteArray({salt}:{password})".format(
salt=b64_salt, password=b64_password
)
print(password_string)

View file

@ -1,3 +0,0 @@
#!/bin/bash
pwgen -s 128 1

View file

@ -1,3 +0,0 @@
#!/bin/bash
python ./hash.py "$(just vars get ulmo qbittorrent/password | jq -r)"

View file

@ -1,3 +0,0 @@
#!/bin/bash
pwgen -s 128 1

View file

@ -17,7 +17,5 @@ mkShell {
nixd nixd
openssl openssl
inputs.clan-core.packages.${stdenv.hostPlatform.system}.clan-cli inputs.clan-core.packages.${stdenv.hostPlatform.system}.clan-cli
nix-output-monitor
dos2unix
]; ];
} }

View file

@ -1,33 +0,0 @@
sops:
age:
- recipient: age19qfpf980tadguqq44zf6xwvjvl428dyrj46ha3n6aeqddwhtnuqqml7etq
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZMC9nRjVFWnZlMHJJK0Nl
dWFTR0FCUGNBYXIrUHlIUUphZll2QU9IOEZrCitFS3JvK3hYYmpEZ05aRStpdUd1
L3JjNDl1Z2hQQ3FuNUZNM1hCRUtQUG8KLS0tIEg4VVEvVjZYN3JHSXljQW1xS3E4
eVpyM1lSWExndlZhMkw2Vis4dVhjSVUKbk+z1h3Hb1A6SEbZ3g5vYui/FfkMyfxx
Zm67JenYittHvQggTIErAgJatTocfVB6Zy4FqJtPCOevTVrRTRkwAg==
-----END AGE ENCRYPTED FILE-----
- recipient: age1ewes0f5snqx3sh5ul6fa6qtxzhd25829v6mf5rx2wnheat6fefps5rme2x
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtOGJXWi9vUzdFbkx2NmVa
YnhITlNMc1RRRXoyOFNPN1B4VWQ5ZDUwNDFBCnVmdDFyUnptekxhOUlwdVcyRjFI
cHRSRkoyWnFVUDJMcXpVcmM5bjRKMkkKLS0tIDROWXR1UFFUa0NxcUtkdEwxQ2Vl
OW50OE9RMWpyT1AvS0QzZ3JVNDViYlkK77H0Uq3eRy0CHgH4bhdo7FVEJpKeR/DB
KZonll74qqsyW4n+hIbIybjaqtF3RBN4kj5ARuIGFmH8sAl6jSyHXA==
-----END AGE ENCRYPTED FILE-----
- recipient: age1jmrmdw4kmjeu9d6z74r2unqt7wpgsx24vqejmdjretsnsn8g4drsl3m98w
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZOURoRmk4QldEZExTRDYx
cXluYkg4OUFUNDNrQUNiNWRwKzhEQkdaemxzCnM3b25GYm5TM3NuNnBsVWRmQzNL
bTRabmx2UzBkN1dadlhwajN5RDIxVW8KLS0tIDhSQ1o4RGZBdlVHaHRKQWFyazU0
N0lnMjMvREpmNWZvTUdiT0tjMk4vTk0KmIN1a3gjmFzaEwJBu41sw5Z61UgiO5fc
/pkS22BeVonuB12SmJX+77A1CxFz1EwM8HSShFKlpN2hPCJFJL7Nng==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-02-25T07:59:24Z"
mac: ENC[AES256_GCM,data:64AkqWb97nUciWtOOHP/SZhUeo/5ahxa0cN14ILw/jmToFkn8uDrSfY8/ibqBB0mmfhwGzcnI/5QpCLVzCSgG1J68bdPeSsYTZPwy2/0S0ven+GeqYHMfJ2Q1eJE7TONyOEvSdYdUWG+ff5t0qhSet9F2BgFnMSKcNeAaxIY6KU=,iv:aMQXbKk8oKSLBHIZyJLJahu5HHEMysmhcgfpDdZG+Ak=,tag:hqBVXis8MdqRorxttYeQaw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.11.0

View file

@ -1,4 +1,5 @@
{ pkgs, ...}: { { ... }:
{
imports = [ imports = [
./disks.nix ./disks.nix
./hardware.nix ./hardware.nix
@ -6,10 +7,6 @@
system.activationScripts.remove-gtkrc.text = "rm -f /home/chris/.gtkrc-2.0"; system.activationScripts.remove-gtkrc.text = "rm -f /home/chris/.gtkrc-2.0";
services.logrotate.checkConfig = false;
environment.systemPackages = with pkgs; [ beyond-all-reason openrct2 ];
sneeuwvlok = { sneeuwvlok = {
hardware.has = { hardware.has = {
gpu.amd = true; gpu.amd = true;
@ -33,6 +30,7 @@
}; };
}; };
services.displayManager.autoLogin = { services.displayManager.autoLogin = {
enable = true; enable = true;
user = "chris"; user = "chris";

View file

@ -1,22 +1,16 @@
{pkgs, ...}: { { ... }:
{
imports = [ imports = [
./disks.nix ./disks.nix
./hardware.nix ./hardware.nix
]; ];
environment.systemPackages = with pkgs; [
azure-cli
github-copilot-cli
];
sneeuwvlok = { sneeuwvlok = {
hardware.has = { hardware.has = {
bluetooth = true; bluetooth = true;
audio = true; audio = true;
}; };
authentication.himmelblau.enable = true;
application = { application = {
steam.enable = true; steam.enable = true;
}; };

View file

@ -1,21 +1,9 @@
{ {...}: {
pkgs,
config,
...
}: {
imports = [ imports = [
./disks.nix ./disks.nix
./hardware.nix ./hardware.nix
]; ];
environment.systemPackages = with pkgs; [bup];
services.postgresqlBackup = {
enable = true;
backupAll = true;
startAt = "*-*-* 01:00:00";
location = "/var/backup/postgresql";
};
networking = { networking = {
interfaces.enp2s0 = { interfaces.enp2s0 = {
ipv6.addresses = [ ipv6.addresses = [
@ -39,6 +27,17 @@
}; };
}; };
# Expose amarht cloud stuff like this until I have a proper solution
services.caddy.virtualHosts = {
"auth.amarth.cloud".extraConfig = ''
reverse_proxy http://192.168.1.223:9092
'';
"amarth.cloud".extraConfig = ''
reverse_proxy http://192.168.1.223:8080
'';
};
sneeuwvlok = { sneeuwvlok = {
services = { services = {
backup.borg.enable = true; backup.borg.enable = true;
@ -115,27 +114,7 @@
}; };
mydia = { mydia = {
redirectUris = ["http://localhost:2100/auth/oidc/callback"]; redirectUris = ["http://localhost:2010/auth/oidc/callback"];
grantTypes = ["authorizationCode"];
responseTypes = ["code"];
};
grafana = {
redirectUris = ["http://localhost:9010/login/generic_oauth"];
grantTypes = ["authorizationCode"];
responseTypes = ["code"];
};
};
};
convex = {
projectRoleCheck = true;
projectRoleAssertion = true;
hasProjectCheck = true;
application = {
scry = {
redirectUris = ["https://nautical-salamander-320.eu-west-1.convex.cloud/api/auth/callback/zitadel"];
grantTypes = ["authorizationCode"]; grantTypes = ["authorizationCode"];
responseTypes = ["code"]; responseTypes = ["code"];
}; };
@ -180,26 +159,15 @@
development.forgejo.enable = true; development.forgejo.enable = true;
networking.ssh.enable = true; networking.ssh.enable = true;
networking.caddy.hosts = {
# Expose amarht cloud stuff like this until I have a proper solution
"auth.amarth.cloud" = ''
reverse_proxy http://192.168.1.223:9092
'';
"amarth.cloud" = ''
reverse_proxy http://192.168.1.223:8080
'';
};
media.enable = true; media.enable = true;
media.glance.enable = true; media.glance.enable = true;
media.mydia.enable = true; media.mydia.enable = true;
media.nfs.enable = true; media.nfs.enable = true;
media.jellyfin.enable = true;
media.servarr = { media.servarr = {
radarr = { radarr = {
enable = true; enable = true;
port = 2010; port = 2001;
rootFolders = [ rootFolders = [
"/var/media/movies" "/var/media/movies"
]; ];
@ -208,7 +176,7 @@
sonarr = { sonarr = {
enable = true; enable = true;
# debug = true; # debug = true;
port = 2020; port = 2002;
rootFolders = [ rootFolders = [
"/var/media/series" "/var/media/series"
]; ];
@ -217,7 +185,7 @@
lidarr = { lidarr = {
enable = true; enable = true;
debug = true; debug = true;
port = 2030; port = 2003;
rootFolders = [ rootFolders = [
"/var/media/music" "/var/media/music"
]; ];
@ -225,21 +193,21 @@
prowlarr = { prowlarr = {
enable = true; enable = true;
# debug = true; debug = true;
port = 2040; port = 2004;
}; };
}; };
observability = { observability = {
alloy.enable = true;
grafana.enable = true; grafana.enable = true;
loki.enable = true;
prometheus.enable = true; prometheus.enable = true;
loki.enable = true;
promtail.enable = true; promtail.enable = true;
tempo.enable = true;
# uptime-kuma.enable = true; # uptime-kuma.enable = true;
}; };
persistance.convex.enable = true;
security.vaultwarden = { security.vaultwarden = {
enable = true; enable = true;
database = { database = {

View file

@ -4,13 +4,13 @@ email:
zitadel: zitadel:
masterKey: ENC[AES256_GCM,data:4MPvBo407qrS7NF4oUTf84tZoPkSRmiHdD7qpkYeHME=,iv:H2NIAN0xBUDqnyco9gA3zYAsKtSeA/JpqYrPhc1eqc0=,tag:6OFGDfsucG5gDerImgpuXA==,type:str] masterKey: ENC[AES256_GCM,data:4MPvBo407qrS7NF4oUTf84tZoPkSRmiHdD7qpkYeHME=,iv:H2NIAN0xBUDqnyco9gA3zYAsKtSeA/JpqYrPhc1eqc0=,tag:6OFGDfsucG5gDerImgpuXA==,type:str]
nix: {} nix: {}
users: ENC[AES256_GCM,data:ikpAuiQT32i4+aaVPz/nRqlf5ESID3khat2MrOySOfF9duJaQLWBonaKau6JVRljnGb+RGTiEH/EpxzXHnNydfHrir/jS4cDFMUMNV9aee0CyEbfqHAFqbC3B4ReZZE+XCkiq1j5jLnRg7EiGRK5+g+ul2iGIAwJ5SoHiOSSBcJ2E4B+AdkhGVO6Qsf+DW3hUZ/MsoaDsOB3IX15iC6/9z+NT+/Jefz5In6jn/vdYpD2i/zWvNHHPVXIkK1Co8FUidRdOjyWiiCb4+A0DI5v9E69xKe4zl26GHv3+1aK7cTxq2meDI4AXKhaTpak0A/neO/E6Xrc78752rTNRUDre9jJNrip/UPu8KvaCzpUi8Y4aN2Qg6ICF6JudzgouFyOGJ/JyxjVcJhUBOof/vCOcihdmHlo8sgyAi5mn/70VqnEF6Ei4KkRMAMlz9mfEVHDmjWMP1wHLw8eJD+Vhn/AJ76VecSCr51OHYtwgEcQXC6ikyPwBn8XQ5CNae/XGhcs0c8UbAcUXCH40zxvn4DFYHzJCkwurqv2iiV5zRN+rre6SoEWIToByq5KAwzkgLrLIVIbYWcLXlBYLvuMjnHbRknqWndQS72fRds0EWg+/OfjO+0SrPkJIoHkMNiUUmoq17ouwz0mcKVEh3o1Wptrp54ArDLkUjdtbOhaGTEzpGH+y0b+LITiN0erGPFITjf8sgGtvg+fRnoqCxPpex99,iv:+MjTW26sd8csWm4RXscFMgUm3wNY5Yj+qP8Xfg/WvsQ=,tag:mXjrEJqpbuqaVLa8EJpjoQ==,type:str] users: ENC[AES256_GCM,data:xkjm0+PBt6gmZyfi3n3OIEe5b+d4OtN0Y3UfmdcbcJHbJZuiz+60oUjlAN0vjtsi0muufoAqtGJTIpm9nDZzzN7b7LK43TAhcuSlIm5LpbZFp1U3H4laRbTwauAT6wA0aDCfAkwTozxAuEUk1jAu+65ktJNJb7b0PR7s/I/wf7IgW2+K4Jv3LIOZIipUwfuvXuTzsxCElYRvGZXmIuXrYq1EaymksHHggemrKeMWLAae7mzz5v3aBbwxiVjQNkQkS4ApsO/5nZUat0oqXA==,iv:fptZn4NmX3iYKSEPLJAOFpt+KQ6TR1w9KaY9IF4p/Wk=,tag:UKvMOSIT5/mhfZA3usbLhQ==,type:str]
forgejo: forgejo:
action_runner_token: ENC[AES256_GCM,data:yJ6OnRq5kinbuhvH06K5o3l86EafuBoojMwg/qhP+cgeH+BwPeE+Ng==,iv:IeXJahPxgLNIUFmkgp495tLVh8UyQBmJ2SnVEUhlhHs=,tag:XYQi613CxSp8AQeilJMrsg==,type:str] action_runner_token: ENC[AES256_GCM,data:yJ6OnRq5kinbuhvH06K5o3l86EafuBoojMwg/qhP+cgeH+BwPeE+Ng==,iv:IeXJahPxgLNIUFmkgp495tLVh8UyQBmJ2SnVEUhlhHs=,tag:XYQi613CxSp8AQeilJMrsg==,type:str]
synapse: synapse:
oidc_id: ENC[AES256_GCM,data:XbCpyGq0LeRJWq8dv/5Dipvp,iv:YDhgl26z1NBbIQLoLdGVz0+ze6o1ZcmgVHPfwoRj57I=,tag:y2vUuqnDmtTvVQmZCAlnLg==,type:str] oidc_id: ENC[AES256_GCM,data:XbCpyGq0LeRJWq8dv/5Dipvp,iv:YDhgl26z1NBbIQLoLdGVz0+ze6o1ZcmgVHPfwoRj57I=,tag:y2vUuqnDmtTvVQmZCAlnLg==,type:str]
oidc_secret: ENC[AES256_GCM,data:nVFi5EFbNMZ0mvrDHVYC0NiwJlo2eEw44D+Fcv9SKSb2oO00lGEDkP/oXDj5YgDq6RLQSe3f/SUOn77ntwnZYg==,iv:awe7VNUYOn9ofl1QlQTrEN5d0i5WkVM35qndruL4VXo=,tag:8Yoc9lFF9aWbtAa5fzQGEA==,type:str] oidc_secret: ENC[AES256_GCM,data:nVFi5EFbNMZ0mvrDHVYC0NiwJlo2eEw44D+Fcv9SKSb2oO00lGEDkP/oXDj5YgDq6RLQSe3f/SUOn77ntwnZYg==,iv:awe7VNUYOn9ofl1QlQTrEN5d0i5WkVM35qndruL4VXo=,tag:8Yoc9lFF9aWbtAa5fzQGEA==,type:str]
shared_secret: ENC[AES256_GCM,data:IkzZ6QV1gLzChAFSsYsK3HM5dKFD4AoDJ53xgoxNpgt5tb45mMw/LRxu4NArGVLUtVGBy6jk6arU+Nxvi8bxPOC8c2UFCRUF+FM1phICEbb4Chgy5g803VKNFOu6BLaEmwDmuZSQP7CwX1hy8TX8yChboHGp7hH+n5SAZpejrLg=,iv:d+Ab91yCltYwudDWhrWPw0Xod/TKriCsoGD8i6PD4H4=,tag:xOXnzNuajcOz+imjMJr3Dg==,type:str] kaas: ENC[AES256_GCM,data:3yI6lH0rw+f2OFJ94Z7zb0pYwy4FDFs9rJi2wpd9VVWghmey5g4O788ypXa34XqKCQDDHDgTxwyDs6KpvCQQaLV1PDhXd4Po0SSlIOkUtCWhOf6Tp3PM2ASoE+AAAzJLJUc6AZdBJRyYU9V+UvO9jW+WmlpZpsg5crnVMzZo7f2AF0ep9A/A5BL1Y2UhYQE4LDVkLC9AL3hl8IhF5xSdZdO0ugrP0x7CKVUxA7fJyOjx7/IKVwvgKD4xlhIgv9lYPTvE2vUs+w==,iv:e6b98ZnBqf7hh3SSKGdTl63OpQm1oK95lHXdwTiLft8=,tag:IS/lDgvJvSd7OmDLP+uG1g==,type:str]
radarr: radarr:
apikey: ENC[AES256_GCM,data:G141GW4PyS5pbAV39HcVscMw3s30txOgTZzWaL7o+ccZfnfDLv796O6xKXdqGZ8saLsveghLw9Z6a5luusHyQ3Q5ESL6W7SVeZVTuSqSC3i/4jl75FJxhnsgVsfrnYxzLGpKiw==,iv:sZl/XLh6y3WgSAn6nH3sFB6atBifZdghm+QsCNDbcjY=,tag:Tw+R80nrF0T0yDti0Uf+ig==,type:str] apikey: ENC[AES256_GCM,data:G141GW4PyS5pbAV39HcVscMw3s30txOgTZzWaL7o+ccZfnfDLv796O6xKXdqGZ8saLsveghLw9Z6a5luusHyQ3Q5ESL6W7SVeZVTuSqSC3i/4jl75FJxhnsgVsfrnYxzLGpKiw==,iv:sZl/XLh6y3WgSAn6nH3sFB6atBifZdghm+QsCNDbcjY=,tag:Tw+R80nrF0T0yDti0Uf+ig==,type:str]
sonarr: sonarr:
@ -24,25 +24,9 @@ mydia:
oidc_secret: ENC[AES256_GCM,data:PgI4hmP/3wt9uj+1QvCYcT8Wav0hgCRADouzWM3V695SSfXfbwDgez8tA/tm1/1jymAU2F2sZH8G2hZ1cdHyHQ==,iv:h3o3jsTmnoNE3+mGX12J3ZU0/6PlQNjdndEvaj/czj0=,tag:p3+p4E8fBtR7a8UpM8cUsg==,type:str] oidc_secret: ENC[AES256_GCM,data:PgI4hmP/3wt9uj+1QvCYcT8Wav0hgCRADouzWM3V695SSfXfbwDgez8tA/tm1/1jymAU2F2sZH8G2hZ1cdHyHQ==,iv:h3o3jsTmnoNE3+mGX12J3ZU0/6PlQNjdndEvaj/czj0=,tag:p3+p4E8fBtR7a8UpM8cUsg==,type:str]
secret_key_base: ENC[AES256_GCM,data:yG7HJ5r74Qtxbeyf8F6dA0uHv2pQ8YAJKlKiKjS+m24JRvJWQaTThJ+c5HbuUa6R3e9XtVHchhlVPkF0Is/b+g==,iv:v65xdRr4JdKZmBtjZ08/J3LLqnphSGt9QfVPNQ2x/xg=,tag:n7tD2dhr4IJn1LWM9WW8UA==,type:str] secret_key_base: ENC[AES256_GCM,data:yG7HJ5r74Qtxbeyf8F6dA0uHv2pQ8YAJKlKiKjS+m24JRvJWQaTThJ+c5HbuUa6R3e9XtVHchhlVPkF0Is/b+g==,iv:v65xdRr4JdKZmBtjZ08/J3LLqnphSGt9QfVPNQ2x/xg=,tag:n7tD2dhr4IJn1LWM9WW8UA==,type:str]
guardian_secret: ENC[AES256_GCM,data:OjnNFSHlecL+qXwlhTm++itRM6ga5E5KrSJxbgIUpbMEkIWgu3xhRtnPdipXbedgall0XdO/s+jnWCagZX94BA==,iv:DukdKvm9vey8BWUiml20tgA/Vji1XVX4+sUPge9nTk0=,tag:q3HdvgUYqR0APiaFz0ul5Q==,type:str] guardian_secret: ENC[AES256_GCM,data:OjnNFSHlecL+qXwlhTm++itRM6ga5E5KrSJxbgIUpbMEkIWgu3xhRtnPdipXbedgall0XdO/s+jnWCagZX94BA==,iv:DukdKvm9vey8BWUiml20tgA/Vji1XVX4+sUPge9nTk0=,tag:q3HdvgUYqR0APiaFz0ul5Q==,type:str]
grafana:
oidc_id: ENC[AES256_GCM,data:NVdIgCQ6nz4BSUDJYCKyILtK,iv:tcljy9PzC/yyd7TSdngyJt+uh60uXi2PKu47czErbaQ=,tag:zE4q3dD4UQaHIpGeZ1L48Q==,type:str]
oidc_secret: ENC[AES256_GCM,data:b7qILK9ZHW2khtM1Hl/KdjCv3Wq6eOo2Ym/cbjcMB8/3Hn2UelpP4K4lFyiV3bn1/GF6Jl5Z7A0EwMybOx0InA==,iv:3HL/7BiyObwT8DmFxzNPI9CdmCH/4j/4oc9x7qBE1k0=,tag:dBhcq1zLKy6N+jp/v42R4A==,type:str]
secret_key: ENC[AES256_GCM,data:u6IRFV1D/4g+eqQIUPW0QHlkoa+MliymThp34k+QCHqQ247er4bCdgftuWsXgPAPY7DtwFVLG7Do5eBqIiii7g==,iv:FY7LIW0O5/Cp2JvYu17ctInt0rgkzjaPHfxZBs0GTac=,tag:Gtu+ZGAgsi5vzILOKDac1g==,type:str]
sabnzbd:
sunnyweb:
password: ENC[AES256_GCM,data:flw8AahqO1Mx,iv:Qhu8iVWMzzqy18y8dj3aHoBnSZatm74/tYvZ456l2sA=,tag:sCYBdw7kD0zJZFFr5EyPIQ==,type:str]
username: ENC[AES256_GCM,data:IboJ8WDWuVNgvrk7c3V8I5S6Xg==,iv:BRohMuQFQz2S+HFasIaok6npT3C5v/SlhAhbLQXfB0s=,tag:M3/u0WBQ3AufHqe4DCtsrA==,type:str]
apikey: ENC[AES256_GCM,data:j5sPXKbBhMdNHOuoTfZ+c8nGu5JameOgK2z428iLdP01Hi6MvHVaN8Zs8YxMoSBtOjdtIEC8MS+3m1S1rU/P4pCRfZpK5ua1DBHq4l0xROUqokFWjDcAmJJv3pYXl0cQxQcGKQ==,iv:v5hu3gmO1Zn1FfXkHLPGN9f7JOcQjzoQahdqJwfM+xY=,tag:uI1LFcTgcyRgAaTJ1kzKow==,type:str]
nzbkey: ENC[AES256_GCM,data:tGFnZ24XNI7U8pVYq45ENSVTeVkkcWfT5/NewqSJ3sm7Bexxml/PFTMBIl+97mWzNMMFklBurX/115P06NHCj1mxEvIjIc1bF4yuYhZFdSTlqRVWaESE/Ei7gke758FCt37N43wADgaKj4i5jizDHJMIbaw8ncP3qBSCy1F4BAU=,iv:RA+3oYGhVLBG+ikHMwBG3t2iN15lGsncdmlkfF6vJhY=,tag:6FNM18KCSzzpIXYDpQfHSg==,type:str]
whisparr:
apikey: ENC[AES256_GCM,data:kIGCsd4mszm90PoQMzlSEBKw9Ow0GvP1qdLtwXYKkAb6b65l89v8lMWJ2X1MyD2gJX+P+Bv1F/2BSjUFXErq/UYnp4dAjwKi/ezGCbhjMutDM1FvwFWEHRnR3gjd9uXPWJ8Xhg==,iv:98aPQlcZHJovpnzACDs6RtKblLnHg6wyi+Er5DAowj8=,tag:Tl8jz/pWYWAtBCfoztKdyw==,type:str]
coturn:
secret: ENC[AES256_GCM,data:5RmLZ7vQIAvIzvax8oNJkImQ6vXR+MZ2eqxaBJCBlccnFC1rP16/6UtausXVf0eWysw+fpMW5yEmUtAdyxQoPiBCK8lziAZBdkekQnAvFouBaWy8WIZt6XRa71P4xDCDGudpMiGwGGNt+R9yylez+azaLrLyJM3481RPohDMoOM=,iv:2P83lgxGtHwYr+ApAdHopVfRWagxWlC+nt53API/SiQ=,tag:Qv+A03BE1QvEqJMtORiQVA==,type:str]
qbittorrent: qbittorrent:
password: ENC[AES256_GCM,data:LIDxh0Ni0JgQGWFix/Ihw7IlUPgzMhrMlWNP5LKkAnEM6EoqA9kFwiPeizB0CZ20+vSqRiL9fikBf8qGLA17L7AKh8I4OTFDlpKpMRtRlMq9S5UBEyOqtOMcvkCSf6/qGoORd1KJSlaitZk47SYRuccOpy/2vAvbMRdLm0SYEqc=,iv:tQdN1N9kXoq7OZbR2eYyy50FltsMAAUI4Lr7U4/SpJE=,tag:3ZOLvjHXD7i7WFy1/Ggqtg==,type:str] password_hash: ENC[AES256_GCM,data:QWuQYmfBn9eLDYztH7TmQvw74MvmzCQ98OlBtyjm1Icr2c63epRuHWzQbm+Q+1jrCSiQreOB3ZyjLzkeV6SlLonryUSD71uBWVwctgPXO0XDrxE1Vi6dkiwC3TF65JTMDhyjDLEj1YkiMP25Fz5NidJTP/r9GlXTfM7gjWo=,iv:bpgL5IoAv+1PUtgNIjLcbzN8C9z55ndypz4LEELAhLc=,tag:VB+XTCwLeIEYKnOr/0f7zA==,type:str]
password_hash: ENC[AES256_GCM,data:urufJbSErLqPdU6jLLZk+27fe4k+cKLXcGRGSqroUDdGMzDnhSF+ZWuPxwDlJQR3ws2GnuiEASncwNO/SALKXFDk2V2gsKJ4hsjyiIbsqCwSEFB/XMY0nY/x0xrcIfMVE0HdrNYeQ3zT01Z5jQpSd7wo2M63LaULL/Av498=,iv:tnUVhOgrImKa6iii2hJZn5LKrySM5v47B2zDZMgmUow=,tag:g3xa/4Z+t1Q9Wnd4XzefLg==,type:str] password: ENC[AES256_GCM,data:UepYY6UjJV/jo2aXTOEnKRtsjSqOSYPQlKlrAa7rf9rdnt2UXGjCkvN+A72pICuIBCAmhXZBAUMvmWTV9trk6NREHe0cY1xTC7pNv3x9TM/ZQmH498pbT/95pYAKwouHp9heJQ==,iv:FzjF+xPoaOp+gplxpz940V2dkWSTWe8dWUxexCoxxHc=,tag:TDZsboq9fEmmBrwJN/HTpQ==,type:str]
backup:
ssh-key: ENC[AES256_GCM,data:aRY+9mYssEXPmfJQ2KOYU4wxkgzgYbv3GJ4KUkECSZ6IdQVv4CpKMg75dEhO5/t7MYjiNXze5WibZ0UHSTnUv4OB6NP6Mp1HZjIZb6paCJxjkoul0BVwtF5AKViJe0LIKoh+,iv:kZgZTqgYdqJSD6rO3lj/IFqhO9mYgZ7YYOCS2b+xpXQ=,tag:xPh0yL2uMyqgrioC36PPpA==,type:str]
sops: sops:
age: age:
- recipient: age19qfpf980tadguqq44zf6xwvjvl428dyrj46ha3n6aeqddwhtnuqqml7etq - recipient: age19qfpf980tadguqq44zf6xwvjvl428dyrj46ha3n6aeqddwhtnuqqml7etq
@ -63,7 +47,7 @@ sops:
TTRWaHhpNWlkVDFmMFN4ZTNHMUxyNVkKV693pzTKRkZboQCMPr9IyMGSgxfuHXcb TTRWaHhpNWlkVDFmMFN4ZTNHMUxyNVkKV693pzTKRkZboQCMPr9IyMGSgxfuHXcb
Y6BNcp6Qg6PWtX5QI7wRkPNINAK1TEbRBba+b8h6gMmVU4DliQyFiQ== Y6BNcp6Qg6PWtX5QI7wRkPNINAK1TEbRBba+b8h6gMmVU4DliQyFiQ==
-----END AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE-----
lastmodified: "2026-04-16T05:20:18Z" lastmodified: "2025-12-04T11:24:52Z"
mac: ENC[AES256_GCM,data:YqkxwV30uqSHhsn4niFEODxxl9R2ZuiyyX4g8zONVjMvdA52C08zPpxdxjtXnUT9m3sT7iSmWcJJZwhMhRIb8LJ2sdIJ4v+wpG9I4pPokhEXI2ozqbzw3k68GnZOzYu3kePQBJjQx1fmlM63dgILIwx7ytPnpm9arQ1rszZynNs=,iv:hxdhU5oH9h9mRH3m76oFkYVNA68PnivVJpJRjxSRtTw=,tag:Fyyg6cWPb96c/Vap+PifUQ==,type:str] mac: ENC[AES256_GCM,data:jIgkl1lcVDSlKqJs9fjaHUAZsGL+22T86/qqKyDziHl0+VU763Ezwm8P+la+55jIIT2zLhFcUjhn2BabBi90OeEPztAC4rGpZj6+ZZ0GDCj/JhjPAAo3LgAKOCG0Xgf8MZWr/rXd6bLhW7Qj36PMJnap26rjEiUZeSvpWS2dz8g=,iv:CDx8fBI9Dl1uwrbMD1fa7/h3C7haK3xZxJI59mtL1LA=,tag:2UDRFJoevGEBKZA/9eUiOw==,type:str]
unencrypted_suffix: _unencrypted unencrypted_suffix: _unencrypted
version: 3.11.0 version: 3.11.0