feat: монолит iot-service (Фаза 1) — config/store/api/bridge/consumer без k8s, устройства в PG, новые Dockerfile/Makefile
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// 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: "#"},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user