Repository files navigation

ISPadmin

Lightweight web-based customer management for small ISPs, built on top of the MikroTik RouterOS API. Multi-site: when you save a customer, the app automatically writes the DHCP lease + Simple Queue (or PPP secret for PPPoE) to the selected MikroTik, based on the customer's status and plan (speed profile).

Plain PHP + SQLite/MySQL. No framework, no dependencies. Up and running in minutes.

Features

  • Customer management: name, address, contact, contract, IP/MAC, plan, status, notes, change history
  • Automatic MikroTik provisioning via RouterOS API — DHCP leases, Simple Queues, ARP, firewall address-lists, PPP secrets (PPPoE)
  • Multi-router / multi-site: every customer belongs to a specific MikroTik and network
  • Plans (speed profiles) with aggregation — max-limit and limit-at are calculated automatically
  • Customer statuses: connected / temporarily disconnected / non-payer / contract terminated — the app blocks automatically based on status (address-list + speed throttling)
  • Users and roles (administrator > admin > user), password management, trash bin, audit log
  • One-click database backup + FTP/FTPS upload, restore from backup
  • Optional geo-blocking of the login (allow selected countries only, via Cloudflare header or offline CIDR lists)
  • 10 languages — language picker on the login screen and in the header (Slovak, Czech, English, German, Polish, Hungarian, Romanian, Ukrainian, Latvian, Russian)
  • Light / dark theme, responsive UI
  • Time zone detected from the server automatically, overridable from the Settings page
  • DHCP Option 82 support — bind a customer's lease to the physical circuit (Agent Circuit ID) instead of the MAC address, so swapping a modem needs no reconfiguration

Screenshots

LoginDashboard
Login with language pickerCustomer dashboard
Customer formDark mode
Customer form (PPPoE)Dark mode

More: Settings · Plans · Routers · Networks · Backup · Users

Requirements

  • PHP 8.1+ with extensions: pdo + pdo_sqlite (or pdo_mysql), openssl (for api-ssl), optionally curl/ftp (FTP backups)
  • MikroTik RouterOS with the API service enabled (port 8728, or api-ssl 8729)
  • Or just Docker + docker compose — nothing else needed

Quick start (testing, SQLite)

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
php -S 0.0.0.0:8000 -t public

Open http://server:8000/login.php

Default login: admin / changeme — change the password right after logging in (Account section).

The database is created automatically on first run in data/ispadmin.sqlite, seeded with example plans (edit them in the UI to match your own offer).

Full install on a clean Debian or Ubuntu server

This is the whole thing from a freshly installed machine, in order. It takes about fifteen minutes and needs no PHP or web server knowledge. Steps 3 and 4 contain the two things that most commonly make people think the install has failed when it hasn't.

1. Install Docker

sudo apt update && sudo apt install -y curl
curl -fsSL https://get.docker.com | sudo sh

2. Get ISPadmin and start it

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
sudo docker compose up -d --build

The container runs with network_mode: host, so it reaches your MikroTiks exactly like the host server does, including over WireGuard or other tunnels. The SQLite database lives in the ispadmin-data volume and survives rebuilds.

3. Reach the web interface

For safety the app listens only on 127.0.0.1:8090, not on the network. Opening http://your-server-ip:8090 in a browser will simply not respond, which looks like a failed install but isn't. You have two options.

Either tunnel to it from your own machine — nothing to configure on the server:

ssh -L 8090:127.0.0.1:8090 youruser@your-server

and then browse to http://localhost:8090.

Or, if the server sits on a trusted internal network and you want to reach it directly, edit docker/ports.conf, change Listen 127.0.0.1:8090 to Listen 8090, and run sudo docker compose up -d --build again.

For anything reachable from the internet, use the reverse proxy in the next step instead.

4. Put it behind nginx with HTTPS

Point a DNS record at the server first, then:

sudo apt install -y nginx certbot python3-certbot-nginx
sudo cp docker/nginx-proxy.conf /etc/nginx/sites-available/ispadmin.conf
sudo nano /etc/nginx/sites-available/ispadmin.conf # set server_name to your domain

Remove the stock Debian site before running certbot. It has a catch-all server_name, so it swallows your domain and certbot installs the certificate into the wrong file — you end up with a valid certificate serving the nginx welcome page:

sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/ispadmin.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ispadmin.example.com

Check that it worked, and that the app is what answers:

curl -I https://ispadmin.example.com/login.php # expect 200

Once HTTPS is confirmed working, enable HSTS by uncommenting the Strict-Transport-Security line in your nginx config and reloading. Don't do it earlier — browsers will then refuse plain HTTP to this host and you can lock yourself out.

5. First login

Open your domain and log in with admin / changeme.

Change the password immediately under Account. There's a Generate password button next to the field. The default is public knowledge, so on an internet-facing install this is not optional.

Then, if more people need access, create accounts under Users. Give colleagues the admin role rather than administratoradmin already unlocks every page, it just can't delete or modify accounts at the same level, which stops someone accidentally locking you out.

6. Add your first router

Under Routers, fill in the host, API username and password, then press Test. If it goes green, everything else in the app will work. See MikroTik setup below for what to enable on the router itself.

Production (MySQL)

  1. In config.php set 'driver' => 'mysql' and fill in the credentials (or use the DB_HOST, DB_NAME, DB_USER, DB_PASS environment variables).
  2. Create the database and import the schema:
mysql -u root -p -e "CREATE DATABASE ispadmin CHARACTER SET utf8mb4"
mysql -u ispadmin -p ispadmin < schema.sql
  1. Point the DocumentRoot at the public/ directory — config.php, lib/, lang/ and data/ stay outside the web root. On classic Apache shared hosting the bundled root .htaccess handles this.

MikroTik setup

Enable the API on every router:

/ip service enable api

(port 8728; for SSL enable api-ssl on 8729 and turn on use_ssl for the router in the app)

Create an API account with permissions for: dhcp-server/lease, queue, firewall/address-list, ppp/secret, system. You enter the API credentials in the Routers section of the UI — they are stored only in your own database.

To actually block non-payers, add a firewall rule on the router:

/ip firewall filter add chain=forward src-address-list=unpaid action=drop
/ip firewall filter add chain=forward src-address-list=suspended action=drop

The app maintains the address lists; the drop rules above are what actually blocks the traffic. List names default to suspended, unpaid and terminated, and are configurable in config.php (block_lists) or via environment variables.

Status logic

StatusWhat the app does on the MikroTik
Connectedlease + queue (enabled), IP removed from block list
Temporarily disconnectedlease stays, queue throttled/disabled, IP added to address-list
Non-payersame as temporary, different address-list
Contract terminateddeletes lease + queue + address-list entry

PPPoE customers are managed through /ppp/secret (login, password, profile) instead of lease/queue.

Circuit ID (DHCP Option 82)

Each customer has an optional Circuit ID field holding the DHCP Option 82 agent circuit identifier — for example an NBN AVC ID in Australia, or a port identifier from a DSLAM or access switch.

When the field is filled, the app binds the DHCP lease to the circuit instead of the MAC address. The MAC is deliberately not sent, so the customer can replace their modem and the lease still applies — no reconfiguration, no manual re-entry. The router matches the lease using the agent-circuit-id parameter:

/ip dhcp-server lease add address=10.0.0.50 \
agent-circuit-id=41564330303032353038313730313138 server=dhcp1

If the Circuit ID is empty, the app falls back to the usual MAC-based lease, so existing setups are unaffected.

Entering the value

RouterOS displays Option 82 identifiers as hex, but the underlying value is usually plain text. You can paste either form and the app normalises it:

What you enterStored / sent to the router
AVC000250817011841564330303032353038313730313138
41564330303032353038313730313138unchanged
0x4156433030...0x prefix stripped

A string is only treated as hex if it decodes to readable text — so a purely numeric circuit ID such as 0012345678 is correctly kept as text rather than misread as hex. To force hex interpretation of a binary identifier, prefix it with 0x.

To find the value on a running system, look at an active lease in RouterOS (IP → DHCP Server → Leases, the Agent Circuit Id field), or take it from the carrier's service order.

Notes and open questions live in issue #1.

Time zone

Every timestamp in the app — change history, logins, backup file names — uses a single time zone, resolved in this order:

  1. the ISPADMIN_TZ environment variable, if set (useful for Docker)
  2. a manual choice saved on the Settings page
  3. the server's own time zone, read from /etc/timezone or /etc/localtime
  4. UTC, as a last resort

In practice step 3 means timestamps are correct straight after installation without configuring anything. If the server's zone is wrong or the app runs somewhere else than your customers, pick the right one under Settings — it is stored in the database, not in config.php, so it survives updates. When ISPADMIN_TZ is set it wins over everything and the Settings field is shown read-only, so it is always obvious where the value comes from.

Plans and aggregation

Per-user Simple Queue:

  • max-limit = user UL/DL (upload/download, in kbit)
  • limit-at = max-limit / aggregation (guaranteed share)

A plan with no speed set (e.g. IPTV, GPON) creates only the lease, no queue.

Importing existing customers

Prepare a JSON file following example_data.json and run:

php import_json.php my_site.json # preview, writes nothing
php import_json.php my_site.json --apply # real import

The import is idempotent — existing customers (same IP or PPPoE login) are skipped, and nothing is changed on the MikroTik.

CLI helper scripts

ScriptPurpose
import_json.phpimport a router, its networks and customers from JSON
pull_speeds.phpfill in customers' real speeds from Simple Queues on the MikroTik (read-only)
set_siet.phpbulk-set the "Network" field on customers
fix_encoding.phpfix diacritics (CP1250 escapes from RouterOS) in already imported data
update_geoip.phpdownload country CIDR lists for geo-blocking (cron-friendly)

All of them run in preview mode until you add --apply.

Run them as the web user. Inside Docker the app runs as www-data, and the SQLite database has to stay writable by it. If you run a script as root, the database file ends up owned by root and the web interface then fails with "attempt to write a readonly database":

sudo docker exec -u www-data -it mt-ispadmin php /var/www/html/import_json.php \
/var/www/html/my_site.json --apply

If it has already happened, fix the ownership with:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

Languages / adding a translation

The UI ships in 10 languages; users pick their language on the login screen (stored in a cookie, auto-detected from the browser on first visit). Slovak is the source language, English is the fallback for missing strings.

To add or improve a language:

  1. Copy lang/en.php to lang/xx.php and translate the values (keys stay in Slovak).
  2. Add the code and native name to LANGS in lib/lang.php.
  3. Check completeness: php lang/verify.php xx (uses lang/keys.txt, verifies keys, %s placeholders and inline HTML).

Pull requests with new languages are welcome.

Geo-blocking (optional)

Access can be limited to selected countries. Enable it with the ISPADMIN_GEO_ENFORCE=1 env variable (disabled by default in docker-compose.yml so you can't lock yourself out). It works via the Cloudflare CF-IPCountry header, or fully offline via CIDR lists (update_geoip.php). Add your own IPs to ISPADMIN_GEO_ALLOW_IPS as a safety net.

Logo and branding

The header logo is text-based and configured in config.php:

'brand_pre' => 'isp', // first (blue) part'brand_post' => 'admin', // second (dark) part'tagline' => 'customer management · MikroTik',

A default SVG logo is included in public/assets/logo.svg — feel free to modify it or replace it with your own.

Security notes

  • Change the default password immediately after the first login (admin / changeme). The default is published here, so anyone who finds your install knows it.
  • config.php, lib/, lang/ and data/ must not be reachable from the web — both Docker and the bundled .htaccess take care of this.
  • Router API credentials and customer data live only in your own database (data/ is in .gitignore) — never commit them.
  • Run the app behind HTTPS (certbot + nginx proxy), ideally on an internal network / behind a VPN.

Troubleshooting

"Fatal error: attempt to write a readonly database" A CLI script was run as root, so the SQLite file is now owned by root while the web app runs as www-data. Fix the ownership and always pass -u www-data to docker exec:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

The browser doesn't respond on port 8090 That's intended — the app listens on 127.0.0.1 only. Use an SSH tunnel or the nginx reverse proxy, see step 3 above.

Certbot succeeded but the domain shows the nginx welcome page Certbot installed the certificate into the stock Debian site because it matched the domain first. Remove /etc/nginx/sites-enabled/default, make sure server_name in your own config is exactly right, then re-run certbot and choose reinstall.

The Test button under Routers fails Check in order: the API service is enabled on the router (/ip service print), the host and port are reachable from the server (nc -vz ROUTER_IP 8728), the API account exists with the right permissions, and no firewall rule on the router blocks the API port.

Timestamps are hours off See Time zone. The quickest fix is to set it explicitly on the Settings page.

Check what the app is actually doing

sudo docker compose logs --tail 50

TODO / possible extensions

  • Parent queue / queue-tree for shared group caps
  • IPv6 prefix delegation
  • Bulk re-sync of all customers to a router
  • Per-field audit log

License

MIT — see LICENSE. Use at your own risk; test everything on a lab router before deploying to production.


Author: Juraj Chudý

About

Web-based customer management for small ISPs built on the MikroTik RouterOS API — DHCP leases, Simple Queues, PPPoE, non-payer blocking, multi-site, 10 languages. Plain PHP, Docker ready.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

ISPadmin

Lightweight web-based customer management for small ISPs, built on top of the MikroTik RouterOS API. Multi-site: when you save a customer, the app automatically writes the DHCP lease + Simple Queue (or PPP secret for PPPoE) to the selected MikroTik, based on the customer's status and plan (speed profile).

Plain PHP + SQLite/MySQL. No framework, no dependencies. Up and running in minutes.

Features

  • Customer management: name, address, contact, contract, IP/MAC, plan, status, notes, change history
  • Automatic MikroTik provisioning via RouterOS API — DHCP leases, Simple Queues, ARP, firewall address-lists, PPP secrets (PPPoE)
  • Multi-router / multi-site: every customer belongs to a specific MikroTik and network
  • Plans (speed profiles) with aggregation — max-limit and limit-at are calculated automatically
  • Customer statuses: connected / temporarily disconnected / non-payer / contract terminated — the app blocks automatically based on status (address-list + speed throttling)
  • Users and roles (administrator > admin > user), password management, trash bin, audit log
  • One-click database backup + FTP/FTPS upload, restore from backup
  • Optional geo-blocking of the login (allow selected countries only, via Cloudflare header or offline CIDR lists)
  • 10 languages — language picker on the login screen and in the header (Slovak, Czech, English, German, Polish, Hungarian, Romanian, Ukrainian, Latvian, Russian)
  • Light / dark theme, responsive UI
  • Time zone detected from the server automatically, overridable from the Settings page
  • DHCP Option 82 support — bind a customer's lease to the physical circuit (Agent Circuit ID) instead of the MAC address, so swapping a modem needs no reconfiguration

Screenshots

LoginDashboard
Login with language pickerCustomer dashboard
Customer formDark mode
Customer form (PPPoE)Dark mode

More: Settings · Plans · Routers · Networks · Backup · Users

Requirements

  • PHP 8.1+ with extensions: pdo + pdo_sqlite (or pdo_mysql), openssl (for api-ssl), optionally curl/ftp (FTP backups)
  • MikroTik RouterOS with the API service enabled (port 8728, or api-ssl 8729)
  • Or just Docker + docker compose — nothing else needed

Quick start (testing, SQLite)

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
php -S 0.0.0.0:8000 -t public

Open http://server:8000/login.php

Default login: admin / changeme — change the password right after logging in (Account section).

The database is created automatically on first run in data/ispadmin.sqlite, seeded with example plans (edit them in the UI to match your own offer).

Full install on a clean Debian or Ubuntu server

This is the whole thing from a freshly installed machine, in order. It takes about fifteen minutes and needs no PHP or web server knowledge. Steps 3 and 4 contain the two things that most commonly make people think the install has failed when it hasn't.

1. Install Docker

sudo apt update && sudo apt install -y curl
curl -fsSL https://get.docker.com | sudo sh

2. Get ISPadmin and start it

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
sudo docker compose up -d --build

The container runs with network_mode: host, so it reaches your MikroTiks exactly like the host server does, including over WireGuard or other tunnels. The SQLite database lives in the ispadmin-data volume and survives rebuilds.

3. Reach the web interface

For safety the app listens only on 127.0.0.1:8090, not on the network. Opening http://your-server-ip:8090 in a browser will simply not respond, which looks like a failed install but isn't. You have two options.

Either tunnel to it from your own machine — nothing to configure on the server:

ssh -L 8090:127.0.0.1:8090 youruser@your-server

and then browse to http://localhost:8090.

Or, if the server sits on a trusted internal network and you want to reach it directly, edit docker/ports.conf, change Listen 127.0.0.1:8090 to Listen 8090, and run sudo docker compose up -d --build again.

For anything reachable from the internet, use the reverse proxy in the next step instead.

4. Put it behind nginx with HTTPS

Point a DNS record at the server first, then:

sudo apt install -y nginx certbot python3-certbot-nginx
sudo cp docker/nginx-proxy.conf /etc/nginx/sites-available/ispadmin.conf
sudo nano /etc/nginx/sites-available/ispadmin.conf # set server_name to your domain

Remove the stock Debian site before running certbot. It has a catch-all server_name, so it swallows your domain and certbot installs the certificate into the wrong file — you end up with a valid certificate serving the nginx welcome page:

sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/ispadmin.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ispadmin.example.com

Check that it worked, and that the app is what answers:

curl -I https://ispadmin.example.com/login.php # expect 200

Once HTTPS is confirmed working, enable HSTS by uncommenting the Strict-Transport-Security line in your nginx config and reloading. Don't do it earlier — browsers will then refuse plain HTTP to this host and you can lock yourself out.

5. First login

Open your domain and log in with admin / changeme.

Change the password immediately under Account. There's a Generate password button next to the field. The default is public knowledge, so on an internet-facing install this is not optional.

Then, if more people need access, create accounts under Users. Give colleagues the admin role rather than administratoradmin already unlocks every page, it just can't delete or modify accounts at the same level, which stops someone accidentally locking you out.

6. Add your first router

Under Routers, fill in the host, API username and password, then press Test. If it goes green, everything else in the app will work. See MikroTik setup below for what to enable on the router itself.

Production (MySQL)

  1. In config.php set 'driver' => 'mysql' and fill in the credentials (or use the DB_HOST, DB_NAME, DB_USER, DB_PASS environment variables).
  2. Create the database and import the schema:
mysql -u root -p -e "CREATE DATABASE ispadmin CHARACTER SET utf8mb4"
mysql -u ispadmin -p ispadmin < schema.sql
  1. Point the DocumentRoot at the public/ directory — config.php, lib/, lang/ and data/ stay outside the web root. On classic Apache shared hosting the bundled root .htaccess handles this.

MikroTik setup

Enable the API on every router:

/ip service enable api

(port 8728; for SSL enable api-ssl on 8729 and turn on use_ssl for the router in the app)

Create an API account with permissions for: dhcp-server/lease, queue, firewall/address-list, ppp/secret, system. You enter the API credentials in the Routers section of the UI — they are stored only in your own database.

To actually block non-payers, add a firewall rule on the router:

/ip firewall filter add chain=forward src-address-list=unpaid action=drop
/ip firewall filter add chain=forward src-address-list=suspended action=drop

The app maintains the address lists; the drop rules above are what actually blocks the traffic. List names default to suspended, unpaid and terminated, and are configurable in config.php (block_lists) or via environment variables.

Status logic

StatusWhat the app does on the MikroTik
Connectedlease + queue (enabled), IP removed from block list
Temporarily disconnectedlease stays, queue throttled/disabled, IP added to address-list
Non-payersame as temporary, different address-list
Contract terminateddeletes lease + queue + address-list entry

PPPoE customers are managed through /ppp/secret (login, password, profile) instead of lease/queue.

Circuit ID (DHCP Option 82)

Each customer has an optional Circuit ID field holding the DHCP Option 82 agent circuit identifier — for example an NBN AVC ID in Australia, or a port identifier from a DSLAM or access switch.

When the field is filled, the app binds the DHCP lease to the circuit instead of the MAC address. The MAC is deliberately not sent, so the customer can replace their modem and the lease still applies — no reconfiguration, no manual re-entry. The router matches the lease using the agent-circuit-id parameter:

/ip dhcp-server lease add address=10.0.0.50 \
agent-circuit-id=41564330303032353038313730313138 server=dhcp1

If the Circuit ID is empty, the app falls back to the usual MAC-based lease, so existing setups are unaffected.

Entering the value

RouterOS displays Option 82 identifiers as hex, but the underlying value is usually plain text. You can paste either form and the app normalises it:

What you enterStored / sent to the router
AVC000250817011841564330303032353038313730313138
41564330303032353038313730313138unchanged
0x4156433030...0x prefix stripped

A string is only treated as hex if it decodes to readable text — so a purely numeric circuit ID such as 0012345678 is correctly kept as text rather than misread as hex. To force hex interpretation of a binary identifier, prefix it with 0x.

To find the value on a running system, look at an active lease in RouterOS (IP → DHCP Server → Leases, the Agent Circuit Id field), or take it from the carrier's service order.

Notes and open questions live in issue #1.

Time zone

Every timestamp in the app — change history, logins, backup file names — uses a single time zone, resolved in this order:

  1. the ISPADMIN_TZ environment variable, if set (useful for Docker)
  2. a manual choice saved on the Settings page
  3. the server's own time zone, read from /etc/timezone or /etc/localtime
  4. UTC, as a last resort

In practice step 3 means timestamps are correct straight after installation without configuring anything. If the server's zone is wrong or the app runs somewhere else than your customers, pick the right one under Settings — it is stored in the database, not in config.php, so it survives updates. When ISPADMIN_TZ is set it wins over everything and the Settings field is shown read-only, so it is always obvious where the value comes from.

Plans and aggregation

Per-user Simple Queue:

  • max-limit = user UL/DL (upload/download, in kbit)
  • limit-at = max-limit / aggregation (guaranteed share)

A plan with no speed set (e.g. IPTV, GPON) creates only the lease, no queue.

Importing existing customers

Prepare a JSON file following example_data.json and run:

php import_json.php my_site.json # preview, writes nothing
php import_json.php my_site.json --apply # real import

The import is idempotent — existing customers (same IP or PPPoE login) are skipped, and nothing is changed on the MikroTik.

CLI helper scripts

ScriptPurpose
import_json.phpimport a router, its networks and customers from JSON
pull_speeds.phpfill in customers' real speeds from Simple Queues on the MikroTik (read-only)
set_siet.phpbulk-set the "Network" field on customers
fix_encoding.phpfix diacritics (CP1250 escapes from RouterOS) in already imported data
update_geoip.phpdownload country CIDR lists for geo-blocking (cron-friendly)

All of them run in preview mode until you add --apply.

Run them as the web user. Inside Docker the app runs as www-data, and the SQLite database has to stay writable by it. If you run a script as root, the database file ends up owned by root and the web interface then fails with "attempt to write a readonly database":

sudo docker exec -u www-data -it mt-ispadmin php /var/www/html/import_json.php \
/var/www/html/my_site.json --apply

If it has already happened, fix the ownership with:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

Languages / adding a translation

The UI ships in 10 languages; users pick their language on the login screen (stored in a cookie, auto-detected from the browser on first visit). Slovak is the source language, English is the fallback for missing strings.

To add or improve a language:

  1. Copy lang/en.php to lang/xx.php and translate the values (keys stay in Slovak).
  2. Add the code and native name to LANGS in lib/lang.php.
  3. Check completeness: php lang/verify.php xx (uses lang/keys.txt, verifies keys, %s placeholders and inline HTML).

Pull requests with new languages are welcome.

Geo-blocking (optional)

Access can be limited to selected countries. Enable it with the ISPADMIN_GEO_ENFORCE=1 env variable (disabled by default in docker-compose.yml so you can't lock yourself out). It works via the Cloudflare CF-IPCountry header, or fully offline via CIDR lists (update_geoip.php). Add your own IPs to ISPADMIN_GEO_ALLOW_IPS as a safety net.

Logo and branding

The header logo is text-based and configured in config.php:

'brand_pre' => 'isp', // first (blue) part'brand_post' => 'admin', // second (dark) part'tagline' => 'customer management · MikroTik',

A default SVG logo is included in public/assets/logo.svg — feel free to modify it or replace it with your own.

Security notes

  • Change the default password immediately after the first login (admin / changeme). The default is published here, so anyone who finds your install knows it.
  • config.php, lib/, lang/ and data/ must not be reachable from the web — both Docker and the bundled .htaccess take care of this.
  • Router API credentials and customer data live only in your own database (data/ is in .gitignore) — never commit them.
  • Run the app behind HTTPS (certbot + nginx proxy), ideally on an internal network / behind a VPN.

Troubleshooting

"Fatal error: attempt to write a readonly database" A CLI script was run as root, so the SQLite file is now owned by root while the web app runs as www-data. Fix the ownership and always pass -u www-data to docker exec:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

The browser doesn't respond on port 8090 That's intended — the app listens on 127.0.0.1 only. Use an SSH tunnel or the nginx reverse proxy, see step 3 above.

Certbot succeeded but the domain shows the nginx welcome page Certbot installed the certificate into the stock Debian site because it matched the domain first. Remove /etc/nginx/sites-enabled/default, make sure server_name in your own config is exactly right, then re-run certbot and choose reinstall.

The Test button under Routers fails Check in order: the API service is enabled on the router (/ip service print), the host and port are reachable from the server (nc -vz ROUTER_IP 8728), the API account exists with the right permissions, and no firewall rule on the router blocks the API port.

Timestamps are hours off See Time zone. The quickest fix is to set it explicitly on the Settings page.

Check what the app is actually doing

sudo docker compose logs --tail 50

TODO / possible extensions

  • Parent queue / queue-tree for shared group caps
  • IPv6 prefix delegation
  • Bulk re-sync of all customers to a router
  • Per-field audit log

License

MIT — see LICENSE. Use at your own risk; test everything on a lab router before deploying to production.


Author: Juraj Chudý

About

Web-based customer management for small ISPs built on the MikroTik RouterOS API — DHCP leases, Simple Queues, PPPoE, non-payer blocking, multi-site, 10 languages. Plain PHP, Docker ready.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ISPadmin

Lightweight web-based customer management for small ISPs, built on top of the MikroTik RouterOS API. Multi-site: when you save a customer, the app automatically writes the DHCP lease + Simple Queue (or PPP secret for PPPoE) to the selected MikroTik, based on the customer's status and plan (speed profile).

Plain PHP + SQLite/MySQL. No framework, no dependencies. Up and running in minutes.

Features

  • Customer management: name, address, contact, contract, IP/MAC, plan, status, notes, change history
  • Automatic MikroTik provisioning via RouterOS API — DHCP leases, Simple Queues, ARP, firewall address-lists, PPP secrets (PPPoE)
  • Multi-router / multi-site: every customer belongs to a specific MikroTik and network
  • Plans (speed profiles) with aggregation — max-limit and limit-at are calculated automatically
  • Customer statuses: connected / temporarily disconnected / non-payer / contract terminated — the app blocks automatically based on status (address-list + speed throttling)
  • Users and roles (administrator > admin > user), password management, trash bin, audit log
  • One-click database backup + FTP/FTPS upload, restore from backup
  • Optional geo-blocking of the login (allow selected countries only, via Cloudflare header or offline CIDR lists)
  • 10 languages — language picker on the login screen and in the header (Slovak, Czech, English, German, Polish, Hungarian, Romanian, Ukrainian, Latvian, Russian)
  • Light / dark theme, responsive UI
  • Time zone detected from the server automatically, overridable from the Settings page
  • DHCP Option 82 support — bind a customer's lease to the physical circuit (Agent Circuit ID) instead of the MAC address, so swapping a modem needs no reconfiguration

Screenshots

LoginDashboard
Login with language pickerCustomer dashboard
Customer formDark mode
Customer form (PPPoE)Dark mode

More: Settings · Plans · Routers · Networks · Backup · Users

Requirements

  • PHP 8.1+ with extensions: pdo + pdo_sqlite (or pdo_mysql), openssl (for api-ssl), optionally curl/ftp (FTP backups)
  • MikroTik RouterOS with the API service enabled (port 8728, or api-ssl 8729)
  • Or just Docker + docker compose — nothing else needed

Quick start (testing, SQLite)

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
php -S 0.0.0.0:8000 -t public

Open http://server:8000/login.php

Default login: admin / changeme — change the password right after logging in (Account section).

The database is created automatically on first run in data/ispadmin.sqlite, seeded with example plans (edit them in the UI to match your own offer).

Full install on a clean Debian or Ubuntu server

This is the whole thing from a freshly installed machine, in order. It takes about fifteen minutes and needs no PHP or web server knowledge. Steps 3 and 4 contain the two things that most commonly make people think the install has failed when it hasn't.

1. Install Docker

sudo apt update && sudo apt install -y curl
curl -fsSL https://get.docker.com | sudo sh

2. Get ISPadmin and start it

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
sudo docker compose up -d --build

The container runs with network_mode: host, so it reaches your MikroTiks exactly like the host server does, including over WireGuard or other tunnels. The SQLite database lives in the ispadmin-data volume and survives rebuilds.

3. Reach the web interface

For safety the app listens only on 127.0.0.1:8090, not on the network. Opening http://your-server-ip:8090 in a browser will simply not respond, which looks like a failed install but isn't. You have two options.

Either tunnel to it from your own machine — nothing to configure on the server:

ssh -L 8090:127.0.0.1:8090 youruser@your-server

and then browse to http://localhost:8090.

Or, if the server sits on a trusted internal network and you want to reach it directly, edit docker/ports.conf, change Listen 127.0.0.1:8090 to Listen 8090, and run sudo docker compose up -d --build again.

For anything reachable from the internet, use the reverse proxy in the next step instead.

4. Put it behind nginx with HTTPS

Point a DNS record at the server first, then:

sudo apt install -y nginx certbot python3-certbot-nginx
sudo cp docker/nginx-proxy.conf /etc/nginx/sites-available/ispadmin.conf
sudo nano /etc/nginx/sites-available/ispadmin.conf # set server_name to your domain

Remove the stock Debian site before running certbot. It has a catch-all server_name, so it swallows your domain and certbot installs the certificate into the wrong file — you end up with a valid certificate serving the nginx welcome page:

sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/ispadmin.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ispadmin.example.com

Check that it worked, and that the app is what answers:

curl -I https://ispadmin.example.com/login.php # expect 200

Once HTTPS is confirmed working, enable HSTS by uncommenting the Strict-Transport-Security line in your nginx config and reloading. Don't do it earlier — browsers will then refuse plain HTTP to this host and you can lock yourself out.

5. First login

Open your domain and log in with admin / changeme.

Change the password immediately under Account. There's a Generate password button next to the field. The default is public knowledge, so on an internet-facing install this is not optional.

Then, if more people need access, create accounts under Users. Give colleagues the admin role rather than administratoradmin already unlocks every page, it just can't delete or modify accounts at the same level, which stops someone accidentally locking you out.

6. Add your first router

Under Routers, fill in the host, API username and password, then press Test. If it goes green, everything else in the app will work. See MikroTik setup below for what to enable on the router itself.

Production (MySQL)

  1. In config.php set 'driver' => 'mysql' and fill in the credentials (or use the DB_HOST, DB_NAME, DB_USER, DB_PASS environment variables).
  2. Create the database and import the schema:
mysql -u root -p -e "CREATE DATABASE ispadmin CHARACTER SET utf8mb4"
mysql -u ispadmin -p ispadmin < schema.sql
  1. Point the DocumentRoot at the public/ directory — config.php, lib/, lang/ and data/ stay outside the web root. On classic Apache shared hosting the bundled root .htaccess handles this.

MikroTik setup

Enable the API on every router:

/ip service enable api

(port 8728; for SSL enable api-ssl on 8729 and turn on use_ssl for the router in the app)

Create an API account with permissions for: dhcp-server/lease, queue, firewall/address-list, ppp/secret, system. You enter the API credentials in the Routers section of the UI — they are stored only in your own database.

To actually block non-payers, add a firewall rule on the router:

/ip firewall filter add chain=forward src-address-list=unpaid action=drop
/ip firewall filter add chain=forward src-address-list=suspended action=drop

The app maintains the address lists; the drop rules above are what actually blocks the traffic. List names default to suspended, unpaid and terminated, and are configurable in config.php (block_lists) or via environment variables.

Status logic

StatusWhat the app does on the MikroTik
Connectedlease + queue (enabled), IP removed from block list
Temporarily disconnectedlease stays, queue throttled/disabled, IP added to address-list
Non-payersame as temporary, different address-list
Contract terminateddeletes lease + queue + address-list entry

PPPoE customers are managed through /ppp/secret (login, password, profile) instead of lease/queue.

Circuit ID (DHCP Option 82)

Each customer has an optional Circuit ID field holding the DHCP Option 82 agent circuit identifier — for example an NBN AVC ID in Australia, or a port identifier from a DSLAM or access switch.

When the field is filled, the app binds the DHCP lease to the circuit instead of the MAC address. The MAC is deliberately not sent, so the customer can replace their modem and the lease still applies — no reconfiguration, no manual re-entry. The router matches the lease using the agent-circuit-id parameter:

/ip dhcp-server lease add address=10.0.0.50 \
agent-circuit-id=41564330303032353038313730313138 server=dhcp1

If the Circuit ID is empty, the app falls back to the usual MAC-based lease, so existing setups are unaffected.

Entering the value

RouterOS displays Option 82 identifiers as hex, but the underlying value is usually plain text. You can paste either form and the app normalises it:

What you enterStored / sent to the router
AVC000250817011841564330303032353038313730313138
41564330303032353038313730313138unchanged
0x4156433030...0x prefix stripped

A string is only treated as hex if it decodes to readable text — so a purely numeric circuit ID such as 0012345678 is correctly kept as text rather than misread as hex. To force hex interpretation of a binary identifier, prefix it with 0x.

To find the value on a running system, look at an active lease in RouterOS (IP → DHCP Server → Leases, the Agent Circuit Id field), or take it from the carrier's service order.

Notes and open questions live in issue #1.

Time zone

Every timestamp in the app — change history, logins, backup file names — uses a single time zone, resolved in this order:

  1. the ISPADMIN_TZ environment variable, if set (useful for Docker)
  2. a manual choice saved on the Settings page
  3. the server's own time zone, read from /etc/timezone or /etc/localtime
  4. UTC, as a last resort

In practice step 3 means timestamps are correct straight after installation without configuring anything. If the server's zone is wrong or the app runs somewhere else than your customers, pick the right one under Settings — it is stored in the database, not in config.php, so it survives updates. When ISPADMIN_TZ is set it wins over everything and the Settings field is shown read-only, so it is always obvious where the value comes from.

Plans and aggregation

Per-user Simple Queue:

  • max-limit = user UL/DL (upload/download, in kbit)
  • limit-at = max-limit / aggregation (guaranteed share)

A plan with no speed set (e.g. IPTV, GPON) creates only the lease, no queue.

Importing existing customers

Prepare a JSON file following example_data.json and run:

php import_json.php my_site.json # preview, writes nothing
php import_json.php my_site.json --apply # real import

The import is idempotent — existing customers (same IP or PPPoE login) are skipped, and nothing is changed on the MikroTik.

CLI helper scripts

ScriptPurpose
import_json.phpimport a router, its networks and customers from JSON
pull_speeds.phpfill in customers' real speeds from Simple Queues on the MikroTik (read-only)
set_siet.phpbulk-set the "Network" field on customers
fix_encoding.phpfix diacritics (CP1250 escapes from RouterOS) in already imported data
update_geoip.phpdownload country CIDR lists for geo-blocking (cron-friendly)

All of them run in preview mode until you add --apply.

Run them as the web user. Inside Docker the app runs as www-data, and the SQLite database has to stay writable by it. If you run a script as root, the database file ends up owned by root and the web interface then fails with "attempt to write a readonly database":

sudo docker exec -u www-data -it mt-ispadmin php /var/www/html/import_json.php \
/var/www/html/my_site.json --apply

If it has already happened, fix the ownership with:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

Languages / adding a translation

The UI ships in 10 languages; users pick their language on the login screen (stored in a cookie, auto-detected from the browser on first visit). Slovak is the source language, English is the fallback for missing strings.

To add or improve a language:

  1. Copy lang/en.php to lang/xx.php and translate the values (keys stay in Slovak).
  2. Add the code and native name to LANGS in lib/lang.php.
  3. Check completeness: php lang/verify.php xx (uses lang/keys.txt, verifies keys, %s placeholders and inline HTML).

Pull requests with new languages are welcome.

Geo-blocking (optional)

Access can be limited to selected countries. Enable it with the ISPADMIN_GEO_ENFORCE=1 env variable (disabled by default in docker-compose.yml so you can't lock yourself out). It works via the Cloudflare CF-IPCountry header, or fully offline via CIDR lists (update_geoip.php). Add your own IPs to ISPADMIN_GEO_ALLOW_IPS as a safety net.

Logo and branding

The header logo is text-based and configured in config.php:

'brand_pre' => 'isp', // first (blue) part'brand_post' => 'admin', // second (dark) part'tagline' => 'customer management · MikroTik',

A default SVG logo is included in public/assets/logo.svg — feel free to modify it or replace it with your own.

Security notes

  • Change the default password immediately after the first login (admin / changeme). The default is published here, so anyone who finds your install knows it.
  • config.php, lib/, lang/ and data/ must not be reachable from the web — both Docker and the bundled .htaccess take care of this.
  • Router API credentials and customer data live only in your own database (data/ is in .gitignore) — never commit them.
  • Run the app behind HTTPS (certbot + nginx proxy), ideally on an internal network / behind a VPN.

Troubleshooting

"Fatal error: attempt to write a readonly database" A CLI script was run as root, so the SQLite file is now owned by root while the web app runs as www-data. Fix the ownership and always pass -u www-data to docker exec:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

The browser doesn't respond on port 8090 That's intended — the app listens on 127.0.0.1 only. Use an SSH tunnel or the nginx reverse proxy, see step 3 above.

Certbot succeeded but the domain shows the nginx welcome page Certbot installed the certificate into the stock Debian site because it matched the domain first. Remove /etc/nginx/sites-enabled/default, make sure server_name in your own config is exactly right, then re-run certbot and choose reinstall.

The Test button under Routers fails Check in order: the API service is enabled on the router (/ip service print), the host and port are reachable from the server (nc -vz ROUTER_IP 8728), the API account exists with the right permissions, and no firewall rule on the router blocks the API port.

Timestamps are hours off See Time zone. The quickest fix is to set it explicitly on the Settings page.

Check what the app is actually doing

sudo docker compose logs --tail 50

TODO / possible extensions

  • Parent queue / queue-tree for shared group caps
  • IPv6 prefix delegation
  • Bulk re-sync of all customers to a router
  • Per-field audit log

License

MIT — see LICENSE. Use at your own risk; test everything on a lab router before deploying to production.


Author: Juraj Chudý

About

Web-based customer management for small ISPs built on the MikroTik RouterOS API — DHCP leases, Simple Queues, PPPoE, non-payer blocking, multi-site, 10 languages. Plain PHP, Docker ready.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ISPadmin

Lightweight web-based customer management for small ISPs, built on top of the MikroTik RouterOS API. Multi-site: when you save a customer, the app automatically writes the DHCP lease + Simple Queue (or PPP secret for PPPoE) to the selected MikroTik, based on the customer's status and plan (speed profile).

Plain PHP + SQLite/MySQL. No framework, no dependencies. Up and running in minutes.

Features

  • Customer management: name, address, contact, contract, IP/MAC, plan, status, notes, change history
  • Automatic MikroTik provisioning via RouterOS API — DHCP leases, Simple Queues, ARP, firewall address-lists, PPP secrets (PPPoE)
  • Multi-router / multi-site: every customer belongs to a specific MikroTik and network
  • Plans (speed profiles) with aggregation — max-limit and limit-at are calculated automatically
  • Customer statuses: connected / temporarily disconnected / non-payer / contract terminated — the app blocks automatically based on status (address-list + speed throttling)
  • Users and roles (administrator > admin > user), password management, trash bin, audit log
  • One-click database backup + FTP/FTPS upload, restore from backup
  • Optional geo-blocking of the login (allow selected countries only, via Cloudflare header or offline CIDR lists)
  • 10 languages — language picker on the login screen and in the header (Slovak, Czech, English, German, Polish, Hungarian, Romanian, Ukrainian, Latvian, Russian)
  • Light / dark theme, responsive UI
  • Time zone detected from the server automatically, overridable from the Settings page
  • DHCP Option 82 support — bind a customer's lease to the physical circuit (Agent Circuit ID) instead of the MAC address, so swapping a modem needs no reconfiguration

Screenshots

LoginDashboard
Login with language pickerCustomer dashboard
Customer formDark mode
Customer form (PPPoE)Dark mode

More: Settings · Plans · Routers · Networks · Backup · Users

Requirements

  • PHP 8.1+ with extensions: pdo + pdo_sqlite (or pdo_mysql), openssl (for api-ssl), optionally curl/ftp (FTP backups)
  • MikroTik RouterOS with the API service enabled (port 8728, or api-ssl 8729)
  • Or just Docker + docker compose — nothing else needed

Quick start (testing, SQLite)

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
php -S 0.0.0.0:8000 -t public

Open http://server:8000/login.php

Default login: admin / changeme — change the password right after logging in (Account section).

The database is created automatically on first run in data/ispadmin.sqlite, seeded with example plans (edit them in the UI to match your own offer).

Full install on a clean Debian or Ubuntu server

This is the whole thing from a freshly installed machine, in order. It takes about fifteen minutes and needs no PHP or web server knowledge. Steps 3 and 4 contain the two things that most commonly make people think the install has failed when it hasn't.

1. Install Docker

sudo apt update && sudo apt install -y curl
curl -fsSL https://get.docker.com | sudo sh

2. Get ISPadmin and start it

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
sudo docker compose up -d --build

The container runs with network_mode: host, so it reaches your MikroTiks exactly like the host server does, including over WireGuard or other tunnels. The SQLite database lives in the ispadmin-data volume and survives rebuilds.

3. Reach the web interface

For safety the app listens only on 127.0.0.1:8090, not on the network. Opening http://your-server-ip:8090 in a browser will simply not respond, which looks like a failed install but isn't. You have two options.

Either tunnel to it from your own machine — nothing to configure on the server:

ssh -L 8090:127.0.0.1:8090 youruser@your-server

and then browse to http://localhost:8090.

Or, if the server sits on a trusted internal network and you want to reach it directly, edit docker/ports.conf, change Listen 127.0.0.1:8090 to Listen 8090, and run sudo docker compose up -d --build again.

For anything reachable from the internet, use the reverse proxy in the next step instead.

4. Put it behind nginx with HTTPS

Point a DNS record at the server first, then:

sudo apt install -y nginx certbot python3-certbot-nginx
sudo cp docker/nginx-proxy.conf /etc/nginx/sites-available/ispadmin.conf
sudo nano /etc/nginx/sites-available/ispadmin.conf # set server_name to your domain

Remove the stock Debian site before running certbot. It has a catch-all server_name, so it swallows your domain and certbot installs the certificate into the wrong file — you end up with a valid certificate serving the nginx welcome page:

sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/ispadmin.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ispadmin.example.com

Check that it worked, and that the app is what answers:

curl -I https://ispadmin.example.com/login.php # expect 200

Once HTTPS is confirmed working, enable HSTS by uncommenting the Strict-Transport-Security line in your nginx config and reloading. Don't do it earlier — browsers will then refuse plain HTTP to this host and you can lock yourself out.

5. First login

Open your domain and log in with admin / changeme.

Change the password immediately under Account. There's a Generate password button next to the field. The default is public knowledge, so on an internet-facing install this is not optional.

Then, if more people need access, create accounts under Users. Give colleagues the admin role rather than administratoradmin already unlocks every page, it just can't delete or modify accounts at the same level, which stops someone accidentally locking you out.

6. Add your first router

Under Routers, fill in the host, API username and password, then press Test. If it goes green, everything else in the app will work. See MikroTik setup below for what to enable on the router itself.

Production (MySQL)

  1. In config.php set 'driver' => 'mysql' and fill in the credentials (or use the DB_HOST, DB_NAME, DB_USER, DB_PASS environment variables).
  2. Create the database and import the schema:
mysql -u root -p -e "CREATE DATABASE ispadmin CHARACTER SET utf8mb4"
mysql -u ispadmin -p ispadmin < schema.sql
  1. Point the DocumentRoot at the public/ directory — config.php, lib/, lang/ and data/ stay outside the web root. On classic Apache shared hosting the bundled root .htaccess handles this.

MikroTik setup

Enable the API on every router:

/ip service enable api

(port 8728; for SSL enable api-ssl on 8729 and turn on use_ssl for the router in the app)

Create an API account with permissions for: dhcp-server/lease, queue, firewall/address-list, ppp/secret, system. You enter the API credentials in the Routers section of the UI — they are stored only in your own database.

To actually block non-payers, add a firewall rule on the router:

/ip firewall filter add chain=forward src-address-list=unpaid action=drop
/ip firewall filter add chain=forward src-address-list=suspended action=drop

The app maintains the address lists; the drop rules above are what actually blocks the traffic. List names default to suspended, unpaid and terminated, and are configurable in config.php (block_lists) or via environment variables.

Status logic

StatusWhat the app does on the MikroTik
Connectedlease + queue (enabled), IP removed from block list
Temporarily disconnectedlease stays, queue throttled/disabled, IP added to address-list
Non-payersame as temporary, different address-list
Contract terminateddeletes lease + queue + address-list entry

PPPoE customers are managed through /ppp/secret (login, password, profile) instead of lease/queue.

Circuit ID (DHCP Option 82)

Each customer has an optional Circuit ID field holding the DHCP Option 82 agent circuit identifier — for example an NBN AVC ID in Australia, or a port identifier from a DSLAM or access switch.

When the field is filled, the app binds the DHCP lease to the circuit instead of the MAC address. The MAC is deliberately not sent, so the customer can replace their modem and the lease still applies — no reconfiguration, no manual re-entry. The router matches the lease using the agent-circuit-id parameter:

/ip dhcp-server lease add address=10.0.0.50 \
agent-circuit-id=41564330303032353038313730313138 server=dhcp1

If the Circuit ID is empty, the app falls back to the usual MAC-based lease, so existing setups are unaffected.

Entering the value

RouterOS displays Option 82 identifiers as hex, but the underlying value is usually plain text. You can paste either form and the app normalises it:

What you enterStored / sent to the router
AVC000250817011841564330303032353038313730313138
41564330303032353038313730313138unchanged
0x4156433030...0x prefix stripped

A string is only treated as hex if it decodes to readable text — so a purely numeric circuit ID such as 0012345678 is correctly kept as text rather than misread as hex. To force hex interpretation of a binary identifier, prefix it with 0x.

To find the value on a running system, look at an active lease in RouterOS (IP → DHCP Server → Leases, the Agent Circuit Id field), or take it from the carrier's service order.

Notes and open questions live in issue #1.

Time zone

Every timestamp in the app — change history, logins, backup file names — uses a single time zone, resolved in this order:

  1. the ISPADMIN_TZ environment variable, if set (useful for Docker)
  2. a manual choice saved on the Settings page
  3. the server's own time zone, read from /etc/timezone or /etc/localtime
  4. UTC, as a last resort

In practice step 3 means timestamps are correct straight after installation without configuring anything. If the server's zone is wrong or the app runs somewhere else than your customers, pick the right one under Settings — it is stored in the database, not in config.php, so it survives updates. When ISPADMIN_TZ is set it wins over everything and the Settings field is shown read-only, so it is always obvious where the value comes from.

Plans and aggregation

Per-user Simple Queue:

  • max-limit = user UL/DL (upload/download, in kbit)
  • limit-at = max-limit / aggregation (guaranteed share)

A plan with no speed set (e.g. IPTV, GPON) creates only the lease, no queue.

Importing existing customers

Prepare a JSON file following example_data.json and run:

php import_json.php my_site.json # preview, writes nothing
php import_json.php my_site.json --apply # real import

The import is idempotent — existing customers (same IP or PPPoE login) are skipped, and nothing is changed on the MikroTik.

CLI helper scripts

ScriptPurpose
import_json.phpimport a router, its networks and customers from JSON
pull_speeds.phpfill in customers' real speeds from Simple Queues on the MikroTik (read-only)
set_siet.phpbulk-set the "Network" field on customers
fix_encoding.phpfix diacritics (CP1250 escapes from RouterOS) in already imported data
update_geoip.phpdownload country CIDR lists for geo-blocking (cron-friendly)

All of them run in preview mode until you add --apply.

Run them as the web user. Inside Docker the app runs as www-data, and the SQLite database has to stay writable by it. If you run a script as root, the database file ends up owned by root and the web interface then fails with "attempt to write a readonly database":

sudo docker exec -u www-data -it mt-ispadmin php /var/www/html/import_json.php \
/var/www/html/my_site.json --apply

If it has already happened, fix the ownership with:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

Languages / adding a translation

The UI ships in 10 languages; users pick their language on the login screen (stored in a cookie, auto-detected from the browser on first visit). Slovak is the source language, English is the fallback for missing strings.

To add or improve a language:

  1. Copy lang/en.php to lang/xx.php and translate the values (keys stay in Slovak).
  2. Add the code and native name to LANGS in lib/lang.php.
  3. Check completeness: php lang/verify.php xx (uses lang/keys.txt, verifies keys, %s placeholders and inline HTML).

Pull requests with new languages are welcome.

Geo-blocking (optional)

Access can be limited to selected countries. Enable it with the ISPADMIN_GEO_ENFORCE=1 env variable (disabled by default in docker-compose.yml so you can't lock yourself out). It works via the Cloudflare CF-IPCountry header, or fully offline via CIDR lists (update_geoip.php). Add your own IPs to ISPADMIN_GEO_ALLOW_IPS as a safety net.

Logo and branding

The header logo is text-based and configured in config.php:

'brand_pre' => 'isp', // first (blue) part'brand_post' => 'admin', // second (dark) part'tagline' => 'customer management · MikroTik',

A default SVG logo is included in public/assets/logo.svg — feel free to modify it or replace it with your own.

Security notes

  • Change the default password immediately after the first login (admin / changeme). The default is published here, so anyone who finds your install knows it.
  • config.php, lib/, lang/ and data/ must not be reachable from the web — both Docker and the bundled .htaccess take care of this.
  • Router API credentials and customer data live only in your own database (data/ is in .gitignore) — never commit them.
  • Run the app behind HTTPS (certbot + nginx proxy), ideally on an internal network / behind a VPN.

Troubleshooting

"Fatal error: attempt to write a readonly database" A CLI script was run as root, so the SQLite file is now owned by root while the web app runs as www-data. Fix the ownership and always pass -u www-data to docker exec:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

The browser doesn't respond on port 8090 That's intended — the app listens on 127.0.0.1 only. Use an SSH tunnel or the nginx reverse proxy, see step 3 above.

Certbot succeeded but the domain shows the nginx welcome page Certbot installed the certificate into the stock Debian site because it matched the domain first. Remove /etc/nginx/sites-enabled/default, make sure server_name in your own config is exactly right, then re-run certbot and choose reinstall.

The Test button under Routers fails Check in order: the API service is enabled on the router (/ip service print), the host and port are reachable from the server (nc -vz ROUTER_IP 8728), the API account exists with the right permissions, and no firewall rule on the router blocks the API port.

Timestamps are hours off See Time zone. The quickest fix is to set it explicitly on the Settings page.

Check what the app is actually doing

sudo docker compose logs --tail 50

TODO / possible extensions

  • Parent queue / queue-tree for shared group caps
  • IPv6 prefix delegation
  • Bulk re-sync of all customers to a router
  • Per-field audit log

License

MIT — see LICENSE. Use at your own risk; test everything on a lab router before deploying to production.


Author: Juraj Chudý

About

Web-based customer management for small ISPs built on the MikroTik RouterOS API — DHCP leases, Simple Queues, PPPoE, non-payer blocking, multi-site, 10 languages. Plain PHP, Docker ready.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

ISPadmin

Lightweight web-based customer management for small ISPs, built on top of the MikroTik RouterOS API. Multi-site: when you save a customer, the app automatically writes the DHCP lease + Simple Queue (or PPP secret for PPPoE) to the selected MikroTik, based on the customer's status and plan (speed profile).

Plain PHP + SQLite/MySQL. No framework, no dependencies. Up and running in minutes.

Features

  • Customer management: name, address, contact, contract, IP/MAC, plan, status, notes, change history
  • Automatic MikroTik provisioning via RouterOS API — DHCP leases, Simple Queues, ARP, firewall address-lists, PPP secrets (PPPoE)
  • Multi-router / multi-site: every customer belongs to a specific MikroTik and network
  • Plans (speed profiles) with aggregation — max-limit and limit-at are calculated automatically
  • Customer statuses: connected / temporarily disconnected / non-payer / contract terminated — the app blocks automatically based on status (address-list + speed throttling)
  • Users and roles (administrator > admin > user), password management, trash bin, audit log
  • One-click database backup + FTP/FTPS upload, restore from backup
  • Optional geo-blocking of the login (allow selected countries only, via Cloudflare header or offline CIDR lists)
  • 10 languages — language picker on the login screen and in the header (Slovak, Czech, English, German, Polish, Hungarian, Romanian, Ukrainian, Latvian, Russian)
  • Light / dark theme, responsive UI
  • Time zone detected from the server automatically, overridable from the Settings page
  • DHCP Option 82 support — bind a customer's lease to the physical circuit (Agent Circuit ID) instead of the MAC address, so swapping a modem needs no reconfiguration

Screenshots

LoginDashboard
Login with language pickerCustomer dashboard
Customer formDark mode
Customer form (PPPoE)Dark mode

More: Settings · Plans · Routers · Networks · Backup · Users

Requirements

  • PHP 8.1+ with extensions: pdo + pdo_sqlite (or pdo_mysql), openssl (for api-ssl), optionally curl/ftp (FTP backups)
  • MikroTik RouterOS with the API service enabled (port 8728, or api-ssl 8729)
  • Or just Docker + docker compose — nothing else needed

Quick start (testing, SQLite)

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
php -S 0.0.0.0:8000 -t public

Open http://server:8000/login.php

Default login: admin / changeme — change the password right after logging in (Account section).

The database is created automatically on first run in data/ispadmin.sqlite, seeded with example plans (edit them in the UI to match your own offer).

Full install on a clean Debian or Ubuntu server

This is the whole thing from a freshly installed machine, in order. It takes about fifteen minutes and needs no PHP or web server knowledge. Steps 3 and 4 contain the two things that most commonly make people think the install has failed when it hasn't.

1. Install Docker

sudo apt update && sudo apt install -y curl
curl -fsSL https://get.docker.com | sudo sh

2. Get ISPadmin and start it

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
sudo docker compose up -d --build

The container runs with network_mode: host, so it reaches your MikroTiks exactly like the host server does, including over WireGuard or other tunnels. The SQLite database lives in the ispadmin-data volume and survives rebuilds.

3. Reach the web interface

For safety the app listens only on 127.0.0.1:8090, not on the network. Opening http://your-server-ip:8090 in a browser will simply not respond, which looks like a failed install but isn't. You have two options.

Either tunnel to it from your own machine — nothing to configure on the server:

ssh -L 8090:127.0.0.1:8090 youruser@your-server

and then browse to http://localhost:8090.

Or, if the server sits on a trusted internal network and you want to reach it directly, edit docker/ports.conf, change Listen 127.0.0.1:8090 to Listen 8090, and run sudo docker compose up -d --build again.

For anything reachable from the internet, use the reverse proxy in the next step instead.

4. Put it behind nginx with HTTPS

Point a DNS record at the server first, then:

sudo apt install -y nginx certbot python3-certbot-nginx
sudo cp docker/nginx-proxy.conf /etc/nginx/sites-available/ispadmin.conf
sudo nano /etc/nginx/sites-available/ispadmin.conf # set server_name to your domain

Remove the stock Debian site before running certbot. It has a catch-all server_name, so it swallows your domain and certbot installs the certificate into the wrong file — you end up with a valid certificate serving the nginx welcome page:

sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/ispadmin.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ispadmin.example.com

Check that it worked, and that the app is what answers:

curl -I https://ispadmin.example.com/login.php # expect 200

Once HTTPS is confirmed working, enable HSTS by uncommenting the Strict-Transport-Security line in your nginx config and reloading. Don't do it earlier — browsers will then refuse plain HTTP to this host and you can lock yourself out.

5. First login

Open your domain and log in with admin / changeme.

Change the password immediately under Account. There's a Generate password button next to the field. The default is public knowledge, so on an internet-facing install this is not optional.

Then, if more people need access, create accounts under Users. Give colleagues the admin role rather than administratoradmin already unlocks every page, it just can't delete or modify accounts at the same level, which stops someone accidentally locking you out.

6. Add your first router

Under Routers, fill in the host, API username and password, then press Test. If it goes green, everything else in the app will work. See MikroTik setup below for what to enable on the router itself.

Production (MySQL)

  1. In config.php set 'driver' => 'mysql' and fill in the credentials (or use the DB_HOST, DB_NAME, DB_USER, DB_PASS environment variables).
  2. Create the database and import the schema:
mysql -u root -p -e "CREATE DATABASE ispadmin CHARACTER SET utf8mb4"
mysql -u ispadmin -p ispadmin < schema.sql
  1. Point the DocumentRoot at the public/ directory — config.php, lib/, lang/ and data/ stay outside the web root. On classic Apache shared hosting the bundled root .htaccess handles this.

MikroTik setup

Enable the API on every router:

/ip service enable api

(port 8728; for SSL enable api-ssl on 8729 and turn on use_ssl for the router in the app)

Create an API account with permissions for: dhcp-server/lease, queue, firewall/address-list, ppp/secret, system. You enter the API credentials in the Routers section of the UI — they are stored only in your own database.

To actually block non-payers, add a firewall rule on the router:

/ip firewall filter add chain=forward src-address-list=unpaid action=drop
/ip firewall filter add chain=forward src-address-list=suspended action=drop

The app maintains the address lists; the drop rules above are what actually blocks the traffic. List names default to suspended, unpaid and terminated, and are configurable in config.php (block_lists) or via environment variables.

Status logic

StatusWhat the app does on the MikroTik
Connectedlease + queue (enabled), IP removed from block list
Temporarily disconnectedlease stays, queue throttled/disabled, IP added to address-list
Non-payersame as temporary, different address-list
Contract terminateddeletes lease + queue + address-list entry

PPPoE customers are managed through /ppp/secret (login, password, profile) instead of lease/queue.

Circuit ID (DHCP Option 82)

Each customer has an optional Circuit ID field holding the DHCP Option 82 agent circuit identifier — for example an NBN AVC ID in Australia, or a port identifier from a DSLAM or access switch.

When the field is filled, the app binds the DHCP lease to the circuit instead of the MAC address. The MAC is deliberately not sent, so the customer can replace their modem and the lease still applies — no reconfiguration, no manual re-entry. The router matches the lease using the agent-circuit-id parameter:

/ip dhcp-server lease add address=10.0.0.50 \
agent-circuit-id=41564330303032353038313730313138 server=dhcp1

If the Circuit ID is empty, the app falls back to the usual MAC-based lease, so existing setups are unaffected.

Entering the value

RouterOS displays Option 82 identifiers as hex, but the underlying value is usually plain text. You can paste either form and the app normalises it:

What you enterStored / sent to the router
AVC000250817011841564330303032353038313730313138
41564330303032353038313730313138unchanged
0x4156433030...0x prefix stripped

A string is only treated as hex if it decodes to readable text — so a purely numeric circuit ID such as 0012345678 is correctly kept as text rather than misread as hex. To force hex interpretation of a binary identifier, prefix it with 0x.

To find the value on a running system, look at an active lease in RouterOS (IP → DHCP Server → Leases, the Agent Circuit Id field), or take it from the carrier's service order.

Notes and open questions live in issue #1.

Time zone

Every timestamp in the app — change history, logins, backup file names — uses a single time zone, resolved in this order:

  1. the ISPADMIN_TZ environment variable, if set (useful for Docker)
  2. a manual choice saved on the Settings page
  3. the server's own time zone, read from /etc/timezone or /etc/localtime
  4. UTC, as a last resort

In practice step 3 means timestamps are correct straight after installation without configuring anything. If the server's zone is wrong or the app runs somewhere else than your customers, pick the right one under Settings — it is stored in the database, not in config.php, so it survives updates. When ISPADMIN_TZ is set it wins over everything and the Settings field is shown read-only, so it is always obvious where the value comes from.

Plans and aggregation

Per-user Simple Queue:

  • max-limit = user UL/DL (upload/download, in kbit)
  • limit-at = max-limit / aggregation (guaranteed share)

A plan with no speed set (e.g. IPTV, GPON) creates only the lease, no queue.

Importing existing customers

Prepare a JSON file following example_data.json and run:

php import_json.php my_site.json # preview, writes nothing
php import_json.php my_site.json --apply # real import

The import is idempotent — existing customers (same IP or PPPoE login) are skipped, and nothing is changed on the MikroTik.

CLI helper scripts

ScriptPurpose
import_json.phpimport a router, its networks and customers from JSON
pull_speeds.phpfill in customers' real speeds from Simple Queues on the MikroTik (read-only)
set_siet.phpbulk-set the "Network" field on customers
fix_encoding.phpfix diacritics (CP1250 escapes from RouterOS) in already imported data
update_geoip.phpdownload country CIDR lists for geo-blocking (cron-friendly)

All of them run in preview mode until you add --apply.

Run them as the web user. Inside Docker the app runs as www-data, and the SQLite database has to stay writable by it. If you run a script as root, the database file ends up owned by root and the web interface then fails with "attempt to write a readonly database":

sudo docker exec -u www-data -it mt-ispadmin php /var/www/html/import_json.php \
/var/www/html/my_site.json --apply

If it has already happened, fix the ownership with:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

Languages / adding a translation

The UI ships in 10 languages; users pick their language on the login screen (stored in a cookie, auto-detected from the browser on first visit). Slovak is the source language, English is the fallback for missing strings.

To add or improve a language:

  1. Copy lang/en.php to lang/xx.php and translate the values (keys stay in Slovak).
  2. Add the code and native name to LANGS in lib/lang.php.
  3. Check completeness: php lang/verify.php xx (uses lang/keys.txt, verifies keys, %s placeholders and inline HTML).

Pull requests with new languages are welcome.

Geo-blocking (optional)

Access can be limited to selected countries. Enable it with the ISPADMIN_GEO_ENFORCE=1 env variable (disabled by default in docker-compose.yml so you can't lock yourself out). It works via the Cloudflare CF-IPCountry header, or fully offline via CIDR lists (update_geoip.php). Add your own IPs to ISPADMIN_GEO_ALLOW_IPS as a safety net.

Logo and branding

The header logo is text-based and configured in config.php:

'brand_pre' => 'isp', // first (blue) part'brand_post' => 'admin', // second (dark) part'tagline' => 'customer management · MikroTik',

A default SVG logo is included in public/assets/logo.svg — feel free to modify it or replace it with your own.

Security notes

  • Change the default password immediately after the first login (admin / changeme). The default is published here, so anyone who finds your install knows it.
  • config.php, lib/, lang/ and data/ must not be reachable from the web — both Docker and the bundled .htaccess take care of this.
  • Router API credentials and customer data live only in your own database (data/ is in .gitignore) — never commit them.
  • Run the app behind HTTPS (certbot + nginx proxy), ideally on an internal network / behind a VPN.

Troubleshooting

"Fatal error: attempt to write a readonly database" A CLI script was run as root, so the SQLite file is now owned by root while the web app runs as www-data. Fix the ownership and always pass -u www-data to docker exec:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

The browser doesn't respond on port 8090 That's intended — the app listens on 127.0.0.1 only. Use an SSH tunnel or the nginx reverse proxy, see step 3 above.

Certbot succeeded but the domain shows the nginx welcome page Certbot installed the certificate into the stock Debian site because it matched the domain first. Remove /etc/nginx/sites-enabled/default, make sure server_name in your own config is exactly right, then re-run certbot and choose reinstall.

The Test button under Routers fails Check in order: the API service is enabled on the router (/ip service print), the host and port are reachable from the server (nc -vz ROUTER_IP 8728), the API account exists with the right permissions, and no firewall rule on the router blocks the API port.

Timestamps are hours off See Time zone. The quickest fix is to set it explicitly on the Settings page.

Check what the app is actually doing

sudo docker compose logs --tail 50

TODO / possible extensions

  • Parent queue / queue-tree for shared group caps
  • IPv6 prefix delegation
  • Bulk re-sync of all customers to a router
  • Per-field audit log

License

MIT — see LICENSE. Use at your own risk; test everything on a lab router before deploying to production.


Author: Juraj Chudý

About

Web-based customer management for small ISPs built on the MikroTik RouterOS API — DHCP leases, Simple Queues, PPPoE, non-payer blocking, multi-site, 10 languages. Plain PHP, Docker ready.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ISPadmin

Lightweight web-based customer management for small ISPs, built on top of the MikroTik RouterOS API. Multi-site: when you save a customer, the app automatically writes the DHCP lease + Simple Queue (or PPP secret for PPPoE) to the selected MikroTik, based on the customer's status and plan (speed profile).

Plain PHP + SQLite/MySQL. No framework, no dependencies. Up and running in minutes.

Features

  • Customer management: name, address, contact, contract, IP/MAC, plan, status, notes, change history
  • Automatic MikroTik provisioning via RouterOS API — DHCP leases, Simple Queues, ARP, firewall address-lists, PPP secrets (PPPoE)
  • Multi-router / multi-site: every customer belongs to a specific MikroTik and network
  • Plans (speed profiles) with aggregation — max-limit and limit-at are calculated automatically
  • Customer statuses: connected / temporarily disconnected / non-payer / contract terminated — the app blocks automatically based on status (address-list + speed throttling)
  • Users and roles (administrator > admin > user), password management, trash bin, audit log
  • One-click database backup + FTP/FTPS upload, restore from backup
  • Optional geo-blocking of the login (allow selected countries only, via Cloudflare header or offline CIDR lists)
  • 10 languages — language picker on the login screen and in the header (Slovak, Czech, English, German, Polish, Hungarian, Romanian, Ukrainian, Latvian, Russian)
  • Light / dark theme, responsive UI
  • Time zone detected from the server automatically, overridable from the Settings page
  • DHCP Option 82 support — bind a customer's lease to the physical circuit (Agent Circuit ID) instead of the MAC address, so swapping a modem needs no reconfiguration

Screenshots

LoginDashboard
Login with language pickerCustomer dashboard
Customer formDark mode
Customer form (PPPoE)Dark mode

More: Settings · Plans · Routers · Networks · Backup · Users

Requirements

  • PHP 8.1+ with extensions: pdo + pdo_sqlite (or pdo_mysql), openssl (for api-ssl), optionally curl/ftp (FTP backups)
  • MikroTik RouterOS with the API service enabled (port 8728, or api-ssl 8729)
  • Or just Docker + docker compose — nothing else needed

Quick start (testing, SQLite)

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
php -S 0.0.0.0:8000 -t public

Open http://server:8000/login.php

Default login: admin / changeme — change the password right after logging in (Account section).

The database is created automatically on first run in data/ispadmin.sqlite, seeded with example plans (edit them in the UI to match your own offer).

Full install on a clean Debian or Ubuntu server

This is the whole thing from a freshly installed machine, in order. It takes about fifteen minutes and needs no PHP or web server knowledge. Steps 3 and 4 contain the two things that most commonly make people think the install has failed when it hasn't.

1. Install Docker

sudo apt update && sudo apt install -y curl
curl -fsSL https://get.docker.com | sudo sh

2. Get ISPadmin and start it

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
sudo docker compose up -d --build

The container runs with network_mode: host, so it reaches your MikroTiks exactly like the host server does, including over WireGuard or other tunnels. The SQLite database lives in the ispadmin-data volume and survives rebuilds.

3. Reach the web interface

For safety the app listens only on 127.0.0.1:8090, not on the network. Opening http://your-server-ip:8090 in a browser will simply not respond, which looks like a failed install but isn't. You have two options.

Either tunnel to it from your own machine — nothing to configure on the server:

ssh -L 8090:127.0.0.1:8090 youruser@your-server

and then browse to http://localhost:8090.

Or, if the server sits on a trusted internal network and you want to reach it directly, edit docker/ports.conf, change Listen 127.0.0.1:8090 to Listen 8090, and run sudo docker compose up -d --build again.

For anything reachable from the internet, use the reverse proxy in the next step instead.

4. Put it behind nginx with HTTPS

Point a DNS record at the server first, then:

sudo apt install -y nginx certbot python3-certbot-nginx
sudo cp docker/nginx-proxy.conf /etc/nginx/sites-available/ispadmin.conf
sudo nano /etc/nginx/sites-available/ispadmin.conf # set server_name to your domain

Remove the stock Debian site before running certbot. It has a catch-all server_name, so it swallows your domain and certbot installs the certificate into the wrong file — you end up with a valid certificate serving the nginx welcome page:

sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/ispadmin.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ispadmin.example.com

Check that it worked, and that the app is what answers:

curl -I https://ispadmin.example.com/login.php # expect 200

Once HTTPS is confirmed working, enable HSTS by uncommenting the Strict-Transport-Security line in your nginx config and reloading. Don't do it earlier — browsers will then refuse plain HTTP to this host and you can lock yourself out.

5. First login

Open your domain and log in with admin / changeme.

Change the password immediately under Account. There's a Generate password button next to the field. The default is public knowledge, so on an internet-facing install this is not optional.

Then, if more people need access, create accounts under Users. Give colleagues the admin role rather than administratoradmin already unlocks every page, it just can't delete or modify accounts at the same level, which stops someone accidentally locking you out.

6. Add your first router

Under Routers, fill in the host, API username and password, then press Test. If it goes green, everything else in the app will work. See MikroTik setup below for what to enable on the router itself.

Production (MySQL)

  1. In config.php set 'driver' => 'mysql' and fill in the credentials (or use the DB_HOST, DB_NAME, DB_USER, DB_PASS environment variables).
  2. Create the database and import the schema:
mysql -u root -p -e "CREATE DATABASE ispadmin CHARACTER SET utf8mb4"
mysql -u ispadmin -p ispadmin < schema.sql
  1. Point the DocumentRoot at the public/ directory — config.php, lib/, lang/ and data/ stay outside the web root. On classic Apache shared hosting the bundled root .htaccess handles this.

MikroTik setup

Enable the API on every router:

/ip service enable api

(port 8728; for SSL enable api-ssl on 8729 and turn on use_ssl for the router in the app)

Create an API account with permissions for: dhcp-server/lease, queue, firewall/address-list, ppp/secret, system. You enter the API credentials in the Routers section of the UI — they are stored only in your own database.

To actually block non-payers, add a firewall rule on the router:

/ip firewall filter add chain=forward src-address-list=unpaid action=drop
/ip firewall filter add chain=forward src-address-list=suspended action=drop

The app maintains the address lists; the drop rules above are what actually blocks the traffic. List names default to suspended, unpaid and terminated, and are configurable in config.php (block_lists) or via environment variables.

Status logic

StatusWhat the app does on the MikroTik
Connectedlease + queue (enabled), IP removed from block list
Temporarily disconnectedlease stays, queue throttled/disabled, IP added to address-list
Non-payersame as temporary, different address-list
Contract terminateddeletes lease + queue + address-list entry

PPPoE customers are managed through /ppp/secret (login, password, profile) instead of lease/queue.

Circuit ID (DHCP Option 82)

Each customer has an optional Circuit ID field holding the DHCP Option 82 agent circuit identifier — for example an NBN AVC ID in Australia, or a port identifier from a DSLAM or access switch.

When the field is filled, the app binds the DHCP lease to the circuit instead of the MAC address. The MAC is deliberately not sent, so the customer can replace their modem and the lease still applies — no reconfiguration, no manual re-entry. The router matches the lease using the agent-circuit-id parameter:

/ip dhcp-server lease add address=10.0.0.50 \
agent-circuit-id=41564330303032353038313730313138 server=dhcp1

If the Circuit ID is empty, the app falls back to the usual MAC-based lease, so existing setups are unaffected.

Entering the value

RouterOS displays Option 82 identifiers as hex, but the underlying value is usually plain text. You can paste either form and the app normalises it:

What you enterStored / sent to the router
AVC000250817011841564330303032353038313730313138
41564330303032353038313730313138unchanged
0x4156433030...0x prefix stripped

A string is only treated as hex if it decodes to readable text — so a purely numeric circuit ID such as 0012345678 is correctly kept as text rather than misread as hex. To force hex interpretation of a binary identifier, prefix it with 0x.

To find the value on a running system, look at an active lease in RouterOS (IP → DHCP Server → Leases, the Agent Circuit Id field), or take it from the carrier's service order.

Notes and open questions live in issue #1.

Time zone

Every timestamp in the app — change history, logins, backup file names — uses a single time zone, resolved in this order:

  1. the ISPADMIN_TZ environment variable, if set (useful for Docker)
  2. a manual choice saved on the Settings page
  3. the server's own time zone, read from /etc/timezone or /etc/localtime
  4. UTC, as a last resort

In practice step 3 means timestamps are correct straight after installation without configuring anything. If the server's zone is wrong or the app runs somewhere else than your customers, pick the right one under Settings — it is stored in the database, not in config.php, so it survives updates. When ISPADMIN_TZ is set it wins over everything and the Settings field is shown read-only, so it is always obvious where the value comes from.

Plans and aggregation

Per-user Simple Queue:

  • max-limit = user UL/DL (upload/download, in kbit)
  • limit-at = max-limit / aggregation (guaranteed share)

A plan with no speed set (e.g. IPTV, GPON) creates only the lease, no queue.

Importing existing customers

Prepare a JSON file following example_data.json and run:

php import_json.php my_site.json # preview, writes nothing
php import_json.php my_site.json --apply # real import

The import is idempotent — existing customers (same IP or PPPoE login) are skipped, and nothing is changed on the MikroTik.

CLI helper scripts

ScriptPurpose
import_json.phpimport a router, its networks and customers from JSON
pull_speeds.phpfill in customers' real speeds from Simple Queues on the MikroTik (read-only)
set_siet.phpbulk-set the "Network" field on customers
fix_encoding.phpfix diacritics (CP1250 escapes from RouterOS) in already imported data
update_geoip.phpdownload country CIDR lists for geo-blocking (cron-friendly)

All of them run in preview mode until you add --apply.

Run them as the web user. Inside Docker the app runs as www-data, and the SQLite database has to stay writable by it. If you run a script as root, the database file ends up owned by root and the web interface then fails with "attempt to write a readonly database":

sudo docker exec -u www-data -it mt-ispadmin php /var/www/html/import_json.php \
/var/www/html/my_site.json --apply

If it has already happened, fix the ownership with:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

Languages / adding a translation

The UI ships in 10 languages; users pick their language on the login screen (stored in a cookie, auto-detected from the browser on first visit). Slovak is the source language, English is the fallback for missing strings.

To add or improve a language:

  1. Copy lang/en.php to lang/xx.php and translate the values (keys stay in Slovak).
  2. Add the code and native name to LANGS in lib/lang.php.
  3. Check completeness: php lang/verify.php xx (uses lang/keys.txt, verifies keys, %s placeholders and inline HTML).

Pull requests with new languages are welcome.

Geo-blocking (optional)

Access can be limited to selected countries. Enable it with the ISPADMIN_GEO_ENFORCE=1 env variable (disabled by default in docker-compose.yml so you can't lock yourself out). It works via the Cloudflare CF-IPCountry header, or fully offline via CIDR lists (update_geoip.php). Add your own IPs to ISPADMIN_GEO_ALLOW_IPS as a safety net.

Logo and branding

The header logo is text-based and configured in config.php:

'brand_pre' => 'isp', // first (blue) part'brand_post' => 'admin', // second (dark) part'tagline' => 'customer management · MikroTik',

A default SVG logo is included in public/assets/logo.svg — feel free to modify it or replace it with your own.

Security notes

  • Change the default password immediately after the first login (admin / changeme). The default is published here, so anyone who finds your install knows it.
  • config.php, lib/, lang/ and data/ must not be reachable from the web — both Docker and the bundled .htaccess take care of this.
  • Router API credentials and customer data live only in your own database (data/ is in .gitignore) — never commit them.
  • Run the app behind HTTPS (certbot + nginx proxy), ideally on an internal network / behind a VPN.

Troubleshooting

"Fatal error: attempt to write a readonly database" A CLI script was run as root, so the SQLite file is now owned by root while the web app runs as www-data. Fix the ownership and always pass -u www-data to docker exec:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

The browser doesn't respond on port 8090 That's intended — the app listens on 127.0.0.1 only. Use an SSH tunnel or the nginx reverse proxy, see step 3 above.

Certbot succeeded but the domain shows the nginx welcome page Certbot installed the certificate into the stock Debian site because it matched the domain first. Remove /etc/nginx/sites-enabled/default, make sure server_name in your own config is exactly right, then re-run certbot and choose reinstall.

The Test button under Routers fails Check in order: the API service is enabled on the router (/ip service print), the host and port are reachable from the server (nc -vz ROUTER_IP 8728), the API account exists with the right permissions, and no firewall rule on the router blocks the API port.

Timestamps are hours off See Time zone. The quickest fix is to set it explicitly on the Settings page.

Check what the app is actually doing

sudo docker compose logs --tail 50

TODO / possible extensions

  • Parent queue / queue-tree for shared group caps
  • IPv6 prefix delegation
  • Bulk re-sync of all customers to a router
  • Per-field audit log

License

MIT — see LICENSE. Use at your own risk; test everything on a lab router before deploying to production.


Author: Juraj Chudý

About

Web-based customer management for small ISPs built on the MikroTik RouterOS API — DHCP leases, Simple Queues, PPPoE, non-payer blocking, multi-site, 10 languages. Plain PHP, Docker ready.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

ISPadmin

Lightweight web-based customer management for small ISPs, built on top of the MikroTik RouterOS API. Multi-site: when you save a customer, the app automatically writes the DHCP lease + Simple Queue (or PPP secret for PPPoE) to the selected MikroTik, based on the customer's status and plan (speed profile).

Plain PHP + SQLite/MySQL. No framework, no dependencies. Up and running in minutes.

Features

  • Customer management: name, address, contact, contract, IP/MAC, plan, status, notes, change history
  • Automatic MikroTik provisioning via RouterOS API — DHCP leases, Simple Queues, ARP, firewall address-lists, PPP secrets (PPPoE)
  • Multi-router / multi-site: every customer belongs to a specific MikroTik and network
  • Plans (speed profiles) with aggregation — max-limit and limit-at are calculated automatically
  • Customer statuses: connected / temporarily disconnected / non-payer / contract terminated — the app blocks automatically based on status (address-list + speed throttling)
  • Users and roles (administrator > admin > user), password management, trash bin, audit log
  • One-click database backup + FTP/FTPS upload, restore from backup
  • Optional geo-blocking of the login (allow selected countries only, via Cloudflare header or offline CIDR lists)
  • 10 languages — language picker on the login screen and in the header (Slovak, Czech, English, German, Polish, Hungarian, Romanian, Ukrainian, Latvian, Russian)
  • Light / dark theme, responsive UI
  • Time zone detected from the server automatically, overridable from the Settings page
  • DHCP Option 82 support — bind a customer's lease to the physical circuit (Agent Circuit ID) instead of the MAC address, so swapping a modem needs no reconfiguration

Screenshots

LoginDashboard
Login with language pickerCustomer dashboard
Customer formDark mode
Customer form (PPPoE)Dark mode

More: Settings · Plans · Routers · Networks · Backup · Users

Requirements

  • PHP 8.1+ with extensions: pdo + pdo_sqlite (or pdo_mysql), openssl (for api-ssl), optionally curl/ftp (FTP backups)
  • MikroTik RouterOS with the API service enabled (port 8728, or api-ssl 8729)
  • Or just Docker + docker compose — nothing else needed

Quick start (testing, SQLite)

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
php -S 0.0.0.0:8000 -t public

Open http://server:8000/login.php

Default login: admin / changeme — change the password right after logging in (Account section).

The database is created automatically on first run in data/ispadmin.sqlite, seeded with example plans (edit them in the UI to match your own offer).

Full install on a clean Debian or Ubuntu server

This is the whole thing from a freshly installed machine, in order. It takes about fifteen minutes and needs no PHP or web server knowledge. Steps 3 and 4 contain the two things that most commonly make people think the install has failed when it hasn't.

1. Install Docker

sudo apt update && sudo apt install -y curl
curl -fsSL https://get.docker.com | sudo sh

2. Get ISPadmin and start it

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
sudo docker compose up -d --build

The container runs with network_mode: host, so it reaches your MikroTiks exactly like the host server does, including over WireGuard or other tunnels. The SQLite database lives in the ispadmin-data volume and survives rebuilds.

3. Reach the web interface

For safety the app listens only on 127.0.0.1:8090, not on the network. Opening http://your-server-ip:8090 in a browser will simply not respond, which looks like a failed install but isn't. You have two options.

Either tunnel to it from your own machine — nothing to configure on the server:

ssh -L 8090:127.0.0.1:8090 youruser@your-server

and then browse to http://localhost:8090.

Or, if the server sits on a trusted internal network and you want to reach it directly, edit docker/ports.conf, change Listen 127.0.0.1:8090 to Listen 8090, and run sudo docker compose up -d --build again.

For anything reachable from the internet, use the reverse proxy in the next step instead.

4. Put it behind nginx with HTTPS

Point a DNS record at the server first, then:

sudo apt install -y nginx certbot python3-certbot-nginx
sudo cp docker/nginx-proxy.conf /etc/nginx/sites-available/ispadmin.conf
sudo nano /etc/nginx/sites-available/ispadmin.conf # set server_name to your domain

Remove the stock Debian site before running certbot. It has a catch-all server_name, so it swallows your domain and certbot installs the certificate into the wrong file — you end up with a valid certificate serving the nginx welcome page:

sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/ispadmin.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ispadmin.example.com

Check that it worked, and that the app is what answers:

curl -I https://ispadmin.example.com/login.php # expect 200

Once HTTPS is confirmed working, enable HSTS by uncommenting the Strict-Transport-Security line in your nginx config and reloading. Don't do it earlier — browsers will then refuse plain HTTP to this host and you can lock yourself out.

5. First login

Open your domain and log in with admin / changeme.

Change the password immediately under Account. There's a Generate password button next to the field. The default is public knowledge, so on an internet-facing install this is not optional.

Then, if more people need access, create accounts under Users. Give colleagues the admin role rather than administratoradmin already unlocks every page, it just can't delete or modify accounts at the same level, which stops someone accidentally locking you out.

6. Add your first router

Under Routers, fill in the host, API username and password, then press Test. If it goes green, everything else in the app will work. See MikroTik setup below for what to enable on the router itself.

Production (MySQL)

  1. In config.php set 'driver' => 'mysql' and fill in the credentials (or use the DB_HOST, DB_NAME, DB_USER, DB_PASS environment variables).
  2. Create the database and import the schema:
mysql -u root -p -e "CREATE DATABASE ispadmin CHARACTER SET utf8mb4"
mysql -u ispadmin -p ispadmin < schema.sql
  1. Point the DocumentRoot at the public/ directory — config.php, lib/, lang/ and data/ stay outside the web root. On classic Apache shared hosting the bundled root .htaccess handles this.

MikroTik setup

Enable the API on every router:

/ip service enable api

(port 8728; for SSL enable api-ssl on 8729 and turn on use_ssl for the router in the app)

Create an API account with permissions for: dhcp-server/lease, queue, firewall/address-list, ppp/secret, system. You enter the API credentials in the Routers section of the UI — they are stored only in your own database.

To actually block non-payers, add a firewall rule on the router:

/ip firewall filter add chain=forward src-address-list=unpaid action=drop
/ip firewall filter add chain=forward src-address-list=suspended action=drop

The app maintains the address lists; the drop rules above are what actually blocks the traffic. List names default to suspended, unpaid and terminated, and are configurable in config.php (block_lists) or via environment variables.

Status logic

StatusWhat the app does on the MikroTik
Connectedlease + queue (enabled), IP removed from block list
Temporarily disconnectedlease stays, queue throttled/disabled, IP added to address-list
Non-payersame as temporary, different address-list
Contract terminateddeletes lease + queue + address-list entry

PPPoE customers are managed through /ppp/secret (login, password, profile) instead of lease/queue.

Circuit ID (DHCP Option 82)

Each customer has an optional Circuit ID field holding the DHCP Option 82 agent circuit identifier — for example an NBN AVC ID in Australia, or a port identifier from a DSLAM or access switch.

When the field is filled, the app binds the DHCP lease to the circuit instead of the MAC address. The MAC is deliberately not sent, so the customer can replace their modem and the lease still applies — no reconfiguration, no manual re-entry. The router matches the lease using the agent-circuit-id parameter:

/ip dhcp-server lease add address=10.0.0.50 \
agent-circuit-id=41564330303032353038313730313138 server=dhcp1

If the Circuit ID is empty, the app falls back to the usual MAC-based lease, so existing setups are unaffected.

Entering the value

RouterOS displays Option 82 identifiers as hex, but the underlying value is usually plain text. You can paste either form and the app normalises it:

What you enterStored / sent to the router
AVC000250817011841564330303032353038313730313138
41564330303032353038313730313138unchanged
0x4156433030...0x prefix stripped

A string is only treated as hex if it decodes to readable text — so a purely numeric circuit ID such as 0012345678 is correctly kept as text rather than misread as hex. To force hex interpretation of a binary identifier, prefix it with 0x.

To find the value on a running system, look at an active lease in RouterOS (IP → DHCP Server → Leases, the Agent Circuit Id field), or take it from the carrier's service order.

Notes and open questions live in issue #1.

Time zone

Every timestamp in the app — change history, logins, backup file names — uses a single time zone, resolved in this order:

  1. the ISPADMIN_TZ environment variable, if set (useful for Docker)
  2. a manual choice saved on the Settings page
  3. the server's own time zone, read from /etc/timezone or /etc/localtime
  4. UTC, as a last resort

In practice step 3 means timestamps are correct straight after installation without configuring anything. If the server's zone is wrong or the app runs somewhere else than your customers, pick the right one under Settings — it is stored in the database, not in config.php, so it survives updates. When ISPADMIN_TZ is set it wins over everything and the Settings field is shown read-only, so it is always obvious where the value comes from.

Plans and aggregation

Per-user Simple Queue:

  • max-limit = user UL/DL (upload/download, in kbit)
  • limit-at = max-limit / aggregation (guaranteed share)

A plan with no speed set (e.g. IPTV, GPON) creates only the lease, no queue.

Importing existing customers

Prepare a JSON file following example_data.json and run:

php import_json.php my_site.json # preview, writes nothing
php import_json.php my_site.json --apply # real import

The import is idempotent — existing customers (same IP or PPPoE login) are skipped, and nothing is changed on the MikroTik.

CLI helper scripts

ScriptPurpose
import_json.phpimport a router, its networks and customers from JSON
pull_speeds.phpfill in customers' real speeds from Simple Queues on the MikroTik (read-only)
set_siet.phpbulk-set the "Network" field on customers
fix_encoding.phpfix diacritics (CP1250 escapes from RouterOS) in already imported data
update_geoip.phpdownload country CIDR lists for geo-blocking (cron-friendly)

All of them run in preview mode until you add --apply.

Run them as the web user. Inside Docker the app runs as www-data, and the SQLite database has to stay writable by it. If you run a script as root, the database file ends up owned by root and the web interface then fails with "attempt to write a readonly database":

sudo docker exec -u www-data -it mt-ispadmin php /var/www/html/import_json.php \
/var/www/html/my_site.json --apply

If it has already happened, fix the ownership with:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

Languages / adding a translation

The UI ships in 10 languages; users pick their language on the login screen (stored in a cookie, auto-detected from the browser on first visit). Slovak is the source language, English is the fallback for missing strings.

To add or improve a language:

  1. Copy lang/en.php to lang/xx.php and translate the values (keys stay in Slovak).
  2. Add the code and native name to LANGS in lib/lang.php.
  3. Check completeness: php lang/verify.php xx (uses lang/keys.txt, verifies keys, %s placeholders and inline HTML).

Pull requests with new languages are welcome.

Geo-blocking (optional)

Access can be limited to selected countries. Enable it with the ISPADMIN_GEO_ENFORCE=1 env variable (disabled by default in docker-compose.yml so you can't lock yourself out). It works via the Cloudflare CF-IPCountry header, or fully offline via CIDR lists (update_geoip.php). Add your own IPs to ISPADMIN_GEO_ALLOW_IPS as a safety net.

Logo and branding

The header logo is text-based and configured in config.php:

'brand_pre' => 'isp', // first (blue) part'brand_post' => 'admin', // second (dark) part'tagline' => 'customer management · MikroTik',

A default SVG logo is included in public/assets/logo.svg — feel free to modify it or replace it with your own.

Security notes

  • Change the default password immediately after the first login (admin / changeme). The default is published here, so anyone who finds your install knows it.
  • config.php, lib/, lang/ and data/ must not be reachable from the web — both Docker and the bundled .htaccess take care of this.
  • Router API credentials and customer data live only in your own database (data/ is in .gitignore) — never commit them.
  • Run the app behind HTTPS (certbot + nginx proxy), ideally on an internal network / behind a VPN.

Troubleshooting

"Fatal error: attempt to write a readonly database" A CLI script was run as root, so the SQLite file is now owned by root while the web app runs as www-data. Fix the ownership and always pass -u www-data to docker exec:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

The browser doesn't respond on port 8090 That's intended — the app listens on 127.0.0.1 only. Use an SSH tunnel or the nginx reverse proxy, see step 3 above.

Certbot succeeded but the domain shows the nginx welcome page Certbot installed the certificate into the stock Debian site because it matched the domain first. Remove /etc/nginx/sites-enabled/default, make sure server_name in your own config is exactly right, then re-run certbot and choose reinstall.

The Test button under Routers fails Check in order: the API service is enabled on the router (/ip service print), the host and port are reachable from the server (nc -vz ROUTER_IP 8728), the API account exists with the right permissions, and no firewall rule on the router blocks the API port.

Timestamps are hours off See Time zone. The quickest fix is to set it explicitly on the Settings page.

Check what the app is actually doing

sudo docker compose logs --tail 50

TODO / possible extensions

  • Parent queue / queue-tree for shared group caps
  • IPv6 prefix delegation
  • Bulk re-sync of all customers to a router
  • Per-field audit log

License

MIT — see LICENSE. Use at your own risk; test everything on a lab router before deploying to production.


Author: Juraj Chudý

About

Web-based customer management for small ISPs built on the MikroTik RouterOS API — DHCP leases, Simple Queues, PPPoE, non-payer blocking, multi-site, 10 languages. Plain PHP, Docker ready.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

ISPadmin

Lightweight web-based customer management for small ISPs, built on top of the MikroTik RouterOS API. Multi-site: when you save a customer, the app automatically writes the DHCP lease + Simple Queue (or PPP secret for PPPoE) to the selected MikroTik, based on the customer's status and plan (speed profile).

Plain PHP + SQLite/MySQL. No framework, no dependencies. Up and running in minutes.

Features

  • Customer management: name, address, contact, contract, IP/MAC, plan, status, notes, change history
  • Automatic MikroTik provisioning via RouterOS API — DHCP leases, Simple Queues, ARP, firewall address-lists, PPP secrets (PPPoE)
  • Multi-router / multi-site: every customer belongs to a specific MikroTik and network
  • Plans (speed profiles) with aggregation — max-limit and limit-at are calculated automatically
  • Customer statuses: connected / temporarily disconnected / non-payer / contract terminated — the app blocks automatically based on status (address-list + speed throttling)
  • Users and roles (administrator > admin > user), password management, trash bin, audit log
  • One-click database backup + FTP/FTPS upload, restore from backup
  • Optional geo-blocking of the login (allow selected countries only, via Cloudflare header or offline CIDR lists)
  • 10 languages — language picker on the login screen and in the header (Slovak, Czech, English, German, Polish, Hungarian, Romanian, Ukrainian, Latvian, Russian)
  • Light / dark theme, responsive UI
  • Time zone detected from the server automatically, overridable from the Settings page
  • DHCP Option 82 support — bind a customer's lease to the physical circuit (Agent Circuit ID) instead of the MAC address, so swapping a modem needs no reconfiguration

Screenshots

LoginDashboard
Login with language pickerCustomer dashboard
Customer formDark mode
Customer form (PPPoE)Dark mode

More: Settings · Plans · Routers · Networks · Backup · Users

Requirements

  • PHP 8.1+ with extensions: pdo + pdo_sqlite (or pdo_mysql), openssl (for api-ssl), optionally curl/ftp (FTP backups)
  • MikroTik RouterOS with the API service enabled (port 8728, or api-ssl 8729)
  • Or just Docker + docker compose — nothing else needed

Quick start (testing, SQLite)

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
php -S 0.0.0.0:8000 -t public

Open http://server:8000/login.php

Default login: admin / changeme — change the password right after logging in (Account section).

The database is created automatically on first run in data/ispadmin.sqlite, seeded with example plans (edit them in the UI to match your own offer).

Full install on a clean Debian or Ubuntu server

This is the whole thing from a freshly installed machine, in order. It takes about fifteen minutes and needs no PHP or web server knowledge. Steps 3 and 4 contain the two things that most commonly make people think the install has failed when it hasn't.

1. Install Docker

sudo apt update && sudo apt install -y curl
curl -fsSL https://get.docker.com | sudo sh

2. Get ISPadmin and start it

git clone https://github.com/MikrotikExe/ispadmin.git
cd ispadmin
sudo docker compose up -d --build

The container runs with network_mode: host, so it reaches your MikroTiks exactly like the host server does, including over WireGuard or other tunnels. The SQLite database lives in the ispadmin-data volume and survives rebuilds.

3. Reach the web interface

For safety the app listens only on 127.0.0.1:8090, not on the network. Opening http://your-server-ip:8090 in a browser will simply not respond, which looks like a failed install but isn't. You have two options.

Either tunnel to it from your own machine — nothing to configure on the server:

ssh -L 8090:127.0.0.1:8090 youruser@your-server

and then browse to http://localhost:8090.

Or, if the server sits on a trusted internal network and you want to reach it directly, edit docker/ports.conf, change Listen 127.0.0.1:8090 to Listen 8090, and run sudo docker compose up -d --build again.

For anything reachable from the internet, use the reverse proxy in the next step instead.

4. Put it behind nginx with HTTPS

Point a DNS record at the server first, then:

sudo apt install -y nginx certbot python3-certbot-nginx
sudo cp docker/nginx-proxy.conf /etc/nginx/sites-available/ispadmin.conf
sudo nano /etc/nginx/sites-available/ispadmin.conf # set server_name to your domain

Remove the stock Debian site before running certbot. It has a catch-all server_name, so it swallows your domain and certbot installs the certificate into the wrong file — you end up with a valid certificate serving the nginx welcome page:

sudo rm -f /etc/nginx/sites-enabled/default
sudo ln -s /etc/nginx/sites-available/ispadmin.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d ispadmin.example.com

Check that it worked, and that the app is what answers:

curl -I https://ispadmin.example.com/login.php # expect 200

Once HTTPS is confirmed working, enable HSTS by uncommenting the Strict-Transport-Security line in your nginx config and reloading. Don't do it earlier — browsers will then refuse plain HTTP to this host and you can lock yourself out.

5. First login

Open your domain and log in with admin / changeme.

Change the password immediately under Account. There's a Generate password button next to the field. The default is public knowledge, so on an internet-facing install this is not optional.

Then, if more people need access, create accounts under Users. Give colleagues the admin role rather than administratoradmin already unlocks every page, it just can't delete or modify accounts at the same level, which stops someone accidentally locking you out.

6. Add your first router

Under Routers, fill in the host, API username and password, then press Test. If it goes green, everything else in the app will work. See MikroTik setup below for what to enable on the router itself.

Production (MySQL)

  1. In config.php set 'driver' => 'mysql' and fill in the credentials (or use the DB_HOST, DB_NAME, DB_USER, DB_PASS environment variables).
  2. Create the database and import the schema:
mysql -u root -p -e "CREATE DATABASE ispadmin CHARACTER SET utf8mb4"
mysql -u ispadmin -p ispadmin < schema.sql
  1. Point the DocumentRoot at the public/ directory — config.php, lib/, lang/ and data/ stay outside the web root. On classic Apache shared hosting the bundled root .htaccess handles this.

MikroTik setup

Enable the API on every router:

/ip service enable api

(port 8728; for SSL enable api-ssl on 8729 and turn on use_ssl for the router in the app)

Create an API account with permissions for: dhcp-server/lease, queue, firewall/address-list, ppp/secret, system. You enter the API credentials in the Routers section of the UI — they are stored only in your own database.

To actually block non-payers, add a firewall rule on the router:

/ip firewall filter add chain=forward src-address-list=unpaid action=drop
/ip firewall filter add chain=forward src-address-list=suspended action=drop

The app maintains the address lists; the drop rules above are what actually blocks the traffic. List names default to suspended, unpaid and terminated, and are configurable in config.php (block_lists) or via environment variables.

Status logic

StatusWhat the app does on the MikroTik
Connectedlease + queue (enabled), IP removed from block list
Temporarily disconnectedlease stays, queue throttled/disabled, IP added to address-list
Non-payersame as temporary, different address-list
Contract terminateddeletes lease + queue + address-list entry

PPPoE customers are managed through /ppp/secret (login, password, profile) instead of lease/queue.

Circuit ID (DHCP Option 82)

Each customer has an optional Circuit ID field holding the DHCP Option 82 agent circuit identifier — for example an NBN AVC ID in Australia, or a port identifier from a DSLAM or access switch.

When the field is filled, the app binds the DHCP lease to the circuit instead of the MAC address. The MAC is deliberately not sent, so the customer can replace their modem and the lease still applies — no reconfiguration, no manual re-entry. The router matches the lease using the agent-circuit-id parameter:

/ip dhcp-server lease add address=10.0.0.50 \
agent-circuit-id=41564330303032353038313730313138 server=dhcp1

If the Circuit ID is empty, the app falls back to the usual MAC-based lease, so existing setups are unaffected.

Entering the value

RouterOS displays Option 82 identifiers as hex, but the underlying value is usually plain text. You can paste either form and the app normalises it:

What you enterStored / sent to the router
AVC000250817011841564330303032353038313730313138
41564330303032353038313730313138unchanged
0x4156433030...0x prefix stripped

A string is only treated as hex if it decodes to readable text — so a purely numeric circuit ID such as 0012345678 is correctly kept as text rather than misread as hex. To force hex interpretation of a binary identifier, prefix it with 0x.

To find the value on a running system, look at an active lease in RouterOS (IP → DHCP Server → Leases, the Agent Circuit Id field), or take it from the carrier's service order.

Notes and open questions live in issue #1.

Time zone

Every timestamp in the app — change history, logins, backup file names — uses a single time zone, resolved in this order:

  1. the ISPADMIN_TZ environment variable, if set (useful for Docker)
  2. a manual choice saved on the Settings page
  3. the server's own time zone, read from /etc/timezone or /etc/localtime
  4. UTC, as a last resort

In practice step 3 means timestamps are correct straight after installation without configuring anything. If the server's zone is wrong or the app runs somewhere else than your customers, pick the right one under Settings — it is stored in the database, not in config.php, so it survives updates. When ISPADMIN_TZ is set it wins over everything and the Settings field is shown read-only, so it is always obvious where the value comes from.

Plans and aggregation

Per-user Simple Queue:

  • max-limit = user UL/DL (upload/download, in kbit)
  • limit-at = max-limit / aggregation (guaranteed share)

A plan with no speed set (e.g. IPTV, GPON) creates only the lease, no queue.

Importing existing customers

Prepare a JSON file following example_data.json and run:

php import_json.php my_site.json # preview, writes nothing
php import_json.php my_site.json --apply # real import

The import is idempotent — existing customers (same IP or PPPoE login) are skipped, and nothing is changed on the MikroTik.

CLI helper scripts

ScriptPurpose
import_json.phpimport a router, its networks and customers from JSON
pull_speeds.phpfill in customers' real speeds from Simple Queues on the MikroTik (read-only)
set_siet.phpbulk-set the "Network" field on customers
fix_encoding.phpfix diacritics (CP1250 escapes from RouterOS) in already imported data
update_geoip.phpdownload country CIDR lists for geo-blocking (cron-friendly)

All of them run in preview mode until you add --apply.

Run them as the web user. Inside Docker the app runs as www-data, and the SQLite database has to stay writable by it. If you run a script as root, the database file ends up owned by root and the web interface then fails with "attempt to write a readonly database":

sudo docker exec -u www-data -it mt-ispadmin php /var/www/html/import_json.php \
/var/www/html/my_site.json --apply

If it has already happened, fix the ownership with:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

Languages / adding a translation

The UI ships in 10 languages; users pick their language on the login screen (stored in a cookie, auto-detected from the browser on first visit). Slovak is the source language, English is the fallback for missing strings.

To add or improve a language:

  1. Copy lang/en.php to lang/xx.php and translate the values (keys stay in Slovak).
  2. Add the code and native name to LANGS in lib/lang.php.
  3. Check completeness: php lang/verify.php xx (uses lang/keys.txt, verifies keys, %s placeholders and inline HTML).

Pull requests with new languages are welcome.

Geo-blocking (optional)

Access can be limited to selected countries. Enable it with the ISPADMIN_GEO_ENFORCE=1 env variable (disabled by default in docker-compose.yml so you can't lock yourself out). It works via the Cloudflare CF-IPCountry header, or fully offline via CIDR lists (update_geoip.php). Add your own IPs to ISPADMIN_GEO_ALLOW_IPS as a safety net.

Logo and branding

The header logo is text-based and configured in config.php:

'brand_pre' => 'isp', // first (blue) part'brand_post' => 'admin', // second (dark) part'tagline' => 'customer management · MikroTik',

A default SVG logo is included in public/assets/logo.svg — feel free to modify it or replace it with your own.

Security notes

  • Change the default password immediately after the first login (admin / changeme). The default is published here, so anyone who finds your install knows it.
  • config.php, lib/, lang/ and data/ must not be reachable from the web — both Docker and the bundled .htaccess take care of this.
  • Router API credentials and customer data live only in your own database (data/ is in .gitignore) — never commit them.
  • Run the app behind HTTPS (certbot + nginx proxy), ideally on an internal network / behind a VPN.

Troubleshooting

"Fatal error: attempt to write a readonly database" A CLI script was run as root, so the SQLite file is now owned by root while the web app runs as www-data. Fix the ownership and always pass -u www-data to docker exec:

sudo docker exec mt-ispadmin chown -R www-data:www-data /data

The browser doesn't respond on port 8090 That's intended — the app listens on 127.0.0.1 only. Use an SSH tunnel or the nginx reverse proxy, see step 3 above.

Certbot succeeded but the domain shows the nginx welcome page Certbot installed the certificate into the stock Debian site because it matched the domain first. Remove /etc/nginx/sites-enabled/default, make sure server_name in your own config is exactly right, then re-run certbot and choose reinstall.

The Test button under Routers fails Check in order: the API service is enabled on the router (/ip service print), the host and port are reachable from the server (nc -vz ROUTER_IP 8728), the API account exists with the right permissions, and no firewall rule on the router blocks the API port.

Timestamps are hours off See Time zone. The quickest fix is to set it explicitly on the Settings page.

Check what the app is actually doing

sudo docker compose logs --tail 50

TODO / possible extensions

  • Parent queue / queue-tree for shared group caps
  • IPv6 prefix delegation
  • Bulk re-sync of all customers to a router
  • Per-field audit log

License

MIT — see LICENSE. Use at your own risk; test everything on a lab router before deploying to production.


Author: Juraj Chudý

About

Web-based customer management for small ISPs built on the MikroTik RouterOS API — DHCP leases, Simple Queues, PPPoE, non-payer blocking, multi-site, 10 languages. Plain PHP, Docker ready.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages