From 0afdf018f980563ddd59e58ebc93a5daf247b9a6 Mon Sep 17 00:00:00 2001
From: cl0vrfi3ld <47996003+cl0vrfi3ld@users.noreply.github.com>
Date: Sat, 14 Jun 2025 20:12:51 -0400
Subject: [PATCH] again attempting to fix this hell of a git history
---
.gitignore | 1 +
sp-modules/actual/config-paths-needed.json | 59 +++++
sp-modules/actual/flake.nix | 41 ++++
sp-modules/actual/icon-lg.svg | 48 +++++
sp-modules/actual/icon.svg | 44 ++++
sp-modules/actual/module.nix | 239 +++++++++++++++++++++
6 files changed, 432 insertions(+)
create mode 100644 sp-modules/actual/config-paths-needed.json
create mode 100644 sp-modules/actual/flake.nix
create mode 100644 sp-modules/actual/icon-lg.svg
create mode 100644 sp-modules/actual/icon.svg
create mode 100644 sp-modules/actual/module.nix
diff --git a/.gitignore b/.gitignore
index 6e35230..d584aee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+.DS_Store
userdata/userdata.json
userdata/tokens.json
hardware-configuration.nix
diff --git a/sp-modules/actual/config-paths-needed.json b/sp-modules/actual/config-paths-needed.json
new file mode 100644
index 0000000..2e4e2fc
--- /dev/null
+++ b/sp-modules/actual/config-paths-needed.json
@@ -0,0 +1,59 @@
+[
+ [
+ "services",
+ "actual"
+ ],
+ [
+ "systemd",
+ "services",
+ "actual"
+ ],
+ [
+ "selfprivacy",
+ "domain"
+ ],
+ [
+ "selfprivacy",
+ "useBinds"
+ ],
+ [
+ "selfprivacy",
+ "modules",
+ "actual"
+ ],
+ [
+ "selfprivacy",
+ "modules",
+ "auth",
+ "enable"
+ ],
+ [
+ "selfprivacy",
+ "sso",
+ "enable"
+ ],
+ [
+ "selfprivacy",
+ "passthru",
+ "auth",
+ "mkOAuth2ClientSecretFP"
+ ],
+ [
+ "selfprivacy",
+ "passthru",
+ "auth",
+ "mkServiceAccountTokenFP"
+ ],
+ [
+ "selfprivacy",
+ "passthru",
+ "auth",
+ "oauth2-discovery-url"
+ ],
+ [
+ "selfprivacy",
+ "passthru",
+ "auth",
+ "oauth2-provider-name"
+ ]
+]
\ No newline at end of file
diff --git a/sp-modules/actual/flake.nix b/sp-modules/actual/flake.nix
new file mode 100644
index 0000000..beaefac
--- /dev/null
+++ b/sp-modules/actual/flake.nix
@@ -0,0 +1,41 @@
+{
+ description = "Flake description";
+
+ outputs =
+ { self }:
+ {
+ nixosModules.default = import ./module.nix;
+ configPathsNeeded = builtins.fromJSON (builtins.readFile ./config-paths-needed.json);
+ meta =
+ { lib, ... }:
+ {
+ spModuleSchemaVersion = 1;
+ id = "actual";
+ name = "Actual";
+ description = "Actual (aka Actual Budget) is a super fast and privacy-focused app for managing your finances.";
+ svgIcon = builtins.readFile ./icon.svg;
+ showUrl = true;
+ primarySubdomain = "subdomain";
+ isMovable = false;
+ isRequired = false;
+ canBeBackedUp = true;
+ backupDescription = "Your budgets, settings, and account secrets (where applicable).";
+ systemdServices = [
+ "actual.service"
+ ];
+ user = "actual";
+ group = "actual";
+ folders = [
+ "/var/lib/actual"
+ ];
+
+ license = [
+ lib.licenses.mit
+ ];
+ homepage = "https://actualbudget.org/";
+ sourcePage = "https://github.com/actualbudget/actual";
+ # since this module hasn't been thoroughly tested, I'd advertise it as `experimental`, but is also a `community` class module
+ supportLevel = "experimental";
+ };
+ };
+}
diff --git a/sp-modules/actual/icon-lg.svg b/sp-modules/actual/icon-lg.svg
new file mode 100644
index 0000000..9f15e98
--- /dev/null
+++ b/sp-modules/actual/icon-lg.svg
@@ -0,0 +1,48 @@
+
+
+
+
diff --git a/sp-modules/actual/icon.svg b/sp-modules/actual/icon.svg
new file mode 100644
index 0000000..762f5eb
--- /dev/null
+++ b/sp-modules/actual/icon.svg
@@ -0,0 +1,44 @@
+
+
+
+
diff --git a/sp-modules/actual/module.nix b/sp-modules/actual/module.nix
new file mode 100644
index 0000000..733391d
--- /dev/null
+++ b/sp-modules/actual/module.nix
@@ -0,0 +1,239 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+let
+ # Just for convenience, this module's config values
+ sp = config.selfprivacy;
+ cfg = sp.modules.actual;
+
+ is-auth-enabled = cfg.enableSso && config.selfprivacy.sso.enable;
+ oauthClientID = "actual";
+ auth-passthru = config.selfprivacy.passthru.auth;
+ full-domain = "https://${cfg.subdomain}.${sp.domain}";
+ redirect-uri = "${full-domain}/openid/callback";
+ landing-uri = "${full-domain}/login";
+ oauthDiscoveryURL = auth-passthru.oauth2-discovery-url oauthClientID;
+ adminsGroup = "sp.${oauthClientID}.admins";
+ usersGroup = "sp.${oauthClientID}.users";
+
+ linuxUserOfService = "actual";
+ linuxGroupOfService = "actual";
+
+ oauthClientSecretFP = auth-passthru.mkOAuth2ClientSecretFP linuxGroupOfService;
+ oauthSecretDir = "/run/actual/shh";
+ oauthSecretFile = "${oauthSecretDir}/totallynotasecretfile.env";
+ # creates an env file with the oauth client secret and configures permissions for the actual user/group
+ oauthClientInjectScript = pkgs.writeShellScript "inject-oidc-secrets" ''
+ mkdir -p ${oauthSecretDir}
+ echo "ACTUAL_OPENID_CLIENT_SECRET=$(<${oauthClientSecretFP})" > ${oauthSecretFile}
+ chown actual:actual ${oauthSecretFile}
+ chmod 600 ${oauthSecretFile}
+ '';
+in
+{
+ # Here go the options you expose to the user.
+ options.selfprivacy.modules.actual = {
+ # This is required and must always be named "enable"
+ enable =
+ (lib.mkOption {
+ default = false;
+ type = lib.types.bool;
+ description = "Enable the Actual Budget server";
+ })
+ // {
+ meta = {
+ type = "enable";
+ };
+ };
+ # This is required if your service stores data on disk
+ location =
+ (lib.mkOption {
+ type = lib.types.str;
+ description = "Data location";
+ })
+ // {
+ meta = {
+ type = "location";
+ };
+ };
+ # This is required if your service needs a subdomain
+ subdomain =
+ (lib.mkOption {
+ default = "actual";
+ type = lib.types.strMatching "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]";
+ description = "Subdomain";
+ })
+ // {
+ meta = {
+ widget = "subdomain";
+ type = "string";
+ regex = "[A-Za-z0-9][A-Za-z0-9\-]{0,61}[A-Za-z0-9]";
+ weight = 0;
+ };
+ };
+ # Other options, that user sees directly.
+ # Refer to Module options reference to learn more.
+ enableSso =
+ (lib.mkOption {
+ default = false;
+ type = lib.types.bool;
+ description = "Enable Single Sign-On";
+ })
+ // {
+ meta = {
+ type = "bool";
+ weight = 2;
+ };
+ };
+ enableDebug =
+ (lib.mkOption {
+ default = false;
+ type = lib.types.bool;
+ description = "Enable Debug Logging";
+ })
+ // {
+ meta = {
+ type = "bool";
+ weight = 3;
+ };
+ };
+ };
+
+ # All your changes to the system must go to this config attrset.
+ # It MUST use lib.mkIf with an enable option.
+ # This makes sure your module only makes changes to the system
+ # if the module is enabled.
+ config = lib.mkIf cfg.enable (
+ lib.mkMerge [
+ {
+ # prevent SSO from being enabled in the module config if SSO isn't available/is disabled
+ assertions = [
+ {
+ assertion = cfg.enableSso -> sp.sso.enable;
+ message = "SSO cannot be enabled for Actual when SSO is disabled globally.";
+ }
+ ];
+ # If your service stores data on disk, you have to mount a folder
+ # for this. useBinds is always true on modern SelfPrivacy installations
+ # but we keep this mkIf to keep migration flow possible.
+ fileSystems = lib.mkIf sp.useBinds {
+ "/var/lib/actual" = {
+ device = "/volumes/${cfg.location}/actual";
+ # Make sure that your service does not start before folder mounts
+ options = [
+ "bind"
+ "x-systemd.required-by=actual.service"
+ "x-systemd.before=actual.service"
+ ];
+ };
+ };
+
+ # actual service config
+ services.actual = {
+ enable = true;
+ settings = {
+ port = 5006;
+ # default to only password logins
+ allowedLoginMethods = [ "password" ];
+ };
+ };
+ # adding the user/group to be used by the service
+ users = {
+ users.actual = {
+ isSystemUser = true;
+ group = "actual";
+ };
+ groups.actual = {};
+ };
+
+ systemd = {
+ services = {
+ actual = {
+ # extra guard against the service starting before the bind has been mounted
+ unitConfig.RequiresMountsFor = lib.mkIf sp.useBinds "/volumes/${cfg.location}/actual";
+ serviceConfig = {
+ Slice = "actual.slice";
+ # override dynamic user since service from nixpkgs enables by default, but it doesn't work in the selfprivacy environment
+ DynamicUser = lib.mkForce false;
+ # use service user
+ User = "actual";
+ # use service group
+ Group = "actual";
+ };
+ environment =
+ # tell actual to log debug info to the console if option is enabled
+ (lib.mkIf cfg.enableDebug {
+ DEBUG = "actual:config,actual-sensitive:config";
+ }
+ );
+ };
+ };
+ # Define the slice itself
+ slices.actual = {
+ description = "Actual server service slice";
+ };
+ };
+
+ # You can define a reverse proxy for your service like this
+ services.nginx.virtualHosts."${cfg.subdomain}.${sp.domain}" = {
+ useACMEHost = sp.domain;
+ forceSSL = true;
+ locations = {
+ "/" = {
+ proxyPass = "http://127.0.0.1:5006";
+ };
+ };
+ };
+ }
+ # SSO config
+ (lib.mkIf is-auth-enabled {
+ services.actual = {
+ settings = {
+ # permit openid logins
+ allowedLoginMethods = [ "openid" ];
+ # default to openid if enabled
+ loginMethod = "openid";
+ # SSO config
+ openId = {
+ discoveryURL = oauthDiscoveryURL;
+ client_id = oauthClientID;
+ server_hostname = full-domain;
+ authMethod = "openid";
+ };
+ };
+ };
+ systemd.services.actual = {
+ serviceConfig={
+ # run inject script with root privileges
+ ExecStartPre = "+${oauthClientInjectScript}";
+ # use the file generated by the inject script, even if it doesn't yet exist
+ EnvironmentFile = "-${oauthSecretFile}";
+ RuntimeDirectory = "actual";
+ };
+ };
+
+ # OIDC for Actual is currently in beta and requires legacy cryptography algorithms
+ services.kanidm.provision.systems.oauth2."${oauthClientID}".enableLegacyCrypto = true;
+ # Configure the OIDC client
+ selfprivacy.auth.clients."${oauthClientID}" = {
+ inherit adminsGroup usersGroup;
+ imageFile = ./icon-lg.svg;
+ displayName = "Actual";
+ subdomain = cfg.subdomain;
+ originLanding = landing-uri;
+ originUrl = redirect-uri;
+ clientSystemdUnits = [ "actual.service" ];
+ enablePkce = true;
+ linuxUserOfClient = linuxUserOfService;
+ linuxGroupOfClient = linuxGroupOfService;
+ useShortPreferredUsername = true;
+ scopeMaps.${usersGroup} = [ "email" "openid" "profile" ];
+ };
+ })
+ ]
+ );
+
+}