Speed Format (kb vs M)
Internally, every speed value in ProxPanel is in kilobits per second (kb / kbps). A download_speed of 2000 means 2 Mbps, not 2 kbps and definitely not 2 Gbps. This page documents the rule, the conversion helper that enforces it on the way out to MikroTik, and the production incident that resulted in customers getting 1000× their plan speed for a few hours.
If you write code that touches a speed column, read this page first. The convention is rigid; getting it wrong is one of the most expensive bugs in the codebase.
The rule
Section titled “The rule”| Column / variable | Unit | Example value | Means |
|---|---|---|---|
services.download_speed (INT) | kb/s | 2000 | 2 Mbps |
services.upload_speed (INT) | kb/s | 1200 | 1.2 Mbps |
services.download_speed_str (VARCHAR) | string with k suffix | "2000k" | 2 Mbps |
services.upload_speed_str (VARCHAR) | string with k suffix | "1200k" | 1.2 Mbps |
services.burst_* (string) | string, k or M accepted on input | "3M" | 3 Mbps |
RADIUS Mikrotik-Rate-Limit | string up/down (always normalized) | "1200k/2000k" | Up 1.2 Mbps, down 2 Mbps |
| Frontend input labels | Read “kb” explicitly | ”Download Speed (kb)“ | Force the operator to think in kb |
The frontend labels were renamed from “Mbps” to “kb” specifically to stop operators typing 2 and meaning 2 Mbps when the field expects 2 kbps.
The conversion helper
Section titled “The conversion helper”normalizeSpeedForMikrotik in internal/radius/server.go converts any input shape to canonical kb format for the Mikrotik-Rate-Limit attribute:
// normalizeSpeedForMikrotik converts all speeds to kb format.// Examples: "1.2M" -> "1200k", "2M" -> "2000k", "1200k" -> "1200k"func normalizeSpeedForMikrotik(speed string) string { if speed == "" { return "" } speed = strings.TrimSpace(speed) lowerSpeed := strings.ToLower(speed)
// Already in k format - return as-is if strings.HasSuffix(lowerSpeed, "k") { return speed }
// Convert M to k if strings.HasSuffix(lowerSpeed, "m") { numPart := speed[:len(speed)-1] val, err := strconv.ParseFloat(numPart, 64) if err == nil { return fmt.Sprintf("%dk", int(val*1000)) } return speed }
// Plain number - add k suffix return speed + "k"}Conversion examples:
| Input | Output |
|---|---|
"2M" | "2000k" |
"1.2M" | "1200k" |
"5.5M" | "5500k" |
"512k" | "512k" (unchanged) |
"2048" | "2048k" (plain number gets k appended) |
"100K" | "100K" (already-suffixed, case preserved) |
"" | "" |
There is no G (gigabit) suffix. RouterOS itself doesn’t accept G in Mikrotik-Rate-Limit; if anyone ever needs a gigabit speed, write "1000000k" (1 Gbps). At the time of writing, the largest deployed plan is 100 Mbps = "100000k".
How the rate-limit string is assembled
Section titled “How the rate-limit string is assembled”The full Mikrotik-Rate-Limit value can have up to four space-separated fields, each in up/down form:
"1200k/2000k 2400k/4000k 3000k/5000k 30/30" | | | | base burst-rate burst-threshold burst-time-up/downMost plans use only the first field. Burst settings are added when the service has burst_* columns set. normalizeRateLimitString iterates each space-separated field, splits on /, normalizes each half, and reassembles — so a service stored as "2M/5M 3M/7M" becomes the wire value "2000k/5000k 3000k/7000k".
The v1.0.387 incident — the cost of guessing wrong
Section titled “The v1.0.387 incident — the cost of guessing wrong”Pre-v1.0.387, several handlers and services in the codebase assumed services.download_speed was in Mbps and multiplied by 1000 before formatting:
// WRONG — assumed Mbps, multiplied by 1000rateLimit := fmt.Sprintf("%dk/%dk", sub.Service.UploadSpeed*1000, sub.Service.DownloadSpeed*1000)For a 2 Mbps plan stored as download_speed = 2000 (kb), this generated "2000000k/2000000k" — 2 Gbps. RouterOS happily accepted the value and rate-limited the queue to 2 Gbps, which on a 100 Mbps WAN circuit means unlimited speed.
The bug lived in multiple places — bulk Renew, bulk Reset FUP, FUP individual reset, bandwidth_rule_service, cdn_bandwidth_rule_service, quota_sync’s restoreOriginalSpeedIfNeeded — anywhere code touched a speed value after reading it from services. The fix was a search-and-destroy on * 1000 patterns near speed columns. The correct form is:
// CORRECT — speeds already in kb, no multiplicationrateLimit := fmt.Sprintf("%dk/%dk", sub.Service.UploadSpeed, sub.Service.DownloadSpeed)How the frontend handles input
Section titled “How the frontend handles input”The frontend lets operators type in M or k and the backend normalizes on save. The handler convertSpeedForMikrotik (in internal/handlers/service.go) accepts:
- Plain numbers:
2000→ stored as"2000k". - Numbers with
ksuffix:2000k→ stored as"2000k". - Numbers with
Msuffix:2M→ stored as"2000k". - Decimal
M:1.5M→ stored as"1500k".
Storing as a string preserves the operator’s intent and lets normalizeSpeedForMikrotik see the format on its way out. The numeric download_speed column is also populated from the same input — both sides agree.
Burst formula consistency
Section titled “Burst formula consistency”Time-based bandwidth boosts apply directly to the kb integer, no conversion:
// 4000k base + 100% boost = 8000kdownloadK := baseDownloadK * (100 + int64(service.TimeDownloadRatio)) / 100A 4 Mbps plan with a 100% NIGHT boost becomes 8 Mbps = "8000k". This is the formula used by quota_sync.go’s checkAndApplyTimeBasedSpeed and bandwidth_rule_service.go’s applyRuleToNasSubscribers. Both were corrected in the v1.0.149 cleanup that paralleled v1.0.387.
What MikroTik does with the value
Section titled “What MikroTik does with the value”RouterOS parses the Mikrotik-Rate-Limit VSA and constructs a simple queue on the PPPoE interface with these limits. Internally MikroTik stores rates in bps (bits per second), but the wire format is always the human-readable k or M suffix.
To confirm a value made it through correctly, on RouterOS:
/queue simple print where target="<pppoe-user>" detailThe max-limit= field shows the actual limits in upload/download bps. 2M/4M = 2,000,000 / 4,000,000 bps = 2 Mbps / 4 Mbps. If you see 2G/4G (2 Gbps / 4 Gbps) — you’ve reproduced the v1.0.387 incident.
Common workflows
Section titled “Common workflows”Plan a new speed tier
Section titled “Plan a new speed tier”- Decide the plan in Mbps as marketing thinks of it — e.g. “10 Mbps down, 4 Mbps up.”
- Translate to kb in your head — multiply by 1000. 10 Mbps = 10000 kb. 4 Mbps = 4000 kb.
- In Services → New Service, enter:
- Download Speed (kb):
10000 - Upload Speed (kb):
4000
- Download Speed (kb):
- Save. The backend stores
download_speed=10000, download_speed_str="10000k". The RADIUS reply will setMikrotik-Rate-Limit = "4000k/10000k".
Diagnose a speed mismatch
Section titled “Diagnose a speed mismatch”- What does the database say?
SELECT download_speed, download_speed_str FROM services WHERE id = N; - What did RADIUS send? Look in the radius logs for the Access-Accept:
docker logs proxpanel-radius 2>&1 | grep "Mikrotik-Rate-Limit". - What’s on the router? SSH to MikroTik,
/queue simple print where target="<user>". Themax-limitfield shows what the router applied. - The three should agree. If radius logs show
2000k/4000kbut the queue shows2M/4M(which is the same), you’re fine. If logs show2000000k(a million kbps = 1 Gbps), you’ve hit v1.0.387.
Related pages
Section titled “Related pages”- RADIUS Server Setup —
Mikrotik-Rate-Limitis one of the reply attributes. - MikroTik Integration — how RouterOS parses the value.
- CoA & Disconnect — mid-session rate-limit updates carry the same string.
- Services — UI where speeds are entered.