Files
IoT/internal/service/api/handler/mqtt_auth.go
T

168 lines
5.6 KiB
Go

// mqtt_auth.go — HTTP-auth и ACL для EMQX (MQTT CONNECT / pub-sub).
//
// Endpoints (без JWT, вызываются EMQX внутри платформы):
//
// POST /internal/mqtt/auth — аутентификация: всегда HTTP 200, {"result":"allow"|"deny"}
// POST /internal/mqtt/acl — авторизация pub/sub
//
// Источник устройств — таблица iot_devices (не k8s Secrets).
package handler
import (
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"strings"
"gitea.services.ngcloud.ru/Nail/IoT/internal/service/store"
)
// mqttAuthRequest — тело запроса от EMQX 5.x (username, password, clientid, peerhost).
type mqttAuthRequest struct {
Username string `json:"username"`
Password string `json:"password"`
ClientID string `json:"clientid"`
PeerHost string `json:"peerhost"`
}
// mqttAuthResponse — ответ для EMQX (всегда HTTP 200).
type mqttAuthResponse struct {
Result string `json:"result"`
ACL []aclRule `json:"acl,omitempty"`
}
// aclRule — правило ACL для EMQX HTTP auth.
type aclRule struct {
Permission string `json:"permission"`
Action string `json:"action"`
Topic string `json:"topic"`
}
// mqttAclRequest — тело запроса ACL от EMQX.
type mqttAclRequest struct {
Username string `json:"username"`
ClientID string `json:"clientid"`
Action string `json:"action"`
Topic string `json:"topic"`
}
// MQTTAuth — POST /internal/mqtt/auth.
//
// Логика:
// 1. bridge-креды (MQTT_USERNAME/MQTT_PASSWORD из env) — проверяются до парсинга;
// 2. username "{ns}_{deviceId}" → поиск устройства в iot_devices;
// 3. constant-time сравнение пароля; проверка enabled;
// 4. best-effort last_connected; ACL по топику устройства.
func (h *Handler) MQTTAuth(w http.ResponseWriter, r *http.Request) {
var req mqttAuthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Внутренний bridge-клиент: фиксированные креды, ACL выдаётся ниже.
if h.Cfg.MQTTUsername != "" && req.Username == h.Cfg.MQTTUsername {
if subtle.ConstantTimeCompare([]byte(h.Cfg.MQTTPassword), []byte(req.Password)) == 1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{
Result: "allow",
ACL: bridgeACLRules(),
})
return
}
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// Парсим username: "{namespace}_{deviceId}" (первый '_' — разделитель).
idx := strings.Index(req.Username, "_")
if idx <= 0 || idx == len(req.Username)-1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
ns := req.Username[:idx]
deviceID := req.Username[idx+1:]
device, err := h.Devices.GetByDeviceID(r.Context(), ns, deviceID)
if err != nil {
if !errors.Is(err, store.ErrNotFound) {
h.Log.Error("mqtt auth: device lookup", "namespace", ns, "device", deviceID, "err", err)
}
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
if !device.Enabled {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
if subtle.ConstantTimeCompare([]byte(device.MQTTPassword), []byte(req.Password)) != 1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
// best-effort: обновляем время последнего подключения
h.Devices.TouchLastConnected(r.Context(), ns, deviceID)
writeJSON(w, http.StatusOK, mqttAuthResponse{
Result: "allow",
ACL: deviceACLRules(ns, deviceID),
})
}
// MQTTAcl — POST /internal/mqtt/acl.
//
// Bridge (clientID из env MQTT_CLIENT_ID): только subscribe.
// Устройство ({ns}_{deviceId}): pub/sub в своём топике.
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
}
if req.ClientID == h.Cfg.MQTTClientID {
if req.Action == "subscribe" {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
return
}
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
idx := strings.Index(req.Username, "_")
if idx <= 0 || idx == len(req.Username)-1 {
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
return
}
ns := req.Username[:idx]
deviceID := req.Username[idx+1:]
deviceTopic := ns + "/telemetry/" + deviceID
switch {
case req.Action == "publish" && req.Topic == deviceTopic:
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
case req.Action == "subscribe" && (req.Topic == deviceTopic || req.Topic == "#"):
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "allow"})
default:
writeJSON(w, http.StatusOK, mqttAuthResponse{Result: "deny"})
}
}
// bridgeACLRules — ACL для внутреннего bridge: subscribe на все telemetry-топики.
func bridgeACLRules() []aclRule {
return []aclRule{
{Permission: "allow", Action: "subscribe", Topic: "+/telemetry/+"},
{Permission: "deny", Action: "all", Topic: "#"},
}
}
// deviceACLRules — ACL для устройства: только свой топик телеметрии.
func deviceACLRules(ns, deviceID string) []aclRule {
topic := ns + "/telemetry/" + deviceID
return []aclRule{
{Permission: "allow", Action: "publish", Topic: topic},
{Permission: "allow", Action: "subscribe", Topic: topic},
{Permission: "deny", Action: "all", Topic: "#"},
}
}