Subscriber, billing and network management for small Internet Service Providers. Django 6, Tailwind, HTMX — provisions and enforces internet service directly on MikroTik routers over the RouterOS API.
Open source, free to use and modify. A complete subscriber, billing and enforcement platform for MikroTik-based ISPs, targeting RouterOS 7 over the native API. It does the job an ISP normally builds a RADIUS server for — authenticate, rate-limit, expire, disconnect, reconnect on payment — without running RADIUS at all. MIT licensed. Fork it, ship it, sell it.
This build is verified against a software simulation of RouterOS, not real hardware. Every line of router code is exercised by 368 tests against an in-memory MikroTik double (
apps/network/fake.py) that reports itself as RouterOS 7.15.2. The logic is proven. Whether a physical router agrees with the simulation is the one thing no test here can tell you.Need it running on real hardware? For a version validated against a live MikroTik fleet — hardware-tested provisioning, a security-reviewed and hardened deployment, migration from your existing billing data, and support when a router misbehaves at 3am — contact info@sabbirmahmud.com.
| Open source (this repo) | Commercial engagement | |
|---|---|---|
| License | MIT — use, modify, resell | Negotiated |
| Feature set | Complete, all 7 phases | Same, plus your requirements |
| Router credentials | Fernet-encrypted at rest | Same, plus key management and rotation |
| Verified against | Simulated RouterOS 7 | Live MikroTik hardware |
| Security review | Community | Full review and hardening |
| Data migration | DIY | Done for you |
| Support | GitHub issues, best-effort | Contracted |
Every phase is built, including automatic enforcement. None of the router code
has ever run against real hardware — only against an in-memory test double
(apps/network/fake.py).
| Area | State |
|---|---|
| Subscribers, packages, POPs, ONUs, tasks, stock, staff | Working |
| Role-based access control, append-only audit log | Working |
| Tailwind UI, light + dark, mobile | Working |
| Invoices, payments, collector flow, revenue reports | Working |
| MikroTik polling, provisioning, reconciliation | Mock-verified only |
| Automatic expiry sweep and suspension | Built, shipped OFF |
The enforcement loop ships with the kill switch off and shadow mode on. A fresh install observes and reports what it would do, and disconnects nobody. Turning it on is a deliberate, documented act — see Safety rails.
Before pointing this at a production router, read the hardware checklist.
The repository is called radius_server_isp and older documentation called this
"a simple RADIUS server". It has never contained any RADIUS code, and by
design it will not — enforcement goes through the RouterOS API instead. The name
is a leftover. Treat "ISP Management" as the real one.
Django owns the truth about who is a customer, what they pay for, and whether they should be online. Routers are treated as caches of that truth that are continuously made to agree with it. Money changes service state; service state changes get pushed to routers by background workers; a reconciler notices when a router has drifted and heals it. Every step writes to an append-only audit log, and the step that can cause an outage is wrapped in four independent kill switches.
flowchart TB
subgraph browser["Browser"]
UI["Server-rendered pages<br/>HTMX live regions · Alpine local state"]
end
subgraph django["Django (no API layer, no SPA)"]
V["Views<br/>permission-gated"]
S["Service layer<br/>transition() · record_payment() · provision()"]
M["Models + constraints"]
end
DB[("PostgreSQL<br/>source of truth")]
R[("Redis<br/>broker")]
subgraph workers["Celery workers"]
Wp["provisioning queue<br/>bulk router writes"]
Wr["priority queue<br/>payment reconnects"]
Wb["beat<br/>scheduled sweeps"]
end
subgraph routers["MikroTik fleet"]
RT["RouterOS API / API-SSL<br/>PPP secrets · profiles · queues<br/>address-lists · live sessions"]
end
UI --> V --> S --> M --> DB
S -- "on_commit" --> R
R --> Wp & Wr & Wb
Wp & Wr & Wb --> RT
Wp & Wr & Wb --> DB
RT -. "poll: sessions, health" .-> Wb
Two rules hold this shape together:
- Router calls never happen inline. A slow or unreachable router must not
block an HTTP request or stall the expiry sweep. Work is enqueued via
transaction.on_commit, so a worker can never read a subscriber before the transaction that changed it has committed. - There is no API layer. This is an internal tool for a handful of staff. An API would be surface area with no consumer.
erDiagram
User ||--o| Profile : has
User ||--o{ ServiceEvent : "acts (null = automatic)"
Pop ||--o{ Router : hosts
Pop ||--o{ Subscriber : serves
Router ||--o{ Subscriber : "PROTECT"
Router ||--o{ RouterSnapshot : "polled health"
Router ||--o{ ProvisioningJob : "write attempts"
Package ||--o{ Subscriber : "PROTECT"
Package ||--o{ Subscription : prices
Onu ||--o{ Subscriber : "SET_NULL"
Subscriber ||--o{ Subscription : "billing periods"
Subscriber ||--o{ Invoice : owes
Subscriber ||--o{ ServiceEvent : "audit trail"
Subscriber ||--o{ ProvisioningJob : queued
Invoice ||--o{ Payment : "settled by"
EnforcementPolicy ||--o{ SweepRun : governs
SweepRun ||--o{ ShadowDecision : "would have done"
SweepRun ||--o| SuspensionBatch : "breaker held"
SuspensionBatch ||--o{ SuspensionBatchItem : contains
Subscriber ||--o{ SuspensionBatchItem : "pending cutoff"
Design decisions worth knowing before you touch any of it:
| Decision | Why |
|---|---|
Subscriber.router and .package are PROTECT; .onu/.pop are SET_NULL |
The original schema used CASCADE throughout — deleting a package deleted its subscribers, and a damaged ONU deleted the customer |
Connection identity is typed (connection_type + typed fields, with check constraints) |
The original had one CharField labelled "IP/UserName". You cannot provision that — there's no way to tell what to create on the router |
ServiceEvent is append-only, enforced at the model |
save() raises if the row has a PK; delete() always raises. It stores subject_label beside the FK so a row stays readable after its subject is deleted |
actor=None explicitly means automatic |
Inferring it later is guesswork. The sweep and a human must be distinguishable in the log |
expires_at may be NULL, and NULL never expires |
Migrated subscribers have no billing history. Inventing a date would suspend the entire existing customer base on the first sweep |
Money is Decimal, never float |
— |
| Router credentials are Fernet-encrypted, not hashed | The worker has to use them. Router.__repr__ is overridden so a credential cannot reach a log line or an exception report |
Subscriber.service_state is the field the whole system exists to move. Only
apps.subscribers.services.transition() may write it — it validates the move
against a transition table, marks the subscriber out of sync, writes a
ServiceEvent, and enqueues router work.
stateDiagram-v2
[*] --> pending: created
pending --> active: first payment
active --> grace: expiry passed (sweep)
active --> suspended: manual
grace --> active: payment / manual restore
grace --> suspended: grace elapsed (sweep)
suspended --> active: payment / manual restore
pending --> terminated
active --> terminated
grace --> terminated
suspended --> terminated
terminated --> [*]: no way back
grace is fully online — the subscriber is warned, not cut. That is why the
active → grace transition never touches a router and is not gated on the kill
switch: marking someone lapsed is a bookkeeping change the dashboard and the
collection sheet need, and it disconnects nobody.
Letting views set the field directly means a customer can lose internet with no record of who did it or why. The audit trail is the entire point.
The core cycle. A collector at a customer's door taps record payment, and the customer is back online before they've put their phone away.
sequenceDiagram
participant C as Collector (phone)
participant V as billing.views
participant S as billing.services
participant DB as PostgreSQL
participant Q as Redis (priority queue)
participant W as Celery worker
participant R as MikroTik
C->>V: record payment
V->>S: record_payment(invoice, amount, method, actor)
S->>DB: Payment row + invoice settled
S->>S: extend_service() → expires_at += cycle
S->>S: transition(sub, ACTIVE, priority=True)
S->>DB: ServiceEvent (actor = collector)
Note over S,Q: enqueued on_commit, never inline
S-->>Q: reconnect_subscriber.delay(pk)
Q->>W: priority queue
W->>R: ensure_pppoe_secret (enabled)
R-->>W: ok
W->>DB: sync_state = SYNCED, ProvisioningJob done
priority=True routes to a separate queue on purpose — a payment reconnect must
not queue behind a bulk reconciliation of 2,000 subscribers, because somebody is
standing there waiting for service to come back.
Billing shape: prepaid periods, one invoice per cycle, manual payment capture.
Invoices are generated ahead of expiry on a daily beat task
(generate_invoices_task).
Every operation is ensure_* and idempotent — desired state, not commands —
so replaying it is always safe.
flowchart TD
A["provision(subscriber_pk)"] --> B{"rate limit<br/>for this router?"}
B -- exceeded --> STOP1["back off"]
B -- ok --> C{"claim job<br/>(another in flight?)"}
C -- in flight --> STOP2["skip"]
C -- claimed --> D{"state?"}
D -- terminated --> E["remove_subscriber()"]
D -- PPPoE --> F["ensure_pppoe_secret()<br/>points at PPP profile"]
D -- static IP --> G{"suspend rule<br/>exists on router?"}
F --> H{"eligible to<br/>be online?"}
H -- no --> I["kick_session()"]
I --> J{"re-read:<br/>session still live?"}
J -- yes --> K["mark UNVERIFIED<br/>not synced"]
J -- no --> L["sync_state = SYNCED"]
H -- yes --> L
G -- missing --> M["RouterRejected<br/>refuse to pretend"]
G -- present --> N["ensure_queue()<br/>+ ensure_address_list()"]
N --> L
E --> L
Three details in that diagram are load-bearing:
Order is not interchangeable. Disable the secret first, then drop the live session. Kicking first leaves a window where the client redials against a still-enabled secret and comes straight back up.
Trust the re-read, not the API call. A subscriber whose secret is disabled
but whose session is still up is not suspended, however successful the call
looked. Reporting that as synced is how a non-payer stays online for a month
unnoticed.
Refuse to pretend. Suspending a static-IP subscriber on a router with no firewall rule dropping the suspended address-list would cut off nobody. That raises rather than silently succeeding.
Packages are mirrored onto each router as PPP profiles, and secrets point at those profiles rather than carrying their own rate limit. Changing a plan's speed is then one profile edit per router instead of one edit per subscriber — and there is no partial-failure state where half your customers got the new speed.
rate_limit is rendered in exactly one place, with a test asserting the order.
RouterOS writes rx/tx from the router's point of view, so rx is the
subscriber's upload. Reversing it ships a fast upload and a slow download, and
gets reported as "the internet is slow", not as a config bug.
Going direct-API instead of RADIUS buys simplicity and costs one hard problem: the router holds state we pushed, and the two can disagree — a failed push, a technician editing WinBox at 2am, a config restored from an old backup.
flowchart LR
subgraph d["Django (desired)"]
DS["subscriber: active<br/>package: 20M"]
end
subgraph r["Router (actual)"]
RS["secret: disabled<br/>profile: 10M"]
end
DS -- "reconcile every 15m" --> CMP{"compare<br/>owned objects only"}
RS --> CMP
CMP -- "costs revenue /<br/>harms customer" --> HEAL["heal automatically<br/>+ log loudly"]
CMP -- "owned object,<br/>no subscriber" --> HUMAN["never auto-delete<br/>flag for a human"]
CMP -- "no ownership tag" --> IGNORE["ignore entirely"]
Ownership tagging is what makes this safe. Every object this system creates
carries ispms:<kind>:<pk> in its RouterOS comment field, and the reconciler
touches nothing without it. Without that rule, the reconciler's first run on a
production router deletes every secret an engineer ever made by hand.
An owned object with no matching subscriber is never auto-deleted — that is what a bad migration looks like, and it needs a human.
The expiry sweep runs on beat every 15 minutes and does two transitions in order:
active → grace (harmless) and grace → suspended (this is the one that cuts
someone off).
flowchart TD
START(["sweep_expiries — every 15 min"]) --> G1["active → grace<br/>expired_now()<br/>no router work, no gate"]
G1 --> CAND["candidates = grace_elapsed()"]
CAND --> K{"1 · kill switch<br/>policy.enabled?"}
K -- off --> OBS["record ShadowDecision<br/>suspend nobody"]
K -- on --> SH{"2 · shadow mode?"}
SH -- on --> OBS
SH -- off --> ROLL{"3 · router<br/>enforcement_enabled?"}
ROLL -- no --> OBS
ROLL -- yes --> BRK{"4 · circuit breaker<br/>count > threshold?"}
BRK -- trips --> HOLD["SuspensionBatch<br/>PENDING APPROVAL<br/>expires in 12h"]
HOLD --> OWNER{"owner reviews"}
OWNER -- approve --> EXEC
OWNER -- reject / TTL --> DROP["nobody suspended"]
BRK -- under --> EXEC["transition → suspended<br/>row-locked, skip_locked<br/>enqueue router work<br/>notify after commit"]
Four independent ways to stop this, because the failure mode is thousands of customers offline at 3am with nobody watching:
| # | Rail | Default | What it's for |
|---|---|---|---|
| 1 | Kill switch (EnforcementPolicy.enabled) |
off | Stop everything, now, from the admin — no deploy needed |
| 2 | Shadow mode (shadow_mode) |
on | Compute every transition, record it as a ShadowDecision, execute none. The gate P7 must pass for a full week before enforcement touches anyone |
| 3 | Per-router rollout (Router.enforcement_enabled) |
off | Stage the rollout one POP at a time |
| 4 | Circuit breaker | 5% / 25 max / 5 floor | A sweep suspending more than the threshold stops and asks an owner for approval |
The policy lives in the database, not in settings — the kill switch has to be reachable mid-incident by someone who cannot deploy. A setting that needs a release to flip is not a kill switch.
expires_at__isnull=False— a NULL expiry must never expire. Dropping this filter suspends the entire migrated customer base on the first sweep, which is the single most likely way to end the company.skip_lockedrow locking so two overlapping sweeps cannot both process the same subscriber. Postgres only; on SQLite it degrades to unlocked and logs a warning, which is why PostgreSQL is required in production.- An empty
Q()matches everything.grace_elapsed()returnsSubscriber.objects.none()when there are no grace-day values, guarded explicitly — it is the one path in the function that could suspend the fleet.
The breaker is evaluated on the total candidate count, not the executable subset. A bug that expires the whole fleet must trip it even when only one POP is switched on — otherwise a staged rollout quietly disables the guard it most needs.
django-celery-beat, database scheduler. Two queues: provisioning for bulk
router writes, priority for payment reconnects.
| Task | Every | Does |
|---|---|---|
poll_all_routers |
30s | Fan out per-router polls (each claims a lock) |
poll_router |
— | Live PPPoE sessions, interface stats, health → RouterSnapshot |
reconcile_all_routers |
15m | Compare owned router objects against the database |
sweep_expiries |
15m | The enforcement loop above |
check_enforcement_health |
10m | Log active alerts (so the 30-min "sweep is dead" alert can fire inside its own window) |
expire_suspension_batches |
1h | Age out batches nobody approved |
prune_snapshots |
1h | Trim poll history |
generate_invoices_task |
24h | Create invoices ahead of expiry |
Health alerts go to logs, deliberately: there's no alerting stack yet, and inventing one here means an untested email path nobody has configured. Structured error logs are something an operator's existing aggregation can already alert on. The one alert this cannot raise is "the sweep is dead" if beat is dead too — that has to come from outside, by watching for the absence of those lines.
Four business roles, mapped to Django permission groups in exactly one place
(apps/users/roles.py). Migrations call sync_roles(), so adding a permission
is picked up by a data migration rather than manual admin work on every
environment.
flowchart TD
O["<b>owner</b><br/>everything, including<br/>the kill switch"]
M["<b>manager</b><br/>day-to-day service<br/>+ disconnections"]
C["<b>collector</b><br/>sees subscribers,<br/>takes money, nothing else"]
T["<b>technician</b><br/>equipment, POPs,<br/>field tasks"]
O --- M
O --- C
O --- T
style O fill:#8a6d3b,color:#fff
Only owner ⊃ manager is a real superset. Collector and technician are deliberately narrow and parallel — a technician cannot take money, and a collector cannot touch equipment.
The guiding rule: "can record a payment" and "can cut off a customer" are not the same trust level, so they are never the same permission.
| Capability | owner | manager | collector | technician |
|---|---|---|---|---|
| View subscribers | ✅ | ✅ | ✅ | ✅ |
| Record payments | ✅ | ✅ | ✅ | — |
| Suspend a subscriber | ✅ | ✅ | — | — |
| Terminate / change package / waive invoice | ✅ | — | — | — |
| Router credentials | ✅ | — | — | — |
| View held suspension batch | ✅ | ✅ | — | — |
| Approve a mass suspension | ✅ | — | — | — |
| Move the kill switch | ✅ | — | — | — |
A manager runs day-to-day service including disconnections, but cannot terminate an account, move someone's package, write off money owed, or touch router credentials. Seeing what the breaker held and deciding to execute it are different jobs.
| App | Route | Holds |
|---|---|---|
apps.core |
— | Shared form plumbing (TailwindFormMixin), template tags, seed_demo |
apps.users |
/ |
Email-based User, role groups, Profile |
apps.subscribers |
/subscribers/ |
Subscriber, state machine, filters, enforcement (policy, sweep, breaker, shadow) |
apps.network |
/network/ |
Router, adapter, provisioning, reconciler, ServiceEvent, credential crypto |
apps.billing |
/billing/ |
Package, Subscription, Invoice, Payment, revenue reports |
apps.accountants |
/dashboard/ |
Dashboard, owners, investment/earnings ledger |
apps.onu apps.pop |
/onus/ /pops/ |
Equipment and distribution points |
apps.tasks apps.warehouse apps.employ |
/tasks/ /warehouse/ |
Field operations |
apps.clients apps.packages |
/clients/ /packages/ |
Legacy. Superseded by subscribers and billing. Kept so the historical data migration stays reversible; routes redirect |
apps/subscribers/services.py the state machine — read this first
apps/subscribers/tasks.py the expiry sweep, heavily commented
apps/subscribers/breaker.py circuit breaker + batch approval
apps/network/adapter.py RouterOS API wrapper, ownership tags
apps/network/provisioning.py ensure_* write path
apps/network/reconcile.py drift detection and healing
apps/billing/services.py invoicing, payments, extend_service
apps/network/fake.py the in-memory router every test runs against
| Backend | Django 6.0, Python 3.12–3.14 |
| Database | PostgreSQL 16 (SQLite works for local dev only) |
| Frontend | Tailwind CSS v4, HTMX 2, Alpine 3 — server-rendered, no SPA |
| Background | Celery 5.6 + Redis, django-celery-beat |
| Crypto | cryptography Fernet for router credentials |
No Node toolchain required — Tailwind runs from the standalone CLI binary,
configured CSS-first in static/src/input.css.
Roughly 14,000 lines of Python across 13 apps, 33 models, 79 templates, 368 tests.
Deeper reasoning behind the design decisions:
docs/architecture.md.
git clone https://github.com/sabbir-mahmud/radius_server_isp.git
cd radius_server_isp
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .envGenerate the two keys .env needs and paste them in:
python -c "from django.core.management.utils import get_random_secret_key as k; print(k())"
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"Then:
make tailwind-install # one-off: fetch the Tailwind binary
make css
python manage.py migrate
python manage.py createsuperuser
python manage.py seed_demo # optional: 420 demo subscribers
python manage.py runserverOpen http://127.0.0.1:8000.
seed_demorefuses to run whenDEBUG=False. Seeded subscribers are indistinguishable from real ones, and one that later gets provisioned onto a router is a genuine outage.
SQLite and eager Celery are the defaults so a fresh clone runs with no infrastructure. For anything beyond local experimentation:
docker compose up -d # postgres + redisDATABASE_URL=postgres://isp:isp@localhost:5432/isp
CELERY_EAGER=FalsePostgreSQL is required for enforcement — the sweep needs
select_for_update(skip_locked=True), which SQLite does not have.
All via .env (see .env.example).
| Variable | Required | Notes |
|---|---|---|
SECRET_KEY |
yes | Django signing key |
DEBUG |
no | Defaults False. True only in dev |
ALLOWED_HOSTS |
in production | No default in production — the app refuses to boot without it rather than accepting any Host header |
DATABASE_URL |
no | Defaults to SQLite. postgres://… for Postgres |
ROUTER_CRED_KEY |
to store routers | Fernet key. Lose it and every stored router password becomes unreadable |
CELERY_BROKER_URL |
no | Defaults to local Redis |
CELERY_EAGER |
no | Dev only — runs tasks inline |
Settings split base / dev / prod. manage.py defaults to dev; WSGI and
ASGI default to prod.
Runtime enforcement switches are not here — they live in the database
(EnforcementPolicy), on purpose.
make help # list targets
make css # build stylesheet
make css-watch # rebuild on change
make test # run tests with warnings as errors
make check # django checks, dev and production
make migrateTests must pass under -W error::UserWarning. That is deliberate:
UnorderedObjectListWarning marks a real pagination bug where rows silently
duplicate across pages, and it should fail the build rather than scroll past.
- Never write
service_statedirectly. Useapps.subscribers.services.transition(), which validates the change and writes an audit event. - Money is
Decimal, neverfloat. - Multi-line template comments use
{% comment %}, never{#…#}— the latter is single-line only, and a multi-line one renders as visible text. - Network values render monospace (
.val) — IPs, usernames, MACs, rate limits. It signals "copy this exactly" and makes transposed digits visible. - Status is never conveyed by colour alone. Every state pill has a label.
brassis the only accent;state-*colours are for service state only.
- Authorization — both historical holes are pinned as regressions: anonymous
access to user management, and
has_permreturningTrueunconditionally. - Pagination — walks every page, asserts each row appears exactly once. An unordered queryset returns 200 on every page while silently duplicating and dropping rows; this is the only check that catches it.
- Rate-limit direction — asserts upload comes first in the RouterOS string.
- Credentials — round-trip encryption, and that a password never appears in
repr()or the stored bytes. - State machine — every legal and illegal transition.
- Suspension ordering — that the secret is disabled before the session is kicked, and that a still-live session is not reported as synced.
- Shadow accuracy — that what shadow mode predicted matches what live mode would have done.
All seven phases are built. The full PRD — scope, architecture, data model,
RouterOS mapping, safety rails and risks — is notes/prd.md.
Per-phase plans with tasks, code sketches and exit criteria are in
notes/.
| Phase | Status | |
|---|---|---|
| 0 | Foundation & security — permissions, audit log, Celery | Done |
| 1 | Data model — Router, Package, Subscriber | Done |
| 2 | Design system — Tailwind, components | Done |
| 3 | UI redesign — every screen converted | Done |
| 4 | Router connectivity, read-only | Done (mock-verified) |
| 5 | Provisioning — the write path | Done (mock-verified) |
| 6 | Billing — invoices and payments | Done |
| 7 | Automatic enforcement — expiry sweep | Built, shipped off |
The remaining work is not code — it is hardware validation. Every phase note was audited against the actual code on 2026-07-21; the unchecked items are the honest remainder, and most of them need a real router.
Not committed, roughly in order of usefulness:
- SMS notification before grace expires (the hook exists,
notifications.py) - Subscriber self-service portal — balance, payment history, usage
- Online payment gateway integration (bKash, Nagad) so reconnection is hands-off
- Usage-based and FUP packages, using accounting data the poller already collects
- Multi-tenancy, if there is demand for running it for several ISPs
- A RADIUS backend implementing the same
RouterAdapterinterface, for fleets that outgrow direct API
Issues and pull requests welcome. See CONTRIBUTING.md.
The most useful contribution right now is running the router code against real
hardware and reporting where RouterOS does not behave as
apps/network/fake.py assumes.
If you run a small ISP and are willing to describe how your billing actually
works — fixed calendar day vs. rolling from payment date, how partial payments
are handled — that is genuinely valuable. Those are open questions in
notes/README.md.
Do not open a public issue for a vulnerability. See SECURITY.md.
This software is designed to be able to disconnect paying customers. Review the authorization model and the four enforcement rails before running it anywhere real.
MIT — see LICENSE.