Created
August 29, 2026 02:45
-
-
Save escherize/c7e3afb637334a233fd5e5895cb984be to your computer and use it in GitHub Desktop.
Parental-controls setup script for Omarchy (Arch + Hyprland): non-admin kid account, DNS filtering, timekpr-nExT screen time, malcontent app restrictions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # ============================================================================ | |
| # setup-kid-omarchy.sh | |
| # --------------------------------------------------------------------------- | |
| # Turn an Omarchy (Arch Linux + Hyprland) machine into a reasonably | |
| # kid-safe box. Layered approach, because there is no single "Google Family | |
| # Link" equivalent on Linux — you stack a few small tools and each one | |
| # closes a different hole: | |
| # | |
| # LAYER TOOL WHAT IT STOPS | |
| # ---------------------------------------------------------------------- | |
| # 1. Account useradd (no wheel/sudo) installing/breaking OS | |
| # 2. Network/DNS NextDNS / Cloudflare adult+gambling+etc. | |
| # (or OpenDNS, AdGuard) at lookup time | |
| # 3. Screen time timekpr-nExT daily/weekly limits, | |
| # forced logout | |
| # 4. App restriction malcontent block apps / flatpak | |
| # installs per-user | |
| # 5. Browser Firefox + uBlock Origin last-mile in-browser | |
| # | |
| # HONEST DISCLAIMER (read this): | |
| # ------------------------------------------------------------- | |
| # None of this is bulletproof. A determined kid with physical access and a | |
| # USB stick can boot a live image and walk around every control here. The | |
| # realistic goal is "keeps an honest kid honest and an opportunistic one | |
| # out," not "prison-grade lockdown." To raise the floor further you'd add: | |
| # * a BIOS/UEFI password + USB-boot disabled | |
| # * full-disk encryption | |
| # * DNS filtering enforced at the ROUTER (not just this box) | |
| # Each is out of scope for this script but noted where relevant below. | |
| # | |
| # USAGE: | |
| # chmod +x setup-kid-omarchy.sh | |
| # sudo ./setup-kid-omarchy.sh | |
| # | |
| # The script must run as root. It re-execs itself via sudo if it can, so | |
| # `sudo` in the line above is optional — but a password prompt may appear. | |
| # ============================================================================ | |
| # --- shell hygiene ---------------------------------------------------------- | |
| # -e: exit on first error (no silently continuing a half-broken setup) | |
| # -u: error on unset variable (catches typos in config) | |
| # -o pipefail: a failed command mid-pipeline fails the whole pipeline | |
| # -E: propagate the ERR trap into functions (so trap fires deep in the stack) | |
| set -Eeuo pipefail | |
| # --- config ----------------------------------------------------------------- | |
| # Edit these. Everything the script does keys off these values. | |
| # The kid's login name. Keep it lowercase, no spaces. | |
| KID_USER="kid" | |
| # Full name stored in the account comment (cosmetic). | |
| KID_FULLNAME="Kid" | |
| # The parent/admin account that will later drive timekpr/malcontent GUIs. | |
| # NOT the kid. Adding this to the `timekpr` group lets you configure limits | |
| # without a password prompt. | |
| SUPERVISOR_USER="" | |
| # Which layers to run. 0 = skip, 1 = run. Toggle off what you don't want. | |
| ENABLE_ACCOUNT=1 | |
| ENABLE_DNS=1 | |
| ENABLE_TIMEKPR=1 | |
| ENABLE_MALCONTENT=1 | |
| # Dry run: print every command instead of executing it. Great first run. | |
| DRY_RUN=0 | |
| # --- DNS config ------------------------------------------------------------- | |
| # Layer 2 picks a DNS provider. The machine's resolver is pointed at these | |
| # two addresses, so the provider's filtering applies to everything that does | |
| # normal DNS lookups on this box (browser, games, updaters, ...). | |
| # OPTION A — NextDNS (RECOMMENDED for kids): | |
| # 1. Create a free account at https://nextdns.io | |
| # 2. Turn on "Parental Control", "SafeSearch", "YouTube Restricted Mode" | |
| # 3. It hands you TWO personal IPs (like 45.90.28.xxx / 45.90.30.xxx). | |
| # Paste them below and set DNS_PROVIDER="nextdns". | |
| # Why it's the best: per-category blocking + forced safe search + a web | |
| # dashboard you can tweak from your phone. Free tier is plenty. | |
| # OPTION B — Cloudflare for Families (zero setup, the default below): | |
| # 1.1.1.3 / 1.0.0.3 block malware AND adult content. No account needed, | |
| # but also no fine-grained control and no dashboard. | |
| # OPTION C — OpenDNS FamilyShield (zero setup, older): | |
| # 208.67.222.123 / 208.67.220.123. Blocks adult content only, no dashboard. | |
| DNS_PROVIDER="cloudflare" # "nextdns" | "cloudflare" | "opendns" | |
| DNS1="1.1.1.3" # primary resolver (Cloudflare default) | |
| DNS2="1.0.0.3" # secondary resolver (Cloudflare default) | |
| # AUR helper used to install timekpr-nExT (it's not in the official repos). | |
| # "auto" detects yay or paru on PATH. Or hardcode e.g. "yay". | |
| AUR_HELPER="auto" | |
| # --- helpers ---------------------------------------------------------------- | |
| # Logging with a little color so the output is scannable. Colors are ANSI | |
| # escape codes; they print as plain text if your terminal lacks color. | |
| readonly C_RESET=$'\033[0m' | |
| readonly C_INFO=$'\033[1;34m' # blue | |
| readonly C_OK=$'\033[1;32m' # green | |
| readonly C_WARN=$'\033[1;33m' # yellow | |
| readonly C_ERR=$'\033[1;31m' # red | |
| log() { printf '%s[%s]%s %s\n' "$C_INFO" ".." "$C_RESET" "$*"; } | |
| ok() { printf '%s[%s]%s %s\n' "$C_OK" "OK" "$C_RESET" "$*"; } | |
| warn() { printf '%s[%s]%s %s\n' "$C_WARN" "WARN" "$C_RESET" "$*"; } | |
| err() { printf '%s[%s]%s %s\n' "$C_ERR" "FAIL" "$C_RESET" "$*"; } | |
| # trap on error: print the line number that blew up so you're not guessing. | |
| on_error() { | |
| err "failed at line ${1:-?}: see output above" | |
| err "script stopped — fix and re-run (it's meant to be re-runnable)" | |
| } | |
| trap 'on_error $LINENO' ERR | |
| # run() is the single choke point for every command the script executes. | |
| # It gives us DRY_RUN for free: in dry-run mode it just echoes what WOULD | |
| # run instead of running it. That keeps the real path and the preview path | |
| # identical, so dry-run output is always trustworthy. | |
| run() { | |
| if [[ $DRY_RUN -eq 1 ]]; then | |
| printf ' [dry-run] %s\n' "$*" | |
| return 0 | |
| fi | |
| "$@" | |
| } | |
| # --- preflight -------------------------------------------------------------- | |
| # Everything below needs root (useradd, pacman, systemctl, nmcli mod). | |
| # If we're not root, try to re-launch ourselves under sudo. This keeps the | |
| # "sudo ./script" vs "./script" distinction from mattering. | |
| if [[ $EUID -ne 0 ]]; then | |
| warn "not running as root — re-execing via sudo (you may be prompted)" | |
| exec sudo bash "$0" "$@" | |
| fi | |
| log "kernel: $(uname -r)" | |
| if [[ $DRY_RUN -eq 1 ]]; then | |
| warn "DRY_RUN is ON — nothing will actually change" | |
| fi | |
| # ============================================================================ | |
| # LAYER 1 — ACCOUNT ISOLATION | |
| # ============================================================================ | |
| # The most important control on Linux is boring: don't give the kid admin. | |
| # On Arch, "admin" == membership in the `wheel` group (that's what sudo | |
| # checks). So we create a plain user and simply never add them to wheel. | |
| # Result: they can run software, but can't `sudo pacman -S` a system-wide | |
| # package, edit configs, or un-do the controls below. | |
| # ---------------------------------------------------------------------------- | |
| if [[ $ENABLE_ACCOUNT -eq 1 ]]; then | |
| echo | |
| log "LAYER 1: creating non-admin account '$KID_USER'" | |
| if id "$KID_USER" &>/dev/null; then | |
| ok "user '$KID_USER' already exists — skipping creation" | |
| else | |
| # -m create the home directory (/home/kid) | |
| # -s default shell | |
| # -c full-name comment (cosmetic, shows in login screen) | |
| # -G "" explicitly NO supplementary groups (we do NOT add wheel) | |
| # NOTE: we deliberately do not pass -G wheel. That's the whole | |
| # point of this layer. | |
| run useradd -m -s /bin/bash -c "$KID_FULLNAME" "$KID_USER" | |
| ok "created '$KID_USER' (home at /home/$KID_USER, NOT in wheel)" | |
| fi | |
| # Explicit guard: if somehow wheel got added, scream. belt-and-suspenders. | |
| if groups "$KID_USER" | tr ' ' '\n' | grep -qx wheel; then | |
| err "'$KID_USER' is in the wheel group — that defeats layer 1!" | |
| err "remove with: sudo gpasswd -d $KID_USER wheel" | |
| exit 1 | |
| fi | |
| # Set the kid's login password. This is INTERACTIVE — it prompts for the | |
| # password twice. If you're running via a non-interactive SSH session this | |
| # will hang/error; run the command manually instead: | |
| # sudo passwd "$KID_USER" | |
| log "set a login password for '$KID_USER' (interactive):" | |
| run passwd "$KID_USER" | |
| ok "layer 1 complete" | |
| fi | |
| # ============================================================================ | |
| # LAYER 2 — DNS FILTERING | |
| # ============================================================================ | |
| # How it works: every domain lookup goes to a resolver that refuses to answer | |
| # for known-bad categories. The site never loads. This is the highest- | |
| # leverage layer because it's one setting that covers every app, and it's | |
| # hard for a casual kid to notice, let alone reverse. | |
| # | |
| # HOW A KID BYPASSES IT (know your enemy): | |
| # * setting their own DNS (8.8.8.8) in the browser/OS settings | |
| # * using DNS-over-HTTPS (Firefox has a toggle) which encrypts lookups and | |
| # goes around your resolver entirely | |
| # * using a VPN | |
| # To actually close those, enforce DNS at the ROUTER (blocks every device), | |
| # and/or firewall outbound port 53/853 so only your resolver is reachable. | |
| # That firewall part is documented, not shipped, below — a bad nftables rule | |
| # cuts the box off the network and is worse than no rule. | |
| # ---------------------------------------------------------------------------- | |
| if [[ $ENABLE_DNS -eq 1 ]]; then | |
| echo | |
| log "LAYER 2: pointing DNS at '$DNS_PROVIDER' ($DNS1 / $DNS2)" | |
| if [[ $DNS_PROVIDER == "nextdns" ]]; then | |
| # NextDNS personal IPs are unique per account — the defaults here are the | |
| # Cloudflare ones, so warn if they look un-configured. | |
| if [[ $DNS1 == "1.1.1.3" || $DNS2 == "1.0.0.3" ]]; then | |
| warn "DNS_PROVIDER=nextdns but DNS1/DNS2 still hold Cloudflare values" | |
| warn "paste your two personal NextDNS IPs into DNS1/DNS2 at the top" | |
| fi | |
| fi | |
| if command -v nmcli &>/dev/null; then | |
| # Omarchy ships NetworkManager, so nmcli is the right tool. We: | |
| # 1. find the currently-active connection's NAME | |
| # 2. set static DNS servers on it | |
| # 3. tell it to ignore DHCP-provided DNS (otherwise DHCP wins) | |
| # 4. bounce the connection to apply | |
| CONN="$(nmcli -t -f NAME connection show --active | head -n1)" | |
| if [[ -z "$CONN" ]]; then | |
| err "no active NetworkManager connection found — is networking up?" | |
| exit 1 | |
| fi | |
| ok "active connection: '$CONN'" | |
| run nmcli connection modify "$CONN" ipv4.dns "$DNS1 $DNS2" | |
| run nmcli connection modify "$CONN" ipv4.ignore-auto-dns yes | |
| run nmcli connection up "$CONN" | |
| ok "DNS set on '$CONN' — filtering is live" | |
| else | |
| # Fallback for a systemd-resolved box (no NetworkManager). NOT the | |
| # Omarchy default, but harmless to leave here for completeness. | |
| warn "nmcli not found — trying systemd-resolved instead" | |
| # resolved.conf only accepts global DNS= and a comma-separated list. | |
| # We back up the old file first so this is reversible. | |
| run cp /etc/systemd/resolved.conf /etc/systemd/resolved.conf.bak | |
| run sed -i "s/^#\?DNS=.*/DNS=$DNS1 $DNS2/" /etc/systemd/resolved.conf | |
| run systemctl restart systemd-resolved | |
| ok "DNS set via systemd-resolved" | |
| fi | |
| # Sanity check: the box should now actually be resolving through the new | |
| # resolver. `resolvectl status` / `nmcli` will show it. We print a reminder | |
| # rather than auto-verify, since the check itself is environment-dependent. | |
| log "verify with: resolvectl status | grep -i 'Current DNS'" | |
| ok "layer 2 complete" | |
| fi | |
| # ============================================================================ | |
| # LAYER 3 — SCREEN TIME (timekpr-nExT) | |
| # ============================================================================ | |
| # timekpr-nExT is THE screen-time tool on Linux. It runs a daemon (service | |
| # name: `timekpr`) that: | |
| # * enforces per-day / per-week / per-month time allowances | |
| # * enforces "allowed hours" windows (e.g. only 15:00–20:00) | |
| # * forcibly logs the kid out when time is up (configurable) | |
| # * has a "PlayTime" mode to limit SPECIFIC apps (games) separately | |
| # * gives the kid a tray icon showing remaining time + warnings | |
| # | |
| # It's configured from a GTK GUI ("Timekpr-nExT Control Panel") after this | |
| # script installs and starts the daemon. The GUI needs either root or | |
| # membership in the `timekpr` group (password-less admin). | |
| # | |
| # WHY IT'S ON THE AUR: it's not in the official repos, so we need an AUR | |
| # helper (yay/paru). If you have neither, this layer is skipped with a hint. | |
| # ---------------------------------------------------------------------------- | |
| if [[ $ENABLE_TIMEKPR -eq 1 ]]; then | |
| echo | |
| log "LAYER 3: installing timekpr-nExT (screen-time limits)" | |
| # Pick the AUR helper. | |
| AUR="" | |
| if [[ $AUR_HELPER != "auto" ]]; then | |
| AUR="$AUR_HELPER" | |
| elif command -v yay &>/dev/null; then | |
| AUR="yay" | |
| elif command -v paru &>/dev/null; then | |
| AUR="paru" | |
| fi | |
| if [[ -z "$AUR" ]]; then | |
| warn "no AUR helper (yay/paru) found — skipping timekpr install" | |
| warn "install one first, e.g.:" | |
| warn " git clone https://aur.archlinux.org/yay.git && cd yay && makepkg -si" | |
| warn "then re-run this script, or install manually: $AUR_HELPER -S timekpr-next" | |
| else | |
| ok "using AUR helper: $AUR" | |
| # --noconfirm skips the "proceed with install?" prompts. AUR helpers still | |
| # show PKGBUILD review diffs by default; yay/paru respect --noconfirm for | |
| # the build+install but may still prompt once for the diff. That's fine — | |
| # you WANT to glance at what you're installing. | |
| run "$AUR" -S --noconfirm timekpr-next | |
| ok "timekpr-nExT installed" | |
| # Start the daemon now and enable it at boot. Arch does NOT auto-enable | |
| # services (no systemd presets), so --now + enable is required or it | |
| # won't survive a reboot. | |
| run systemctl enable --now timekpr | |
| ok "timekpr daemon running and enabled at boot" | |
| # Convenience: if a supervisor account was provided, put them in the | |
| # `timekpr` group so they can open the control panel without sudo. | |
| if [[ -n "$SUPERVISOR_USER" ]] && id "$SUPERVISOR_USER" &>/dev/null; then | |
| run gpasswd -a "$SUPERVISOR_USER" timekpr | |
| ok "added '$SUPERVISOR_USER' to timekpr group (password-less admin)" | |
| warn "'$SUPERVISOR_USER' must log out/in for the group to take effect" | |
| elif [[ -n "$SUPERVISOR_USER" ]]; then | |
| warn "SUPERVISOR_USER '$SUPERVISOR_USER' does not exist — skipping group add" | |
| fi | |
| log "configure limits via: Timekpr-nExT Control Panel (run as root)" | |
| log " recommended starter: 2h/day allowance + 15:00-20:00 allowed window" | |
| fi | |
| ok "layer 3 complete" | |
| fi | |
| # ============================================================================ | |
| # LAYER 4 — APP RESTRICTION (malcontent) | |
| # ============================================================================ | |
| # malcontent is the modern Linux parental-controls backend. It stores a | |
| # per-user "app filter" and "session limits" via accounts-service (D-Bus), | |
| # and apps that RESPECT it refuse to run for that user. | |
| # | |
| # IMPORTANT REALITY CHECK on enforcement: | |
| # * Flatpak apps enforce it fully (they check through the flatpak portal). | |
| # * GNOME apps that link libmalcontent enforce it. | |
| # * A plain pacman-installed binary (e.g. `pacman -S firefox`) IGNORES it — | |
| # it has no code to ask "am I allowed for this user?" | |
| # => If you want malcontent to actually gate the browser, install Firefox | |
| # as a Flatpak: flatpak install flathub org.mozilla.firefox | |
| # Then the x-scheme-handler/http block below actually bites. | |
| # | |
| # The `malcontent-client` CLI drives it (the GUI lives in GNOME and won't | |
| # run under Hyprland). Its CLI API is explicitly "unstable" — flags may | |
| # shift between versions, so if a command errors, check: | |
| # malcontent-client --help | |
| # ---------------------------------------------------------------------------- | |
| if [[ $ENABLE_MALCONTENT -eq 1 ]]; then | |
| echo | |
| log "LAYER 4: installing malcontent + accounts-service" | |
| # malcontent is in the official `extra` repo. accounts-service is the | |
| # D-Bus backend that actually stores the parental-controls data; without | |
| # its daemon running, malcontent-client errors with a D-Bus connect error. | |
| run pacman -S --noconfirm malcontent accounts-service | |
| ok "malcontent + accounts-service installed" | |
| # accounts-daemon.service = the org.freedesktop.Accounts D-Bus service. | |
| # Enable + start it. (On a full GNOME desktop it's already running; under | |
| # Hyprland it usually isn't, hence the explicit start.) | |
| run systemctl enable --now accounts-daemon.service | |
| ok "accounts-daemon running" | |
| # --- Example restrictions (edit to taste) --------------------------------- | |
| # Each set-app-filter call REPLACES the user's whole filter, so put all your | |
| # items in ONE call. Items are: | |
| # --disallow-user-installation kid can't `flatpak install --user` | |
| # --disallow-system-installation kid can't `flatpak install` system-wide | |
| # x-scheme-handler/http block apps handling http:// (browsers) | |
| # /absolute/path/to/binary block a specific program | |
| # app/com.foo.Bar/x86_64/stable block a specific flatpak ref | |
| # section=value OARS rating gate (e.g. violence=intense) | |
| log "applying example app filter to '$KID_USER':" | |
| run malcontent-client set-app-filter "$KID_USER" \ | |
| --disallow-user-installation \ | |
| --disallow-system-installation \ | |
| x-scheme-handler/http | |
| ok "app filter set (block flatpak installs + http:// handlers)" | |
| # --- Example session limit (mutually exclusive with each other) ---------- | |
| # set-session-limits also REPLACES. Pick exactly ONE of: | |
| # none (no limit) | |
| # daily-limit --daily-limit SECONDS (X seconds/day) | |
| # daily-schedule --start-time HH:MM --end-time HH:MM (allowed window) | |
| # NOTE: if you also installed timekpr (layer 3), SKIP malcontent session | |
| # limits — timekpr is far richer for screen time. This is here as the | |
| # no-extra-tools fallback. | |
| # run malcontent-client set-session-limits "$KID_USER" daily-limit --daily-limit 7200 | |
| log "read current filter: malcontent-client get-app-filter $KID_USER" | |
| log "check one app: malcontent-client check-app-filter $KID_USER \$(which firefox)" | |
| ok "layer 4 complete" | |
| fi | |
| # ============================================================================ | |
| # LAYER 5 — BROWSER (manual, script can't do it for you) | |
| # ============================================================================ | |
| # Not scriptable, but it matters. DNS blocks domains; the browser layer blocks | |
| # what DNS can't (ads, trackers, page-level junk) and adds safe-search. | |
| # | |
| # 1. Install Firefox (ideally the Flatpak, so malcontent can gate it): | |
| # flatpak install flathub org.mozilla.firefox | |
| # 2. Install uBlock Origin add-on (menu -> Add-ons -> search). | |
| # 3. Set default search to DuckDuckGo (strict) or Kagi. | |
| # 4. If you enforce DNS locally, turn OFF Firefox's "DNS over HTTPS" | |
| # (Settings -> Privacy & Security -> DNS over HTTPS -> Off), otherwise | |
| # the kid can flip DoH on and walk right around layer 2. | |
| # ---------------------------------------------------------------------------- | |
| echo | |
| log "LAYER 5: browser (manual) — see the comments in this script" | |
| # ============================================================================ | |
| # SUMMARY | |
| # ============================================================================ | |
| echo | |
| ok "done" | |
| echo "--------------------------------------------------------------------" | |
| echo " applied layers:" | |
| echo " account isolation ......... $([[ $ENABLE_ACCOUNT -eq 1 ]] && echo yes || echo no)" | |
| echo " DNS filtering ............. $([[ $ENABLE_DNS -eq 1 ]] && echo yes || echo no) ($DNS_PROVIDER)" | |
| echo " timekpr-nExT .............. $([[ $ENABLE_TIMEKPR -eq 1 ]] && echo yes || echo no)" | |
| echo " malcontent ................ $([[ $ENABLE_MALCONTENT -eq 1 ]] && echo yes || echo no)" | |
| echo "--------------------------------------------------------------------" | |
| echo " NEXT STEPS:" | |
| echo " * configure timekpr limits via its Control Panel (run as root)" | |
| echo " * install Firefox as a Flatpak + uBlock Origin (layer 5)" | |
| echo " * enforce DNS at the router so phones/tablets/consoles are covered too" | |
| echo " * consider BIOS password + disabled USB boot for real hardening" | |
| echo "--------------------------------------------------------------------" | |
| echo " To undo DNS: nmcli connection modify \"<conn>\" ipv4.ignore-auto-dns no" | |
| echo " then: nmcli connection up \"<conn>\"" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@escherize hey this is great you should join the omarchy-kids discord :) https://discord.gg/tXFUdasqhY