168 lines
5.6 KiB
Go
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: "#"},
|
|
}
|
|
}
|