增加转发开关
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
每条传入的 `PUBLISH` 都会先进入:
|
||||
|
||||
```go
|
||||
valid, _, record := mqtpp.MQTTPP(topic, payload, key)
|
||||
valid, _, record := mqtpp.MQTTPP(topic, payload, key, mqtpp.Options{})
|
||||
```
|
||||
|
||||
- `valid == true`:保留原始 topic、payload、QoS、retain 等字段,正常转发给订阅匹配 topic 的客户端
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const allowEncryptedForwardingLabel = "Allow encrypted MQTT packets to be forwarded when they cannot be decrypted"
|
||||
|
||||
type runtimeSettingsRequest struct {
|
||||
AllowEncryptedForwarding bool `json:"allow_encrypted_forwarding"`
|
||||
}
|
||||
|
||||
func registerAdminRuntimeSettingsRoutes(r gin.IRouter, store *store, settings *runtimeSettingsCache) {
|
||||
r.GET("/runtime-settings", func(c *gin.Context) {
|
||||
snapshot, err := store.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": runtimeSettingsDTO(snapshot)})
|
||||
})
|
||||
|
||||
r.PUT("/runtime-settings", func(c *gin.Context) {
|
||||
var req runtimeSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid runtime settings request"})
|
||||
return
|
||||
}
|
||||
if _, err := store.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, req.AllowEncryptedForwarding, allowEncryptedForwardingLabel); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if settings != nil {
|
||||
if err := settings.Reload(store); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
snapshot, err := store.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"item": runtimeSettingsDTO(snapshot)})
|
||||
})
|
||||
}
|
||||
|
||||
func runtimeSettingsDTO(settings runtimeSettingsSnapshot) gin.H {
|
||||
return gin.H{"allow_encrypted_forwarding": settings.AllowEncryptedForwarding}
|
||||
}
|
||||
@@ -102,6 +102,19 @@ func (helpContentRecord) TableName() string {
|
||||
return "help_content"
|
||||
}
|
||||
|
||||
type runtimeSettingRecord struct {
|
||||
Key string `gorm:"column:key;primaryKey;size:128;not null"`
|
||||
Value string `gorm:"column:value;type:text;not null"`
|
||||
ValueType string `gorm:"column:value_type;size:32;not null;index"`
|
||||
Label string `gorm:"column:label"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index"`
|
||||
}
|
||||
|
||||
func (runtimeSettingRecord) TableName() string {
|
||||
return "runtime_settings"
|
||||
}
|
||||
|
||||
type discardDetailsRecord struct {
|
||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
Topic string `gorm:"column:topic"`
|
||||
@@ -401,6 +414,7 @@ func (s *store) migrate() error {
|
||||
{label: "users", model: &userRecord{}},
|
||||
{label: "login_log", model: &loginLogRecord{}},
|
||||
{label: "help_content", model: &helpContentRecord{}},
|
||||
{label: "runtime_settings", model: &runtimeSettingRecord{}},
|
||||
{label: "discard_details", model: &discardDetailsRecord{}},
|
||||
{label: "node_blocking", model: &nodeBlockingRecord{}},
|
||||
{label: "ip_blocking", model: &ipBlockingRecord{}},
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ func TestOpenStoreCreatesTables(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
for _, table := range []string{"users", "login_log", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
|
||||
for _, table := range []string{"users", "login_log", "runtime_settings", "discard_details", "node_blocking", "ip_blocking", "forbidden_word_blocking", "nodeinfo", "map_report", "text_message", "position", "telemetry", "routing", "traceroute"} {
|
||||
var name string
|
||||
if err := rawTestDB(t, st).QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil {
|
||||
t.Fatalf("%s table missing: %v", table, err)
|
||||
|
||||
@@ -39,6 +39,7 @@ type meshtasticFilterHook struct {
|
||||
dbQueue *dbWriteQueue
|
||||
stats *meshtasticMessageStats
|
||||
blocking *blockingCache
|
||||
settings *runtimeSettingsCache
|
||||
}
|
||||
|
||||
// ID 返回用于识别 Meshtastic payload 过滤器的 hook 名称。
|
||||
@@ -63,7 +64,7 @@ func (h *meshtasticFilterHook) OnConnect(cl *mqtt.Client, pk packets.Packet) err
|
||||
|
||||
// OnPublish 在 broker 转发消息前校验 payload;无效消息会被拒绝并丢弃。
|
||||
func (h *meshtasticFilterHook) OnPublish(cl *mqtt.Client, pk packets.Packet) (packets.Packet, error) {
|
||||
valid, _, record := mqtpp.MQTTPP(pk.TopicName, pk.Payload, h.key)
|
||||
valid, _, record := mqtpp.MQTTPP(pk.TopicName, pk.Payload, h.key, mqtpp.Options{AllowEncryptedForwarding: h.settings.AllowEncryptedForwarding()})
|
||||
if !valid {
|
||||
h.rejectPublish(cl, pk, record)
|
||||
return pk, packets.ErrRejectPacket
|
||||
@@ -213,9 +214,13 @@ func run(cfg *config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings, err := newRuntimeSettingsCache(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
messageStats := &meshtasticMessageStats{}
|
||||
server, mqttAddr, err := startMQTTServer(cfg, dbQueue, messageStats, blocking)
|
||||
server, mqttAddr, err := startMQTTServer(cfg, dbQueue, messageStats, blocking, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -234,7 +239,7 @@ func run(cfg *config) error {
|
||||
return err
|
||||
}
|
||||
mqttStatus := mqttRuntimeStatus{server: server, address: mqttAddr, tls: cfg.MQTT.TLS.Enabled, stats: messageStats, dbQueue: dbQueue}
|
||||
httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager)
|
||||
httpServer = newHTTPServer(cfg.Web, store, sessions, mqttStatus, blocking, forwardManager, settings)
|
||||
webAddress := httpServer.Addr
|
||||
go func() {
|
||||
if cfg.Web.SocketPath != "" {
|
||||
@@ -275,12 +280,12 @@ func run(cfg *config) error {
|
||||
return runErr
|
||||
}
|
||||
|
||||
func startMQTTServer(cfg *config, dbQueue *dbWriteQueue, stats *meshtasticMessageStats, blocking *blockingCache) (*mqtt.Server, string, error) {
|
||||
func startMQTTServer(cfg *config, dbQueue *dbWriteQueue, stats *meshtasticMessageStats, blocking *blockingCache, settings *runtimeSettingsCache) (*mqtt.Server, string, error) {
|
||||
server := mqtt.New(nil)
|
||||
if err := server.AddHook(new(auth.AllowHook), nil); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := server.AddHook(&meshtasticFilterHook{key: cfg.key, dbQueue: dbQueue, stats: stats, blocking: blocking}, nil); err != nil {
|
||||
if err := server.AddHook(&meshtasticFilterHook{key: cfg.key, dbQueue: dbQueue, stats: stats, blocking: blocking, settings: settings}, nil); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
AdminLoginResponse,
|
||||
AdminManagedUserResponse,
|
||||
AdminMqttStatus,
|
||||
AdminRuntimeSettingsPayload,
|
||||
AdminRuntimeSettingsResponse,
|
||||
AdminUsersResponse,
|
||||
BlockingRuleResponse,
|
||||
DiscardDetails,
|
||||
@@ -163,6 +165,14 @@ export function getAdminMqttStatus(): Promise<AdminMqttStatus> {
|
||||
return getJSON<AdminMqttStatus>('/api/admin/mqtt/status')
|
||||
}
|
||||
|
||||
export function getAdminRuntimeSettings(): Promise<AdminRuntimeSettingsResponse> {
|
||||
return getJSON<AdminRuntimeSettingsResponse>('/api/admin/runtime-settings')
|
||||
}
|
||||
|
||||
export function updateAdminRuntimeSettings(payload: AdminRuntimeSettingsPayload): Promise<AdminRuntimeSettingsResponse> {
|
||||
return putJSON<AdminRuntimeSettingsResponse>('/api/admin/runtime-settings', payload)
|
||||
}
|
||||
|
||||
export function getAdminHelpContent(): Promise<HelpContentResponse> {
|
||||
return getJSON<HelpContentResponse>('/api/admin/help')
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { getAdminMqttStatus } from '../api'
|
||||
import type { AdminMqttStatus } from '../types'
|
||||
import { getAdminMqttStatus, getAdminRuntimeSettings, updateAdminRuntimeSettings } from '../api'
|
||||
import type { AdminMqttStatus, AdminRuntimeSettings } from '../types'
|
||||
|
||||
const status = ref<AdminMqttStatus | null>(null)
|
||||
const runtimeSettings = ref<AdminRuntimeSettings | null>(null)
|
||||
const loading = ref(false)
|
||||
const settingsLoading = ref(false)
|
||||
const error = ref('')
|
||||
const settingsError = ref('')
|
||||
const settingsMessage = ref('')
|
||||
let timer: number | undefined
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
@@ -27,8 +31,43 @@ async function refreshStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRuntimeSettings() {
|
||||
settingsLoading.value = true
|
||||
settingsError.value = ''
|
||||
try {
|
||||
const response = await getAdminRuntimeSettings()
|
||||
runtimeSettings.value = response.item
|
||||
} catch (err) {
|
||||
settingsError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEncryptedForwarding(value: boolean) {
|
||||
if (!runtimeSettings.value) {
|
||||
return
|
||||
}
|
||||
const previous = runtimeSettings.value.allow_encrypted_forwarding
|
||||
runtimeSettings.value.allow_encrypted_forwarding = value
|
||||
settingsLoading.value = true
|
||||
settingsError.value = ''
|
||||
settingsMessage.value = ''
|
||||
try {
|
||||
const response = await updateAdminRuntimeSettings({ allow_encrypted_forwarding: value })
|
||||
runtimeSettings.value = response.item
|
||||
settingsMessage.value = '设置已保存'
|
||||
} catch (err) {
|
||||
runtimeSettings.value.allow_encrypted_forwarding = previous
|
||||
settingsError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
settingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshStatus()
|
||||
refreshRuntimeSettings()
|
||||
timer = window.setInterval(refreshStatus, 5000)
|
||||
})
|
||||
|
||||
@@ -69,6 +108,44 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel admin-status-panel mqtt-control-panel">
|
||||
<div class="panel-header control-header">
|
||||
<div class="control-title">
|
||||
<div>
|
||||
<p class="eyebrow">MQTT Forwarding</p>
|
||||
<h2>MQTT 转发控制</h2>
|
||||
</div>
|
||||
</div>
|
||||
<span class="control-badge" :class="{ active: runtimeSettings?.allow_encrypted_forwarding }">
|
||||
{{ runtimeSettings?.allow_encrypted_forwarding ? '加密包放行' : '默认拦截' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="control-body">
|
||||
<div class="control-copy">
|
||||
<h3>加密转发</h3>
|
||||
<p>
|
||||
控制 Broker 在无法解密 Meshtastic 加密包时是否仍允许转发。关闭时保持当前行为:无法解密的加密包会被丢弃并记录到丢弃详情。
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="!runtimeSettings" class="empty control-empty">正在加载转发设置...</div>
|
||||
<label v-else class="switch-card" :class="{ enabled: runtimeSettings.allow_encrypted_forwarding, saving: settingsLoading }">
|
||||
<span class="switch-text">
|
||||
<strong>允许无法解密的加密包继续转发</strong>
|
||||
<small>{{ runtimeSettings.allow_encrypted_forwarding ? '已开启,原始 payload 将继续转发' : '已关闭,无法解密时会拒绝转发' }}</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="runtimeSettings.allow_encrypted_forwarding"
|
||||
:disabled="settingsLoading"
|
||||
@change="saveEncryptedForwarding(($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="switch-toggle" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="settingsError" class="error">{{ settingsError }}</p>
|
||||
<p v-if="settingsMessage" class="success">{{ settingsMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel admin-status-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
@@ -105,3 +182,176 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mqtt-control-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
border: 1px solid rgba(37, 99, 235, 0.14);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.16), transparent 32%),
|
||||
linear-gradient(135deg, #ffffff 0%, #f8fbff 52%, #eef6ff 100%);
|
||||
}
|
||||
|
||||
.control-header {
|
||||
position: relative;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.control-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.control-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
padding: 6px 12px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.control-badge.active {
|
||||
border-color: rgba(22, 163, 74, 0.32);
|
||||
color: #15803d;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.control-body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.85fr);
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-copy,
|
||||
.switch-card {
|
||||
border: 1px solid rgba(203, 213, 225, 0.78);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
box-shadow: 0 14px 36px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.control-copy {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.control-copy h3 {
|
||||
margin: 0 0 0.45rem;
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.control-copy p {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.control-empty {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.switch-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 108px;
|
||||
padding: 1rem;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.switch-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(37, 99, 235, 0.35);
|
||||
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.09);
|
||||
}
|
||||
|
||||
.switch-card.enabled {
|
||||
border-color: rgba(22, 163, 74, 0.35);
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f0fdf4 100%);
|
||||
}
|
||||
|
||||
.switch-card.saving {
|
||||
cursor: wait;
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.switch-card input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.switch-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.switch-text strong {
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.switch-text small {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.switch-toggle {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 54px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
box-shadow: inset 0 2px 4px rgba(15, 23, 42, 0.14);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.switch-toggle::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.24);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.switch-card.enabled .switch-toggle {
|
||||
background: linear-gradient(135deg, #16a34a, #22c55e);
|
||||
}
|
||||
|
||||
.switch-card.enabled .switch-toggle::after {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.control-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-header {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -225,6 +225,18 @@ export interface AdminMqttClient {
|
||||
remote_port: string
|
||||
}
|
||||
|
||||
export interface AdminRuntimeSettings {
|
||||
allow_encrypted_forwarding: boolean
|
||||
}
|
||||
|
||||
export interface AdminRuntimeSettingsPayload {
|
||||
allow_encrypted_forwarding: boolean
|
||||
}
|
||||
|
||||
export interface AdminRuntimeSettingsResponse {
|
||||
item: AdminRuntimeSettings
|
||||
}
|
||||
|
||||
export interface AdminMqttStatus {
|
||||
running: boolean
|
||||
address: string
|
||||
|
||||
+6
-2
@@ -32,6 +32,10 @@ var defaultMeshtasticPSK = []byte{
|
||||
0xCF, 0x4E, 0x69, 0x01,
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
AllowEncryptedForwarding bool
|
||||
}
|
||||
|
||||
type serviceEnvelope struct {
|
||||
Packet *meshPacket
|
||||
ChannelID string
|
||||
@@ -115,7 +119,7 @@ type telemetryInfo struct {
|
||||
|
||||
// MQTTPP 处理一个 MQTT 原始 payload,返回合规状态、原始数据和解码后的记录。
|
||||
// 第一个返回值表示数据是否合规;第二个返回值在不合规时为 nil;第三个返回值是解码结果记录。
|
||||
func MQTTPP(topic string, raw []byte, key []byte) (bool, []byte, map[string]any) {
|
||||
func MQTTPP(topic string, raw []byte, key []byte, opts Options) (bool, []byte, map[string]any) {
|
||||
|
||||
env, err := parseServiceEnvelope(raw)
|
||||
if err != nil {
|
||||
@@ -127,7 +131,7 @@ func MQTTPP(topic string, raw []byte, key []byte) (bool, []byte, map[string]any)
|
||||
//解码失败
|
||||
return false, nil, map[string]any{"topic": topic, "error": err.Error(), "payload_len": len(raw)}
|
||||
}
|
||||
if record["type"] == "encrypted_packet" {
|
||||
if record["type"] == "encrypted_packet" && !opts.AllowEncryptedForwarding {
|
||||
record["error"] = "cannot be decrypted"
|
||||
return false, nil, record
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package mqtpp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func TestMQTTPPEncryptedPacketDefaultRejected(t *testing.T) {
|
||||
raw := encryptedServiceEnvelopeTestPayload()
|
||||
valid, payload, record := MQTTPP("msh/test", raw, nil, Options{})
|
||||
if valid {
|
||||
t.Fatalf("valid = true, want false")
|
||||
}
|
||||
if payload != nil {
|
||||
t.Fatalf("payload = %v, want nil", payload)
|
||||
}
|
||||
if record["type"] != "encrypted_packet" {
|
||||
t.Fatalf("type = %v, want encrypted_packet", record["type"])
|
||||
}
|
||||
if record["error"] != "cannot be decrypted" {
|
||||
t.Fatalf("error = %v, want cannot be decrypted", record["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTPPEncryptedPacketAllowed(t *testing.T) {
|
||||
raw := encryptedServiceEnvelopeTestPayload()
|
||||
valid, payload, record := MQTTPP("msh/test", raw, nil, Options{AllowEncryptedForwarding: true})
|
||||
if !valid {
|
||||
t.Fatalf("valid = false, want true: %+v", record)
|
||||
}
|
||||
if string(payload) != string(raw) {
|
||||
t.Fatalf("payload = %v, want raw payload", payload)
|
||||
}
|
||||
if record["type"] != "encrypted_packet" {
|
||||
t.Fatalf("type = %v, want encrypted_packet", record["type"])
|
||||
}
|
||||
if record["error"] != nil {
|
||||
t.Fatalf("error = %v, want nil", record["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func encryptedServiceEnvelopeTestPayload() []byte {
|
||||
packet := protowire.AppendTag(nil, 5, protowire.BytesType)
|
||||
packet = protowire.AppendBytes(packet, []byte{1, 2, 3, 4})
|
||||
envelope := protowire.AppendTag(nil, 1, protowire.BytesType)
|
||||
return protowire.AppendBytes(envelope, packet)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type runtimeSettingsCache struct {
|
||||
mu sync.RWMutex
|
||||
settings runtimeSettingsSnapshot
|
||||
}
|
||||
|
||||
func newRuntimeSettingsCache(store *store) (*runtimeSettingsCache, error) {
|
||||
cache := &runtimeSettingsCache{}
|
||||
if err := cache.Reload(store); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
|
||||
func (c *runtimeSettingsCache) Reload(store *store) error {
|
||||
if store == nil {
|
||||
return fmt.Errorf("store is required")
|
||||
}
|
||||
settings, err := store.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.settings = settings
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *runtimeSettingsCache) Snapshot() runtimeSettingsSnapshot {
|
||||
if c == nil {
|
||||
return runtimeSettingsSnapshot{}
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.settings
|
||||
}
|
||||
|
||||
func (c *runtimeSettingsCache) AllowEncryptedForwarding() bool {
|
||||
return c.Snapshot().AllowEncryptedForwarding
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRuntimeSettingsCacheReload(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
cache, err := newRuntimeSettingsCache(st)
|
||||
if err != nil {
|
||||
t.Fatalf("newRuntimeSettingsCache() error = %v", err)
|
||||
}
|
||||
if cache.AllowEncryptedForwarding() {
|
||||
t.Fatalf("AllowEncryptedForwarding() = true, want false")
|
||||
}
|
||||
|
||||
if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil {
|
||||
t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err)
|
||||
}
|
||||
if err := cache.Reload(st); err != nil {
|
||||
t.Fatalf("Reload() after true error = %v", err)
|
||||
}
|
||||
if !cache.AllowEncryptedForwarding() {
|
||||
t.Fatalf("AllowEncryptedForwarding() = false, want true")
|
||||
}
|
||||
|
||||
if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil {
|
||||
t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err)
|
||||
}
|
||||
if err := cache.Reload(st); err != nil {
|
||||
t.Fatalf("Reload() after false error = %v", err)
|
||||
}
|
||||
if cache.AllowEncryptedForwarding() {
|
||||
t.Fatalf("AllowEncryptedForwarding() = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeSettingAllowEncryptedForwarding = "mqtt.allow_encrypted_forwarding"
|
||||
runtimeSettingTypeBool = "bool"
|
||||
)
|
||||
|
||||
type runtimeSettingsSnapshot struct {
|
||||
AllowEncryptedForwarding bool
|
||||
}
|
||||
|
||||
func (s *store) GetRuntimeSettings() (runtimeSettingsSnapshot, error) {
|
||||
allowEncrypted, err := s.GetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false)
|
||||
if err != nil {
|
||||
return runtimeSettingsSnapshot{}, err
|
||||
}
|
||||
return runtimeSettingsSnapshot{AllowEncryptedForwarding: allowEncrypted}, nil
|
||||
}
|
||||
|
||||
func (s *store) GetBoolRuntimeSetting(key string, defaultValue bool) (bool, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return false, fmt.Errorf("runtime setting key is required")
|
||||
}
|
||||
|
||||
var row runtimeSettingRecord
|
||||
err := s.db.Where("key = ?", key).Take(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return defaultValue, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if row.ValueType != "" && row.ValueType != runtimeSettingTypeBool {
|
||||
return false, fmt.Errorf("runtime setting %s has type %s, want %s", key, row.ValueType, runtimeSettingTypeBool)
|
||||
}
|
||||
value, err := strconv.ParseBool(strings.TrimSpace(row.Value))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse runtime setting %s: %w", key, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *store) SetBoolRuntimeSetting(key string, value bool, label string) (*runtimeSettingRecord, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("runtime setting key is required")
|
||||
}
|
||||
|
||||
row := runtimeSettingRecord{
|
||||
Key: key,
|
||||
Value: strconv.FormatBool(value),
|
||||
ValueType: runtimeSettingTypeBool,
|
||||
Label: strings.TrimSpace(label),
|
||||
}
|
||||
if err := s.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"value": row.Value,
|
||||
"value_type": row.ValueType,
|
||||
"label": row.Label,
|
||||
"updated_at": time.Now(),
|
||||
}),
|
||||
}).Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Where("key = ?", key).Take(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRuntimeSettingsDefaultAndUpdates(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
defer st.Close()
|
||||
|
||||
settings, err := st.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetRuntimeSettings() error = %v", err)
|
||||
}
|
||||
if settings.AllowEncryptedForwarding {
|
||||
t.Fatalf("AllowEncryptedForwarding = true, want false")
|
||||
}
|
||||
|
||||
if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, true, "test setting"); err != nil {
|
||||
t.Fatalf("SetBoolRuntimeSetting(true) error = %v", err)
|
||||
}
|
||||
settings, err = st.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetRuntimeSettings() after true error = %v", err)
|
||||
}
|
||||
if !settings.AllowEncryptedForwarding {
|
||||
t.Fatalf("AllowEncryptedForwarding = false, want true")
|
||||
}
|
||||
|
||||
if _, err := st.SetBoolRuntimeSetting(runtimeSettingAllowEncryptedForwarding, false, "test setting"); err != nil {
|
||||
t.Fatalf("SetBoolRuntimeSetting(false) error = %v", err)
|
||||
}
|
||||
settings, err = st.GetRuntimeSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("GetRuntimeSettings() after false error = %v", err)
|
||||
}
|
||||
if settings.AllowEncryptedForwarding {
|
||||
t.Fatalf("AllowEncryptedForwarding = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader) *http.Server {
|
||||
func newHTTPServer(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||
Handler: newRouter(cfg, store, sessions, mqttStatus, blocking, forwarder),
|
||||
Handler: newRouter(cfg, store, sessions, mqttStatus, blocking, forwarder, settings),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,12 +47,12 @@ func serveHTTPUnixSocket(server *http.Server, socketPath string) error {
|
||||
return server.Serve(listener)
|
||||
}
|
||||
|
||||
func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader) *gin.Engine {
|
||||
func newRouter(cfg webConfig, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
api := r.Group("/api")
|
||||
registerAPIRoutes(api, store)
|
||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder)
|
||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings)
|
||||
registerStaticRoutes(r, cfg.StaticDir)
|
||||
return r
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func registerAPIRoutes(r gin.IRouter, store *store) {
|
||||
})
|
||||
}
|
||||
|
||||
func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader) {
|
||||
func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager, mqttStatus mqttStatusProvider, blocking *blockingCache, forwarder mqttForwardReloader, settings *runtimeSettingsCache) {
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -185,6 +185,7 @@ func registerAdminRoutes(r gin.IRouter, store *store, sessions *sessionManager,
|
||||
protected.Use(requireAdmin(sessions))
|
||||
registerAdminBlockingRoutes(protected, store, blocking)
|
||||
registerAdminMQTTForwardRoutes(protected, store, forwarder)
|
||||
registerAdminRuntimeSettingsRoutes(protected, store, settings)
|
||||
registerAdminHelpRoutes(protected, store)
|
||||
protected.GET("/me", func(c *gin.Context) {
|
||||
claims := c.MustGet("admin_claims").(*sessionClaims)
|
||||
|
||||
Reference in New Issue
Block a user