Skip to content

Instantly share code, notes, and snippets.

@escherize
Created August 29, 2026 02:45
Show Gist options
  • Select an option

  • Save escherize/c7e3afb637334a233fd5e5895cb984be to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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>\""
@kenhara

kenhara commented Sep 2, 2026

Copy link
Copy Markdown

@escherize hey this is great you should join the omarchy-kids discord :) https://discord.gg/tXFUdasqhY

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment