diff --git a/nixos/doc/manual/configuration/user-mgmt.chapter.md b/nixos/doc/manual/configuration/user-mgmt.chapter.md index 36f4717d2204..0a3c952fc5cf 100644 --- a/nixos/doc/manual/configuration/user-mgmt.chapter.md +++ b/nixos/doc/manual/configuration/user-mgmt.chapter.md @@ -152,3 +152,16 @@ original files are by default stored in `/var/lib/nixos`. Userborn implements immutable users by re-mounting the password files read-only. This means that unlike when using the Perl script, trying to add a new user (e.g. via `useradd`) will fail right away. + +## Restrict usage time {#sec-restrict-usage-time} + +[Timekpr-nExT](https://mjasnik.gitlab.io/timekpr-next/) is a screen time managing application that helps optimizing time spent at computer for your subordinates, children or even for yourself. + +You can enable it via: + +```nix +{ services.timekpr.enable = true; } +``` + +This will install the `timekpr` package and start the `timekpr` service. +You can then use the `timekpra` application to configure time limits for users. diff --git a/nixos/doc/manual/redirects.json b/nixos/doc/manual/redirects.json index 5cdbbd8a2faf..0c044a143ae3 100644 --- a/nixos/doc/manual/redirects.json +++ b/nixos/doc/manual/redirects.json @@ -335,6 +335,9 @@ "sec-userborn": [ "index.html#sec-userborn" ], + "sec-restrict-usage-time": [ + "index.html#sec-restrict-usage-time" + ], "ch-file-systems": [ "index.html#ch-file-systems" ], diff --git a/nixos/doc/manual/release-notes/rl-2511.section.md b/nixos/doc/manual/release-notes/rl-2511.section.md index c647ad73d380..1ede3c494405 100644 --- a/nixos/doc/manual/release-notes/rl-2511.section.md +++ b/nixos/doc/manual/release-notes/rl-2511.section.md @@ -80,6 +80,8 @@ - [mautrix-discord](https://github.com/mautrix/discord), a Matrix-Discord puppeting/relay bridge. Available as [services.mautrix-discord](#opt-services.mautrix-discord.enable). +- [Timekpr-nExT](https://mjasnik.gitlab.io/timekpr-next/), a time managing application that helps optimizing time spent at computer for your subordinates, children or even for yourself. Available as [](#opt-services.timekpr.enable). + - [SuiteNumérique Meet](https://github.com/suitenumerique/meet) is an open source alternative to Google Meet and Zoom powered by LiveKit: HD video calls, screen sharing, and chat features. Built with Django and React. Available as [services.lasuite-meet](#opt-services.lasuite-meet.enable). - [lemurs](https://github.com/coastalwhite/lemurs), a customizable TUI display/login manager. Available at [services.displayManager.lemurs](#opt-services.displayManager.lemurs.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 38881059011b..97186136271d 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1468,6 +1468,7 @@ ./services/security/sslmate-agent.nix ./services/security/step-ca.nix ./services/security/tang.nix + ./services/security/timekpr.nix ./services/security/tor.nix ./services/security/torify.nix ./services/security/torsocks.nix diff --git a/nixos/modules/services/security/timekpr.nix b/nixos/modules/services/security/timekpr.nix new file mode 100644 index 000000000000..cbf7866ea2b9 --- /dev/null +++ b/nixos/modules/services/security/timekpr.nix @@ -0,0 +1,65 @@ +{ + pkgs, + lib, + config, + ... +}: +let + cfg = config.services.timekpr; + targetBaseDir = "/var/lib/timekpr"; + daemonUser = "root"; + daemonGroup = "root"; +in +{ + options = { + services.timekpr = { + package = lib.mkPackageOption pkgs "timekpr" { }; + enable = lib.mkEnableOption "Timekpr-nExT, a screen time managing application that helps optimizing time spent at computer for your subordinates, children or even for yourself"; + adminUsers = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ + "alice" + "bob" + ]; + description = '' + All listed users will become part of the `timekpr` group so they can manage timekpr settings without requiring sudo. + ''; + }; + }; + }; + + config = lib.mkIf cfg.enable { + users.groups.timekpr = { + gid = 2000; + members = cfg.adminUsers; + }; + + environment.systemPackages = [ + # Add timekpr to system packages so that polkit can find it + cfg.package + ]; + services.dbus.enable = true; + services.dbus.packages = [ + cfg.package + ]; + environment.etc."timekpr" = { + source = "${cfg.package}/etc/timekpr"; + }; + systemd.packages = [ + cfg.package + ]; + systemd.services.timekpr = { + enable = true; + wantedBy = [ "multi-user.target" ]; + }; + security.polkit.enable = true; + systemd.tmpfiles.rules = [ + "d ${targetBaseDir} 0755 ${daemonUser} ${daemonGroup} -" + "d ${targetBaseDir}/config 0755 ${daemonUser} ${daemonGroup} -" + "d ${targetBaseDir}/work 0755 ${daemonUser} ${daemonGroup} -" + ]; + }; + + meta.maintainers = [ lib.maintainers.atry ]; +} diff --git a/nixos/tests/timekpr.nix b/nixos/tests/timekpr.nix new file mode 100644 index 000000000000..1ae793d8f70e --- /dev/null +++ b/nixos/tests/timekpr.nix @@ -0,0 +1,17 @@ +{ pkgs, lib, ... }: +{ + name = "timekpr"; + meta.maintainers = [ lib.maintainers.atry ]; + + nodes.machine = + { pkgs, lib, ... }: + { + services.timekpr.enable = true; + }; + + testScript = '' + start_all() + machine.wait_for_file("/etc/timekpr/timekpr.conf") + machine.wait_for_unit("timekpr.service") + ''; +} diff --git a/pkgs/by-name/ti/timekpr/package.nix b/pkgs/by-name/ti/timekpr/package.nix new file mode 100644 index 000000000000..e56b2aefc406 --- /dev/null +++ b/pkgs/by-name/ti/timekpr/package.nix @@ -0,0 +1,150 @@ +{ + fetchgit, + gitUpdater, + glib, + gobject-introspection, + gtk3, + lib, + python3Packages, + sound-theme-freedesktop, + stdenv, + wrapGAppsHook4, +}: +python3Packages.buildPythonApplication rec { + pname = "timekpr"; + version = "0.5.8"; + + src = fetchgit { + url = "https://git.launchpad.net/timekpr-next"; + tag = "v${version}"; + hash = "sha256-Y0jAKl553HjoP59wJnKBKq4Ogko1cs8uazW2dy7AlBo="; + }; + + buildInputs = [ + glib + gtk3 + ]; + + nativeBuildInputs = [ + gobject-introspection + wrapGAppsHook4 + ]; + + pyproject = true; + + build-system = with python3Packages; [ + setuptools + ]; + + dependencies = with python3Packages; [ + dbus-python + pygobject3 + psutil + ]; + + # Generate setup.py because the upstream repository does not include it + SETUP_PY = '' + from setuptools import setup, find_namespace_packages + + package_dir={"timekpr": "."} + setup( + name="timekpr-next", + version="${version}", + package_dir=package_dir, + packages=[ + f"{package_prefix}.{package_suffix}" + for package_prefix, where in package_dir.items() + for package_suffix in find_namespace_packages(where=where) + ], + install_requires=[ + ${lib.concatMapStringsSep ", " (dependency: "'${dependency.pname}'") dependencies} + ], + ) + ''; + + postPatch = '' + shopt -s globstar extglob nullglob + + substituteInPlace bin/* **/*.py resource/server/systemd/timekpr.service \ + --replace-quiet /usr/lib/python3/dist-packages "$out"/${lib.escapeShellArg python3Packages.python.sitePackages} + + substituteInPlace **/*.desktop **/*.policy **/*.service \ + --replace-fail /usr/bin/timekpr "$out"/bin/timekpr + + substituteInPlace common/constants/constants.py \ + --replace-fail /usr/share/sounds/freedesktop ${lib.escapeShellArg sound-theme-freedesktop}/share/sounds/freedesktop \ + --replace-fail /usr/share/timekpr "$out"/share/timekpr \ + --replace-fail /usr/share/locale "$out"/share/locale + + substituteInPlace resource/server/timekpr.conf \ + --replace-fail /usr/share/timekpr "$out"/share/timekpr \ + + # The original file name `timekpra` is renamed to `..timekpra-wrapped-wrapped` because `makeCWrapper` was used multiple times. + substituteInPlace client/admin/adminprocessor.py \ + --replace-fail '"/timekpra" in ' '"/..timekpra-wrapped-wrapped" in ' + + printf %s "$SETUP_PY" > setup.py + ''; + + # We need to manually inject $PYTHONPATH here, because `buildPythonApplication` does not recognize timekpr's executables as Python scripts, and therefore it does not automatically inject $PYTHONPATH into them. + postFixup = '' + for executable in $out/bin/* + do + wrapProgram "$executable" --prefix PYTHONPATH : "$PYTHONPATH" + done + ''; + + preInstall = '' + while IFS= read -r line + do + # Trim leading/trailing whitespace + line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + + # Skip empty lines and comments + if [[ -z "$line" || "$line" =~ ^# ]]; then + continue + fi + + # Separate source and destination + # This assumes the destination is the last field and source path doesn't contain problematic spaces + # More robust parsing might be needed if source paths have spaces. + source_path=$(echo "$line" | awk '{ $NF=""; print $0 }' | sed 's/[[:space:]]*$//') + dest_path=$(echo "$line" | awk '{ print $NF }') + + # Check destination path prefix and map to $out/* + case "$dest_path" in + usr/share/*) + # Remove "usr/" prefix and prepend "$out/" + install -D --mode=444 "$source_path" --target-directory="$out/''${dest_path#usr/}" + ;; + usr/bin/*) + # Remove "usr/" prefix and prepend "$out/" + install -D --mode=555 "$source_path" --target-directory="$out/''${dest_path#usr/}" + ;; + etc/*|lib/*|var/*) + # Prepend "$out/" + install -D --mode=444 "$source_path" --target-directory="$out/$dest_path" + ;; + usr/lib/python3/dist-packages/*) + # Skip this line if the destination is a Python module + # because it will be handled by the Python build process + continue + ;; + *) + echo "Error: Unknown destination prefix: '$dest_path'" >&2 + exit 1 + ;; + esac + done < debian/install + ''; + + passthru.updateScript = gitUpdater { rev-prefix = "v"; }; + + meta = { + description = "Manages and restricts user screen time by enforcing time limits"; + homepage = "https://mjasnik.gitlab.io/timekpr-next/"; + license = lib.licenses.gpl3; + maintainers = [ lib.maintainers.atry ]; + platforms = lib.platforms.linux; + }; +}