Skip to content

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.

Column / variableUnitExample valueMeans
services.download_speed (INT)kb/s20002 Mbps
services.upload_speed (INT)kb/s12001.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-Limitstring up/down (always normalized)"1200k/2000k"Up 1.2 Mbps, down 2 Mbps
Frontend input labelsRead “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.

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:

InputOutput
"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".

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/down

Most 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 1000
rateLimit := 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 multiplication
rateLimit := fmt.Sprintf("%dk/%dk",
sub.Service.UploadSpeed,
sub.Service.DownloadSpeed)

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 k suffix: 2000k → stored as "2000k".
  • Numbers with M suffix: 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.

Time-based bandwidth boosts apply directly to the kb integer, no conversion:

// 4000k base + 100% boost = 8000k
downloadK := baseDownloadK * (100 + int64(service.TimeDownloadRatio)) / 100

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

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

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

  1. Decide the plan in Mbps as marketing thinks of it — e.g. “10 Mbps down, 4 Mbps up.”
  2. Translate to kb in your head — multiply by 1000. 10 Mbps = 10000 kb. 4 Mbps = 4000 kb.
  3. In Services → New Service, enter:
    • Download Speed (kb): 10000
    • Upload Speed (kb): 4000
  4. Save. The backend stores download_speed=10000, download_speed_str="10000k". The RADIUS reply will set Mikrotik-Rate-Limit = "4000k/10000k".
  1. What does the database say? SELECT download_speed, download_speed_str FROM services WHERE id = N;
  2. What did RADIUS send? Look in the radius logs for the Access-Accept: docker logs proxpanel-radius 2>&1 | grep "Mikrotik-Rate-Limit".
  3. What’s on the router? SSH to MikroTik, /queue simple print where target="<user>". The max-limit field shows what the router applied.
  4. The three should agree. If radius logs show 2000k/4000k but the queue shows 2M/4M (which is the same), you’re fine. If logs show 2000000k (a million kbps = 1 Gbps), you’ve hit v1.0.387.