chore(iot): убрать мёртвый mqtt_auth.go + constant-time админ-токен (Этап 5)
- Удалён internal/service/api/handler/mqtt_auth.go: методы MQTTAuth/MQTTAcl нигде не подключены (мёртвый код; при подключении небезопасен — доверял клиентскому clientid и разрешал subscribe на '#'). - admin.go: сравнение ADMIN_STATS_TOKEN через subtle.ConstantTimeCompare.
This commit is contained in:
@@ -7,6 +7,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -19,8 +20,11 @@ func (h *Handler) AdminStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Constant-time сравнение токена: обычное != давало утечку по времени (ревью 2026-10-10).
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") || strings.TrimPrefix(authHeader, "Bearer ") != h.Cfg.AdminStatsToken {
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") ||
|
||||||
|
subtle.ConstantTimeCompare([]byte(token), []byte(h.Cfg.AdminStatsToken)) != 1 {
|
||||||
writeJSON(w, http.StatusUnauthorized, errResp("unauthorized"))
|
writeJSON(w, http.StatusUnauthorized, errResp("unauthorized"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
// 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