安全加固: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:
2026-08-20 17:00:41 +08:00
parent fe0cf036cf
commit f21e8337af
13 changed files with 658 additions and 48 deletions
+10 -3
View File
@@ -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)
}
}
+173
View File
@@ -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))
}
+81
View File
@@ -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")
}
}
+94 -2
View File
@@ -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"
}
+83
View File
@@ -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
View File
@@ -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}
}