diff --git a/README.md b/README.md index 4fde771..15d92be 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Erpnext on NixOS +# ERPNext on NixOS ### Cachix Using the https://pub-solar.cachix.org binary cache: @@ -22,15 +22,40 @@ nix build --json .#run-erpnext \ ### NixOS VM ``` +# Build the test VM nix build '.#nixosConfigurations.test-vm.config.system.build.vm' +# Start the VM, to generate SSH host key files required for secrets ./result/bin/run-nixos-vm -# In the VM, use root & empty password to login +# Get the ed25519 SSH hostkey +ssh-keyscan -p 2222 127.0.0.1 + +# Edit secrets.nix and add the SSH hostkey to machine 'test-vm' +vim secrets/secrets.nix + +# Rekey the secrets with agenix +cd secrets +agenix --rekey +cd .. + +# Stop with CTRL-c & build the test VM again +nix build '.#nixosConfigurations.test-vm.config.system.build.vm' +# Start the VM +./result/bin/run-nixos-vm + +# Access the VM via SSH, use an empty password (Enter) to login +ssh root@localhost -p 2222 + # Watch erpnext startup: -# journalctl -fu erpnext.service +# The initial startup can take a few minutes +journalctl -fu erpnext-web.service + # Open http://localhost:8081 in your browser # User: Administrator # Password: admin + +# To reset all data for the VM +rm nixos.qcow2 ``` ### Docker diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 0000000..1ae1eb4 --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,117 @@ +# Secrets + +Secrets are managed using [agenix][agenix] +so you can keep your flake in a public repository like GitHub without +exposing your password or other sensitive data. + +## Agenix + +Currently, there is [no mechanism][secrets-issue] in nix itself to deploy secrets +within the nix store because it is world-readable. + +Most NixOS modules have the ability to set options to files in the system, outside +the nix store, that contain sensitive information. You can use [agenix][agenix] +to easily setup those secret files declaratively. + +[agenix][agenix] encrypts secrets and stores them as .age files in your repository. +Age files are encrypted with multiple ssh public keys, so any host or user with a +matching ssh private key can read the data. The [age module][age module] will add those +encrypted files to the nix store and decrypt them on activation to `/run/agenix`. + +### Setup + +All hosts must have openssh enabled, this is done by default in the core profile. + +You need to populate your `secrets/secrets.nix` with the proper ssh public keys. +Be extra careful to make sure you only add public keys, you should never share a +private key!! + +secrets/secrets.nix: + +```nix +let + system = ""; + user = ""; + allKeys = [ system user ]; +in +``` + +On most systems, you can get your systems ssh public key from `/etc/ssh/ssh_host_ed25519_key.pub`. If +this file doesn't exist you likely need to enable openssh and rebuild your system. + +Your users ssh public key is probably stored in `~/.ssh/id_ed25519.pub` or +`~/.ssh/id_rsa.pub`. If you haven't generated a ssh key yet, be sure do so: + +```sh +ssh-keygen -t ed25519 +``` + +> ##### _Note:_ +> +> The underlying tool used by agenix, rage, doesn't work well with password protected +> ssh keys. So if you have lots of secrets you might have to type in your password many +> times. + +### Secrets + +You will need the `agenix` command to create secrets. DevOS conveniently provides that +in the devShell, so just run `nix develop` whenever you want to edit secrets. Make sure +to always run `agenix` while in the `secrets/` folder, so it can pick up your `secrets.nix`. + +To create secrets, simply add lines to your `secrets/secrets.nix`: + +``` +let + ... + allKeys = [ system user ]; +in +{ + "secret.age".publicKeys = allKeys; +} +``` + +That would tell agenix to create a `secret.age` file that is encrypted with the `system` +and `user` ssh public key. + +Then go into the `secrets` folder and run: + +```sh +agenix -e secret.age +``` + +This will create the `secret.age`, if it doesn't already exist, and allow you to edit it. + +If you ever change the `publicKeys` entry of any secret make sure to rekey the secrets: + +```sh +agenix --rekey +``` + +### Usage + +Once you have your secret file encrypted and ready to use, you can utilize the [age module][age module] +to ensure that your secrets end up in `/run/secrets`. + +In any profile that uses a NixOS module that requires a secret you can enable a particular secret like so: + +```nix +{ self, ... }: +{ + age.secrets.mysecret.file = "${self}/secrets/mysecret.age"; +} +``` + +Then you can just pass the path `/run/agenix/mysecret` to the module. + +You can make use of the many options provided by the age module to customize where and how +secrets get decrypted. You can learn about them by looking at the +[age module][age module]. + +> ##### _Note:_ +> +> You can take a look at the [agenix repository][agenix] for more information +> about the tool. + +[agenix]: https://github.com/ryantm/agenix +[age module]: https://github.com/ryantm/agenix/blob/master/modules/age.nix +[secrets-issue]: https://github.com/NixOS/nix/issues/8 diff --git a/flake.lock b/flake.lock index 980e0ea..7e006e1 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,48 @@ { "nodes": { + "agenix": { + "inputs": { + "darwin": "darwin", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1682101079, + "narHash": "sha256-MdAhtjrLKnk2uiqun1FWABbKpLH090oeqCSiWemtuck=", + "owner": "ryantm", + "repo": "agenix", + "rev": "2994d002dcff5353ca1ac48ec584c7f6589fe447", + "type": "github" + }, + "original": { + "owner": "ryantm", + "repo": "agenix", + "type": "github" + } + }, + "darwin": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1689163348, + "narHash": "sha256-qL3FnBauu3PmYf0OZzv2cKtVyRg4ELVXPQl0AAYqlqs=", + "owner": "lnl7", + "repo": "nix-darwin", + "rev": "43ce086813c83184b88f67fc544af2050a3035ba", + "type": "github" + }, + "original": { + "owner": "lnl7", + "ref": "master", + "repo": "nix-darwin", + "type": "github" + } + }, "devshell": { "inputs": { "nixpkgs": [ @@ -41,6 +84,7 @@ }, "root": { "inputs": { + "agenix": "agenix", "devshell": "devshell", "nixpkgs": "nixpkgs", "systems": "systems" diff --git a/flake.nix b/flake.nix index c582151..6179a26 100644 --- a/flake.nix +++ b/flake.nix @@ -1,15 +1,20 @@ { description = "Dev Setup"; - inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - inputs.systems.url = "github:nix-systems/default"; + systems.url = "github:nix-systems/default"; - inputs.devshell.url = "github:numtide/devshell"; - inputs.devshell.inputs.nixpkgs.follows = "nixpkgs"; - inputs.devshell.inputs.systems.follows = "systems"; + devshell.url = "github:numtide/devshell"; + devshell.inputs.nixpkgs.follows = "nixpkgs"; + devshell.inputs.systems.follows = "systems"; - outputs = {self, nixpkgs, systems, devshell }: + agenix.url = "github:ryantm/agenix"; + agenix.inputs.nixpkgs.follows = "nixpkgs"; + }; + + outputs = {agenix, devshell, nixpkgs, self, systems}: let eachSystem = nixpkgs.lib.genAttrs (import systems); # Nixpkgs instantiated for system types in nix-systems @@ -19,6 +24,7 @@ overlays = [ self.overlays.default self.overlays.pythonOverlay + agenix.overlays.default devshell.overlays.default ]; } @@ -45,6 +51,11 @@ name = pkgs.cachix.pname; package = pkgs.cachix; } + { + help = pkgs.agenix.meta.description; + name = pkgs.agenix.pname; + package = pkgs.agenix; + } ]; bash.extra = '' ''; @@ -67,7 +78,10 @@ { test-vm = nixpkgs.lib.nixosSystem { inherit system pkgs; - modules = [./test-vm/configuration.nix]; + modules = [ + agenix.nixosModules.age + ./test-vm/configuration.nix + ]; }; }; }; diff --git a/modules/erpnext.nix b/modules/erpnext.nix index 5dcccbb..afa3b88 100644 --- a/modules/erpnext.nix +++ b/modules/erpnext.nix @@ -42,7 +42,7 @@ in adminPasswordFile = mkOption { type = types.nullOr types.path; default = null; - example = "/run/secrets/erpnext-password"; + example = "/run/secrets/erpnext-admin-password"; description = lib.mdDoc '' A file containing the Administrator user password. ''; @@ -69,10 +69,10 @@ in default = "erpnext"; description = lib.mdDoc "Database username."; }; - passwordFile = mkOption { + userPasswordFile = mkOption { type = types.nullOr types.path; default = null; - example = "/run/secrets/erpnext-db-password"; + example = "/run/secrets/erpnext-db-user-password"; description = lib.mdDoc '' A file containing the MariaDB erpnext user password. ''; @@ -80,7 +80,7 @@ in rootPasswordFile = mkOption { type = types.nullOr types.path; default = null; - example = "/run/secrets/erpnext-root-db-password"; + example = "/run/secrets/erpnext-db-root-password"; description = lib.mdDoc '' A file containing the MariaDB root user password. ''; @@ -144,7 +144,7 @@ in example = lib.literalExpression '' { serverAliases = [ - "dolibarr.''${config.networking.domain}" + "erpnext.''${config.networking.domain}" "erp.''${config.networking.domain}" ]; enableACME = false; @@ -195,10 +195,10 @@ in db_host = "${cfg.database.host}"; db_port = "${toString cfg.database.port}"; db_name = "${cfg.database.name}"; - db_password = "#NIXOS_ERPNEXT_DB_PASSWORD#"; - redis_cache = "redis://${cfg.redis.host}:${toString cfg.redis.port}"; - redis_queue = "redis://${cfg.redis.host}:${toString cfg.redis.port}"; - redis_socketio = "redis://${cfg.redis.host}:${toString cfg.redis.port}"; + db_password = "#NIXOS_ERPNEXT_DB_USER_PASSWORD#"; + redis_cache = "redis://${cfg.redis.host}:${toString cfg.redis.port}?db=1"; + redis_queue = "redis://${cfg.redis.host}:${toString cfg.redis.port}?db=2"; + redis_socketio = "redis://${cfg.redis.host}:${toString cfg.redis.port}?db=0"; socketio_port = "${toString cfg.socketIoPort}"; }; commonSiteConfigFile = pkgs.writeText "erpnext-common_site_config.json" (builtins.toJSON commonSiteConfig); @@ -209,6 +209,7 @@ in Type = "simple"; BindReadOnlyPaths = [ "/etc/hosts:/etc/hosts" + "/run/agenix:/run/agenix" "${pkgs.frappe-app}:${pkgs.frappe-app}" "${pkgs.frappe-app}/share/apps/frappe:${cfg.benchDir}/apps/frappe" "${pkgs.erpnext-app}:${pkgs.erpnext-app}" @@ -264,7 +265,7 @@ in wantedBy = [ "erpnext-web.service" ]; partOf = [ "erpnext-web.service" ]; script = '' - ${pkgs.mariadb-client}/bin/mysql -e "SET PASSWORD FOR 'root'@'localhost' = PASSWORD('${builtins.readFile cfg.database.rootPasswordFile}')"; + ${pkgs.mariadb-client}/bin/mysql -e "SET PASSWORD FOR 'root'@'localhost' = PASSWORD('$(cat "${cfg.database.rootPasswordFile}")')"; ''; serviceConfig = { RemainAfterExit = true; @@ -342,18 +343,21 @@ in ExecStartPre = assert cfg.adminPasswordFile != null && cfg.database.rootPasswordFile != null; pkgs.writeScript "erpnext-web-init" '' #!/bin/sh if ! test -e ${escapeShellArg "${cfg.workDir}/.db-created"}; then + # Fail on error + set -e + install -m0600 ${commonSiteConfigFile} ${cfg.benchDir}/sites/common_site_config.json ${pkgs.replace-secret}/bin/replace-secret \ - '#NIXOS_ERPNEXT_DB_PASSWORD#' \ - ${cfg.database.passwordFile} \ + '#NIXOS_ERPNEXT_DB_USER_PASSWORD#' \ + ${cfg.database.userPasswordFile} \ ${cfg.benchDir}/sites/common_site_config.json ADMIN_PASSWORD="$(cat "${cfg.adminPasswordFile}")" - ROOT_DB_PASSWORD="$(cat "${cfg.database.rootPasswordFile}")" + DB_ROOT_PASSWORD="$(cat "${cfg.database.rootPasswordFile}")" # Upstream initializes the database with this command ${penv}/bin/bench new-site localhost \ - --mariadb-root-password "$ROOT_DB_PASSWORD" \ + --mariadb-root-password "$DB_ROOT_PASSWORD" \ --admin-password "$ADMIN_PASSWORD" \ --install-app erpnext diff --git a/secrets/admin-password.age b/secrets/admin-password.age new file mode 100644 index 0000000..d1dc9e8 --- /dev/null +++ b/secrets/admin-password.age @@ -0,0 +1,15 @@ +age-encryption.org/v1 +-> ssh-ed25519 Wp/X/Q CkX9o53yvwG6TWGy86wjz8K08iEKcDWlCFGD/rOqjG8 +I1JY9R+wMRDD0NYJQ5xYGYXeEMyBzg7J6E+BAVzlTmY +-> ssh-ed25519 FnuwSg TIWzdHqIIpGG6GqTspw6tFSWgIfdNnUPiRcWCJyARwA +xYMIfXDTl0KcaEa+cQPF0Ld5py4cFAgQUNha4Lrxqa0 +-> ssh-ed25519 BVsyTA G0DnHKpVXXJPOMsJz9GGUX+RNEQ9FHv5AzUQl0AkkzA +OUsicHWAoiDuOQGHQDcgnBX58GdFKP0CpQiB+fF7KPs +-> ssh-ed25519 BVsyTA SQ7tgv5R8XNEv9nZaq27Y1/wDhgTzG628EuJh0yHOGk +cyBVqL5j0oIsVoIHMz9If9sJvRq7zNrB8aSqlFyeyYw +-> nr#`|-grease ORM\VtI 0#l nsQ`v=O 3[zMr%O( +Zs1oB4DhG5oE2QYXl/8yVUcdafTSJlK5LtB3gWYhi1pgyox63Pi5oZ0XpzzqN855 +/egFwpJ9QIMpU7etG94XLjEJq/k0T7P3Nu66Z4pGuRDTybqLCAKlbkcwWGxMxEkH +Yg +--- aRI3fjSGDjF34d8JK+Ow6dfmncI8qvl3Betzlc1hsVc +{H/6kSӚcle_0_]< h( \ No newline at end of file diff --git a/secrets/database-root-password.age b/secrets/database-root-password.age new file mode 100644 index 0000000..5137ec4 --- /dev/null +++ b/secrets/database-root-password.age @@ -0,0 +1,14 @@ +age-encryption.org/v1 +-> ssh-ed25519 Wp/X/Q uMcrhm3fS+M/P2uaEJvdRab5G4Nk6zZNAlHyRISlGg0 +9QNitHuuKYzn99Hr76+yOa51/4qfTlhUwf5cD/Px/FI +-> ssh-ed25519 FnuwSg ukuZCjWTcyC4LiHWmrunskAk3WbNZsdAXzHi3fydJwI +k3N3F4hxw55E6C68jLoaIQfuDUaOILxbw8BYwIcI2e8 +-> ssh-ed25519 BVsyTA q+2w2O+4c8NahzHuo2KLDxa8RHB79+L+h9KOY9GdGkA +eWRM4yyQtefZ6tnozqg37eLLMz7rQYl3eem2jxGThwo +-> ssh-ed25519 BVsyTA 6qkLU9OPuBv6KlgaILTkeFsyNgm8uW2PgZvsIQ8pGwM +kXBSETY11ZCJs5HZ2jH9LAw0ntrsSW2J5ES97X7FkpI +-> }6jwb-grease ,2 {su +Yypdzw8GT6HmlqgBxHAuEiKDWsH+wSYK2rMxketTP6OlWJHfmV9jc/YBMXwWNkhh +jcQfjmwx2Wjcn0mjiv6RUYi3tulpbkB+jctRW+vstPHTIptEMhOfs5FM +--- WPd5tG8HvNuhMZfqvzaCeDz2tavijgWewAuLV+5wFuE +9ԯY%"|V`WV%l''oK$W`!)BؘNp \ No newline at end of file diff --git a/secrets/database-user-password.age b/secrets/database-user-password.age new file mode 100644 index 0000000..92bf5b8 --- /dev/null +++ b/secrets/database-user-password.age @@ -0,0 +1,14 @@ +age-encryption.org/v1 +-> ssh-ed25519 Wp/X/Q 3hW/8PTjCcRN4cj1OJlmKgGrupW/At/XcZP9mX5relM +DrYHHxhA6DDj5tkUwMEP1Diedo5ZRtdaDaytodWcRlU +-> ssh-ed25519 FnuwSg 7szTuXVOxA1gEKvXFDDpPBak5Eh2CwUGnL52wGLmogw +J/no4LHtQ5FR43r6evCk4n6V/6gQ5navfvVGOc6Mnl0 +-> ssh-ed25519 BVsyTA cGS0ASOgef9VUxNQF0h70h9aJnctBZRc6FBQgahLn0c +b35sRRi+yNu2arU7yipZctcie0itomjJtc3Nr4i2nss +-> ssh-ed25519 BVsyTA VG8SwHZkdvGwmbx/kOT5oabV+WpO5QGIJXtse4CtKgE +WiEuIMlsaW98kPB6pChEZhCNWo0pVbDMEIwfRtuKNnY +-> 2IOJi1-grease s^`TsZL %{ &~L* +TU4AdoaLUEYR8YQRMcUdFho/WOs9IpwM1z4t36FbM3GPFqz0IY5MCC9shj+oxhh1 +aW5/FKRXoTLG5A +--- 9HuacZ7Sr0o0XKFkdT4qIQSdx8Idymsb/jc3QxLOwg0 +M0H{˻;c*$6d(1D3Oe \ No newline at end of file diff --git a/secrets/secrets.nix b/secrets/secrets.nix new file mode 100644 index 0000000..e7cbbbf --- /dev/null +++ b/secrets/secrets.nix @@ -0,0 +1,16 @@ +let + # set ssh public keys here for your system and user + machines = { + dumpyourvms = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILDATEWAgDZFfYs1ZPh33Kg4sqQ9tWMVKyk8XqFu3Koe host@dumpyourvms"; + test-vm = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILD8RziyMKgRj6MLkRjdHnNwG2+VolUl8ejjeteehVnw host@test-vm"; + }; + users = { + teutat3s = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHcU6KPy4b1MQXd6EJhcYwbJu7E+0IrBZF/IP6T7gbMf teutat3s@dumpyourvms"; + root = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHcU6KPy4b1MQXd6EJhcYwbJu7E+0IrBZF/IP6T7gbMf root@test-vm"; + }; + allKeys = [machines.dumpyourvms machines.test-vm users.root users.teutat3s]; +in { + "admin-password.age".publicKeys = allKeys; + "database-root-password.age".publicKeys = allKeys; + "database-user-password.age".publicKeys = allKeys; +} diff --git a/test-vm/configuration.nix b/test-vm/configuration.nix index b013e7f..c410354 100644 --- a/test-vm/configuration.nix +++ b/test-vm/configuration.nix @@ -60,11 +60,27 @@ neovim ]; + age.secrets.erpnext-admin-password = { + file = ../secrets/admin-password.age; + mode = "700"; + owner = "erpnext"; + }; + age.secrets.erpnext-db-root-password = { + file = ../secrets/database-root-password.age; + mode = "700"; + owner = "erpnext"; + }; + age.secrets.erpnext-db-user-password = { + file = ../secrets/database-user-password.age; + mode = "700"; + owner = "erpnext"; + }; + services.erpnext = { enable = true; - adminPasswordFile = ../adminpass.txt; - database.rootPasswordFile = ../dbrootpass.txt; - database.passwordFile = ../dbuserpass.txt; + adminPasswordFile = config.age.secrets.erpnext-admin-password.path; + database.rootPasswordFile = config.age.secrets.erpnext-db-root-password.path; + database.userPasswordFile = config.age.secrets.erpnext-db-user-password.path; caddy = {}; }; };