- Author: Syed Jahanzaib ~A Humble Human being! nothing else
- Platform: aacable.wordpress.com
- Category: Corporate Offices / DHCP-DNS Engineering
- Audience: Systems Administrators, IT Support, NOC Teams, Network Architects
Disclaimer & Note on Writing Style
Every network environment is unique. A solution that works effectively in one infrastructure may require modification in another. Readers are strongly encouraged to understand the underlying concepts and adapt the guidance according to their own architecture, operational policies, and risk tolerance.
Blind copy-paste implementation without proper validation, testing, and change management is never recommended — especially in production environments. Always ensure proper backups and risk assessment before applying any configuration.
The content shared here is based on hands-on experience from real-world deployments, ISP environments, lab testing, and continuous learning. While I strive for technical accuracy, no technical implementation is entirely free from the possibility of error. Constructive discussion and alternative approaches are always welcome.
Due to professional commitments, it is not always feasible to publish highly detailed or multi-part write-ups. The technical logic and implementation details are written based on my own practical experience. AI tools such as ChatGPT are used only to refine grammar, structure, and presentation — not to generate the core technical concepts.
This blog is not intended for client acquisition or follower growth. It exists solely to share practical knowledge and real-world experience with the community.
Thank you for your understanding and continued support.
Implementing Daily Quota‑Based Speed Throttling in FreeRADIUS 3.2.x
Production‑Grade Design (Without sqlcounter)
Overview
In many ISP and enterprise environments, not all subscribers should be treated equally. A common requirement is:
- Allow full speed (e.g., 10 Mbps) up to a daily quota (e.g., 100 GB/day)
- Automatically downgrade speed (e.g., to 5 Mbps) once the quota is exceeded
- Restore full speed automatically the next day
This article documents a real, production‑tested implementation using:
- FreeRADIUS (tested on 3.2.7)
- MySQL backend
- MikroTik NAS with CoA support
- Cron‑based enforcement
- Explicit SQL logic (no magic counters)
.my.cnfsecurity
⚠️ Important: This design intentionally does NOT use sqlcounter. The reasoning is explained later.
Also we can adapt this logic for Huawei / Cisco & other NASES too… 😉
What This Design Supports
- Multiple service profiles (e.g., 5M, 10M, 20M)
- Per‑service daily quota limits
- Automatic speed downgrade after quota exhaustion
- Real‑time enforcement for already‑connected users via CoA (We send only explicit bandwidth-update CoA attributes, not disconnect requests.)
- Automatic speed restoration on the next day
- No infinite CoA loops (Avoid sending CoA too frequently because the NAS might be overloaded otherwise
- Safe to run every few minutes in production
This approach scales cleanly and is widely used in real ISP deployments.
High‑Level Architecture
Responsibilities are cleanly separated:
| Component | Responsibility |
| FreeRADIUS | Authentication & policy decision |
| MySQL | Usage data, services, enforcement state |
| Cron script | Quota detection & CoA triggering |
| CoA | Force re‑authorization / speed change |
| State table | Prevent repeated enforcement |
Why this matters
- Easier debugging
- Predictable behavior
- Safe upgrades
- No hidden logic in authentication path
Design Principles (Read This First)
Before touching configuration, understand these realities:
- Authorization happens once (at login)
- Quota is crossed later (during accounting)
- FreeRADIUS does not automatically re‑authorize sessions
- CoA is mandatory for real‑time speed changes
- Repeated CoA without state tracking causes loops
To solve this correctly, we introduce a quota state table.
Database Tables Used
| Table | Purpose |
radcheck |
Authentication only |
radacct |
Usage accounting |
service_profiles |
Speed + quota rules |
user_services |
User → service mapping |
user_quota_state |
Enforcement memory (critical) |
This is exactly how large ISP RADIUS systems are structured.
Mental Model (Clean Separation)
Large systems scale by separating responsibility — not by collapsing everything into one table.
For this design we use three custom tables.
1️⃣ service_profiles — Rules Table
Purpose
Defines how a service behaves.
Controls
- Normal speed (e.g., 10M/10M)
- Throttled speed (e.g., 5M/5M)
- Daily quota (GB)
- Whether quota enforcement is enabled
One‑Line Summary
service_profiles defines HOW a service behaves.
Table Creation
CREATE TABLE service_profiles ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50), normal_rate VARCHAR(50), throttled_rate VARCHAR(50), daily_quota_gb INT, quota_enabled TINYINT(1) );
Example Entry
INSERT INTO service_profiles
(name, normal_rate, throttled_rate, daily_quota_gb, quota_enabled)
VALUES
('10mb', '10M/10M', '5M/5M', 100, 1);
2️⃣ user_services — User → Service Mapping
Purpose
Maps which user is on which service plan.
One‑Line Summary
user_services defines WHICH rules apply to WHICH user.
Table Creation
CREATE TABLE user_services ( username VARCHAR(64) PRIMARY KEY, service_name VARCHAR(50) );
This keeps radcheck clean and avoids overloading authentication tables.
3️⃣ user_quota_state — Enforcement Memory (Critical)
Purpose
Remembers who has already been throttled today.
It prevents infinite CoA loops.
One‑Line Summary
user_quota_state answers one question: “Has quota enforcement already been applied for this user today?”
Table Creation
CREATE TABLE user_quota_state ( username VARCHAR(64) NOT NULL, service_name VARCHAR(50) NOT NULL, quota_date DATE NOT NULL, is_throttled TINYINT(1) DEFAULT 0, PRIMARY KEY (username, quota_date) );
What This Table IS
- A daily flag
- A loop‑prevention mechanism
- A CoA control switch
What This Table IS NOT
- ❌ Billing record
- ❌ Usage log
- ❌ Authorization source
- ❌ Permanent status
It does not control access. It only prevents repeated enforcement.
Lifecycle of a User
- Login → Normal speed
- Quota exceeded → Script detects breach
- CoA sent → Speed reduced
- State recorded → No further CoA today
- New day → State cleared → Full speed restored
Authorization Logic (Speed Assignment)
During authorization, FreeRADIUS decides which speed profile applies.
Logic
On login (or re-auth):
- If user is not throttled today → Normal speed
- If user is throttled today → Throttled speed
🧠 One Sentence Mental Model
Authorization decides speed, accounting measures usage, CoA enforces change, one flag prevents repetition.
1️⃣ Is putting this logic in sites-enabled/default efficient?
Short answer
Yes, if done correctly — and no, it will NOT overload authorization in your use-case.
Why this is safe
In FreeRADIUS, authorize {} is executed anyway for every Access-Request.
You are not adding a new phase, you are only adding conditional logic.
The real cost is:
- SQL queries
- String expansion
- Unlang branching
These are cheap compared to:
- TLS
- PAP/CHAP/MSCHAP
- SQL auth itself
- Accounting writes
What would be inefficient (and what you should avoid)
❌ Multiple SQL queries per request
❌ Repeating the same SQL lookup again and again
❌ Logic applied to every NAS when not required
✅ Production-safe optimization pattern (Recommended)
- Move logic into a policy
- Cache service data in control
- Proper way to detect NAS type and send correct attributes (MikroTik vs others)
- Best Practice: Use nas table (already supported by FreeRADIUS)
- Use NAS-Type in unlang
FreeRADIUS authorize {} Configuration
Edit:
nano /etc/freeradius/sites-enabled/default
authorize {
preprocess
filter_username
suffix
# 1) Load credentials
sql
# 2) Determine user's service
update control {
Service-Name := "%{sql:SELECT service_name FROM user_services WHERE username='%{User-Name}' LIMIT 1}"
}
# 3) Quota‑based speed decision
if ("%{sql:SELECT 1 FROM user_quota_state WHERE username='%{User-Name}' AND quota_date=CURDATE() LIMIT 1}" == "1") {
update reply {
Mikrotik-Rate-Limit := "%{sql:SELECT throttled_rate FROM service_profiles WHERE name='%{control:Service-Name}'}"
}
}
else {
update reply {
Mikrotik-Rate-Limit := "%{sql:SELECT normal_rate FROM service_profiles WHERE name='%{control:Service-Name}'}"
}
}
pap
chap
mschap
digest
eap {
ok = return
}
expiration
logintime
}
Result
- Normal login → Normal speed
- Over quota → Throttled speed
- CoA re‑auth → Correct speed
- Next day → Full speed restored
Real‑Time Enforcement Using CoA (qc.sh)
Mental Model
SQL detects quota breach → CoA enforces change → State table prevents repetition
Authorization alone is not enough because users may stay connected for days.
Selection Criteria (Critical)
The script must select only:
- Active sessions
- Services with quota_enabled = 1
- Users who exceeded daily quota
- Users not already throttled today
Production‑Ready qc.sh (quota check every 5 minutes)
#!/bin/bash
while read user nas ip secret; do
echo "Applying quota throttle for user=$user on NAS=$nas"
echo "User-Name=$user, Framed-IP-Address=$ip" | \
radclient -x "$nas" coa "$secret"
mysql <<EOF
INSERT IGNORE INTO user_quota_state (username, quota_date)
VALUES ('$user', CURDATE());
EOF
done < <(
mysql -N <<EOF
SELECT
r.username,
r.nasipaddress,
r.framedipaddress,
n.secret
FROM radacct r
JOIN user_services u ON r.username = u.username
JOIN service_profiles s ON u.service_name = s.name
JOIN nas n ON n.nasname = r.nasipaddress
WHERE r.acctstoptime IS NULL
AND s.quota_enabled = 1
AND NOT EXISTS (
SELECT 1
FROM user_quota_state q
WHERE q.username = r.username
AND q.quota_date = CURDATE()
)
GROUP BY r.username, r.nasipaddress, r.framedipaddress, n.secret
HAVING
SUM(r.acctinputoctets + r.acctoutputoctets)
> (MAX(s.daily_quota_gb) * 1024 * 1024 * 1024);
EOF
)
Safe to run every 5–10 minutes. Cron it to run every 5 minutes.
Why This Does NOT Loop
Once a user is throttled:
- is_throttled = 1 is recorded
- Script excludes that user
- No repeated CoA packets
- No NAS overload
This is idempotent enforcement, which is mandatory in ISP systems.
Daily Quota Reset & restore already connected users to there original speed – Script
Restoring Original Speed for Already Connected Users at Midnight
✅ The correct restore logic (mirror of qc.sh)
The restore script must:
- Target only users who were throttled
- Target only active sessions
- Send CoA only
- NOT touch accounting
- NOT disconnect
That means:
- Same CoA
- Different condition
The Problem
At midnight, we reset daily quota (qc_reset.sh) state by running:
This correctly unmarks users as “quota exceeded”.
However:
❗ Users who are already connected and throttled will remain throttled
until their session is re-authorized.
Because:
- FreeRADIUS only applies speed on authentication / re-auth
- Clearing the table alone does not push a change to active sessions
So we need a way to force already connected users to re-apply policy.
Change bandwidth of ALREADY CONNECTED users using CoA, both when quota is exceeded and when quota is reset
The Correct Solution (Industry Practice)
✔ Use CoA (Change of Authorization) at midnight
to force policy re-evaluation for affected users.
This is exactly how ISPs do it.
Design Principle (Important)
We do NOT want to:
- Disconnect users
- Restart NAS
- Restart FreeRADIUS
- Touch
radacct
Example of qc_restore.sh
#!/bin/bash #set -x while read user nas rate secret; do echo "Restoring user=$user to $rate" # I have disabled this for test, you may enable it , syedjahanzaib # echo "User-Name=$user, Mikrotik-Rate-Limit=$rate" | \ # radclient -q -c 1 "$nas:3799" coa "$secret" done < <( mysql radius -N <<EOF SELECT r.username, r.nasipaddress, s.normal_rate, n.secret FROM user_quota_state q JOIN radacct r ON r.username = q.username JOIN user_services u ON u.username = r.username JOIN service_profiles s ON s.name = u.service_name JOIN nas n ON n.nasname = r.nasipaddress WHERE q.quota_date < CURDATE() AND r.acctstoptime IS NULL; EOF )
NOTE:
WHERE q.quota_date < CURDATE()
This is intentional: only past-quota users restored after the new day begins
Why You MUST NOT Rely Only on FreeRADIUS Logic??
You might ask:
“Why not let FreeRADIUS re-evaluate on every packet?”
Because:
- Authorization happens once
- Accounting happens later
- Usage thresholds are crossed after auth
➡ CoA + state table is the only correct solution
qc_reset.sh
DELETE FROM user_quota_state WHERE quota_date < CURDATE();
Cron Job (Run this script every night at 00:05 (12:05 am)
At midnight:
quota_reset.shdeletes stateqc_restore.shsends CoA- NAS re-asks RADIUS
- RADIUS sees NO quota state
- RADIUS replies with normal_rate
- NAS updates bandwidth live
✔ Same session
✔ Same IP
✔ Same user
✔ Only bandwidth changes
🕛 Correct cron order
| Script | Purpose | Frequency |
|---|---|---|
qc.sh |
Detect quota exceeded & throttle | Every 5–10 min |
quota_reset.sh |
Reset daily enforcement state | Once per day |
qc_restore.sh |
Restore normal speed via CoA | Once per day |
🧠 Final Mental Model (This is the key)
CoA is used in BOTH directions
Throttle AND restore.
🟢 There is NO DISCONNECT Anywhere
If you wanted to disconnect, you would see:
You are not doing that.
So your system is:
- Correct
- Clean
- ISP-grade
- Non-disruptive
Final sanity checklist
| Item | Status |
|---|---|
| Throttle works | ✅ |
| Restore logic correct | ✅ |
| No disconnects | ✅ |
| Explicit bandwidth CoA | ✅ |
| Daily lifecycle clean | ✅ |
| Script behavior predictable | ✅ |
One-line takeaway (this explains everything)
Restore script does nothing during the same day — and that is exactly correct.
You’ve reached the correct end-state.
Data Consistency Lesson (Important)
Problem: Enforcement query returned no rows even though users were active.
Root Cause:
- nasipaddress did not match nas.nasname
Fix:
- Ensure nasname contains the NAS IP address, not only a hostname
This alignment is critical when dynamically pulling NAS secrets.
Why We Did NOT Use sqlcounter
This was a deliberate engineering decision.
Problems with sqlcounter
- Runs inside authentication path
- Executes on every login / re‑auth
- Awkward speed downgrade logic
- No native state memory
- Fragile across upgrades
Correct Reality
- Authorization happens once
- Usage thresholds are crossed later
- CoA + state tracking is mandatory
🔹 Simulate Quota Usage (Insert Fake Active Session)
When testing quota logic, you may not have a real online user yet.
In that case, insert a fake but valid active session into radacct.
✅ Example: Insert Active Session for User zaib
Why these fields matter
| Column | Reason |
|---|---|
acctsessionid |
Must be unique |
username |
Must match user_services |
nasipaddress |
Must match nas.nasname |
framedipaddress |
Used for session identification |
acctstarttime |
Marks session start |
acctstoptime = NULL |
Marks session as active |
acctinputoctets |
Simulates data usage |
acctoutputoctets |
Optional (can be 0) |
🔹 Verify Inserted Session
Expected result:
(≈120 GB)
🔹 Now Run QC (Quota and Throttle Mode Check)
Expected behavior
- Script detects quota exceeded
- Sends bandwidth-only CoA
- Inserts entry into
user_quota_state - User remains connected
- Speed changes to throttled_rate
⚠️ Important Cleanup After Testing
After simulation, remove the fake session to avoid confusion:
🔹 One-Line Reminder
radacctmust contain an active (acctstoptime = NULL) session for quota logic to trigger.
Final Architecture Summary
| Component | Role |
| radacct | Usage accounting |
| service_profiles | Speed & quota rules |
| user_services | User mapping |
| user_quota_state | Enforcement memory |
| qc.sh | Detection & CoA |
| quota_reset.sh | Daily reset |
| FreeRADIUS | Policy decision |
Securing MySQL Credentials (.my.cnf)
⚠️ Use a .my.cnf file in production (don’t hardcode passwords). this is a security + ops best practice, so let’s do it correctly and simply.
Location
/root/.my.cnf
Content
[client] user=radius password=YOUR_DB_PASSWORD host=localhost database=radius
🔒 VERY IMPORTANT: Set strict permissions
MySQL will ignore the file if permissions are weak.
chmod 600 /root/.my.cnf chown root:root /root/.my.cnf
Verify:
ls -l /root/.my.cnf
Expected:
-rw------- 1 root root ... /root/.my.cnf
Result
❌ No passwords in scripts ❌ No exposure via ps or history
✅ How this changes your scripts (Clean & Safe)
❌ Old (bad practice)
mysql radius -u radius -pPASSWORD -e "DELETE FROM user_quota_state ..."
✅ New (correct)
mysql -e "DELETE FROM user_quota_state WHERE quota_date < CURDATE();"
- No username
- No password
- No exposure in ps, history, or logs
🧠 How MySQL finds .my.cnf (important to know)
When you run mysql, it checks in this order:
- /etc/my.cnf
- /etc/mysql/my.cnf
- ~/.my.cnf ← this one
- Command-line options
So /root/.my.cnf is automatically used.
Conclusion
By avoiding sqlcounter and using explicit SQL + CoA + state tracking, we achieve:
- Reliable daily quota enforcement
- Correct speed throttling
- Clean FreeRADIUS configuration
- Predictable behavior
- Production‑grade stability
This reflects how real ISP systems are built, not lab examples.
Performance Tuning & Scaling Tips (Production ISPs)
This solution is already efficient by design, but the following optimizations ensure stable performance at scale (thousands to tens of thousands of users).
1️⃣ Indexing: The Single Biggest Performance Win
Your scripts heavily query radacct and user_quota_state.
Without proper indexes, MySQL will scan entire tables.
✅ Required Indexes
📌 These indexes reduce query time from seconds to milliseconds.
2️⃣ Limit radacct Growth (Very Important)
radacct grows fast in ISP environments.
Best Practices
- Enable Interim-Update (5–10 minutes)
- Periodically archive old records:
Or move to an archive table.
3️⃣ Cron Frequency: Don’t Overdo It
Recommended schedule
| Script | Frequency |
|---|---|
qc.sh |
Every 5–10 minutes |
quota_reset.sh |
Once per day |
quota_restore.sh |
Once per day |
Running throttle every minute does not improve accuracy, only DB load.
5️⃣ Reduce CoA Traffic (Selective Targeting)
Your design sends CoA only when needed:
✔ User exceeded quota
✔ User not already throttled
✔ User still online
This is far better than sending CoA to all users.
6️⃣ Avoid sqlcounter for High-Scale Quota Logic
Why your design scales better:
| Aspect | sqlcounter | This Design |
|---|---|---|
| Runs on auth | Yes | No |
| DB queries | Per login | Scheduled |
| CoA support | Limited | Native |
| Loop protection | No | Yes |
| Debuggability | Hard | Easy |
For large ISPs, offloading quota logic from auth path is critical.
7️⃣ MySQL Tuning (Basic but Important)
Minimum recommended settings for radius DB:
[mysqld] innodb_buffer_pool_size = 2G innodb_log_file_size = 512M max_connections = 300</div>
Adjust according to RAM and load.
8️⃣ Use .my.cnf (Already Done)
Avoid command-line passwords.
Benefits:
- No leaks in
ps - Cleaner scripts
- Safer cron jobs
9️⃣ Logging Without Killing Performance
Avoid excessive debug logging.
Recommended approach
- Log actions, not every query
- Rotate logs weekly
🔟 Test at Scale (Before Production)
Before enabling in production:
- Simulate 100–500 fake users
- Run throttle mode
- Observe:
- MySQL CPU
- Disk I/O
- CoA response time
If MySQL is slow → indexes are missing.
Final Performance Rule (Remember This)
Quota logic must run OUTSIDE the authentication path.
Your design already follows this rule — which is why it scales.
One-Line Takeaway
Indexes + selective CoA + cron-based logic = ISP-grade performance.
Syed Jahanzaib




