dnd-daemon 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ name: Lint
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 0
17
+ fetch-tags: true
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Install dependencies
25
+ run: |
26
+ python -m pip install --upgrade pip
27
+ pip install -e ".[dev]"
28
+
29
+ - name: Black
30
+ run: black --check src/dnd
31
+
32
+ - name: isort
33
+ run: isort --check-only src/dnd
34
+
35
+ - name: Pyright
36
+ run: pyright
37
+
38
+ - name: Codespell
39
+ run: codespell src/dnd
@@ -0,0 +1,51 @@
1
+ name: Publish Release to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+ id-token: write
11
+
12
+ env:
13
+ IS_NIGHTLY_BUILD: "false"
14
+
15
+ jobs:
16
+ build-and-publish:
17
+ runs-on: ubuntu-latest
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ with:
22
+ fetch-depth: 0
23
+ fetch-tags: true
24
+ ref: ${{ github.event.release.tag_name || github.ref }}
25
+
26
+ - name: Set up Python
27
+ uses: actions/setup-python@v5
28
+ with:
29
+ python-version: "3.12"
30
+
31
+ - name: Install build dependencies
32
+ run: |
33
+ python -m pip install --upgrade pip
34
+ pip install build twine setuptools setuptools-scm wheel
35
+
36
+ - name: Verify version from tag
37
+ run: |
38
+ git describe --tags
39
+ python -c "from setuptools_scm import get_version; print('Version:', get_version())"
40
+
41
+ - name: Build distribution
42
+ run: |
43
+ python -m build
44
+
45
+ - name: Check distribution
46
+ run: |
47
+ twine check dist/*
48
+ ls -la dist/
49
+
50
+ - name: Publish to PyPI
51
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,48 @@
1
+ name: Publish Nightly to Test PyPI
2
+
3
+ on:
4
+ # schedule:
5
+ # - cron: '0 8 * * *'
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+ id-token: write
11
+
12
+ env:
13
+ IS_NIGHTLY_BUILD: "true"
14
+
15
+ jobs:
16
+ build-and-publish:
17
+ runs-on: ubuntu-latest
18
+
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ with:
22
+ fetch-depth: 0
23
+ fetch-tags: true
24
+
25
+ - name: Set up Python
26
+ uses: actions/setup-python@v5
27
+ with:
28
+ python-version: "3.12"
29
+
30
+ - name: Install build dependencies
31
+ run: |
32
+ python -m pip install --upgrade pip
33
+ pip install build twine setuptools setuptools-scm wheel
34
+
35
+ - name: Build distribution
36
+ run: |
37
+ python -m build
38
+
39
+ - name: Check distribution
40
+ run: |
41
+ twine check dist/*
42
+ ls -la dist/
43
+
44
+ - name: Publish to Test PyPI
45
+ uses: pypa/gh-action-pypi-publish@release/v1
46
+ with:
47
+ repository-url: https://test.pypi.org/legacy/
48
+ skip-existing: true
@@ -0,0 +1,31 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ with:
16
+ fetch-depth: 0
17
+ fetch-tags: true
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Install dependencies
25
+ run: |
26
+ python -m pip install --upgrade pip
27
+ pip install -e ".[dev]"
28
+
29
+ - name: Run tests
30
+ run: |
31
+ pytest tests/ -v --tb=short --cov=dnd --cov-report=xml --cov-report=term-missing
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ *.log
7
+ .nfs*
8
+ src/dnd/_version.py
9
+ .pytest_cache/
10
+ test_reports/
@@ -0,0 +1,81 @@
1
+ # dnd-daemon Makefile
2
+
3
+ SHELL := bash
4
+ .DEFAULT_GOAL := help
5
+
6
+ .PHONY: help
7
+ help: ## show this help message
8
+ @echo "dnd-daemon Development Tools"
9
+ @echo ""
10
+ @echo "Usage: make <target>"
11
+ @echo ""
12
+ @echo "Targets:"
13
+ @awk 'BEGIN {FS = ":.*##"; printf "\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
14
+
15
+ ##@ Setup
16
+
17
+ .PHONY: install install-dev
18
+ install: ## install the package
19
+ pip install .
20
+
21
+ install-dev: ## install in development mode
22
+ pip install -e ".[dev]"
23
+
24
+ ##@ Linting
25
+
26
+ .PHONY: lint lint/black lint/isort lint/autoflake lint/pyright lint/codespell format format/black format/isort format/autoflake
27
+ lint: lint/black lint/isort lint/autoflake lint/pyright lint/codespell ## run all linters
28
+
29
+ lint/black: ## check style with black
30
+ black --check src/dnd
31
+
32
+ lint/isort: ## check import order
33
+ isort --check-only src/dnd
34
+
35
+ lint/autoflake: ## check for unused imports
36
+ autoflake --recursive --remove-all-unused-imports --check src/dnd
37
+
38
+ lint/pyright: ## run type checking
39
+ pyright
40
+
41
+ lint/codespell: ## check for misspellings
42
+ codespell src/dnd
43
+
44
+ format: format/autoflake format/isort format/black ## format all code
45
+
46
+ format/black: ## format code with black
47
+ black src/dnd
48
+
49
+ format/isort: ## format imports with isort
50
+ isort src/dnd
51
+
52
+ format/autoflake: ## remove unused imports
53
+ autoflake --recursive --remove-all-unused-imports --in-place src/dnd
54
+
55
+ ##@ Testing
56
+
57
+ .PHONY: test test/unit test/coverage
58
+ test: test/unit ## run all tests
59
+
60
+ test/unit: ## run unit tests
61
+ pytest tests/ -v --tb=short
62
+
63
+ test/coverage: ## run tests with coverage
64
+ pytest --cov=dnd --cov-report=term-missing
65
+
66
+ ##@ Building
67
+
68
+ .PHONY: build clean
69
+ build: clean ## build the package
70
+ python -m build
71
+
72
+ clean: ## clean build artifacts
73
+ rm -rf build/ dist/ *.egg-info/ src/*.egg-info/ .pytest_cache/
74
+ find . -type d -name __pycache__ -exec rm -rf {} +
75
+ find . -type f -name "*.pyc" -delete
76
+
77
+ ##@ Release
78
+
79
+ .PHONY: version
80
+ version: ## show current version
81
+ python -c "import dnd; print(dnd.__version__)"
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: dnd-daemon
3
+ Version: 0.0.1
4
+ Summary: Do-Not-Disturb process enforcer — kill processes from unauthorized users on shared machines
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/AgrawalAmey/dnd-daemon
7
+ Project-URL: Bug Tracker, https://github.com/AgrawalAmey/dnd-daemon/issues
8
+ Keywords: process,enforcer,daemon,shared,machine,reservation
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: No Input/Output (Daemon)
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
22
+ Requires-Dist: pytest-cov; extra == "dev"
23
+ Requires-Dist: black>=23.0.0; extra == "dev"
24
+ Requires-Dist: isort>=5.12.0; extra == "dev"
25
+ Requires-Dist: autoflake; extra == "dev"
26
+ Requires-Dist: codespell; extra == "dev"
27
+ Requires-Dist: pyright>=1.1.300; extra == "dev"
28
+ Requires-Dist: build>=1.0.0; extra == "dev"
29
+
30
+ # dnd — Do Not Disturb
31
+
32
+ Process enforcer for shared machines. Monitors running processes and kills
33
+ anything owned by users who aren't on the allowed list. Designed for
34
+ honor-code reservation systems where some users don't follow the rules.
35
+
36
+ ## Quick start
37
+
38
+ No install needed — run directly with [`uvx`](https://docs.astral.sh/uv/):
39
+
40
+ ```bash
41
+ # Allow everyone by default, block specific users:
42
+ uvx dnd-daemon allow '*'
43
+ uvx dnd-daemon block debopman
44
+
45
+ # Start the daemon (foreground, for testing):
46
+ uvx dnd-daemon run --dry-run
47
+
48
+ # Start for real:
49
+ uvx dnd-daemon run
50
+ ```
51
+
52
+ ## Install
53
+
54
+ For persistent use, install globally with `uv`:
55
+
56
+ ```bash
57
+ uv tool install dnd-daemon
58
+ ```
59
+
60
+ Then use `dnd` directly:
61
+
62
+ ```bash
63
+ dnd allow '*'
64
+ dnd block debopman
65
+ dnd run
66
+ ```
67
+
68
+ Alternatively, install with pip:
69
+
70
+ ```bash
71
+ pip install dnd-daemon
72
+ ```
73
+
74
+ ## Commands
75
+
76
+ | Command | Description |
77
+ |---------|-------------|
78
+ | `dnd run` | Start the enforcement daemon |
79
+ | `dnd allow <users...>` | Add users to the allowed list (also unblocks them) |
80
+ | `dnd block <users...>` | Add users to the blocked list (also removes from allowed) |
81
+ | `dnd list` | Show current allowed and blocked users |
82
+ | `dnd install` | Install as a systemd service (requires `sudo`) |
83
+ | `dnd uninstall` | Remove the systemd service (requires `sudo`) |
84
+ | `dnd status` | Show systemd service status |
85
+
86
+ ### `dnd run` options
87
+
88
+ - `--dry-run` — log what would be killed without actually killing
89
+ - `--daemon` — fork into background
90
+ - `--interval N` — seconds between scans (default: 10)
91
+ - `--verbose` — debug-level logging
92
+
93
+ ### Wildcards
94
+
95
+ Use `*` in the allowed list to allow everyone by default. The blocked list
96
+ always takes priority over the wildcard:
97
+
98
+ ```bash
99
+ dnd allow '*' # everyone is allowed
100
+ dnd block baduser # ...except baduser
101
+ ```
102
+
103
+ ## Configuration
104
+
105
+ User lists are stored in `~/.cache/dnd/`:
106
+
107
+ ```
108
+ ~/.cache/dnd/
109
+ allowed_users.txt
110
+ blocked_users.txt
111
+ dnd.log
112
+ ```
113
+
114
+ Override with `--allow-config` and `--block-config`:
115
+
116
+ ```bash
117
+ dnd --allow-config /path/to/allowed.txt list
118
+ ```
119
+
120
+ ## Systemd service
121
+
122
+ ```bash
123
+ sudo dnd install # creates, enables, and starts the service
124
+ sudo dnd uninstall # stops, disables, and removes the service
125
+ dnd status # show service status
126
+ ```
127
+
128
+ The service runs as the installing user with `CAP_KILL` for permission to
129
+ terminate other users' processes. Config changes via `dnd allow` / `dnd block`
130
+ are automatically picked up (the daemon reloads on SIGHUP).
131
+
132
+ ## How it works
133
+
134
+ 1. Scans `/proc` every 10 seconds for all running processes
135
+ 2. Skips system/service accounts (UID <= 999, known service names)
136
+ 3. Checks each remaining process owner against the allowed/blocked lists
137
+ 4. Sends SIGTERM, waits 5 seconds, then SIGKILL if still alive
@@ -0,0 +1,108 @@
1
+ # dnd — Do Not Disturb
2
+
3
+ Process enforcer for shared machines. Monitors running processes and kills
4
+ anything owned by users who aren't on the allowed list. Designed for
5
+ honor-code reservation systems where some users don't follow the rules.
6
+
7
+ ## Quick start
8
+
9
+ No install needed — run directly with [`uvx`](https://docs.astral.sh/uv/):
10
+
11
+ ```bash
12
+ # Allow everyone by default, block specific users:
13
+ uvx dnd-daemon allow '*'
14
+ uvx dnd-daemon block debopman
15
+
16
+ # Start the daemon (foreground, for testing):
17
+ uvx dnd-daemon run --dry-run
18
+
19
+ # Start for real:
20
+ uvx dnd-daemon run
21
+ ```
22
+
23
+ ## Install
24
+
25
+ For persistent use, install globally with `uv`:
26
+
27
+ ```bash
28
+ uv tool install dnd-daemon
29
+ ```
30
+
31
+ Then use `dnd` directly:
32
+
33
+ ```bash
34
+ dnd allow '*'
35
+ dnd block debopman
36
+ dnd run
37
+ ```
38
+
39
+ Alternatively, install with pip:
40
+
41
+ ```bash
42
+ pip install dnd-daemon
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ | Command | Description |
48
+ |---------|-------------|
49
+ | `dnd run` | Start the enforcement daemon |
50
+ | `dnd allow <users...>` | Add users to the allowed list (also unblocks them) |
51
+ | `dnd block <users...>` | Add users to the blocked list (also removes from allowed) |
52
+ | `dnd list` | Show current allowed and blocked users |
53
+ | `dnd install` | Install as a systemd service (requires `sudo`) |
54
+ | `dnd uninstall` | Remove the systemd service (requires `sudo`) |
55
+ | `dnd status` | Show systemd service status |
56
+
57
+ ### `dnd run` options
58
+
59
+ - `--dry-run` — log what would be killed without actually killing
60
+ - `--daemon` — fork into background
61
+ - `--interval N` — seconds between scans (default: 10)
62
+ - `--verbose` — debug-level logging
63
+
64
+ ### Wildcards
65
+
66
+ Use `*` in the allowed list to allow everyone by default. The blocked list
67
+ always takes priority over the wildcard:
68
+
69
+ ```bash
70
+ dnd allow '*' # everyone is allowed
71
+ dnd block baduser # ...except baduser
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ User lists are stored in `~/.cache/dnd/`:
77
+
78
+ ```
79
+ ~/.cache/dnd/
80
+ allowed_users.txt
81
+ blocked_users.txt
82
+ dnd.log
83
+ ```
84
+
85
+ Override with `--allow-config` and `--block-config`:
86
+
87
+ ```bash
88
+ dnd --allow-config /path/to/allowed.txt list
89
+ ```
90
+
91
+ ## Systemd service
92
+
93
+ ```bash
94
+ sudo dnd install # creates, enables, and starts the service
95
+ sudo dnd uninstall # stops, disables, and removes the service
96
+ dnd status # show service status
97
+ ```
98
+
99
+ The service runs as the installing user with `CAP_KILL` for permission to
100
+ terminate other users' processes. Config changes via `dnd allow` / `dnd block`
101
+ are automatically picked up (the daemon reloads on SIGHUP).
102
+
103
+ ## How it works
104
+
105
+ 1. Scans `/proc` every 10 seconds for all running processes
106
+ 2. Skips system/service accounts (UID <= 999, known service names)
107
+ 3. Checks each remaining process owner against the allowed/blocked lists
108
+ 4. Sends SIGTERM, waits 5 seconds, then SIGKILL if still alive
@@ -0,0 +1,72 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "setuptools-scm>=8", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "dnd-daemon"
7
+ dynamic = ["version"]
8
+ description = "Do-Not-Disturb process enforcer — kill processes from unauthorized users on shared machines"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ keywords = ["process", "enforcer", "daemon", "shared", "machine", "reservation"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: No Input/Output (Daemon)",
16
+ "Intended Audience :: System Administrators",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: System :: Systems Administration",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=7.4.0",
28
+ "pytest-cov",
29
+ "black>=23.0.0",
30
+ "isort>=5.12.0",
31
+ "autoflake",
32
+ "codespell",
33
+ "pyright>=1.1.300",
34
+ "build>=1.0.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/AgrawalAmey/dnd-daemon"
39
+ "Bug Tracker" = "https://github.com/AgrawalAmey/dnd-daemon/issues"
40
+
41
+ [project.scripts]
42
+ dnd = "dnd.cli:main"
43
+
44
+ [tool.black]
45
+ line-length = 88
46
+ target-version = ['py310']
47
+
48
+ [tool.isort]
49
+ profile = "black"
50
+ multi_line_output = 3
51
+ line_length = 88
52
+ known_first_party = ["dnd"]
53
+
54
+ [tool.autoflake]
55
+ remove-all-unused-imports = true
56
+ recursive = true
57
+ in-place = true
58
+ remove-unused-variables = true
59
+ ignore-init-module-imports = true
60
+
61
+ [tool.pyright]
62
+ include = ["src/dnd"]
63
+ exclude = ["**/__pycache__", "tests"]
64
+ reportMissingImports = false
65
+ pythonVersion = "3.10"
66
+
67
+ [tool.codespell]
68
+ skip = "*.log"
69
+
70
+ [tool.setuptools.packages.find]
71
+ where = ["src"]
72
+ include = ["dnd*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,35 @@
1
+ """Setup script for dnd-daemon package.
2
+
3
+ Uses setuptools_scm to determine version from git tags.
4
+ """
5
+
6
+ import os
7
+ from datetime import datetime
8
+
9
+ from setuptools import setup
10
+ from setuptools_scm import get_version
11
+
12
+
13
+ def get_dnd_version() -> str:
14
+ """Get version string, handling release and nightly builds."""
15
+ version = get_version(
16
+ write_to="src/dnd/_version.py",
17
+ )
18
+
19
+ is_nightly_build = os.getenv("IS_NIGHTLY_BUILD", "false") == "true"
20
+
21
+ if is_nightly_build:
22
+ base = version.split(".dev")[0] if ".dev" in version else version.split("+")[0]
23
+ version = f"{base}.dev{datetime.now().strftime('%Y%m%d%H')}"
24
+ else:
25
+ if ".dev" in version:
26
+ version = version.split(".dev")[0]
27
+ elif "+" in version:
28
+ version = version.split("+")[0]
29
+
30
+ return version
31
+
32
+
33
+ setup(
34
+ version=get_dnd_version(),
35
+ )
@@ -0,0 +1,6 @@
1
+ """dnd — Do-Not-Disturb process enforcer daemon."""
2
+
3
+ try:
4
+ from ._version import version as __version__
5
+ except ImportError:
6
+ __version__ = "0.0.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m dnd`."""
2
+
3
+ from .cli import main
4
+
5
+ main()
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.0.1'
22
+ __version_tuple__ = version_tuple = (0, 0, 1)
23
+
24
+ __commit_id__ = commit_id = 'g85a003a42'
@@ -0,0 +1,130 @@
1
+ """CLI entry point for dnd."""
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from .config import (
7
+ DEFAULT_ALLOW_CONFIG,
8
+ DEFAULT_BLOCK_CONFIG,
9
+ SCAN_INTERVAL,
10
+ ALLOW_HEADER,
11
+ BLOCK_HEADER,
12
+ ensure_config_dir,
13
+ load_user_list,
14
+ save_user_list,
15
+ )
16
+ from .daemon import cmd_run
17
+ from .service import cmd_install, cmd_uninstall, cmd_status, signal_daemon
18
+
19
+
20
+ def _print_status(allowed: set[str], blocked: set[str]) -> None:
21
+ if "*" in allowed:
22
+ named = sorted(allowed - {"*"})
23
+ if named:
24
+ print(f"Allowed: * (everyone) + explicitly: {', '.join(named)}")
25
+ else:
26
+ print("Allowed: * (everyone)")
27
+ elif allowed:
28
+ print(f"Allowed: {', '.join(sorted(allowed))}")
29
+ else:
30
+ print("Allowed: (none)")
31
+ if blocked:
32
+ print(f"Blocked: {', '.join(sorted(blocked))}")
33
+ else:
34
+ print("Blocked: (none)")
35
+
36
+
37
+ def cmd_allow(args: argparse.Namespace) -> None:
38
+ allowed = load_user_list(args.allow_config, log=False)
39
+ blocked = load_user_list(args.block_config, log=False)
40
+ added = []
41
+ for name in args.users:
42
+ if name not in allowed:
43
+ allowed.add(name)
44
+ added.append(name)
45
+ blocked.discard(name)
46
+ save_user_list(args.allow_config, allowed, ALLOW_HEADER)
47
+ save_user_list(args.block_config, blocked, BLOCK_HEADER)
48
+ if added:
49
+ print(f"Allowed: {', '.join(sorted(added))}")
50
+ signal_daemon()
51
+ else:
52
+ print("All specified users were already allowed.")
53
+ _print_status(allowed, blocked)
54
+
55
+
56
+ def cmd_block(args: argparse.Namespace) -> None:
57
+ allowed = load_user_list(args.allow_config, log=False)
58
+ blocked = load_user_list(args.block_config, log=False)
59
+ added = []
60
+ for name in args.users:
61
+ if name not in blocked:
62
+ blocked.add(name)
63
+ added.append(name)
64
+ allowed.discard(name)
65
+ save_user_list(args.allow_config, allowed, ALLOW_HEADER)
66
+ save_user_list(args.block_config, blocked, BLOCK_HEADER)
67
+ if added:
68
+ print(f"Blocked: {', '.join(sorted(added))}")
69
+ signal_daemon()
70
+ else:
71
+ print("All specified users were already blocked.")
72
+ _print_status(allowed, blocked)
73
+
74
+
75
+ def cmd_list(args: argparse.Namespace) -> None:
76
+ allowed = load_user_list(args.allow_config, log=False)
77
+ blocked = load_user_list(args.block_config, log=False)
78
+ _print_status(allowed, blocked)
79
+
80
+
81
+ def main() -> None:
82
+ ensure_config_dir()
83
+
84
+ parser = argparse.ArgumentParser(
85
+ prog="dnd",
86
+ description="Do-Not-Disturb — kill processes from unauthorized users on shared machines",
87
+ )
88
+ parser.add_argument("--allow-config", type=Path, default=DEFAULT_ALLOW_CONFIG,
89
+ help="Path to allowed-users file (default: %(default)s)")
90
+ parser.add_argument("--block-config", type=Path, default=DEFAULT_BLOCK_CONFIG,
91
+ help="Path to blocked-users file (default: %(default)s)")
92
+ sub = parser.add_subparsers(dest="command")
93
+
94
+ # run
95
+ run_p = sub.add_parser("run", help="Start the enforcement daemon")
96
+ run_p.add_argument("--interval", type=int, default=SCAN_INTERVAL,
97
+ help="Seconds between scans (default: %(default)s)")
98
+ run_p.add_argument("--dry-run", action="store_true",
99
+ help="Log but don't actually kill anything")
100
+ run_p.add_argument("--daemon", action="store_true",
101
+ help="Fork into background")
102
+ run_p.add_argument("--verbose", action="store_true")
103
+
104
+ # allow / block / list
105
+ allow_p = sub.add_parser("allow", help="Add users to the allowed list")
106
+ allow_p.add_argument("users", nargs="+", help="Usernames to allow (use * for everyone)")
107
+ block_p = sub.add_parser("block", help="Add users to the blocked list")
108
+ block_p.add_argument("users", nargs="+", help="Usernames to block")
109
+ sub.add_parser("list", help="Show current allowed and blocked users")
110
+
111
+ # service management
112
+ sub.add_parser("install", help="Install as a systemd service (requires root)")
113
+ sub.add_parser("uninstall", help="Remove the systemd service (requires root)")
114
+ sub.add_parser("status", help="Show systemd service status")
115
+
116
+ args = parser.parse_args()
117
+
118
+ commands = {
119
+ "run": cmd_run,
120
+ "allow": cmd_allow,
121
+ "block": cmd_block,
122
+ "list": cmd_list,
123
+ "install": cmd_install,
124
+ "uninstall": cmd_uninstall,
125
+ "status": cmd_status,
126
+ }
127
+ if args.command in commands:
128
+ commands[args.command](args)
129
+ else:
130
+ parser.print_help()
@@ -0,0 +1,75 @@
1
+ """Constants, paths, and user-list I/O."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # Paths — all runtime data lives in ~/.cache/dnd/
8
+ # ---------------------------------------------------------------------------
9
+ CONFIG_DIR = Path.home() / ".cache" / "dnd"
10
+
11
+ DEFAULT_ALLOW_CONFIG = CONFIG_DIR / "allowed_users.txt"
12
+ DEFAULT_BLOCK_CONFIG = CONFIG_DIR / "blocked_users.txt"
13
+ LOG_FILE = CONFIG_DIR / "dnd.log"
14
+
15
+ SCAN_INTERVAL = 10 # seconds between sweeps
16
+ KILL_GRACE = 5 # seconds between SIGTERM and SIGKILL
17
+
18
+ ALLOW_HEADER = "# Allowed users — one per line. Use * to allow everyone by default.\n"
19
+ BLOCK_HEADER = "# Blocked users — one per line. Blocked users override * in allowed list.\n"
20
+
21
+ # System / service accounts whose processes must never be touched.
22
+ SYSTEM_USERS = frozenset({
23
+ "root", "daemon", "bin", "sys", "sync", "games", "man", "lp", "mail",
24
+ "news", "uucp", "proxy", "www-data", "backup", "list", "irc", "gnats",
25
+ "nobody", "systemd-network", "systemd-resolve", "systemd-timesync",
26
+ "messagebus", "syslog", "uuidd", "tcpdump", "ntp", "_chrony", "_rpc",
27
+ "statd", "sensu", "dbus", "polkitd", "avahi", "colord", "pulse",
28
+ "rtkit", "geoclue", "gdm", "sssd", "chrony", "cortexuser",
29
+ "service", "serviceuser",
30
+ })
31
+
32
+ SYSTEM_UID_MAX = 999
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Helpers
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def ensure_config_dir() -> None:
40
+ """Create ~/.cache/dnd/ and seed empty config files if needed."""
41
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
42
+ if not DEFAULT_ALLOW_CONFIG.exists():
43
+ DEFAULT_ALLOW_CONFIG.write_text(ALLOW_HEADER)
44
+ if not DEFAULT_BLOCK_CONFIG.exists():
45
+ DEFAULT_BLOCK_CONFIG.write_text(BLOCK_HEADER)
46
+
47
+
48
+ def load_user_list(config_path: Path, log: bool = True) -> set[str]:
49
+ """Return the set of usernames from a config file."""
50
+ users: set[str] = set()
51
+ try:
52
+ for line in config_path.read_text().splitlines():
53
+ line = line.strip()
54
+ if line and not line.startswith("#"):
55
+ users.add(line)
56
+ except FileNotFoundError:
57
+ if log:
58
+ logging.error("Config file not found: %s", config_path)
59
+ return users
60
+
61
+
62
+ def save_user_list(config_path: Path, users: set[str], header: str) -> None:
63
+ """Write a user set back to a config file with the given header."""
64
+ config_path.write_text(header + "\n".join(sorted(users)) + "\n")
65
+
66
+
67
+ def is_system_account(uid: int, username: str) -> bool:
68
+ """Return True if this looks like a system / service account."""
69
+ if uid <= SYSTEM_UID_MAX:
70
+ return True
71
+ if username in SYSTEM_USERS:
72
+ return True
73
+ if username.startswith("_"):
74
+ return True
75
+ return False
@@ -0,0 +1,135 @@
1
+ """Core daemon loop: process scanning, filtering, and killing."""
2
+
3
+ import argparse
4
+ import logging
5
+ import os
6
+ import pwd
7
+ import signal
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from .config import (
13
+ KILL_GRACE,
14
+ LOG_FILE,
15
+ is_system_account,
16
+ load_user_list,
17
+ )
18
+
19
+
20
+ def setup_logging(verbose: bool) -> None:
21
+ handlers = [logging.StreamHandler(), logging.FileHandler(LOG_FILE)]
22
+ logging.basicConfig(
23
+ level=logging.DEBUG if verbose else logging.INFO,
24
+ format="%(asctime)s %(levelname)-8s %(message)s",
25
+ handlers=handlers,
26
+ )
27
+
28
+
29
+ def iter_user_processes():
30
+ """Yield (pid, uid, username, proc_name) for every visible process."""
31
+ my_pid = os.getpid()
32
+ for entry in Path("/proc").iterdir():
33
+ if not entry.name.isdigit():
34
+ continue
35
+ pid = int(entry.name)
36
+ if pid in (1, 2, my_pid):
37
+ continue
38
+ try:
39
+ status = (entry / "status").read_text()
40
+ except (PermissionError, FileNotFoundError, ProcessLookupError):
41
+ continue
42
+ uid = None
43
+ name = None
44
+ for sline in status.splitlines():
45
+ if sline.startswith("Uid:"):
46
+ uid = int(sline.split()[1])
47
+ if sline.startswith("Name:"):
48
+ name = sline.split("\t", 1)[1].strip()
49
+ if uid is None:
50
+ continue
51
+ try:
52
+ username = pwd.getpwuid(uid).pw_name
53
+ except KeyError:
54
+ continue
55
+ yield pid, uid, username, name or "(unknown)"
56
+
57
+
58
+ def kill_process(pid: int, username: str, proc_name: str, dry_run: bool) -> None:
59
+ """SIGTERM -> grace period -> SIGKILL."""
60
+ if dry_run:
61
+ logging.warning("[DRY RUN] Would kill PID %d (%s) owned by %s", pid, proc_name, username)
62
+ return
63
+ logging.warning("Sending SIGTERM to PID %d (%s) owned by %s", pid, proc_name, username)
64
+ try:
65
+ os.kill(pid, signal.SIGTERM)
66
+ except ProcessLookupError:
67
+ return
68
+ except PermissionError:
69
+ logging.error("Permission denied killing PID %d — need root or CAP_KILL?", pid)
70
+ return
71
+ time.sleep(KILL_GRACE)
72
+ try:
73
+ os.kill(pid, 0)
74
+ logging.warning("PID %d still alive, sending SIGKILL", pid)
75
+ os.kill(pid, signal.SIGKILL)
76
+ except ProcessLookupError:
77
+ pass
78
+ except PermissionError:
79
+ logging.error("Permission denied sending SIGKILL to PID %d", pid)
80
+
81
+
82
+ def is_user_allowed(username: str, allowed: set[str], blocked: set[str]) -> bool:
83
+ """Blocked list always wins over wildcard."""
84
+ if username in blocked:
85
+ return False
86
+ return "*" in allowed or username in allowed
87
+
88
+
89
+ def scan_and_enforce(allowed: set[str], blocked: set[str], dry_run: bool) -> int:
90
+ """One sweep. Returns the number of processes killed."""
91
+ killed = 0
92
+ for pid, uid, username, proc_name in iter_user_processes():
93
+ if is_system_account(uid, username):
94
+ continue
95
+ if is_user_allowed(username, allowed, blocked):
96
+ continue
97
+ kill_process(pid, username, proc_name, dry_run)
98
+ killed += 1
99
+ return killed
100
+
101
+
102
+ def cmd_run(args: argparse.Namespace) -> None:
103
+ """Run the enforcement daemon."""
104
+ if args.daemon:
105
+ pid = os.fork()
106
+ if pid > 0:
107
+ print(f"dnd daemon started (PID {pid})")
108
+ sys.exit(0)
109
+ os.setsid()
110
+
111
+ setup_logging(args.verbose)
112
+ logging.info("dnd starting (PID %d, interval %ds, dry_run=%s)",
113
+ os.getpid(), args.interval, args.dry_run)
114
+
115
+ allowed = load_user_list(args.allow_config)
116
+ blocked = load_user_list(args.block_config)
117
+ logging.info("Allowed: %s | Blocked: %s", sorted(allowed), sorted(blocked))
118
+
119
+ def handle_hup(*_):
120
+ nonlocal allowed, blocked
121
+ logging.info("SIGHUP received — reloading config")
122
+ allowed = load_user_list(args.allow_config)
123
+ blocked = load_user_list(args.block_config)
124
+ logging.info("Allowed: %s | Blocked: %s", sorted(allowed), sorted(blocked))
125
+
126
+ signal.signal(signal.SIGHUP, handle_hup)
127
+
128
+ try:
129
+ while True:
130
+ killed = scan_and_enforce(allowed, blocked, args.dry_run)
131
+ if killed:
132
+ logging.info("Killed %d unauthorized process(es) this sweep", killed)
133
+ time.sleep(args.interval)
134
+ except KeyboardInterrupt:
135
+ logging.info("dnd shutting down")
@@ -0,0 +1,108 @@
1
+ """Systemd service install / uninstall / status."""
2
+
3
+ import argparse
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ SERVICE_NAME = "dnd"
11
+ UNIT_PATH = Path(f"/etc/systemd/system/{SERVICE_NAME}.service")
12
+
13
+ UNIT_TEMPLATE = """\
14
+ [Unit]
15
+ Description=Do-Not-Disturb process enforcer
16
+ After=network-online.target nss-user-lookup.target
17
+ Wants=network-online.target
18
+
19
+ [Service]
20
+ Type=simple
21
+ User={user}
22
+ ExecStart={dnd_bin} run
23
+ ExecReload=/bin/kill -HUP $MAINPID
24
+ Restart=on-failure
25
+ RestartSec=5
26
+ AmbientCapabilities=CAP_KILL
27
+ CapabilityBoundingSet=CAP_KILL
28
+
29
+ [Install]
30
+ WantedBy=multi-user.target
31
+ """
32
+
33
+
34
+ def _require_root(action: str) -> None:
35
+ if os.geteuid() != 0:
36
+ sys.exit(f"Error: {action} requires root. Run with sudo.")
37
+
38
+
39
+ def _resolve_dnd_bin() -> str:
40
+ """Find the installed `dnd` entry-point binary."""
41
+ path = shutil.which("dnd")
42
+ if path:
43
+ return str(Path(path).resolve())
44
+ # Fallback: use the current interpreter + module.
45
+ return f"{sys.executable} -m dnd"
46
+
47
+
48
+ def signal_daemon() -> None:
49
+ """Send SIGHUP to a running dnd daemon so it reloads config."""
50
+ result = subprocess.run(
51
+ ["systemctl", "reload", SERVICE_NAME],
52
+ capture_output=True, text=True,
53
+ )
54
+ if result.returncode == 0:
55
+ print("Reloaded dnd service.")
56
+ return
57
+ # Fallback for manual `dnd run` invocations.
58
+ result = subprocess.run(
59
+ ["pgrep", "-f", r"dnd.*run"],
60
+ capture_output=True, text=True,
61
+ )
62
+ for pid_str in result.stdout.split():
63
+ try:
64
+ os.kill(int(pid_str), 1) # SIGHUP
65
+ print(f"Sent SIGHUP to running daemon (PID {pid_str}).")
66
+ return
67
+ except (ProcessLookupError, PermissionError):
68
+ continue
69
+
70
+
71
+ def cmd_install(_args: argparse.Namespace) -> None:
72
+ """Install dnd as a systemd service."""
73
+ _require_root("install")
74
+
75
+ user = os.environ.get("SUDO_USER", os.getlogin())
76
+ dnd_bin = _resolve_dnd_bin()
77
+
78
+ unit_content = UNIT_TEMPLATE.format(user=user, dnd_bin=dnd_bin)
79
+ UNIT_PATH.write_text(unit_content)
80
+ print(f"Wrote {UNIT_PATH}")
81
+
82
+ subprocess.run(["systemctl", "daemon-reload"], check=True)
83
+ subprocess.run(["systemctl", "enable", SERVICE_NAME], check=True)
84
+ subprocess.run(["systemctl", "start", SERVICE_NAME], check=True)
85
+ print(f"Service '{SERVICE_NAME}' installed, enabled, and started.")
86
+ subprocess.run(["systemctl", "status", SERVICE_NAME, "--no-pager"])
87
+
88
+
89
+ def cmd_uninstall(_args: argparse.Namespace) -> None:
90
+ """Uninstall the dnd systemd service."""
91
+ _require_root("uninstall")
92
+
93
+ subprocess.run(["systemctl", "stop", SERVICE_NAME], check=False)
94
+ subprocess.run(["systemctl", "disable", SERVICE_NAME], check=False)
95
+
96
+ if UNIT_PATH.exists():
97
+ UNIT_PATH.unlink()
98
+ print(f"Removed {UNIT_PATH}")
99
+ else:
100
+ print(f"{UNIT_PATH} not found — already removed?")
101
+
102
+ subprocess.run(["systemctl", "daemon-reload"], check=True)
103
+ print(f"Service '{SERVICE_NAME}' uninstalled.")
104
+
105
+
106
+ def cmd_status(_args: argparse.Namespace) -> None:
107
+ """Show systemd service status."""
108
+ subprocess.run(["systemctl", "status", SERVICE_NAME, "--no-pager"])
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: dnd-daemon
3
+ Version: 0.0.1
4
+ Summary: Do-Not-Disturb process enforcer — kill processes from unauthorized users on shared machines
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/AgrawalAmey/dnd-daemon
7
+ Project-URL: Bug Tracker, https://github.com/AgrawalAmey/dnd-daemon/issues
8
+ Keywords: process,enforcer,daemon,shared,machine,reservation
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: No Input/Output (Daemon)
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: System :: Systems Administration
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
22
+ Requires-Dist: pytest-cov; extra == "dev"
23
+ Requires-Dist: black>=23.0.0; extra == "dev"
24
+ Requires-Dist: isort>=5.12.0; extra == "dev"
25
+ Requires-Dist: autoflake; extra == "dev"
26
+ Requires-Dist: codespell; extra == "dev"
27
+ Requires-Dist: pyright>=1.1.300; extra == "dev"
28
+ Requires-Dist: build>=1.0.0; extra == "dev"
29
+
30
+ # dnd — Do Not Disturb
31
+
32
+ Process enforcer for shared machines. Monitors running processes and kills
33
+ anything owned by users who aren't on the allowed list. Designed for
34
+ honor-code reservation systems where some users don't follow the rules.
35
+
36
+ ## Quick start
37
+
38
+ No install needed — run directly with [`uvx`](https://docs.astral.sh/uv/):
39
+
40
+ ```bash
41
+ # Allow everyone by default, block specific users:
42
+ uvx dnd-daemon allow '*'
43
+ uvx dnd-daemon block debopman
44
+
45
+ # Start the daemon (foreground, for testing):
46
+ uvx dnd-daemon run --dry-run
47
+
48
+ # Start for real:
49
+ uvx dnd-daemon run
50
+ ```
51
+
52
+ ## Install
53
+
54
+ For persistent use, install globally with `uv`:
55
+
56
+ ```bash
57
+ uv tool install dnd-daemon
58
+ ```
59
+
60
+ Then use `dnd` directly:
61
+
62
+ ```bash
63
+ dnd allow '*'
64
+ dnd block debopman
65
+ dnd run
66
+ ```
67
+
68
+ Alternatively, install with pip:
69
+
70
+ ```bash
71
+ pip install dnd-daemon
72
+ ```
73
+
74
+ ## Commands
75
+
76
+ | Command | Description |
77
+ |---------|-------------|
78
+ | `dnd run` | Start the enforcement daemon |
79
+ | `dnd allow <users...>` | Add users to the allowed list (also unblocks them) |
80
+ | `dnd block <users...>` | Add users to the blocked list (also removes from allowed) |
81
+ | `dnd list` | Show current allowed and blocked users |
82
+ | `dnd install` | Install as a systemd service (requires `sudo`) |
83
+ | `dnd uninstall` | Remove the systemd service (requires `sudo`) |
84
+ | `dnd status` | Show systemd service status |
85
+
86
+ ### `dnd run` options
87
+
88
+ - `--dry-run` — log what would be killed without actually killing
89
+ - `--daemon` — fork into background
90
+ - `--interval N` — seconds between scans (default: 10)
91
+ - `--verbose` — debug-level logging
92
+
93
+ ### Wildcards
94
+
95
+ Use `*` in the allowed list to allow everyone by default. The blocked list
96
+ always takes priority over the wildcard:
97
+
98
+ ```bash
99
+ dnd allow '*' # everyone is allowed
100
+ dnd block baduser # ...except baduser
101
+ ```
102
+
103
+ ## Configuration
104
+
105
+ User lists are stored in `~/.cache/dnd/`:
106
+
107
+ ```
108
+ ~/.cache/dnd/
109
+ allowed_users.txt
110
+ blocked_users.txt
111
+ dnd.log
112
+ ```
113
+
114
+ Override with `--allow-config` and `--block-config`:
115
+
116
+ ```bash
117
+ dnd --allow-config /path/to/allowed.txt list
118
+ ```
119
+
120
+ ## Systemd service
121
+
122
+ ```bash
123
+ sudo dnd install # creates, enables, and starts the service
124
+ sudo dnd uninstall # stops, disables, and removes the service
125
+ dnd status # show service status
126
+ ```
127
+
128
+ The service runs as the installing user with `CAP_KILL` for permission to
129
+ terminate other users' processes. Config changes via `dnd allow` / `dnd block`
130
+ are automatically picked up (the daemon reloads on SIGHUP).
131
+
132
+ ## How it works
133
+
134
+ 1. Scans `/proc` every 10 seconds for all running processes
135
+ 2. Skips system/service accounts (UID <= 999, known service names)
136
+ 3. Checks each remaining process owner against the allowed/blocked lists
137
+ 4. Sends SIGTERM, waits 5 seconds, then SIGKILL if still alive
@@ -0,0 +1,22 @@
1
+ .gitignore
2
+ Makefile
3
+ README.md
4
+ pyproject.toml
5
+ setup.py
6
+ .github/workflows/lint.yml
7
+ .github/workflows/publish.yml
8
+ .github/workflows/publish_nightly.yml
9
+ .github/workflows/test.yml
10
+ src/dnd/__init__.py
11
+ src/dnd/__main__.py
12
+ src/dnd/_version.py
13
+ src/dnd/cli.py
14
+ src/dnd/config.py
15
+ src/dnd/daemon.py
16
+ src/dnd/service.py
17
+ src/dnd_daemon.egg-info/PKG-INFO
18
+ src/dnd_daemon.egg-info/SOURCES.txt
19
+ src/dnd_daemon.egg-info/dependency_links.txt
20
+ src/dnd_daemon.egg-info/entry_points.txt
21
+ src/dnd_daemon.egg-info/requires.txt
22
+ src/dnd_daemon.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dnd = dnd.cli:main
@@ -0,0 +1,10 @@
1
+
2
+ [dev]
3
+ pytest>=7.4.0
4
+ pytest-cov
5
+ black>=23.0.0
6
+ isort>=5.12.0
7
+ autoflake
8
+ codespell
9
+ pyright>=1.1.300
10
+ build>=1.0.0