bwflow 0.1.0__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.
bwflow-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.3
2
+ Name: bwflow
3
+ Version: 0.1.0
4
+ Summary: Bandwidth manager for Linux
5
+ Author: Blake Axon
6
+ License: MIT
7
+ Requires-Dist: typer>=0.24.1
8
+ Requires-Dist: rich>=14.3.3
9
+ Requires-Python: >=3.13
10
+ Description-Content-Type: text/markdown
11
+
12
+ # bwflow
13
+
14
+ Egress bandwidth manager for Linux. Tracks monthly traffic via [vnstat](https://humdi.net/vnstat/) and enforces rate limits using `tc` when your budget runs out.
15
+
16
+ ## How it works
17
+
18
+ At the start of each month, bwflow credits a configurable **burst allowance** (e.g. 50 GB) immediately. The remaining budget drips in continuously over the month. If egress exceeds the available tokens, a `tc` TBF rate limit is applied to your network interface. When the next month starts, the limit is lifted automatically.
19
+
20
+ ## Prerequisites
21
+
22
+ | Package | Purpose |
23
+ | ----------------- | ------------------------------------ |
24
+ | `vnstat ≥ 2.6` | Traffic accounting (source of truth) |
25
+ | `iproute2` (`tc`) | Applies rate limits to the interface |
26
+ | Python ≥ 3.13 | Runtime |
27
+
28
+ ```bash
29
+ # Ubuntu / Debian
30
+ sudo apt install vnstat iproute2
31
+
32
+ # Amazon Linux / RHEL
33
+ sudo yum install vnstat iproute2
34
+
35
+ # Alpine
36
+ apk add vnstat iproute2
37
+ ```
38
+
39
+ Enable the vnstat daemon:
40
+
41
+ ```bash
42
+ sudo systemctl enable --now vnstat
43
+ ```
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install bwflow
49
+ ```
50
+
51
+ Verify:
52
+
53
+ ```bash
54
+ bwflow --help
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ **1. Initialise** (creates `/etc/bwflow/config.toml`, offers to patch `vnstat.conf`):
60
+
61
+ ```bash
62
+ sudo bwflow init
63
+ ```
64
+
65
+ **2. Review the config:**
66
+
67
+ ```toml
68
+ # /etc/bwflow/config.toml
69
+
70
+ [bandwidth]
71
+ monthly_budget_gb = 100 # total monthly egress budget
72
+ pre_charge_gb = 50 # burst tokens available from day 1
73
+ billing_day = 1 # reset on the 1st of each month
74
+
75
+ [network]
76
+ interface = "eth0" # interface to monitor and throttle
77
+
78
+ [daemon]
79
+ poll_interval_seconds = 15
80
+ ```
81
+
82
+ **3. Install and start the systemd service:**
83
+
84
+ ```bash
85
+ sudo cp /usr/local/lib/python3.x/site-packages/bwflow/contrib/bwflow.service \
86
+ /etc/systemd/system/
87
+ # or from a source checkout:
88
+ sudo cp contrib/bwflow.service /etc/systemd/system/
89
+
90
+ sudo systemctl daemon-reload
91
+ sudo systemctl enable --now bwflow
92
+ ```
93
+
94
+ **4. Check logs:**
95
+
96
+ ```bash
97
+ journalctl -u bwflow -f
98
+ ```
99
+
100
+ ## Commands
101
+
102
+ | Command | Description |
103
+ | --------------------------- | -------------------------------------------- |
104
+ | `sudo bwflow init` | First-time setup: config + vnstat.conf patch |
105
+ | `sudo bwflow run` | Start the daemon (called by systemd) |
106
+ | `bwflow report` | Show monthly traffic report |
107
+ | `sudo bwflow run --verbose` | Debug logging (shows every 15 s tick) |
108
+
109
+ ## Notes
110
+
111
+ - `billing_day` must be `1` in v0.1 (calendar-month only). Arbitrary billing days are planned.
112
+ - bwflow requires `UseUTC 1` in `/etc/vnstat.conf` on non-UTC systems. `bwflow init` offers to patch this automatically.
113
+ - Rate limiting uses Linux `tc` TBF (Token Bucket Filter) and requires `CAP_NET_ADMIN`. The simplest setup is to run as root; see `contrib/bwflow.service` for a least-privilege alternative.
bwflow-0.1.0/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # bwflow
2
+
3
+ Egress bandwidth manager for Linux. Tracks monthly traffic via [vnstat](https://humdi.net/vnstat/) and enforces rate limits using `tc` when your budget runs out.
4
+
5
+ ## How it works
6
+
7
+ At the start of each month, bwflow credits a configurable **burst allowance** (e.g. 50 GB) immediately. The remaining budget drips in continuously over the month. If egress exceeds the available tokens, a `tc` TBF rate limit is applied to your network interface. When the next month starts, the limit is lifted automatically.
8
+
9
+ ## Prerequisites
10
+
11
+ | Package | Purpose |
12
+ | ----------------- | ------------------------------------ |
13
+ | `vnstat ≥ 2.6` | Traffic accounting (source of truth) |
14
+ | `iproute2` (`tc`) | Applies rate limits to the interface |
15
+ | Python ≥ 3.13 | Runtime |
16
+
17
+ ```bash
18
+ # Ubuntu / Debian
19
+ sudo apt install vnstat iproute2
20
+
21
+ # Amazon Linux / RHEL
22
+ sudo yum install vnstat iproute2
23
+
24
+ # Alpine
25
+ apk add vnstat iproute2
26
+ ```
27
+
28
+ Enable the vnstat daemon:
29
+
30
+ ```bash
31
+ sudo systemctl enable --now vnstat
32
+ ```
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install bwflow
38
+ ```
39
+
40
+ Verify:
41
+
42
+ ```bash
43
+ bwflow --help
44
+ ```
45
+
46
+ ## Quick start
47
+
48
+ **1. Initialise** (creates `/etc/bwflow/config.toml`, offers to patch `vnstat.conf`):
49
+
50
+ ```bash
51
+ sudo bwflow init
52
+ ```
53
+
54
+ **2. Review the config:**
55
+
56
+ ```toml
57
+ # /etc/bwflow/config.toml
58
+
59
+ [bandwidth]
60
+ monthly_budget_gb = 100 # total monthly egress budget
61
+ pre_charge_gb = 50 # burst tokens available from day 1
62
+ billing_day = 1 # reset on the 1st of each month
63
+
64
+ [network]
65
+ interface = "eth0" # interface to monitor and throttle
66
+
67
+ [daemon]
68
+ poll_interval_seconds = 15
69
+ ```
70
+
71
+ **3. Install and start the systemd service:**
72
+
73
+ ```bash
74
+ sudo cp /usr/local/lib/python3.x/site-packages/bwflow/contrib/bwflow.service \
75
+ /etc/systemd/system/
76
+ # or from a source checkout:
77
+ sudo cp contrib/bwflow.service /etc/systemd/system/
78
+
79
+ sudo systemctl daemon-reload
80
+ sudo systemctl enable --now bwflow
81
+ ```
82
+
83
+ **4. Check logs:**
84
+
85
+ ```bash
86
+ journalctl -u bwflow -f
87
+ ```
88
+
89
+ ## Commands
90
+
91
+ | Command | Description |
92
+ | --------------------------- | -------------------------------------------- |
93
+ | `sudo bwflow init` | First-time setup: config + vnstat.conf patch |
94
+ | `sudo bwflow run` | Start the daemon (called by systemd) |
95
+ | `bwflow report` | Show monthly traffic report |
96
+ | `sudo bwflow run --verbose` | Debug logging (shows every 15 s tick) |
97
+
98
+ ## Notes
99
+
100
+ - `billing_day` must be `1` in v0.1 (calendar-month only). Arbitrary billing days are planned.
101
+ - bwflow requires `UseUTC 1` in `/etc/vnstat.conf` on non-UTC systems. `bwflow init` offers to patch this automatically.
102
+ - Rate limiting uses Linux `tc` TBF (Token Bucket Filter) and requires `CAP_NET_ADMIN`. The simplest setup is to run as root; see `contrib/bwflow.service` for a least-privilege alternative.
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.10.6"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "bwflow"
7
+ version = "0.1.0"
8
+ description = "Bandwidth manager for Linux"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Blake Axon" },
14
+ ]
15
+ dependencies = [
16
+ "typer>=0.24.1",
17
+ "rich>=14.3.3",
18
+ ]
19
+
20
+ [project.scripts]
21
+ bwflow = "bwflow.main:app"
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pytest>=9.0.2",
26
+ "pytest-mock>=3.15.1",
27
+ "ruff>=0.15.4",
28
+ "ty>=0.0.19",
29
+ ]
30
+
31
+ [tool.ruff]
32
+ line-length = 79
33
+
34
+ [tool.ruff.lint]
35
+ select = ["ALL"]
36
+ ignore = [
37
+ "D203",
38
+ "D213",
39
+ "E501",
40
+ "COM812",
41
+ "FBT001",
42
+ "FBT003",
43
+ "PLC0415",
44
+ "PLR0913",
45
+ ]
46
+ fixable = ["ALL"]
47
+
48
+ [tool.ruff.lint.per-file-ignores]
49
+ # ignore unused imports in __init__.py files
50
+ "__init__.py" = ["F401"]
51
+ # Ignore missing type annotations in tests
52
+ "tests/**/*.py" = [
53
+ "ANN",
54
+ "D103",
55
+ "INP001",
56
+ "PLR2004",
57
+ "S101",
58
+ "SLF001",
59
+ ]
60
+
61
+ [tool.uv]
62
+ package = true
@@ -0,0 +1,3 @@
1
+ """bwflow package."""
2
+
3
+ from bwflow import config
@@ -0,0 +1,92 @@
1
+ """Token bucket math: calculate remaining monthly egress budget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import calendar
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from bwflow.config import BwflowConfig
12
+
13
+ # 1 GB = 10^9 bytes (SI units, matching CSP billing conventions).
14
+ GB = 1_000_000_000
15
+
16
+
17
+ @dataclass
18
+ class BudgetState:
19
+ """Current budget calculation output for daemon decision making."""
20
+
21
+ tokens_bytes: int # Remaining bytes (may be negative when over budget).
22
+ throttle_rate_kbps: (
23
+ int # Rate cap to apply when throttled (kbits/sec for tc).
24
+ )
25
+ period_start: datetime # UTC start of the current billing period.
26
+
27
+ @property
28
+ def is_exhausted(self) -> bool:
29
+ """True when the token bucket is empty (budget exceeded)."""
30
+ return self.tokens_bytes <= 0
31
+
32
+
33
+ def calculate(
34
+ cfg: BwflowConfig,
35
+ monthly_tx_bytes: int,
36
+ now: datetime | None = None,
37
+ ) -> BudgetState:
38
+ """Calculate the current token bucket state.
39
+
40
+ Args:
41
+ cfg: bwflow configuration.
42
+ monthly_tx_bytes: Current billing period's egress in bytes (from vnstat).
43
+ now: Current UTC time. Defaults to ``datetime.now(UTC)``.
44
+
45
+ Returns:
46
+ BudgetState with remaining tokens and throttle parameters.
47
+
48
+ """
49
+ if now is None:
50
+ now = datetime.now(UTC)
51
+
52
+ period_start = _period_start(cfg, now)
53
+ total_seconds = _seconds_in_month(period_start)
54
+ elapsed_seconds = (now - period_start).total_seconds()
55
+
56
+ pre_charge_bytes = cfg.pre_charge_gb * GB
57
+ drip_total_bytes = cfg.drip_gb * GB
58
+ drip_rate_bps = drip_total_bytes / total_seconds # bytes / second
59
+
60
+ drip_accrued = min(drip_rate_bps * elapsed_seconds, drip_total_bytes)
61
+ tokens = int(pre_charge_bytes + drip_accrued - monthly_tx_bytes)
62
+
63
+ # Throttle rate: drip rate expressed in kbits/sec (tc's unit).
64
+ throttle_kbps = max(1, int(drip_rate_bps * 8 / 1000))
65
+
66
+ return BudgetState(
67
+ tokens_bytes=tokens,
68
+ throttle_rate_kbps=throttle_kbps,
69
+ period_start=period_start,
70
+ )
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Internal helpers
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ def _period_start(cfg: BwflowConfig, now: datetime) -> datetime:
79
+ """Return the UTC datetime of the current billing period start."""
80
+ day = cfg.billing_day
81
+ if now.day >= day:
82
+ return datetime(now.year, now.month, day, tzinfo=UTC)
83
+ # billing started last month
84
+ if now.month == 1:
85
+ return datetime(now.year - 1, 12, day, tzinfo=UTC)
86
+ return datetime(now.year, now.month - 1, day, tzinfo=UTC)
87
+
88
+
89
+ def _seconds_in_month(period_start: datetime) -> float:
90
+ """Total seconds in the calendar month that *period_start* falls in."""
91
+ days = calendar.monthrange(period_start.year, period_start.month)[1]
92
+ return float(days * 86400)
@@ -0,0 +1,189 @@
1
+ """bwflow configuration: schema, defaults, and /etc/bwflow/config.toml management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ from bwflow import vnstat
11
+
12
+ CONFIG_DIR = Path("/etc/bwflow")
13
+ CONFIG_PATH = CONFIG_DIR / "config.toml"
14
+
15
+ _CONFIG_TEMPLATE = """\
16
+ [bandwidth]
17
+ # Total monthly egress budget in GB.
18
+ monthly_budget_gb = {monthly_budget_gb}
19
+
20
+ # Pre-charged burst tokens credited at the start of each billing cycle.
21
+ # The remainder ({drip_gb} GB) drips in continuously over the month.
22
+ pre_charge_gb = {pre_charge_gb}
23
+
24
+ # Day-of-month when the budget resets.
25
+ # Note: only 1 is supported currently (calendar-month reset).
26
+ billing_day = {billing_day}
27
+
28
+ [network]
29
+ # Network interface to monitor and throttle.
30
+ # Run `ip link show` or `vnstat` to list available interfaces.
31
+ interface = "{interface}"
32
+
33
+ [daemon]
34
+ # How often (in seconds) the daemon polls vnstat and adjusts tc rules.
35
+ poll_interval_seconds = {poll_interval_seconds}
36
+ """
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Config dataclass
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ @dataclass
45
+ class BwflowConfig:
46
+ """Runtime configuration loaded from /etc/bwflow/config.toml."""
47
+
48
+ monthly_budget_gb: int = 100
49
+ pre_charge_gb: int = 50
50
+ billing_day: int = 1
51
+ interface: str = "eth0"
52
+ poll_interval_seconds: int = 15
53
+
54
+ @property
55
+ def drip_gb(self) -> int:
56
+ """Return the monthly budget portion that accrues over time."""
57
+ return self.monthly_budget_gb - self.pre_charge_gb
58
+
59
+ def to_toml_str(self) -> str:
60
+ """Serialize this config to the bwflow TOML file format."""
61
+ return _CONFIG_TEMPLATE.format(
62
+ monthly_budget_gb=self.monthly_budget_gb,
63
+ pre_charge_gb=self.pre_charge_gb,
64
+ drip_gb=self.drip_gb,
65
+ billing_day=self.billing_day,
66
+ interface=self.interface,
67
+ poll_interval_seconds=self.poll_interval_seconds,
68
+ )
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Public API
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def detect_default_interface() -> str:
77
+ """Return the first interface reported by vnstat, or 'eth0' as fallback."""
78
+ try:
79
+ stats = vnstat.get_monthly_stats(limit=1)
80
+ if stats:
81
+ return stats[0].name
82
+ except RuntimeError:
83
+ pass
84
+ return "eth0"
85
+
86
+
87
+ def build_default_config() -> BwflowConfig:
88
+ """Build a BwflowConfig with sensible defaults, auto-detecting the interface."""
89
+ return BwflowConfig(interface=detect_default_interface())
90
+
91
+
92
+ def check_root() -> None:
93
+ """Raise PermissionError if the process is not running as root."""
94
+ if os.geteuid() != 0:
95
+ msg = (
96
+ "bwflow init must be run as root to write to /etc/bwflow/.\n"
97
+ "Re-run with: sudo bwflow init"
98
+ )
99
+ raise PermissionError(
100
+ msg,
101
+ )
102
+
103
+
104
+ def write_config(cfg: BwflowConfig, *, force: bool = False) -> None:
105
+ """Write cfg to /etc/bwflow/config.toml.
106
+
107
+ Args:
108
+ cfg: Config to serialise.
109
+ force: Overwrite an existing config file without prompting.
110
+
111
+ Raises:
112
+ FileExistsError: If the config already exists and force is False.
113
+ PermissionError: Propagated from the OS if the process lacks write access.
114
+
115
+ """
116
+ CONFIG_DIR.mkdir(mode=0o755, parents=True, exist_ok=True)
117
+
118
+ if CONFIG_PATH.exists() and not force:
119
+ msg = (
120
+ f"{CONFIG_PATH} already exists.\n"
121
+ "Edit it manually, or re-run with --force to overwrite."
122
+ )
123
+ raise FileExistsError(
124
+ msg,
125
+ )
126
+
127
+ CONFIG_PATH.write_text(cfg.to_toml_str(), encoding="utf-8")
128
+ # Owner-readable/writable; group+other readable — same as typical /etc files.
129
+ CONFIG_PATH.chmod(0o644)
130
+
131
+
132
+ def config_exists() -> bool:
133
+ """Return True when the default config file is present."""
134
+ return CONFIG_PATH.exists()
135
+
136
+
137
+ def load_config(path: Path = CONFIG_PATH) -> BwflowConfig:
138
+ """Load and validate /etc/bwflow/config.toml into a BwflowConfig.
139
+
140
+ Raises:
141
+ FileNotFoundError: If the config file does not exist.
142
+ ValueError: If required keys are missing or values are invalid.
143
+
144
+ """
145
+ if not path.exists():
146
+ msg = f"{path} not found. Run: sudo bwflow init"
147
+ raise FileNotFoundError(msg)
148
+
149
+ with path.open("rb") as f:
150
+ data = tomllib.load(f)
151
+
152
+ try:
153
+ bw = data["bandwidth"]
154
+ net = data["network"]
155
+ dm = data["daemon"]
156
+
157
+ cfg = BwflowConfig(
158
+ monthly_budget_gb=int(bw["monthly_budget_gb"]),
159
+ pre_charge_gb=int(bw["pre_charge_gb"]),
160
+ billing_day=int(bw["billing_day"]),
161
+ interface=str(net["interface"]),
162
+ poll_interval_seconds=int(dm["poll_interval_seconds"]),
163
+ )
164
+ except KeyError as exc:
165
+ msg = f"Missing required config key: {exc}"
166
+ raise ValueError(msg) from exc
167
+ except (TypeError, ValueError) as exc:
168
+ msg = f"Invalid config value: {exc}"
169
+ raise ValueError(msg) from exc
170
+
171
+ # Basic sanity checks.
172
+ if cfg.billing_day != 1:
173
+ msg = (
174
+ "billing_day != 1 is not yet supported.\n"
175
+ "vnstat only provides calendar-month totals, so any other value\n"
176
+ "would understate usage and delay throttling incorrectly.\n"
177
+ "Set billing_day = 1 for now."
178
+ )
179
+ raise ValueError(
180
+ msg,
181
+ )
182
+ if cfg.pre_charge_gb > cfg.monthly_budget_gb:
183
+ msg = "pre_charge_gb must be less or equal than monthly_budget_gb."
184
+ raise ValueError(msg)
185
+ if cfg.poll_interval_seconds < 1:
186
+ msg = "poll_interval_seconds must be >= 1."
187
+ raise ValueError(msg)
188
+
189
+ return cfg
@@ -0,0 +1,42 @@
1
+ [Unit]
2
+ Description=bwflow bandwidth rate-limit daemon
3
+ Documentation=https://github.com/blakeaxon/bwflow
4
+ # Start after the network is up and vnstat has started.
5
+ After=network.target vnstat.service
6
+ Wants=vnstat.service
7
+
8
+ [Service]
9
+ Type=simple
10
+
11
+ # Verify the binary path with: which bwflow
12
+ # Common locations: /usr/local/bin/bwflow (pip/pipx) or /usr/bin/bwflow (distro package)
13
+ ExecStart=/usr/local/bin/bwflow run
14
+
15
+ # Restart automatically on unexpected exits (e.g. crash).
16
+ # Does NOT restart on clean exit (SIGTERM from 'systemctl stop').
17
+ Restart=on-failure
18
+ RestartSec=10s
19
+
20
+ # --- Privilege model ---
21
+ # Option A (simple): run as root.
22
+ User=root
23
+
24
+ # Option B (recommended for production): dedicated system user with only
25
+ # the capability needed to call tc. Uncomment after creating the user:
26
+ # useradd --system --no-create-home --shell /sbin/nologin bwflow
27
+ # setcap cap_net_admin+eip /usr/local/bin/bwflow
28
+ #
29
+ # User=bwflow
30
+ # AmbientCapabilities=CAP_NET_ADMIN
31
+ # CapabilityBoundingSet=CAP_NET_ADMIN
32
+ # NoNewPrivileges=yes
33
+
34
+ # --- Logging ---
35
+ # Logs go to journald automatically. View with:
36
+ # journalctl -u bwflow -f
37
+ StandardOutput=journal
38
+ StandardError=journal
39
+ SyslogIdentifier=bwflow
40
+
41
+ [Install]
42
+ WantedBy=multi-user.target