security(iot): MQTT ACL isolation via EMQX HTTP authorization
Each IoT device can only pub/sub to its own topics: {namespace}/{deviceId}/#
Any attempt to access foreign topics → EMQX denies and disconnects.
Changes:
- internal/api/handler: add MQTTAcl handler (POST /internal/mqtt/acl)
- internal/api/router: register /internal/mqtt/acl route
- deployments/k8s/emqx.yaml: add HTTP authorization backend, no_match=deny
- Operator v0.1.52 deployed
Tested: own topic ALLOWED, foreign topic → authorization_permission_denied + disconnect
This commit is contained in:
@@ -84,8 +84,20 @@ type mqttAuthRequest struct {
|
||||
|
||||
// mqttAuthResponse — ответ для EMQX. Всегда HTTP 200.
|
||||
// result = "allow" | "deny"
|
||||
// ACL — список правил pub/sub, изолирует топики по устройству.
|
||||
type mqttAuthResponse struct {
|
||||
Result string `json:"result"`
|
||||
Result string `json:"result"`
|
||||
ACL []aclRule `json:"acl,omitempty"`
|
||||
}
|
||||
|
||||
// aclRule — одно правило ACL для EMQX HTTP auth plugin.
|
||||
// permission: "allow" | "deny"
|
||||
// action: "publish" | "subscribe" | "all"
|
||||
// topic: точный топик или wildcard (#, +)
|
||||
type aclRule struct {
|
||||
Permission string `json:"permission"`
|
||||
Action string `json:"action"`
|
||||
Topic string `json:"topic"`
|
||||
}
|
||||
|
||||
// ——————————————————————————————————————————
|
||||
@@ -189,8 +201,20 @@ func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
|
||||
// Продолжаем — это некритично, устройство всё равно авторизовано
|
||||
}
|
||||
|
||||
// Проверки пройдены — разрешаем подключение
|
||||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
|
||||
// Проверки пройдены — разрешаем подключение.
|
||||
// ACL ограничивает устройство только его собственным топиком:
|
||||
// publish: {namespace}/{deviceId} (данные устройства)
|
||||
// subscribe: {namespace}/{deviceId} (команды устройству, если нужны)
|
||||
// deny all: всё остальное запрещено — нельзя читать чужие данные
|
||||
ownerTopic := ns + "/" + deviceID + "/#"
|
||||
writeJSON(w, http.StatusOK, mqttAuthResponse{
|
||||
Result: "allow",
|
||||
ACL: []aclRule{
|
||||
{Permission: "allow", Action: "publish", Topic: ownerTopic},
|
||||
{Permission: "allow", Action: "subscribe", Topic: ownerTopic},
|
||||
{Permission: "deny", Action: "all", Topic: "#"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ——————————————————————————————————————————
|
||||
@@ -351,3 +375,53 @@ func (h *Handler) UpdateIoTDevice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
writeJSON(w, http.StatusOK, deviceToResponse(device, ""))
|
||||
}
|
||||
|
||||
// ——————————————————————————————————————————
|
||||
// MQTT ACL — авторизация pub/sub
|
||||
// ——————————————————————————————————————————
|
||||
|
||||
// mqttAclRequest — тело запроса от EMQX при каждом publish/subscribe.
|
||||
type mqttAclRequest struct {
|
||||
Username string `json:"username"`
|
||||
ClientID string `json:"clientid"`
|
||||
Action string `json:"action"` // "publish" | "subscribe"
|
||||
Topic string `json:"topic"`
|
||||
}
|
||||
|
||||
// MQTTAcl — POST /internal/mqtt/acl
|
||||
// Вызывается EMQX для каждого pub/sub действия.
|
||||
// НЕ защищён JWT — доступен только из кластера.
|
||||
//
|
||||
// Логика: клиент видит только топики вида {namespace}/{deviceId}/#
|
||||
// Любой другой топик — deny и disconnect.
|
||||
func (h *Handler) MQTTAcl(w http.ResponseWriter, r *http.Request) {
|
||||
var req mqttAclRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
||||
return
|
||||
}
|
||||
|
||||
// Парсим username → namespace + deviceId (формат: "{ns}_{deviceId}")
|
||||
idx := strings.Index(req.Username, "_")
|
||||
if idx < 0 {
|
||||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
||||
return
|
||||
}
|
||||
ns := req.Username[:idx]
|
||||
deviceID := req.Username[idx+1:]
|
||||
if ns == "" || deviceID == "" {
|
||||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
||||
return
|
||||
}
|
||||
|
||||
// Разрешаем только топики этого устройства: {ns}/{deviceId}/...
|
||||
// Используем strings.HasPrefix — wildcard не нужен, проверяем prefix реального топика.
|
||||
allowedPrefix := ns + "/" + deviceID + "/"
|
||||
exactMatch := ns + "/" + deviceID
|
||||
if strings.HasPrefix(req.Topic, allowedPrefix) || req.Topic == exactMatch {
|
||||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user