Skip to content

Repository files navigation

ISP Management

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.

License: MIT Python Django RouterOS Tests Verification

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

Status: feature-complete, hardware-unverified. Read this before you deploy it.

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.

On the name

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.


The one-paragraph version

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.


System architecture

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
Loading

Two rules hold this shape together:

  1. 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.
  2. There is no API layer. This is an internal tool for a handful of staff. An API would be surface area with no consumer.

Data model

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"
Loading

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

Service state machine

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
Loading

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 money → service loop

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
Loading

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).


Provisioning: the write path

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
Loading

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 map to PPP profiles

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.


Drift and reconciliation

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"]
Loading

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.


Automatic enforcement, and its safety rails

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"]
Loading

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.

Three details in the sweep query carry most of the risk

  • 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_locked row 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() returns Subscriber.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.


Scheduled work

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.


Roles and authorization

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
Loading

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 map

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

Where to start reading

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

Tech

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.


Quick start

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 .env

Generate 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 runserver

Open http://127.0.0.1:8000.

seed_demo refuses to run when DEBUG=False. Seeded subscribers are indistinguishable from real ones, and one that later gets provisioned onto a router is a genuine outage.

With PostgreSQL and Redis

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 + redis
DATABASE_URL=postgres://isp:isp@localhost:5432/isp
CELERY_EAGER=False

PostgreSQL is required for enforcement — the sweep needs select_for_update(skip_locked=True), which SQLite does not have.


Configuration

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.


Development

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 migrate

Tests 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.

Conventions

  • Never write service_state directly. Use apps.subscribers.services.transition(), which validates the change and writes an audit event.
  • Money is Decimal, never float.
  • 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.
  • brass is the only accent; state-* colours are for service state only.

What the tests actually pin

  • Authorization — both historical holes are pinned as regressions: anonymous access to user management, and has_perm returning True unconditionally.
  • 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.

Roadmap

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.

Beyond that

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 RouterAdapter interface, for fleets that outgrow direct API

Contributing

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.

Security

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.

License

MIT — see LICENSE.

About

simple Radius server for Internet Service Providers (ISPs). With this web application, ISPs can automate tasks such as bill collection, support ticket management, and device tracking

Topics

Resources

Contributing

Security policy

Stars

25 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages