commit 5afee7e394413a1e874543984f9c58a3c2b30d5f Author: h7x4 Date: Wed Nov 19 13:25:07 2025 +0900 Initial commit diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e537d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +result +result-* +*.qcow2 +target diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..b4c8ee1 --- /dev/null +++ b/flake.lock @@ -0,0 +1,26 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1763283776, + "narHash": "sha256-Y7TDFPK4GlqrKrivOcsHG8xSGqQx3A6c+i7novT85Uk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "50a96edd8d0db6cc8db57dab6bb6d6ee1f3dc49a", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-unstable", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..629bbbe --- /dev/null +++ b/flake.nix @@ -0,0 +1,115 @@ +{ + inputs.nixpkgs.url = "nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: let + inherit (nixpkgs) lib; + + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system nixpkgs.legacyPackages.${system}); + in { + apps = forAllSystems (system: pkgs: { + default = self.apps.${system}.vm; + vm = { + type = "app"; + program = "${lib.getExe self.nixosConfigurations."vm-${system}".config.system.build.vm}"; + }; + }); + + nixosModules.default = ./module.nix; + + nixosConfigurations = lib.mapAttrs' (n: v: lib.nameValuePair "vm-${n}" v) (forAllSystems (system: pkgs: + lib.nixosSystem { + inherit system pkgs; + modules = [ + "${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix" + self.nixosModules.default + + ({ config, ... }: { + system.stateVersion = config.system.nixos.release; + virtualisation.graphics = false; + + services.getty.autologinUser = "root"; + + users.motd = '' + ================================== + Welcome to the user-jails test vm! + + Try logging in as a user: + ssh user1@localhost + ssh user2@localhost + + user1: default jail + - private networking + - global users (private users doesn't work atm) + - allow outside network access + + user2: permissive jail + - global networking + - global users + - allow outside network access + + All users have password 'foobar' + + To exit, press Ctrl+A, then X + ================================== + ''; + + users.users.user1 = { + uid = 1000; + isNormalUser = true; + createHome = true; + password = "foobar"; + }; + + users.users'.user1.jail = { + enable = true; + # Private users doesn't work inside VM for now + # See https://github.com/NixOS/nixpkgs/issues/451167 + useGlobalUsers = true; + }; + + users.users.user2 = { + uid = 1001; + isNormalUser = true; + createHome = true; + password = "foobar"; + }; + + users.users'.user2.jail = { + enable = true; + # bindGlobalNixStore = true; # doesn't do anything for now + useGlobalNetworking = true; + useGlobalUsers = true; + }; + + # users.users.user3 = { + # uid = 1002; + # isNormalUser = true; + # createHome = true; + # password = "foobar"; + # }; + + # users.users'.user3.jail = { + # enable = true; + # allowNetworking = false; + # }; + + # MOTD description: + # user3: strict jail + # - private networking + # - private users + # - deny outside network access + + services.openssh.enable = true; + + programs.vim.enable = true; + }) + ]; + } + )); + }; +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..39fdf3e --- /dev/null +++ b/module.nix @@ -0,0 +1,199 @@ +{ config, pkgs, lib, ... }: +let + extraUserOpts = { + options.jail = { + enable = lib.mkEnableOption ""; + # TODO: it's not possible to configure this with the current container module + # chrootPath = lib.mkOption { + # type = with lib.types; nullOr path; + # default = null; + # example = "/run/users//root-mnt"; + # description = '' + # ''; + # }; + + # Bind global nix store instead of a separate isolated one. + bindGlobalNixStore = lib.mkOption { + type = lib.types.bool; + description = '' + Whether to bindmount the global nix store into the user jail. + Note that this will expose any content in the nix store downloaded by + other users as well. + ''; + default = false; + example = true; + }; + + allowNetworking = lib.mkOption { + type = lib.types.bool; + description = '' + Whether to bridge the user's private network namespace to the main namespace + so the user can establish network connections outwards from the host machine. + ''; + default = true; + example = false; + }; + + useGlobalNetworking = lib.mkOption { + type = lib.types.bool; + description = '' + Whether to let the user be a part of the main network namespace. + ''; + default = false; + example = true; + }; + + # TODO: not sure if systemd exposes this as an option? In either case, the + # nix socket is bindmounted in, so I think the IPC namespace is global + # by default + # useGlobalIPC = lib.mkOption { + # type = lib.types.bool; + # description = '' + # Whether to let the user be a part of the main IPC namespace. + # ''; + # default = false; + # example = true; + # }; + + useGlobalUsers = lib.mkOption { + type = lib.types.bool; + description = '' + Whether to let the user be a part of the main user namespace. + ''; + default = false; + example = true; + }; + + # NOTE: I believe this is inherently disabled + # useGlobalPIDs = lib.mkOption { + # type = lib.types.bool; + # description = '' + # Whether to let the user be a part of the main pid namespace. + # ''; + # default = false; + # example = true; + # }; + + # TODO: Let the user configure their own timezone + # useGlobalUTS = lib.mkOption { + # type = lib.types.bool; + # description = '' + # Whether to let the user be a part of the main UTS namespace. + # ''; + # default = false; + # example = true; + # }; + + extraBindPaths = lib.mkOption { + type = with lib.types; listOf str; + description = '' + Additional paths to bindmount into the user's jail. + ''; + default = [ ]; + example = [ ]; + }; + + extraBindReadOnlyPaths = lib.mkOption { + type = with lib.types; listOf str; + description = '' + Additional paths to bindmount as readonly into the user's jail. + ''; + default = [ ]; + example = [ ]; + }; + }; + }; +in +{ + options = { + users.users' = lib.mkOption { + default = { }; + type = with lib.types; attrsOf (submodule extraUserOpts); + }; + }; + + config = { + assertions = [{ + assertion = config.boot.enableContainers; + message = "Machine needs to be able to run containers in order to create user jails"; + }] + # NOTE: needed for uid mapping to work correctly? + ++ lib.mapAttrsToList (k: _: { + assertion = config.users.users.${k}.uid != null; + message = "All jailed users need a deterministic uid, missing for user '${k}'"; + }) (lib.filterAttrs (_: v: v.jail.enable) config.users.users'); + + users.users = lib.pipe config.users.users' [ + (lib.filterAttrs (_: v: v.jail.enable)) + (lib.mapAttrs' (k: v: { + name = k; + value.group = k; + })) + ]; + + users.groups = lib.pipe config.users.users' [ + (lib.filterAttrs (_: v: v.jail.enable)) + (lib.mapAttrs' (k: v: { + name = k; + value = { gid = config.users.users.${k}.uid; }; + })) + ]; + + containers = lib.mapAttrs' (k: v: { + name = "user-jail-${k}"; + value = { + # TODO: don't linger unless users.users.linger = true; + autoStart = true; + + ephemeral = true; + privateNetwork = !v.jail.useGlobalNetworking; + privateUsers = if v.jail.useGlobalUsers then "no" else "pick"; + extraFlags = [ + # TODO: add support for bindmount arguments instead of hacking it in here + # "--bind=${config.users.users.${k}.home}:${config.users.users.${k}.home}:rbind,idmap" + "--bind=${config.users.users.${k}.home}:${config.users.users.${k}.home}" + # TODO: add support for bind-user in nixos-container module + # "--bind-user=${k}" + ]; + config = { + system.stateVersion = config.system.stateVersion; + networking.hostName = config.networking.hostName; + # NOTE: seemingly not needed with --bind-user. + users.users.${k} = config.users.users.${k}; + users.groups.${config.users.users.${k}.group} = config.users.groups.${config.users.users.${k}.group}; + }; + }; + }) (lib.filterAttrs (_: v: v.jail.enable) config.users.users'); + + systemd.services = lib.mapAttrs' (k: v: { + name = "container@user-jail-${k}"; + value = { + # unitConfig.RequiresMountsFor = lib.mapAttrsToList (k: v: if v.hostPath != null then v.hostPath else k) config.containers."user-jail-${k}".bindMounts; + }; + }) (lib.filterAttrs (_: v: v.jail.enable) config.users.users'); + + services.openssh.extraConfig = lib.pipe config.users.users' [ + (lib.filterAttrs (_: v: v.jail.enable)) + (lib.mapAttrsToList (k: v: '' + Match User ${k} + ForceCommand 'machinectl' --quiet shell '${k}@user-jail-${k}' $SSH_ORIGINAL_COMMAND + '')) + lib.concatStrings + ]; + + security.polkit.enable = true; + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if ( + action.id === "org.freedesktop.machine1.shell" + && action.lookup("user") === subject.user + && action.lookup("machine") === "user-jail-" + subject.user + ) { + return polkit.Result.YES; + } + }); + ''; + + # TODO: use pam module to maybe stop the container upon closing the connection + }; +}