forked from kevin/meshtastic_mqtt_server
安全加固:Web 登录防爆破(IP+用户名双维度限速 5次/分钟锁10分钟,未知用户名 dummy bcrypt 防枚举),瓦片代理 SSRF 加固(DialContext 拒绝内网/链路本地/CGNAT/ULA 与重定向限制,Content-Type image 白名单+nosniff),/api/discard-details 公开去敏(新增 admin 全字段端点),公开地图源接口外部 URL/key 一律代理化,install.sh 随机管理员密码+非回环默认口令拒绝启动,后端 v1.4.0
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -163,11 +164,11 @@ func AdminDTO(row storepkg.MapTileSourceRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "name": row.Name, "url_template": row.URLTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom, "enabled": row.Enabled, "is_default": row.IsDefault, "proxy_enabled": row.ProxyEnabled, "created_at": row.CreatedAt, "updated_at": row.UpdatedAt}
|
||||
}
|
||||
|
||||
// PublicDTO 是给前端用户使用的视图:当 ProxyEnabled 为 true 时,url 改写为
|
||||
// 通过本服务的 /api/map/{hash} 代理路径,避免暴露上游瓦片地址。
|
||||
// PublicDTO 是给前端用户使用的视图:外部 http(s) 模板一律改写为经本服务的
|
||||
// /api/map/{hash} 代理路径,避免向下游暴露上游瓦片地址与密钥。
|
||||
func PublicDTO(row storepkg.MapTileSourceRecord) gin.H {
|
||||
urlTemplate := row.URLTemplate
|
||||
if row.ProxyEnabled {
|
||||
if isExternalTileURLTemplate(urlTemplate) {
|
||||
hash := row.URLTemplateHash
|
||||
if hash == "" {
|
||||
hash = storepkg.MapTileSourceHash(row.URLTemplate)
|
||||
@@ -176,3 +177,9 @@ func PublicDTO(row storepkg.MapTileSourceRecord) gin.H {
|
||||
}
|
||||
return gin.H{"id": row.ID, "name": row.Name, "url_template": urlTemplate, "attribution": row.Attribution, "max_zoom": row.MaxZoom}
|
||||
}
|
||||
|
||||
// isExternalTileURLTemplate 判断模板是否指向外部 http/https 资源。
|
||||
func isExternalTileURLTemplate(template string) bool {
|
||||
t := strings.ToLower(strings.TrimSpace(template))
|
||||
return strings.HasPrefix(t, "http://") || strings.HasPrefix(t, "https://")
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package mapsource
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
)
|
||||
|
||||
func TestIsExternalTileURLTemplate(t *testing.T) {
|
||||
for _, in := range []string{"http://tile.openstreetmap.org/x", "https://webst03.is.autonavi.com/appmaptile?key=1", "HTTPS://EXAMPLE.COM/x"} {
|
||||
if !isExternalTileURLTemplate(in) {
|
||||
t.Errorf("%q should be external", in)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"/api/map/abc?x={x}", "", " /relative/path "} {
|
||||
if isExternalTileURLTemplate(in) {
|
||||
t.Errorf("%q should not be external", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicDTOAlwaysProxiesExternalURL(t *testing.T) {
|
||||
row := storepkg.MapTileSourceRecord{
|
||||
URLTemplate: "https://tile.example.com/style=7&x={x}&y={y}&z={z}&key=SECRET_KEY",
|
||||
}
|
||||
dto := PublicDTO(row)
|
||||
got, _ := dto["url_template"].(string)
|
||||
want := "/api/map/" + storepkg.MapTileSourceHash(row.URLTemplate) + "?x={x}&y={y}&z={z}"
|
||||
if got != want {
|
||||
t.Errorf("url_template = %q, want %q", got, want)
|
||||
}
|
||||
if dto["url_template"].(string) == row.URLTemplate {
|
||||
t.Error("external url_template must not be exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicDTOKeepsRelativeTemplate(t *testing.T) {
|
||||
row := storepkg.MapTileSourceRecord{
|
||||
URLTemplate: "/api/map/abc?x={x}&y={y}&z={z}",
|
||||
}
|
||||
dto := PublicDTO(row)
|
||||
if got := dto["url_template"].(string); got != row.URLTemplate {
|
||||
t.Errorf("relative template should stay as-is, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Package ratelimit 提供按 key(IP、用户名等)计数的失败限速器。
|
||||
//
|
||||
// 语义:key 在 Window 时间窗内连续失败 MaxFailures 次后,封锁 BlockFor 时长;
|
||||
// 成功调用 Reset 清空计数。并发安全,内部定期清理过期条目。
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMaxFailures = 5
|
||||
defaultWindow = time.Minute
|
||||
defaultBlockFor = 10 * time.Minute
|
||||
)
|
||||
|
||||
// FailureLimiter 按 key 跟踪失败次数并执行临时封锁。
|
||||
type FailureLimiter struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
window time.Duration
|
||||
blockFor time.Duration
|
||||
fails map[string]*failState
|
||||
now func() time.Time
|
||||
stopped chan struct{}
|
||||
stopOnce sync.Once
|
||||
maxEntries int
|
||||
}
|
||||
|
||||
type failState struct {
|
||||
count int
|
||||
windowStart time.Time
|
||||
blockedUntil time.Time
|
||||
}
|
||||
|
||||
// Options 自定义限速参数,零值使用默认(5 次/分钟,封锁 10 分钟)。
|
||||
type Options struct {
|
||||
MaxFailures int
|
||||
Window time.Duration
|
||||
BlockFor time.Duration
|
||||
}
|
||||
|
||||
// New 构造限速器并启动清理协程;服务退出时应调用 Stop。
|
||||
func New(opts Options) *FailureLimiter {
|
||||
if opts.MaxFailures <= 0 {
|
||||
opts.MaxFailures = defaultMaxFailures
|
||||
}
|
||||
if opts.Window <= 0 {
|
||||
opts.Window = defaultWindow
|
||||
}
|
||||
if opts.BlockFor <= 0 {
|
||||
opts.BlockFor = defaultBlockFor
|
||||
}
|
||||
l := &FailureLimiter{
|
||||
max: opts.MaxFailures,
|
||||
window: opts.Window,
|
||||
blockFor: opts.BlockFor,
|
||||
fails: make(map[string]*failState),
|
||||
now: time.Now,
|
||||
stopped: make(chan struct{}),
|
||||
maxEntries: 8192,
|
||||
}
|
||||
go l.cleanupLoop()
|
||||
return l
|
||||
}
|
||||
|
||||
// Stop 结束清理协程。
|
||||
func (l *FailureLimiter) Stop() error {
|
||||
l.stopOnce.Do(func() { close(l.stopped) })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *FailureLimiter) cleanupLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-l.stopped:
|
||||
return
|
||||
case <-ticker.C:
|
||||
l.purge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *FailureLimiter) purge() {
|
||||
now := l.now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for key, st := range l.fails {
|
||||
if now.After(st.blockedUntil) && now.Sub(st.windowStart) > l.window {
|
||||
delete(l.fails, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked 报告 key 当前是否被封锁;未封锁时返回剩余限制描述。
|
||||
func (l *FailureLimiter) Blocked(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
now := l.now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st, ok := l.fails[key]
|
||||
return ok && now.Before(st.blockedUntil)
|
||||
}
|
||||
|
||||
// BlockedRemaining 返回封锁剩余时长;未封锁返回 0。
|
||||
func (l *FailureLimiter) BlockedRemaining(key string) time.Duration {
|
||||
if key == "" {
|
||||
return 0
|
||||
}
|
||||
now := l.now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st, ok := l.fails[key]
|
||||
if !ok || now.After(st.blockedUntil) {
|
||||
return 0
|
||||
}
|
||||
return time.Until(st.blockedUntil)
|
||||
}
|
||||
|
||||
// Fail 记录一次失败;达到阈值返回 true 表示本次触发封锁。
|
||||
func (l *FailureLimiter) Fail(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
now := l.now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st, ok := l.fails[key]
|
||||
if !ok || now.Sub(st.windowStart) > l.window {
|
||||
st = &failState{windowStart: now}
|
||||
l.fails[key] = st
|
||||
if len(l.fails) > l.maxEntries {
|
||||
l.purgeLocked(now)
|
||||
}
|
||||
}
|
||||
st.count++
|
||||
if st.count >= l.max {
|
||||
st.blockedUntil = now.Add(l.blockFor)
|
||||
st.count = 0
|
||||
st.windowStart = now
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Reset 清除 key 的失败计数。
|
||||
func (l *FailureLimiter) Reset(key string) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.fails, key)
|
||||
}
|
||||
|
||||
func (l *FailureLimiter) purgeLocked(now time.Time) {
|
||||
for key, st := range l.fails {
|
||||
if now.After(st.blockedUntil) && now.Sub(st.windowStart) > l.window {
|
||||
delete(l.fails, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BlockedError 构造统一的封锁提示文案。
|
||||
func (l *FailureLimiter) BlockedError(key string) error {
|
||||
return fmt.Errorf("too many failed attempts, retry after %s", l.BlockedRemaining(key).Round(time.Second))
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestLimiter(t *testing.T, opts Options) *FailureLimiter {
|
||||
t.Helper()
|
||||
if opts.MaxFailures == 0 {
|
||||
opts.MaxFailures = 3
|
||||
}
|
||||
l := New(opts)
|
||||
t.Cleanup(func() { _ = l.Stop() })
|
||||
return l
|
||||
}
|
||||
|
||||
func TestFailBlocksAndExpires(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{})
|
||||
now := time.Unix(1700000000, 0)
|
||||
l.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
l.Fail("1.2.3.4")
|
||||
}
|
||||
if !l.Blocked("1.2.3.4") {
|
||||
t.Fatal("key should be blocked after 5 failures")
|
||||
}
|
||||
now = now.Add(11 * time.Minute)
|
||||
if l.Blocked("1.2.3.4") {
|
||||
t.Fatal("key should be unblocked after blockFor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailWindowResets(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{})
|
||||
now := time.Unix(1700000000, 0)
|
||||
l.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
l.Fail("ip")
|
||||
}
|
||||
now = now.Add(2 * time.Minute) // 超过 window,计数应清零
|
||||
l.Fail("ip")
|
||||
l.Fail("ip")
|
||||
if l.Blocked("ip") {
|
||||
t.Fatal("counts should reset after window passes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessResets(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{})
|
||||
l.Fail("ip")
|
||||
l.Reset("ip")
|
||||
if l.Blocked("ip") {
|
||||
t.Fatal("reset must clear failures")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentKeysIndependent(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{})
|
||||
for i := 0; i < 10; i++ {
|
||||
l.Fail("a")
|
||||
}
|
||||
if !l.Blocked("a") {
|
||||
t.Fatal("a should be blocked")
|
||||
}
|
||||
if l.Blocked("b") {
|
||||
t.Fatal("b must not be affected by a")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyKeyIgnored(t *testing.T) {
|
||||
l := newTestLimiter(t, Options{})
|
||||
for i := 0; i < 100; i++ {
|
||||
l.Fail("")
|
||||
}
|
||||
if l.Blocked("") {
|
||||
t.Fatal("empty key must not be tracked")
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -20,6 +23,7 @@ import (
|
||||
const (
|
||||
mapTileCacheControl = "public, max-age=86400"
|
||||
maxMapTileBytes = 10 << 20
|
||||
maxMapTileRedirects = 2
|
||||
)
|
||||
|
||||
type mapTileProxy struct {
|
||||
@@ -32,11 +36,88 @@ func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir s
|
||||
proxy := &mapTileProxy{
|
||||
store: store,
|
||||
cacheDir: cacheDir,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
client: newMapTileHTTPClient(),
|
||||
}
|
||||
r.GET("/map/:sourceHash", proxy.handle)
|
||||
}
|
||||
|
||||
// newMapTileHTTPClient 构造带 SSRF 防护的 HTTP 客户端:
|
||||
// - DialContext 在连接时按解析后的 IP 拒绝内网/链路本地/元数据等地址,防 DNS rebinding;
|
||||
// - CheckRedirect 限制跳转次数,每次跳转同样经过 DialContext 检查。
|
||||
func newMapTileHTTPClient() *http.Client {
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
if !isMapTileDialTargetAllowed(ctx, network, addr) {
|
||||
return nil, fmt.Errorf("blocked map tile dial target %q", addr)
|
||||
}
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
},
|
||||
MaxIdleConns: 8,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxMapTileRedirects {
|
||||
return errors.New("too many map tile redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// isMapTileDialTargetAllowed 检查拨号目标是否允许:
|
||||
// 仅允许 tcp 协议,且解析出的每个 IP 均为公网地址。
|
||||
func isMapTileDialTargetAllowed(ctx context.Context, network, addr string) bool {
|
||||
if network != "tcp" && network != "tcp4" && network != "tcp6" {
|
||||
return false
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if ip, err := netip.ParseAddr(host); err == nil {
|
||||
return isPublicAddr(ip)
|
||||
}
|
||||
// 主机名:解析后要求所有结果均为公网地址,避免 DNS rebinding 落到内网。
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, r := range ips {
|
||||
addr, ok := netip.AddrFromSlice(r.IP)
|
||||
if !ok || !isPublicAddr(addr) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isPublicAddr 判定 IP 是否可安全外访;拒绝回环、私有、链路本地、CGNAT、
|
||||
// 组播、未指定与 IPv6 ULA 地址。
|
||||
func isPublicAddr(ip netip.Addr) bool {
|
||||
if !ip.IsValid() {
|
||||
return false
|
||||
}
|
||||
ip = ip.Unmap()
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
// CGNAT 100.64.0.0/10 不在 IsPrivate 范围内,单独拦截。
|
||||
if ip.Is4() {
|
||||
ipv4 := ip.As4()
|
||||
ipv4Int := uint32(ipv4[0])<<24 | uint32(ipv4[1])<<16 | uint32(ipv4[2])<<8 | uint32(ipv4[3])
|
||||
if ipv4Int >= 0x64400000 && ipv4Int <= 0x647fffff {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *mapTileProxy) handle(c *gin.Context) {
|
||||
sourceHash := strings.ToLower(c.Param("sourceHash"))
|
||||
if !isMapTileSourceHash(sourceHash) {
|
||||
@@ -196,7 +277,18 @@ func writeMapTileCacheFile(path string, data []byte) error {
|
||||
}
|
||||
|
||||
func writeMapTile(c *gin.Context, data []byte) {
|
||||
contentType := http.DetectContentType(data)
|
||||
contentType := mapTileContentType(data)
|
||||
c.Header("Cache-Control", mapTileCacheControl)
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Data(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
// mapTileContentType 只放行 image/*,其余一律按二进制下发,
|
||||
// 防止上游返回 HTML/脚本在本站同源渲染(存储型 XSS 面)。
|
||||
func mapTileContentType(data []byte) string {
|
||||
detected := http.DetectContentType(data)
|
||||
if strings.HasPrefix(detected, "image/") {
|
||||
return detected
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsPublicAddr(t *testing.T) {
|
||||
cases := []struct {
|
||||
ip string
|
||||
allow bool
|
||||
}{
|
||||
{"8.8.8.8", true},
|
||||
{"1.1.1.1", true},
|
||||
{"2001:4860:4860::8888", true},
|
||||
{"127.0.0.1", false},
|
||||
{"::1", false},
|
||||
{"10.0.0.1", false},
|
||||
{"172.16.5.4", false},
|
||||
{"192.168.1.1", false},
|
||||
{"169.254.169.254", false}, // AWS metadata
|
||||
{"100.64.0.1", false}, // CGNAT
|
||||
{"100.127.255.254", false}, // CGNAT
|
||||
{"224.0.0.1", false}, // multicast
|
||||
{"0.0.0.0", false},
|
||||
{"fc00::1", false}, // ULA
|
||||
{"fe80::1", false}, // link-local IPv6
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
addr, err := netip.ParseAddr(tc.ip)
|
||||
got := false
|
||||
if err == nil {
|
||||
got = isPublicAddr(addr)
|
||||
}
|
||||
if got != tc.allow {
|
||||
t.Errorf("isPublicAddr(%q)=%v, want %v", tc.ip, got, tc.allow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMapTileDialTargetAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
network string
|
||||
addr string
|
||||
allow bool
|
||||
}{
|
||||
{"tcp", "8.8.8.8:443", true},
|
||||
{"tcp4", "1.1.1.1:80", true},
|
||||
{"tcp", "127.0.0.1:80", false},
|
||||
{"tcp", "10.0.0.1:80", false},
|
||||
{"tcp", "169.254.169.254:80", false},
|
||||
{"tcp", "100.64.1.1:80", false},
|
||||
{"tcp6", "[::1]:1883", false},
|
||||
{"unix", "/tmp/x.sock", false},
|
||||
{"tcp", "no-such-host.invalid:80", false},
|
||||
{"tcp", "localhost:80", false}, // 解析到 127.0.0.1
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isMapTileDialTargetAllowed(context.Background(), tc.network, tc.addr); got != tc.allow {
|
||||
t.Errorf("isMapTileDialTargetAllowed(%q, %q)=%v, want %v", tc.network, tc.addr, got, tc.allow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapTileContentType(t *testing.T) {
|
||||
png := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
|
||||
if got := mapTileContentType(png); got != "image/png" {
|
||||
t.Errorf("png: got %q", got)
|
||||
}
|
||||
html := []byte("<html><script>alert(1)</script></html>")
|
||||
if got := mapTileContentType(html); got != "application/octet-stream" {
|
||||
t.Errorf("html must be blocked, got %q", got)
|
||||
}
|
||||
svg := []byte("<svg xmlns='http://www.w3.org/2000/svg'></svg>")
|
||||
if got := mapTileContentType(svg); got != "application/octet-stream" {
|
||||
t.Errorf("svg must be blocked, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLoopbackHost(t *testing.T) {
|
||||
}
|
||||
+45
-3
@@ -22,6 +22,7 @@ import (
|
||||
llmadminpkg "meshtastic_mqtt_server/internal/llmadmin"
|
||||
mappkg "meshtastic_mqtt_server/internal/mapsource"
|
||||
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
||||
"meshtastic_mqtt_server/internal/ratelimit"
|
||||
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
|
||||
signpkg "meshtastic_mqtt_server/internal/sign"
|
||||
storepkg "meshtastic_mqtt_server/internal/store"
|
||||
@@ -84,7 +85,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store,
|
||||
return r
|
||||
}
|
||||
|
||||
const BackendVersion = "1.3.0"
|
||||
const BackendVersion = "1.4.0"
|
||||
|
||||
var CommitVersion = "dev"
|
||||
|
||||
@@ -157,7 +158,8 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
|
||||
return
|
||||
}
|
||||
rows, err := store.ListDiscardDetails(opts)
|
||||
writeListResponse(c, rows, opts, err, discardDetailsDTO)
|
||||
// 公开接口去敏:不含 MQTT 客户端 IP/端口与原始报文,完整数据见 /api/admin/discard-details。
|
||||
writeListResponse(c, rows, opts, err, discardDetailsPublicDTO)
|
||||
})
|
||||
r.GET("/positions", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
@@ -194,6 +196,8 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
|
||||
}
|
||||
|
||||
func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) {
|
||||
// 登录防爆破:按来源 IP 与用户名双维度限速。
|
||||
loginLimiter := ratelimit.New(ratelimit.Options{})
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -223,6 +227,8 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
|
||||
remoteAddr, remoteHost := remoteInfo(c)
|
||||
_ = store.InsertLoginLog(storepkg.LoginLogRecord{Username: username, UserID: userID, Success: success, Reason: reason, RemoteAddr: remoteAddr, RemoteHost: remoteHost, UserAgent: c.GetHeader("User-Agent")})
|
||||
}
|
||||
// dummyAdminPasswordHash 是固定 bcrypt 散列,用于未知用户名登录时的耗时对齐。
|
||||
const dummyAdminPasswordHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
|
||||
|
||||
r.POST("/login", func(c *gin.Context) {
|
||||
var req loginRequest
|
||||
@@ -231,12 +237,34 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid login request"})
|
||||
return
|
||||
}
|
||||
_, ipKey := remoteInfo(c)
|
||||
userKey := "user:" + req.Username
|
||||
if loginLimiter.Blocked(ipKey) || loginLimiter.Blocked(userKey) {
|
||||
recordLogin(c, req.Username, nil, false, "rate limited")
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts, retry later"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := store.GetUserByUsername(req.Username)
|
||||
if err != nil || user.Role != auth.AdminRole || !auth.VerifyPassword(user.PasswordHash, req.Password) {
|
||||
if err != nil || user.Role != auth.AdminRole {
|
||||
// 未知用户名也执行一次 bcrypt 比较,消除用户名枚举的时间侧信道。
|
||||
_ = auth.VerifyPassword(dummyAdminPasswordHash, req.Password)
|
||||
loginLimiter.Fail(ipKey)
|
||||
loginLimiter.Fail(userKey)
|
||||
recordLogin(c, req.Username, nil, false, "invalid username or password")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||
return
|
||||
}
|
||||
if !auth.VerifyPassword(user.PasswordHash, req.Password) {
|
||||
loginLimiter.Fail(ipKey)
|
||||
loginLimiter.Fail(userKey)
|
||||
recordLogin(c, req.Username, nil, false, "invalid username or password")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||
return
|
||||
}
|
||||
loginLimiter.Reset(ipKey)
|
||||
loginLimiter.Reset(userKey)
|
||||
|
||||
cookie, err := sessions.NewCookie(*user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -399,6 +427,14 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
|
||||
rows, err := store.ListLoginLogs(opts)
|
||||
writeListResponse(c, rows, opts, err, loginLogDTO)
|
||||
})
|
||||
protected.GET("/discard-details", func(c *gin.Context) {
|
||||
opts, ok := parseListOptions(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := store.ListDiscardDetails(opts)
|
||||
writeListResponse(c, rows, opts, err, discardDetailsDTO)
|
||||
})
|
||||
protected.POST("/discard-details/batch-delete", func(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []uint64 `json:"ids"`
|
||||
@@ -633,6 +669,12 @@ func discardDetailsDTO(row storepkg.DiscardDetailsRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "topic": row.Topic, "error": row.Error, "payload_len": row.PayloadLen, "raw_base64": row.RawBase64, "mqtt_client_id": ptrString(row.MQTTClientID), "mqtt_username": ptrString(row.MQTTUsername), "mqtt_listener": ptrString(row.MQTTListener), "mqtt_remote_addr": ptrString(row.MQTTRemoteAddr), "mqtt_remote_host": ptrString(row.MQTTRemoteHost), "mqtt_remote_port": ptrString(row.MQTTRemotePort), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||
}
|
||||
|
||||
// discardDetailsPublicDTO 是公开接口的无敏感字段视图:
|
||||
// 剔除 MQTT 客户端 IP/端口与原始报文,避免泄露发布者身份。
|
||||
func discardDetailsPublicDTO(row storepkg.DiscardDetailsRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "topic": row.Topic, "error": row.Error, "payload_len": row.PayloadLen, "mqtt_client_id": ptrString(row.MQTTClientID), "mqtt_username": ptrString(row.MQTTUsername), "mqtt_listener": ptrString(row.MQTTListener), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||
}
|
||||
|
||||
func positionDTO(row storepkg.PositionRecord) gin.H {
|
||||
return gin.H{"id": row.ID, "from_id": row.FromID, "from_num": row.FromNum, "latitude": ptrFloat64(row.Latitude), "longitude": ptrFloat64(row.Longitude), "altitude": ptrInt64(row.Altitude), "created_at": row.CreatedAt, "content_json": row.ContentJSON}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user