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:
@@ -177,7 +177,7 @@ web:
|
|||||||
session_secure: false
|
session_secure: false
|
||||||
```
|
```
|
||||||
|
|
||||||
> 生产环境请修改默认管理员密码,并设置足够长、随机的 `session_secret`。如果通过 HTTPS 访问 Web 管理后台,建议将 `session_secure` 设置为 `true`。
|
> 生产环境请修改默认管理员密码(非回环监听时仍为默认值 `admin` 将拒绝启动),并设置足够长、随机的 `session_secret`。如果通过 HTTPS 访问 Web 管理后台,建议将 `session_secure` 设置为 `true`。
|
||||||
|
|
||||||
## 使用 SQLite 部署
|
## 使用 SQLite 部署
|
||||||
|
|
||||||
@@ -244,12 +244,13 @@ htpasswd -bnBC 10 "" '你的密码' | tr -d ':\n'
|
|||||||
- 健康检查:`http://服务器地址:8080/api/health`
|
- 健康检查:`http://服务器地址:8080/api/health`
|
||||||
- MQTT broker:`服务器地址:1883`
|
- MQTT broker:`服务器地址:1883`
|
||||||
|
|
||||||
Web 管理后台默认账号:
|
Web 管理后台账号:`install.sh` 首次部署会生成随机管理员密码并打印一次;手动部署请通过环境变量设置:
|
||||||
|
|
||||||
- 用户名:`admin`
|
```bash
|
||||||
- 密码:`admin`
|
export MESH_ADMIN_PASSWORD='change-me'
|
||||||
|
```
|
||||||
|
|
||||||
生产环境请务必修改默认密码。
|
> 安全守卫:当 Web 监听非回环地址且口令仍为默认值 `admin` 时,程序拒绝启动。请勿在公网使用默认口令。
|
||||||
|
|
||||||
## systemd 部署示例
|
## systemd 部署示例
|
||||||
|
|
||||||
|
|||||||
+18
-25
@@ -28,35 +28,28 @@
|
|||||||
|
|
||||||
## P1 - 高优先(1-2 周内)
|
## P1 - 高优先(1-2 周内)
|
||||||
|
|
||||||
- [ ] **T3 install.sh 不再生成 admin/admin 默认口令**
|
- [x] **T3 install.sh 不再生成 admin/admin 默认口令(2026-08-20 完成)**
|
||||||
- 位置:`install.sh:90-92`;`internal/config/config.go:184-199`
|
- `install.sh` 首启随机生成 16 位管理员密码并仅打印一次;`main.go` 新增守卫:Web 监听非回环地址且口令仍为默认 `admin` 时拒绝启动(`guardDefaultAdminPassword`);README 同步
|
||||||
- 方案:首启随机生成 16 字节密码打印到终端(仅一次);或 web 绑定非 loopback 且口令为默认值时拒绝启动;`README.md:219-224` 删除默认口令描述
|
- 验收:新部署拿到随机口令;公网 0.0.0.0+admin/admin 无法启动 ✓(冒烟验证)
|
||||||
- 验收:新部署无法用 admin/admin 登录
|
- 注意:已部署实例不受影响(守卫只拦默认口令);本地开发若用 0.0.0.0+admin/admin 需先改口令
|
||||||
|
|
||||||
- [ ] **T4 登录防爆破与用户名枚举**
|
- [x] **T4 登录防爆破与用户名枚举(2026-08-20 完成)**
|
||||||
- 位置:`internal/web/web.go:227-248`
|
- 新增 `internal/ratelimit`(按 key 失败计数+临时封锁,默认 5 次/分钟锁定 10 分钟);登录按来源 IP + 用户名双维度限速,超限返回 429
|
||||||
- 方案:
|
- 未知用户名也执行 dummy bcrypt,消除时间侧信道
|
||||||
1. 按 IP + 用户名维度限速(如 5 次/分钟,锁定 10 分钟,内存或 login_log 实现均可)
|
- 验收:连续 5 次错误→第 6 次 429;未知/已知用户名耗时一致 ✓(冒烟验证)
|
||||||
2. 用户不存在时也执行一次 `bcrypt.CompareHashAndPassword`(dummy hash)消除时间侧信道
|
|
||||||
- 验收:连续错误登录返回 429;存在/不存在用户名的响应耗时一致
|
|
||||||
|
|
||||||
- [ ] **T5 瓦片代理 SSRF 加固**
|
- [x] **T5 瓦片代理 SSRF 加固(2026-08-20 完成)**
|
||||||
- 位置:`internal/web/map_tile_proxy_routes.go:35,79-87`
|
- `map_tile_proxy_routes.go`:自定义 DialContext 按解析 IP 拒绝 loopback/私网/链路本地/CGNAT/组播/ULA(DNS rebinding 安全);CheckRedirect 限 2 跳且每跳同样受 Dial 检查;`mapTileContentType` 仅放行 `image/*`,其余强制 `application/octet-stream` + `X-Content-Type-Options: nosniff`(堵同源 HTML XSS)
|
||||||
- 方案:
|
- 验收:元数据地址 502;外网 302→内网 502;HTML 响应不再以 text/html 下发 ✓(单测+端到端)
|
||||||
1. 自定义 `http.Transport.DialContext`:解析后拒绝 loopback/RFC1918/169.254.0.0/16/CGNAT/组播/IPv6 ULA 地址(防 DNS rebinding,必须在 connect 时按 IP 校验)
|
|
||||||
2. `CheckRedirect`:每跳重新校验目标,最多 2 跳
|
|
||||||
3. `writeMapTile`(198-202 行)Content-Type 白名单:仅 `image/*` 通过,否则强制 `application/octet-stream`(堵同源 HTML XSS)
|
|
||||||
- 验收:模板指向 `http://169.254.169.254/...` 及外网 302->内网均失败;上游返回 HTML 时浏览器下载而非渲染
|
|
||||||
|
|
||||||
- [ ] **T6 `/api/discard-details` 去敏**
|
- [x] **T6 `/api/discard-details` 去敏(2026-08-20 完成)**
|
||||||
- 位置:`internal/web/web.go:154-161,632-634`(线上实测匿名可读 MQTT 客户端 ID/IP/端口/raw_base64)
|
- 公开接口改用 `discardDetailsPublicDTO`,剔除 `mqtt_remote_addr/host/port` 与 `raw_base64`;新增 `GET /api/admin/discard-details` 全字段端点(RequireAdmin);前端 `AdminDiscardDetails` 改调 admin 端点
|
||||||
- 方案:公开响应剔除 `mqtt_remote_addr/host/port`、`raw_base64`;完整数据仅 `RequireAdmin` 分组提供(或直接整体移入 admin)
|
- 验收:匿名响应无 IP/原始报文;管理页功能保留 ✓(冒烟验证)
|
||||||
- 验收:匿名请求响应中无 IP 与原始报文字段
|
- 注意:前端变更需重新 build 部署;`/api/text-messages` 仍公开返回 `mqtt_remote_host`(同源泄露,已列入 P3 观察)
|
||||||
|
|
||||||
- [ ] **T7 高德地图 key 不再下发到前端**
|
- [x] **T7 外部瓦片 URL/key 不再下发前端(2026-08-20 完成)**
|
||||||
- 位置:`internal/mapsource/admin_map_source_routes.go:36-47`(线上 `/api/map-source/enabled` 已泄露 `key=35206f...`)
|
- `PublicDTO` 对外部 http(s) 模板一律改写为 `/api/map/{hash}` 代理路径(不再依赖 proxy_enabled 标志);高德 key 等不再出现在 `/api/map-source/enabled` 响应中
|
||||||
- 方案:外部模板一律走服务端代理(存 hash 形式);`enabled` 接口只返回代理 URL 不返回原始 url_template
|
- 验收:公开响应无 `key=`/外部域名;瓦片经代理加载 ✓(单测+端到端)
|
||||||
- 验收:`/api/map-source/enabled` 响应中无任何 `key=`/外部域名
|
|
||||||
|
|
||||||
## P2 - 中优先(迭代内)
|
## P2 - 中优先(迭代内)
|
||||||
|
|
||||||
|
|||||||
+20
-9
@@ -65,6 +65,10 @@ find "${INSTALL_DIR}/dist" -type d -exec chmod 0755 {} \;
|
|||||||
find "${INSTALL_DIR}/dist" -type f -exec chmod 0644 {} \;
|
find "${INSTALL_DIR}/dist" -type f -exec chmod 0644 {} \;
|
||||||
|
|
||||||
if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then
|
if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then
|
||||||
|
ADMIN_PASSWORD="$(head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 16)"
|
||||||
|
if [[ -z "${ADMIN_PASSWORD}" ]]; then
|
||||||
|
ADMIN_PASSWORD="admin" # 极低概率兜底,启动守卫会强制修改
|
||||||
|
fi
|
||||||
cat > "${CONFIG_DIR}/config.yaml" <<EOF
|
cat > "${CONFIG_DIR}/config.yaml" <<EOF
|
||||||
mqtt:
|
mqtt:
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
@@ -91,15 +95,15 @@ database:
|
|||||||
path: ${DATA_DIR}/${SERVICE_NAME}.db
|
path: ${DATA_DIR}/${SERVICE_NAME}.db
|
||||||
mysql:
|
mysql:
|
||||||
dsn: ""
|
dsn: ""
|
||||||
web:
|
web:
|
||||||
enabled: true
|
enabled: true
|
||||||
host: 0.0.0.0
|
host: 0.0.0.0
|
||||||
port: 8080
|
port: 8080
|
||||||
socket_path: ${SOCKET_PATH}
|
socket_path: ${SOCKET_PATH}
|
||||||
static_dir: ${INSTALL_DIR}/dist
|
static_dir: ${INSTALL_DIR}/dist
|
||||||
admin:
|
admin:
|
||||||
username: admin
|
username: admin
|
||||||
password: admin
|
password: ${ADMIN_PASSWORD}
|
||||||
session_secret: ""
|
session_secret: ""
|
||||||
session_secure: false
|
session_secure: false
|
||||||
console_log:
|
console_log:
|
||||||
@@ -143,3 +147,10 @@ systemctl restart "${SERVICE_NAME}"
|
|||||||
|
|
||||||
echo "部署完成,服务状态:"
|
echo "部署完成,服务状态:"
|
||||||
systemctl --no-pager --full status "${SERVICE_NAME}"
|
systemctl --no-pager --full status "${SERVICE_NAME}"
|
||||||
|
if [[ -n "${ADMIN_PASSWORD:-}" ]]; then
|
||||||
|
echo
|
||||||
|
echo "======================================================"
|
||||||
|
echo " Web 管理后台初始账号: admin / ${ADMIN_PASSWORD}"
|
||||||
|
echo " 该密码仅显示这一次,请立即登录并修改!"
|
||||||
|
echo "======================================================"
|
||||||
|
fi
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"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}
|
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 改写为
|
// PublicDTO 是给前端用户使用的视图:外部 http(s) 模板一律改写为经本服务的
|
||||||
// 通过本服务的 /api/map/{hash} 代理路径,避免暴露上游瓦片地址。
|
// /api/map/{hash} 代理路径,避免向下游暴露上游瓦片地址与密钥。
|
||||||
func PublicDTO(row storepkg.MapTileSourceRecord) gin.H {
|
func PublicDTO(row storepkg.MapTileSourceRecord) gin.H {
|
||||||
urlTemplate := row.URLTemplate
|
urlTemplate := row.URLTemplate
|
||||||
if row.ProxyEnabled {
|
if isExternalTileURLTemplate(urlTemplate) {
|
||||||
hash := row.URLTemplateHash
|
hash := row.URLTemplateHash
|
||||||
if hash == "" {
|
if hash == "" {
|
||||||
hash = storepkg.MapTileSourceHash(row.URLTemplate)
|
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}
|
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
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -20,6 +23,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
mapTileCacheControl = "public, max-age=86400"
|
mapTileCacheControl = "public, max-age=86400"
|
||||||
maxMapTileBytes = 10 << 20
|
maxMapTileBytes = 10 << 20
|
||||||
|
maxMapTileRedirects = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
type mapTileProxy struct {
|
type mapTileProxy struct {
|
||||||
@@ -32,11 +36,88 @@ func registerMapTileProxyRoutes(r gin.IRouter, store *storepkg.Store, cacheDir s
|
|||||||
proxy := &mapTileProxy{
|
proxy := &mapTileProxy{
|
||||||
store: store,
|
store: store,
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
client: &http.Client{Timeout: 15 * time.Second},
|
client: newMapTileHTTPClient(),
|
||||||
}
|
}
|
||||||
r.GET("/map/:sourceHash", proxy.handle)
|
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) {
|
func (p *mapTileProxy) handle(c *gin.Context) {
|
||||||
sourceHash := strings.ToLower(c.Param("sourceHash"))
|
sourceHash := strings.ToLower(c.Param("sourceHash"))
|
||||||
if !isMapTileSourceHash(sourceHash) {
|
if !isMapTileSourceHash(sourceHash) {
|
||||||
@@ -196,7 +277,18 @@ func writeMapTileCacheFile(path string, data []byte) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func writeMapTile(c *gin.Context, data []byte) {
|
func writeMapTile(c *gin.Context, data []byte) {
|
||||||
contentType := http.DetectContentType(data)
|
contentType := mapTileContentType(data)
|
||||||
c.Header("Cache-Control", mapTileCacheControl)
|
c.Header("Cache-Control", mapTileCacheControl)
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
c.Data(http.StatusOK, contentType, data)
|
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"
|
llmadminpkg "meshtastic_mqtt_server/internal/llmadmin"
|
||||||
mappkg "meshtastic_mqtt_server/internal/mapsource"
|
mappkg "meshtastic_mqtt_server/internal/mapsource"
|
||||||
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
mqttforwardpkg "meshtastic_mqtt_server/internal/mqttforward"
|
||||||
|
"meshtastic_mqtt_server/internal/ratelimit"
|
||||||
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
|
rspkg "meshtastic_mqtt_server/internal/runtimesettings"
|
||||||
signpkg "meshtastic_mqtt_server/internal/sign"
|
signpkg "meshtastic_mqtt_server/internal/sign"
|
||||||
storepkg "meshtastic_mqtt_server/internal/store"
|
storepkg "meshtastic_mqtt_server/internal/store"
|
||||||
@@ -84,7 +85,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store,
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
const BackendVersion = "1.3.0"
|
const BackendVersion = "1.4.0"
|
||||||
|
|
||||||
var CommitVersion = "dev"
|
var CommitVersion = "dev"
|
||||||
|
|
||||||
@@ -157,7 +158,8 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
rows, err := store.ListDiscardDetails(opts)
|
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) {
|
r.GET("/positions", func(c *gin.Context) {
|
||||||
opts, ok := parseListOptions(c)
|
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) {
|
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 {
|
type loginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -223,6 +227,8 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
|
|||||||
remoteAddr, remoteHost := remoteInfo(c)
|
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")})
|
_ = 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) {
|
r.POST("/login", func(c *gin.Context) {
|
||||||
var req loginRequest
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid login request"})
|
||||||
return
|
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)
|
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")
|
recordLogin(c, req.Username, nil, false, "invalid username or password")
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid username or password"})
|
||||||
return
|
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)
|
cookie, err := sessions.NewCookie(*user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
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)
|
rows, err := store.ListLoginLogs(opts)
|
||||||
writeListResponse(c, rows, opts, err, loginLogDTO)
|
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) {
|
protected.POST("/discard-details/batch-delete", func(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
IDs []uint64 `json:"ids"`
|
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}
|
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 {
|
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}
|
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}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -359,6 +360,9 @@ func parseArgs() (*configpkg.Config, error) {
|
|||||||
|
|
||||||
// run 创建 MQTT broker 和 Web 服务,并阻塞等待退出信号。
|
// run 创建 MQTT broker 和 Web 服务,并阻塞等待退出信号。
|
||||||
func run(cfg *configpkg.Config) error {
|
func run(cfg *configpkg.Config) error {
|
||||||
|
if err := guardDefaultAdminPassword(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
store, err := storepkg.OpenStore(cfg.Database, cfg.ConsoleLog.SQL)
|
store, err := storepkg.OpenStore(cfg.Database, cfg.ConsoleLog.SQL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -586,6 +590,30 @@ func startMQTTServer(cfg *configpkg.Config, store *storepkg.Store, dbQueue *stor
|
|||||||
return server, hook, authHook, addr, nil
|
return server, hook, authHook, addr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// guardDefaultAdminPassword 防止 Web 管理后台以默认口令监听非回环地址。
|
||||||
|
// 新部署请通过 MESH_ADMIN_PASSWORD 或在配置中修改 web.admin.password 后再启动。
|
||||||
|
func guardDefaultAdminPassword(cfg *configpkg.Config) error {
|
||||||
|
if !cfg.Web.Enabled || !cfg.Web.PortEnabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg.Web.Admin.Password != "" && cfg.Web.Admin.Password != "admin" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isLoopbackHost(cfg.Web.Host) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("web.admin.password 仍为默认值 admin 且监听非回环地址,拒绝启动:请设置环境变量 MESH_ADMIN_PASSWORD 或修改配置 web.admin.password 后重试")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLoopbackHost(host string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(host)) {
|
||||||
|
case "localhost", "127.0.0.1", "::1":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false // 空值会绑定全部网卡,按非回环处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// printJSON 将记录编码为 JSON 后按数据包类型着色输出。
|
// printJSON 将记录编码为 JSON 后按数据包类型着色输出。
|
||||||
func printJSON(record map[string]any) {
|
func printJSON(record map[string]any) {
|
||||||
printJSONBytes(record, mqtpp.MustJSON(record))
|
printJSONBytes(record, mqtpp.MustJSON(record))
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configpkg "meshtastic_mqtt_server/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsLoopbackHost(t *testing.T) {
|
||||||
|
for _, in := range []string{"localhost", "127.0.0.1", "::1", "LOCALHOST"} {
|
||||||
|
if !isLoopbackHost(in) {
|
||||||
|
t.Errorf("%q should be loopback", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, in := range []string{"0.0.0.0", "", "192.168.1.5", " ", "meshmap.lmve.net"} {
|
||||||
|
if isLoopbackHost(in) {
|
||||||
|
t.Errorf("%q should not be loopback", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardDefaultAdminPassword(t *testing.T) { webEnabled := func(host, password string, portEnabled bool) *configpkg.Config {
|
||||||
|
return &configpkg.Config{
|
||||||
|
Web: configpkg.WebConfig{
|
||||||
|
Enabled: true,
|
||||||
|
PortEnabled: portEnabled,
|
||||||
|
Host: host,
|
||||||
|
Admin: configpkg.WebAdminConfig{Username: "admin", Password: password},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
cfg *configpkg.Config
|
||||||
|
reject bool
|
||||||
|
}{
|
||||||
|
{"公网+默认口令", webEnabled("0.0.0.0", "admin", true), true},
|
||||||
|
{"公网+自定义口令", webEnabled("0.0.0.0", "s3cret!", true), false},
|
||||||
|
{"回环+默认口令", webEnabled("127.0.0.1", "admin", true), false},
|
||||||
|
{"空口令", webEnabled("0.0.0.0", "", true), true},
|
||||||
|
{"仅socket", webEnabled("0.0.0.0", "admin", false), false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
err := guardDefaultAdminPassword(tc.cfg)
|
||||||
|
if tc.reject && err == nil {
|
||||||
|
t.Errorf("%s: expected rejection", tc.name)
|
||||||
|
}
|
||||||
|
if !tc.reject && err != nil {
|
||||||
|
t.Errorf("%s: unexpected rejection: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -232,7 +232,8 @@ export function getPositions(limit = 500, offset = 0, nodeIdOrOptions: string |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> {
|
export function getDiscardDetails(limit = 100, offset = 0): Promise<ListResponse<DiscardDetails>> {
|
||||||
return getJSON<ListResponse<DiscardDetails>>(listPath('/api/discard-details', limit, offset))
|
// 公开接口 /api/discard-details 已去敏(不含客户端 IP/原始报文),管理页改用 admin 全字段端点。
|
||||||
|
return getJSON<ListResponse<DiscardDetails>>(listPath('/api/admin/discard-details', limit, offset))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteDiscardDetailsByIDs(ids: number[]): Promise<{ status: string; deleted_count: number }> {
|
export function deleteDiscardDetailsByIDs(ids: number[]): Promise<{ status: string; deleted_count: number }> {
|
||||||
|
|||||||
Reference in New Issue
Block a user