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.
- dnd_daemon-0.0.1/.github/workflows/lint.yml +39 -0
- dnd_daemon-0.0.1/.github/workflows/publish.yml +51 -0
- dnd_daemon-0.0.1/.github/workflows/publish_nightly.yml +48 -0
- dnd_daemon-0.0.1/.github/workflows/test.yml +31 -0
- dnd_daemon-0.0.1/.gitignore +10 -0
- dnd_daemon-0.0.1/Makefile +81 -0
- dnd_daemon-0.0.1/PKG-INFO +137 -0
- dnd_daemon-0.0.1/README.md +108 -0
- dnd_daemon-0.0.1/pyproject.toml +72 -0
- dnd_daemon-0.0.1/setup.cfg +4 -0
- dnd_daemon-0.0.1/setup.py +35 -0
- dnd_daemon-0.0.1/src/dnd/__init__.py +6 -0
- dnd_daemon-0.0.1/src/dnd/__main__.py +5 -0
- dnd_daemon-0.0.1/src/dnd/_version.py +24 -0
- dnd_daemon-0.0.1/src/dnd/cli.py +130 -0
- dnd_daemon-0.0.1/src/dnd/config.py +75 -0
- dnd_daemon-0.0.1/src/dnd/daemon.py +135 -0
- dnd_daemon-0.0.1/src/dnd/service.py +108 -0
- dnd_daemon-0.0.1/src/dnd_daemon.egg-info/PKG-INFO +137 -0
- dnd_daemon-0.0.1/src/dnd_daemon.egg-info/SOURCES.txt +22 -0
- dnd_daemon-0.0.1/src/dnd_daemon.egg-info/dependency_links.txt +1 -0
- dnd_daemon-0.0.1/src/dnd_daemon.egg-info/entry_points.txt +2 -0
- dnd_daemon-0.0.1/src/dnd_daemon.egg-info/requires.txt +10 -0
- dnd_daemon-0.0.1/src/dnd_daemon.egg-info/top_level.txt +1 -0
|
@@ -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,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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dnd
|