ingest: meshcore MQTT->ClickHouse stack

Vendor the ingest service under ingest/ and move the web app under meshexplorer/.
The ingest builds the meshcoreingest daemon and the goose migration runner,
applies the meshcore ClickHouse schema (packets, adverts, unified node view),
and loads its MQTT broker list and ClickHouse settings entirely from environment
variables (MQTT_BROKERS as a JSON array, CLICKHOUSE_*). No credentials are baked
into the source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Alex Vanderpot
2026-05-29 00:37:36 -04:00
parent 815d465566
commit 83978609f0
111 changed files with 1736 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# Build artifacts
clickhouse-meshingest
clickhouse-meshingest.exe
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Log files
*.log
# Configuration files with sensitive data
config.json
.env
# Temporary files
tmp/
temp/
mqtt/internal/data/
mqtt/external/data/
tiles/
cache/
splat*
.env
+57
View File
@@ -0,0 +1,57 @@
# syntax=docker/dockerfile:1
FROM golang:1.24-alpine AS builder
# Install build dependencies
RUN apk add --no-cache git ca-certificates tzdata
# Set working directory
WORKDIR /app
# Copy go mod files for dependency caching
COPY go.mod go.sum ./
# Download dependencies (cached by BuildKit)
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy source code
COPY . .
# Build the meshcore ingest daemon
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o meshcoreingest ./cmd/meshcoreingest
# Build the migration runner
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o migrate ./internal/migrate
# Final stage
FROM alpine:latest
# Install runtime dependencies
RUN apk --no-cache add ca-certificates tzdata
# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
# Set working directory
WORKDIR /app
# Copy binaries from builder stage
COPY --from=builder /app/meshcoreingest .
COPY --from=builder /app/migrate .
# Bundle migrations so the migrate service can apply them on startup
COPY --from=builder /app/migrations ./migrations
# Change ownership to non-root user
RUN chown -R appuser:appgroup /app
# Switch to non-root user
USER appuser
# Run the meshcore ingest daemon by default; the migrate service overrides this.
CMD ["./meshcoreingest"]
+59
View File
@@ -0,0 +1,59 @@
# MeshCore Ingest
A Go service that ingests MeshCore MQTT messages into ClickHouse, plus the
ClickHouse image and SQL migrations for the schema.
This directory is normally run as part of the full stack via the
[root `docker compose`](../README.md). The notes below cover running and
developing it on its own.
## Components
- `cmd/meshcoreingest` — the ingest daemon. Subscribes to MeshCore MQTT topics
and writes raw packets into the `meshcore_packets` table.
- `internal/ingestcommon` — shared MQTT + ClickHouse connection/daemon logic.
- `internal/migrate` — a [goose](https://github.com/pressly/goose) based
migration runner (ClickHouse dialect).
- `migrations/` — the ClickHouse schema: the `meshcore_packets` table, the decoded
`meshcore_adverts` / `meshcore_adverts_latest` / `meshcore_public_channel_messages`
views, and the `unified_latest_nodeinfo` view consumed by the web app.
- `clickhouse/` — a thin ClickHouse server image plus the read-only user used by
the web app.
## Configuration
All configuration is via environment variables (no credentials are baked into the
source):
| Variable | Description |
|----------|-------------|
| `MQTT_BROKERS` | JSON array of brokers: `[{"url","username","password","topics"}]`. `topics` defaults to `["meshcore/#"]`. Required; the daemon exits if unset. |
| `MQTT_CLIENT_ID` | MQTT client id prefix (default `meshcore-ingest`). |
| `CLICKHOUSE_HOST` / `CLICKHOUSE_PORT` | ClickHouse address (native protocol, default `127.0.0.1:9000`). |
| `CLICKHOUSE_DB` / `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` | ClickHouse database and read/write credentials. |
## Building
```bash
go build ./...
go test ./...
```
## Running migrations
```bash
go run ./internal/migrate \
-host localhost -port 9000 \
-username default -password "$CLICKHOUSE_PASSWORD" \
-path migrations -action up
```
Actions: `up`, `down`, `reset`, `status`, `version`.
## Running the ingest daemon
```bash
export MQTT_BROKERS='[{"url":"tcp://mqtt.example.com:1883","username":"u","password":"p","topics":["meshcore/#"]}]'
export CLICKHOUSE_HOST=localhost CLICKHOUSE_PORT=9000 CLICKHOUSE_PASSWORD=...
go run ./cmd/meshcoreingest
```
+26
View File
@@ -0,0 +1,26 @@
FROM clickhouse/clickhouse-server:latest as builder
RUN apt-get update && \
apt-get install -y build-essential libssl-dev libmsgpack-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY meshcore_decrypt_udf.c ./
RUN gcc -O2 -o meshcore_decrypt_udf meshcore_decrypt_udf.c -lssl -lcrypto -lmsgpackc
FROM clickhouse/clickhouse-server:latest
# Copy UDF binary to /usr/local/share/clickhouse-udf-bins
COPY --from=builder /build/meshcore_decrypt_udf /usr/local/share/clickhouse-udf-bins/meshcore_decrypt_udf
# Copy UDF XML config directly to the location and name matching ClickHouse config pattern
COPY meshcore_decrypt_udf.xml /etc/clickhouse-server/meshcore_decrypt_function.xml
# Copy users configuration file
COPY users.xml /etc/clickhouse-server/users.d/readonly_user.xml
# Copy access control configuration
COPY config.xml /etc/clickhouse-server/config.d/access_control.xml
RUN chmod +x /usr/local/share/clickhouse-udf-bins/meshcore_decrypt_udf
# Remove custom entrypoint, use default
+5
View File
@@ -0,0 +1,5 @@
<clickhouse>
<access_control_improvements>
<settings_constraints_replace_previous>true</settings_constraints_replace_previous>
</access_control_improvements>
</clickhouse>
+99
View File
@@ -0,0 +1,99 @@
#include <stdio.h>
#include <string.h>
#include <openssl/aes.h>
#include <openssl/hmac.h>
#include <openssl/sha.h>
#include <msgpack.h>
#define CIPHER_MAC_SIZE 2
#define CIPHER_KEY_SIZE 16
#define MAX_PAYLOAD 184
// Default "public" channel key
static const unsigned char public_channel_private_key[16] = {
0x8B, 0x33, 0x87, 0xE5, 0xCD, 0xEA, 0x6A, 0xC9,
0xE5, 0xED, 0xBA, 0xA1, 0x15, 0xCD, 0x72, 0x0F
};
static int decrypt_msg(const unsigned char *key, const unsigned char *data, size_t len, unsigned char *out, size_t *out_len) {
if (len <= CIPHER_MAC_SIZE) return 0;
unsigned char mac[SHA256_DIGEST_LENGTH];
HMAC(EVP_sha256(), key, CIPHER_KEY_SIZE, data + CIPHER_MAC_SIZE,
len - CIPHER_MAC_SIZE, mac, NULL);
if (memcmp(mac, data, CIPHER_MAC_SIZE) != 0) return 0;
AES_KEY aes_key;
AES_set_decrypt_key(key, 128, &aes_key);
size_t blocks = (len - CIPHER_MAC_SIZE) / 16;
for (size_t i = 0; i < blocks; i++) {
AES_decrypt(data + CIPHER_MAC_SIZE + (i * 16), out + (i * 16), &aes_key);
}
*out_len = blocks * 16;
return 1;
}
int main() {
unsigned char buf[8192];
size_t len = fread(buf, 1, sizeof(buf), stdin);
if (len == 0) return 0;
msgpack_unpacked result;
msgpack_unpacked_init(&result);
size_t off = 0;
msgpack_unpack_return ret = msgpack_unpack_next(&result, (char*)buf, len, &off);
if (ret == MSGPACK_UNPACK_SUCCESS && result.data.type == MSGPACK_OBJECT_ARRAY) {
msgpack_object_array arr = result.data.via.array;
msgpack_sbuffer sbuf;
msgpack_sbuffer_init(&sbuf);
msgpack_packer pk;
msgpack_packer_init(&pk, &sbuf, msgpack_sbuffer_write);
msgpack_pack_array(&pk, arr.size);
for (size_t i = 0; i < arr.size; i++) {
msgpack_object row = arr.ptr[i];
if (row.type == MSGPACK_OBJECT_ARRAY && row.via.array.size >= 3) {
msgpack_object mac_obj = row.via.array.ptr[0];
msgpack_object enc_obj = row.via.array.ptr[1];
msgpack_object hash_obj = row.via.array.ptr[2];
if (mac_obj.type == MSGPACK_OBJECT_STR && enc_obj.type == MSGPACK_OBJECT_STR && hash_obj.type == MSGPACK_OBJECT_STR && hash_obj.via.str.size > 0) {
// Concatenate mac + encrypted_data
size_t mac_len = mac_obj.via.str.size;
size_t enc_len = enc_obj.via.str.size;
unsigned char mac_and_enc[MAX_PAYLOAD];
if (mac_len + enc_len > MAX_PAYLOAD) { msgpack_pack_nil(&pk); continue; }
memcpy(mac_and_enc, mac_obj.via.str.ptr, mac_len);
memcpy(mac_and_enc + mac_len, enc_obj.via.str.ptr, enc_len);
unsigned char decrypted[MAX_PAYLOAD];
size_t decrypted_len;
if (decrypt_msg(public_channel_private_key, mac_and_enc, mac_len + enc_len, decrypted, &decrypted_len)) {
msgpack_pack_array(&pk, 2);
msgpack_pack_str(&pk, decrypted_len);
msgpack_pack_str_body(&pk, (char*)decrypted, decrypted_len);
unsigned char full_hash[SHA256_DIGEST_LENGTH];
SHA256(public_channel_private_key, CIPHER_KEY_SIZE, full_hash);
msgpack_pack_str(&pk, SHA256_DIGEST_LENGTH);
msgpack_pack_str_body(&pk, (char*)full_hash, SHA256_DIGEST_LENGTH);
} else {
msgpack_pack_nil(&pk);
}
} else {
msgpack_pack_nil(&pk);
}
} else {
msgpack_pack_nil(&pk);
}
}
fwrite(sbuf.data, 1, sbuf.size, stdout);
msgpack_sbuffer_destroy(&sbuf);
}
msgpack_unpacked_destroy(&result);
return 0;
}
@@ -0,0 +1,30 @@
<?xml version="1.0"?>
<clickhouse>
<function>
<name>meshcore_try_decrypt</name>
<type>executable</type>
<command>/usr/local/share/clickhouse-udf-bins/meshcore_decrypt_udf</command>
<argument>
<type>String</type>
<name>mac</name>
</argument>
<argument>
<type>String</type>
<name>encrypted_data</name>
</argument>
<argument>
<type>String</type>
<name>channel_hash_raw</name>
</argument>
<format>MsgPack</format>
<return_type>Nullable(Array(String))</return_type>
<return_name>result</return_name>
<execute_direct>1</execute_direct>
<max_command_execution_time>30</max_command_execution_time>
<command_termination_timeout>10</command_termination_timeout>
<command_read_timeout>10000</command_read_timeout>
<command_write_timeout>10000</command_write_timeout>
<send_chunk_header>false</send_chunk_header>
<deterministic>true</deterministic>
</function>
</clickhouse>
+4
View File
@@ -0,0 +1,4 @@
<clickhouse>
<user_defined_executable_functions_config>/usr/local/share/clickhouse-udfs/*.xml</user_defined_executable_functions_config>
<user_scripts_path>/usr/local/share/clickhouse-udf-bins/</user_scripts_path>
</clickhouse>
+33
View File
@@ -0,0 +1,33 @@
<clickhouse>
<users>
<!-- Readonly user configuration. Used by the web UI / discord bot.
Only reachable on the internal docker network, so the password is fine inline. -->
<readonly>
<password>readonly</password>
<networks incl="networks" replace="replace">
<ip>::/0</ip>
</networks>
<profile>readonly</profile>
<quota>default</quota>
</readonly>
</users>
<profiles>
<!-- Profile for readonly user -->
<readonly>
<readonly>1</readonly>
<allow_ddl>0</allow_ddl>
<max_memory_usage>10000000000</max_memory_usage>
<max_execution_time>300</max_execution_time>
<max_rows_to_read>10000000</max_rows_to_read>
<max_bytes_to_read>500000000</max_bytes_to_read>
<constraints>
<max_execution_time>
<changeable_in_readonly/>
<min>0</min>
<max>180</max>
</max_execution_time>
</constraints>
</readonly>
</profiles>
</clickhouse>
+334
View File
@@ -0,0 +1,334 @@
package main
import (
"encoding/hex"
"encoding/json"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/ClickHouse/ch-go/proto"
"github.com/ajvpot/clickhouse-meshingest/internal/ingestcommon"
mqtt "github.com/eclipse/paho.mqtt.golang"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
func parseMeshCoreRawMessage(payload []byte) (origin string, originPubkey []byte, meshTimestamp time.Time, packet []byte, err error) {
type RawPacket struct {
Origin string `json:"origin"`
OriginID string `json:"origin_id"`
Timestamp string `json:"timestamp"`
Type string `json:"type"`
Data string `json:"data"`
}
var pkt RawPacket
if err = json.Unmarshal(payload, &pkt); err != nil {
return "", nil, time.Time{}, nil, err
}
packet, err = hex.DecodeString(pkt.Data)
if err != nil {
return "", nil, time.Time{}, nil, err
}
// Clean origin and origin_id by removing carriage returns and newlines
cleanOrigin := strings.ReplaceAll(strings.ReplaceAll(pkt.Origin, "\r", ""), "\n", "")
cleanOriginID := strings.ReplaceAll(strings.ReplaceAll(pkt.OriginID, "\r", ""), "\n", "")
// Decode origin_id hex to binary bytes for compact storage
var originPubkeyBytes []byte
if cleanOriginID != "" {
if decoded, decErr := hex.DecodeString(cleanOriginID); decErr == nil {
originPubkeyBytes = decoded
} else {
originPubkeyBytes = []byte(cleanOriginID)
}
}
ts := pkt.Timestamp
if len(ts) > 0 && ts[len(ts)-1] != 'Z' && !strings.ContainsAny(ts[len(ts)-6:], "+-") {
ts = ts + "Z"
}
meshTimestamp, err = time.Parse(time.RFC3339Nano, ts)
if err != nil {
return "", nil, time.Time{}, nil, err
}
return cleanOrigin, originPubkeyBytes, meshTimestamp, packet, nil
}
func parseMeshCorePacketsMessage(payload []byte) (origin string, originPubkey []byte, meshTimestamp time.Time, packet []byte, err error) {
type PacketMessage struct {
Origin string `json:"origin"`
OriginID string `json:"origin_id"`
Timestamp string `json:"timestamp"`
Type string `json:"type"`
Direction string `json:"direction"`
Time string `json:"time"`
Date string `json:"date"`
Len string `json:"len"`
PacketType string `json:"packet_type"`
Route string `json:"route"`
PayloadLen string `json:"payload_len"`
Raw string `json:"raw"`
SNR string `json:"SNR"`
RSSI string `json:"RSSI"`
Score string `json:"score"`
Duration string `json:"duration"`
Hash string `json:"hash"`
}
var pkt PacketMessage
if err = json.Unmarshal(payload, &pkt); err != nil {
return "", nil, time.Time{}, nil, err
}
// Decode the raw hex packet data
packet, err = hex.DecodeString(pkt.Raw)
if err != nil {
return "", nil, time.Time{}, nil, err
}
// Clean origin and origin_id by removing carriage returns and newlines
cleanOrigin := strings.ReplaceAll(strings.ReplaceAll(pkt.Origin, "\r", ""), "\n", "")
cleanOriginID := strings.ReplaceAll(strings.ReplaceAll(pkt.OriginID, "\r", ""), "\n", "")
// Decode origin_id hex to binary bytes for compact storage
var originPubkeyBytes []byte
if cleanOriginID != "" {
if decoded, decErr := hex.DecodeString(cleanOriginID); decErr == nil {
originPubkeyBytes = decoded
} else {
originPubkeyBytes = []byte(cleanOriginID)
}
}
// Parse timestamp
ts := pkt.Timestamp
if len(ts) > 0 && ts[len(ts)-1] != 'Z' && !strings.ContainsAny(ts[len(ts)-6:], "+-") {
ts = ts + "Z"
}
meshTimestamp, err = time.Parse(time.RFC3339Nano, ts)
if err != nil {
return "", nil, time.Time{}, nil, err
}
return cleanOrigin, originPubkeyBytes, meshTimestamp, packet, nil
}
// extractBaseTopic extracts the base topic from a full MQTT topic path
// For topics like:
// - meshcore/raw -> returns "meshcore"
// - meshcore/salish/raw -> returns "meshcore/salish"
// - meshcore/salish/a/raw -> returns "meshcore/salish/a"
// - meshcore/salish/a/b/raw -> returns "meshcore/salish/a/b"
// - meshcore/binary/[gatewayId] -> returns "meshcore"
// - meshcore/salish/binary/[gatewayId] -> returns "meshcore/salish"
// - meshcore/packets -> returns "meshcore"
// - meshcore/salish/packets -> returns "meshcore/salish"
// - meshcore/SEA/[gatewayId]/packets -> returns "meshcore/SEA"
func extractBaseTopic(topic string) string {
topicParts := strings.Split(topic, "/")
if len(topicParts) < 2 {
return topic
}
// Check if it's a binary topic (last 2 parts are "binary" and gateway ID)
if len(topicParts) >= 3 && topicParts[len(topicParts)-2] == "binary" {
// Remove the last 2 parts: "binary" and "[gatewayId]"
return strings.Join(topicParts[:len(topicParts)-2], "/")
}
// Check if it's a raw topic (last part is "raw")
if topicParts[len(topicParts)-1] == "raw" {
// Remove the last 1 part: "raw"
return strings.Join(topicParts[:len(topicParts)-1], "/")
}
// Check if it's a packets topic with gateway ID (last part is "packets" and second-to-last looks like a gateway ID)
if len(topicParts) >= 4 && topicParts[len(topicParts)-1] == "packets" {
gatewayID := topicParts[len(topicParts)-2]
// If the second-to-last part is a long hex string (likely a gateway ID), remove both parts
if len(gatewayID) >= 40 && isHexString(gatewayID) {
// Remove the last 2 parts: "[gatewayId]" and "packets"
return strings.Join(topicParts[:len(topicParts)-2], "/")
}
}
// Check if it's a packets topic (last part is "packets")
if topicParts[len(topicParts)-1] == "packets" {
// Remove the last 1 part: "packets"
return strings.Join(topicParts[:len(topicParts)-1], "/")
}
// Fallback: if no "raw", "binary", or "packets" pattern found, return the first part
return topicParts[0]
}
// isHexString checks if a string contains only hexadecimal characters
func isHexString(s string) bool {
for _, c := range s {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return false
}
}
return true
}
func handleMeshCoreMessage(client mqtt.Client, msg mqtt.Message, d *ingestcommon.Daemon) {
broker := ""
clientOptions := client.OptionsReader()
if servers := clientOptions.Servers(); len(servers) > 0 {
broker = servers[0].String()
}
if broker == "tcp://mqtt-internal:1883" {
broker = "tcp://mqtt.w0z.is:1883"
}
zap.L().Debug("Received MeshCore packet", zap.String("topic", msg.Topic()), zap.String("broker", broker))
// Split topic once for efficient processing
topicParts := strings.Split(msg.Topic(), "/")
// Extract base topic (e.g., meshcore, meshcore/salish, meshcore/salish/a, etc.)
// This ensures we store only the base topic in the database, not the full topic path
baseTopic := extractBaseTopic(msg.Topic())
startTime := time.Now()
// Handle meshcore/raw and meshcore/*/raw topics (including multi-level paths like meshcore/salish/a/raw)
if len(topicParts) >= 2 && topicParts[len(topicParts)-1] == "raw" {
origin, originPubkey, meshTimestamp, decoded, err := parseMeshCoreRawMessage(msg.Payload())
if err != nil {
zap.L().Warn("Failed to parse meshcore/*/raw message", zap.Error(err))
return
}
query := `INSERT INTO meshcore_packets (ingest_timestamp, origin, origin_pubkey, broker, topic, mesh_timestamp, packet) VALUES (?, ?, ?, ?, ?, ?, ?)`
ingestTime := time.Now()
err = d.CHConn.Exec(d.Ctx, query, proto.ToDateTime64(ingestTime, proto.PrecisionMilli), origin, string(originPubkey), broker, baseTopic, meshTimestamp, string(decoded))
if err != nil {
zap.L().Warn("Failed to insert MeshCore packet into ClickHouse", zap.Error(err))
return
}
zap.L().Info("Successfully ingested MeshCore RAW packet", zap.String("broker", broker), zap.String("topic", baseTopic), zap.Duration("duration", time.Since(startTime)))
return
}
// Handle meshcore/*/[gatewayId]/packets topics (e.g., meshcore/SEA/[gatewayId]/packets)
if len(topicParts) >= 4 && topicParts[len(topicParts)-1] == "packets" {
gatewayID := topicParts[len(topicParts)-2]
// Check if the second-to-last part is a gateway ID (long hex string)
if len(gatewayID) >= 40 && isHexString(gatewayID) {
origin, originPubkey, meshTimestamp, decoded, err := parseMeshCorePacketsMessage(msg.Payload())
if err != nil {
zap.L().Warn("Failed to parse meshcore/*/[gatewayId]/packets message", zap.Error(err))
return
}
query := `INSERT INTO meshcore_packets (ingest_timestamp, origin, origin_pubkey, broker, topic, mesh_timestamp, packet) VALUES (?, ?, ?, ?, ?, ?, ?)`
ingestTime := time.Now()
err = d.CHConn.Exec(d.Ctx, query, proto.ToDateTime64(ingestTime, proto.PrecisionMilli), origin, string(originPubkey), broker, baseTopic, meshTimestamp, string(decoded))
if err != nil {
zap.L().Warn("Failed to insert MeshCore packet into ClickHouse", zap.Error(err))
return
}
zap.L().Info("Successfully ingested MeshCore PACKETS packet (with gateway ID)", zap.String("broker", broker), zap.String("topic", baseTopic), zap.String("gatewayID", gatewayID), zap.Duration("duration", time.Since(startTime)))
return
}
}
// Handle meshcore/packets and meshcore/*/packets topics (including multi-level paths)
if len(topicParts) >= 2 && topicParts[len(topicParts)-1] == "packets" {
origin, originPubkey, meshTimestamp, decoded, err := parseMeshCorePacketsMessage(msg.Payload())
if err != nil {
zap.L().Warn("Failed to parse meshcore/*/packets message", zap.Error(err))
return
}
query := `INSERT INTO meshcore_packets (ingest_timestamp, origin, origin_pubkey, broker, topic, mesh_timestamp, packet) VALUES (?, ?, ?, ?, ?, ?, ?)`
ingestTime := time.Now()
err = d.CHConn.Exec(d.Ctx, query, proto.ToDateTime64(ingestTime, proto.PrecisionMilli), origin, string(originPubkey), broker, baseTopic, meshTimestamp, string(decoded))
if err != nil {
zap.L().Warn("Failed to insert MeshCore packet into ClickHouse", zap.Error(err))
return
}
zap.L().Info("Successfully ingested MeshCore PACKETS packet", zap.String("broker", broker), zap.String("topic", baseTopic), zap.Duration("duration", time.Since(startTime)))
return
}
// Handle meshcore/*/binary/[gatewayid] topics (including multi-level paths)
if len(topicParts) >= 3 && topicParts[len(topicParts)-2] == "binary" {
// Extract gateway ID from the last part of the topic
gatewayID := topicParts[len(topicParts)-1]
// Decode gatewayID hex to binary bytes for compact storage; fall back to raw bytes on error
originPubkeyBytes, decErr := hex.DecodeString(gatewayID)
if decErr != nil {
originPubkeyBytes = []byte{}
}
query := `INSERT INTO meshcore_packets (ingest_timestamp, origin, origin_pubkey, broker, topic, mesh_timestamp, packet) VALUES (?, ?, ?, ?, ?, ?, ?)`
ingestTime := time.Now()
err := d.CHConn.Exec(d.Ctx, query, proto.ToDateTime64(ingestTime, proto.PrecisionMilli), gatewayID, string(originPubkeyBytes), broker, baseTopic, time.Time{}, string(msg.Payload()))
if err != nil {
zap.L().Warn("Failed to insert MeshCore binary packet into ClickHouse", zap.Error(err))
return
}
zap.L().Info("Successfully ingested MeshCore BINARY packet", zap.String("broker", broker), zap.String("topic", baseTopic), zap.String("gatewayID", gatewayID), zap.Duration("duration", time.Since(startTime)))
return
}
}
func loadConfig() (*ingestcommon.Config, error) {
brokers, err := ingestcommon.LoadMQTTBrokersFromEnv()
if err != nil {
return nil, err
}
config := &ingestcommon.Config{
MQTTBrokers: brokers,
MQTTClientID: ingestcommon.GetEnvOrDefault("MQTT_CLIENT_ID", "meshcore-ingest"),
}
config.ClickHouse.Host = ingestcommon.GetEnvOrDefault("CLICKHOUSE_HOST", "127.0.0.1")
config.ClickHouse.Port = ingestcommon.GetEnvIntOrDefault("CLICKHOUSE_PORT", 9000)
config.ClickHouse.Database = ingestcommon.GetEnvOrDefault("CLICKHOUSE_DB", "default")
config.ClickHouse.Username = ingestcommon.GetEnvOrDefault("CLICKHOUSE_USER", "default")
config.ClickHouse.Password = ingestcommon.GetEnvOrDefault("CLICKHOUSE_PASSWORD", "")
return config, nil
}
func main() {
zapConfig := zap.NewProductionConfig()
zapConfig.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
zapConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
logger, err := zapConfig.Build()
if err != nil {
log.Fatalf("Failed to initialize logger: %v", err)
}
defer logger.Sync()
zap.ReplaceGlobals(logger)
config, err := loadConfig()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
daemon := ingestcommon.NewDaemon(config, handleMeshCoreMessage)
if err := daemon.ConnectClickHouse(); err != nil {
log.Fatalf("Failed to connect to ClickHouse: %v", err)
}
defer daemon.CHConn.Close()
if err := daemon.ConnectMQTT(); err != nil {
log.Fatalf("Failed to connect to MQTT brokers: %v", err)
}
defer func() {
for _, client := range daemon.MQTTClients {
if client != nil {
client.Disconnect(250)
}
}
}()
go daemon.MonitorConnections()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
zap.L().Info("MeshCore ingest daemon started. Press Ctrl+C to stop.")
<-sigChan
zap.L().Info("Shutting down daemon...")
daemon.Cancel()
}
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"encoding/hex"
"strings"
"testing"
"time"
)
func TestParseMeshCoreRawMessage_Valid(t *testing.T) {
payload := []byte(`{
"origin": "WW7STR/PugetMesh Cougar^",
"origin_id": "CB1F3E60913AC96ECCD9AE1053BB8646C5AA0A8EDBAC6D17822D06B0E054F6A5",
"timestamp": "2025-07-02T00:13:53.335723",
"type": "RAW",
"data": "050258C454C59A5A6A081C07D97E4A4C22FE575584F2BCE8267D76E0AAAC55283350EDCE7AF4ECBDCF12C599017ACC03D659F6C2A2AEF684B66774501A77BD4F361C6E5436BB6625"
}`)
origin, originPubkey, meshTimestamp, packet, err := parseMeshCoreRawMessage(payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if origin != "WW7STR/PugetMesh Cougar^" {
t.Errorf("unexpected origin: %s", origin)
}
if hex.EncodeToString(originPubkey) != strings.ToLower("CB1F3E60913AC96ECCD9AE1053BB8646C5AA0A8EDBAC6D17822D06B0E054F6A5") {
t.Errorf("unexpected origin_pubkey: %s", hex.EncodeToString(originPubkey))
}
if meshTimestamp.Format(time.RFC3339Nano) != "2025-07-02T00:13:53.335723Z" {
t.Errorf("unexpected meshTimestamp: %s", meshTimestamp)
}
if len(packet) == 0 {
t.Error("packet should not be empty")
}
}
func TestParseMeshCoreRawMessage_InvalidHex(t *testing.T) {
payload := []byte(`{
"origin": "WW7STR/PugetMesh Cougar^",
"origin_id": "CB1F3E60913AC96ECCD9AE1053BB8646C5AA0A8EDBAC6D17822D06B0E054F6A5",
"timestamp": "2025-07-02T00:13:53.335723",
"type": "RAW",
"data": "nothex"
}`)
_, _, _, _, err := parseMeshCoreRawMessage(payload)
if err == nil {
t.Error("expected error for invalid hex")
}
}
func TestParseMeshCoreRawMessage_InvalidTimestamp(t *testing.T) {
payload := []byte(`{
"origin": "WW7STR/PugetMesh Cougar^",
"origin_id": "CB1F3E60913AC96ECCD9AE1053BB8646C5AA0A8EDBAC6D17822D06B0E054F6A5",
"timestamp": "notatime",
"type": "RAW",
"data": "050258C454C59A5A6A081C07D97E4A4C22FE575584F2BCE8267D76E0AAAC55283350EDCE7AF4ECBDCF12C599017ACC03D659F6C2A2AEF684B66774501A77BD4F361C6E5436BB6625"
}`)
_, _, _, _, err := parseMeshCoreRawMessage(payload)
if err == nil {
t.Error("expected error for invalid timestamp")
}
}
func TestParseMeshCoreRawMessage_InvalidJSON(t *testing.T) {
payload := []byte(`notjson`)
_, _, _, _, err := parseMeshCoreRawMessage(payload)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestParseMeshCoreRawMessage_WithOriginID(t *testing.T) {
payload := []byte(`{
"origin": "HAX!peater^",
"origin_id": "CB1F3E60913AC96ECCD9AE1053BB8646C5AA0A8EDBAC6D17822D06B0E054F6A5",
"timestamp": "2025-08-18T17:27:28.053612",
"type": "RAW",
"data": "0500857E3083FDA8C09D70B84A77460B03F3408A1C24B478BA397BB9563CDDB09FE48CB9F0B04BEE7976EAD62B894E5FE91D9F000FC4B6437F46AB94CC6FE0936A97675698D9"
}`)
origin, originPubkey, meshTimestamp, packet, err := parseMeshCoreRawMessage(payload)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if origin != "HAX!peater^" {
t.Errorf("unexpected origin: %s", origin)
}
if hex.EncodeToString(originPubkey) != strings.ToLower("CB1F3E60913AC96ECCD9AE1053BB8646C5AA0A8EDBAC6D17822D06B0E054F6A5") {
t.Errorf("unexpected origin_pubkey: %s", hex.EncodeToString(originPubkey))
}
if meshTimestamp.Format(time.RFC3339Nano) != "2025-08-18T17:27:28.053612Z" {
t.Errorf("unexpected meshTimestamp: %s", meshTimestamp)
}
if len(packet) == 0 {
t.Error("packet should not be empty")
}
}
func TestExtractBaseTopic(t *testing.T) {
tests := []struct {
name string
topic string
expected string
}{
// Raw topic cases
{
name: "meshcore/raw",
topic: "meshcore/raw",
expected: "meshcore",
},
{
name: "meshcore/salish/raw",
topic: "meshcore/salish/raw",
expected: "meshcore/salish",
},
{
name: "meshcore/salish/a/raw",
topic: "meshcore/salish/a/raw",
expected: "meshcore/salish/a",
},
{
name: "meshcore/salish/a/b/raw",
topic: "meshcore/salish/a/b/raw",
expected: "meshcore/salish/a/b",
},
// Binary topic cases
{
name: "meshcore/binary/gateway123",
topic: "meshcore/binary/gateway123",
expected: "meshcore",
},
{
name: "meshcore/salish/binary/gateway456",
topic: "meshcore/salish/binary/gateway456",
expected: "meshcore/salish",
},
{
name: "meshcore/salish/a/binary/gateway789",
topic: "meshcore/salish/a/binary/gateway789",
expected: "meshcore/salish/a",
},
// Edge cases
{
name: "single part topic",
topic: "meshcore",
expected: "meshcore",
},
{
name: "empty topic",
topic: "",
expected: "",
},
{
name: "topic with only raw",
topic: "raw",
expected: "raw",
},
{
name: "topic with only binary",
topic: "binary",
expected: "binary",
},
// Complex nested cases
{
name: "deep nested raw",
topic: "meshcore/region/state/city/neighborhood/raw",
expected: "meshcore/region/state/city/neighborhood",
},
{
name: "deep nested binary",
topic: "meshcore/region/state/city/neighborhood/binary/gateway999",
expected: "meshcore/region/state/city/neighborhood",
},
// Mixed cases that shouldn't match our patterns
{
name: "topic ending with other than raw or binary",
topic: "meshcore/salish/other",
expected: "meshcore",
},
{
name: "topic with raw in middle",
topic: "meshcore/raw/salish",
expected: "meshcore",
},
{
name: "topic with binary in middle",
topic: "meshcore/binary/salish",
expected: "meshcore",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractBaseTopic(tt.topic)
if result != tt.expected {
t.Errorf("extractBaseTopic(%q) = %q, want %q", tt.topic, result, tt.expected)
}
})
}
}
func TestExtractGatewayID(t *testing.T) {
tests := []struct {
name string
topic string
expected string
shouldHave bool
}{
{
name: "meshcore/binary/gateway123",
topic: "meshcore/binary/gateway123",
expected: "gateway123",
shouldHave: true,
},
{
name: "meshcore/salish/binary/gateway456",
topic: "meshcore/salish/binary/gateway456",
expected: "gateway456",
shouldHave: true,
},
{
name: "meshcore/salish/a/binary/gateway789",
topic: "meshcore/salish/a/binary/gateway789",
expected: "gateway789",
shouldHave: true,
},
{
name: "deep nested binary",
topic: "meshcore/region/state/city/neighborhood/binary/gateway999",
expected: "gateway999",
shouldHave: true,
},
{
name: "not a binary topic",
topic: "meshcore/raw",
expected: "",
shouldHave: false,
},
{
name: "not a binary topic 2",
topic: "meshcore/salish/other",
expected: "",
shouldHave: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
topicParts := strings.Split(tt.topic, "/")
var gatewayID string
// Match the exact logic from the main function
if len(topicParts) >= 3 && topicParts[len(topicParts)-2] == "binary" {
gatewayID = topicParts[len(topicParts)-1]
}
if tt.shouldHave {
if gatewayID != tt.expected {
t.Errorf("extractGatewayID(%q) = %q, want %q", tt.topic, gatewayID, tt.expected)
}
} else {
if gatewayID != "" {
t.Errorf("extractGatewayID(%q) = %q, but should not have gateway ID", tt.topic, gatewayID)
}
}
})
}
}
+38
View File
@@ -0,0 +1,38 @@
module github.com/ajvpot/clickhouse-meshingest
go 1.24
toolchain go1.24.4
require (
github.com/ClickHouse/ch-go v0.66.1
github.com/ClickHouse/clickhouse-go/v2 v2.37.2
github.com/eclipse/paho.mqtt.golang v1.5.0
github.com/pressly/goose/v3 v3.24.3
go.uber.org/zap v1.27.0
)
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+154
View File
@@ -0,0 +1,154 @@
github.com/ClickHouse/ch-go v0.66.1 h1:LQHFslfVYZsISOY0dnOYOXGkOUvpv376CCm8g7W74A4=
github.com/ClickHouse/ch-go v0.66.1/go.mod h1:NEYcg3aOFv2EmTJfo4m2WF7sHB/YFbLUuIWv9iq76xY=
github.com/ClickHouse/clickhouse-go/v2 v2.37.2 h1:wRLNKoynvHQEN4znnVHNLaYnrqVc9sGJmGYg+GGCfto=
github.com/ClickHouse/clickhouse-go/v2 v2.37.2/go.mod h1:pH2zrBGp5Y438DMwAxXMm1neSXPPjSI7tD4MURVULw8=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
+307
View File
@@ -0,0 +1,307 @@
package ingestcommon
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"time"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
mqtt "github.com/eclipse/paho.mqtt.golang"
"go.uber.org/zap"
)
type MQTTBrokerConfig struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
Topics []string `json:"topics"`
}
// LoadMQTTBrokersFromEnv parses the MQTT_BROKERS environment variable, which must
// contain a JSON array of broker configs, e.g.:
//
// [{"url":"tcp://host:1883","username":"u","password":"p","topics":["meshcore/#"]}]
//
// It returns an error when the variable is unset/empty or cannot be parsed so the
// daemon fails fast with a clear message rather than silently connecting to nothing.
// Brokers without an explicit topic list default to subscribing to "meshcore/#".
func LoadMQTTBrokersFromEnv() ([]MQTTBrokerConfig, error) {
raw := os.Getenv("MQTT_BROKERS")
if raw == "" {
return nil, fmt.Errorf("MQTT_BROKERS is not set; provide a JSON array of broker configs (see .env.example)")
}
var brokers []MQTTBrokerConfig
if err := json.Unmarshal([]byte(raw), &brokers); err != nil {
return nil, fmt.Errorf("failed to parse MQTT_BROKERS as JSON: %w", err)
}
if len(brokers) == 0 {
return nil, fmt.Errorf("MQTT_BROKERS contained no brokers")
}
for i := range brokers {
if brokers[i].URL == "" {
return nil, fmt.Errorf("MQTT_BROKERS[%d] is missing a url", i)
}
if len(brokers[i].Topics) == 0 {
brokers[i].Topics = []string{"meshcore/#"}
}
}
return brokers, nil
}
type Config struct {
MQTTBrokers []MQTTBrokerConfig `json:"mqtt_brokers"`
MQTTClientID string `json:"mqtt_client_id"`
ClickHouse struct {
Host string `json:"host"`
Port int `json:"port"`
Database string `json:"database"`
Username string `json:"username"`
Password string `json:"password"`
} `json:"clickhouse"`
}
type MessageHandler func(client mqtt.Client, msg mqtt.Message, d *Daemon)
type Daemon struct {
Config *Config
MQTTClients []mqtt.Client
BrokerStatus map[string]bool
CHConn driver.Conn
Ctx context.Context
Cancel context.CancelFunc
MessageHandler MessageHandler
}
func NewDaemon(config *Config, handler MessageHandler) *Daemon {
ctx, cancel := context.WithCancel(context.Background())
return &Daemon{
Config: config,
BrokerStatus: make(map[string]bool),
Ctx: ctx,
Cancel: cancel,
MessageHandler: handler,
}
}
func (d *Daemon) connectToBroker(broker MQTTBrokerConfig, maxRetries int) (mqtt.Client, error) {
baseDelay := time.Second
maxDelay := 30 * time.Second
for attempt := 0; attempt <= maxRetries; attempt++ {
opts := mqtt.NewClientOptions()
opts.AddBroker(broker.URL)
opts.SetClientID(fmt.Sprintf("%s-%d", d.Config.MQTTClientID, attempt))
opts.SetDefaultPublishHandler(func(client mqtt.Client, msg mqtt.Message) {
d.MessageHandler(client, msg, d)
})
opts.SetAutoReconnect(true)
opts.SetConnectRetry(true)
opts.SetConnectTimeout(10 * time.Second)
opts.SetMaxReconnectInterval(30 * time.Second)
opts.SetKeepAlive(30 * time.Second)
opts.SetPingTimeout(10 * time.Second)
opts.SetCleanSession(false)
opts.SetResumeSubs(true)
opts.SetOnConnectHandler(func(client mqtt.Client) {
zap.L().Debug("Connected to MQTT broker", zap.String("broker", broker.URL))
})
opts.SetConnectionLostHandler(func(client mqtt.Client, err error) {
zap.L().Warn("Connection lost to MQTT broker", zap.String("broker", broker.URL), zap.Error(err))
d.BrokerStatus[broker.URL] = false
})
if broker.Username != "" {
opts.SetUsername(broker.Username)
if broker.Password != "" {
opts.SetPassword(broker.Password)
}
}
client := mqtt.NewClient(opts)
if token := client.Connect(); token.Wait() && token.Error() != nil {
zap.L().Warn("Failed to connect to MQTT broker",
zap.String("broker", broker.URL),
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", maxRetries+1),
zap.Error(token.Error()))
if attempt < maxRetries {
delay := time.Duration(float64(baseDelay) * math.Pow(2, float64(attempt)))
if delay > maxDelay {
delay = maxDelay
}
zap.L().Debug("Retrying connection", zap.String("broker", broker.URL), zap.Duration("delay", delay))
time.Sleep(delay)
continue
}
return nil, fmt.Errorf("failed to connect to MQTT broker %s after %d attempts: %w", broker.URL, maxRetries+1, token.Error())
}
for _, topic := range broker.Topics {
if token := client.Subscribe(topic, 0, nil); token.Wait() && token.Error() != nil {
zap.L().Warn("Failed to subscribe to topic",
zap.String("topic", topic),
zap.String("broker", broker.URL),
zap.Int("attempt", attempt+1),
zap.Int("maxRetries", maxRetries+1),
zap.Error(token.Error()))
if attempt < maxRetries {
client.Disconnect(250)
delay := time.Duration(float64(baseDelay) * math.Pow(2, float64(attempt)))
if delay > maxDelay {
delay = maxDelay
}
zap.L().Debug("Retrying subscription", zap.String("broker", broker.URL), zap.Duration("delay", delay))
time.Sleep(delay)
continue
}
client.Disconnect(250)
return nil, fmt.Errorf("failed to subscribe to topic %s on broker %s after %d attempts: %w", topic, broker.URL, maxRetries+1, token.Error())
}
}
zap.L().Debug("Successfully connected to MQTT broker and subscribed to topics",
zap.Strings("topics", broker.Topics),
zap.String("broker", broker.URL))
return client, nil
}
return nil, fmt.Errorf("unexpected error in connectToBroker for %s", broker.URL)
}
func (d *Daemon) ConnectMQTT() error {
maxRetries := 5
successfulConnections := 0
totalBrokers := len(d.Config.MQTTBrokers)
zap.L().Debug("Attempting to connect to MQTT brokers", zap.Int("totalBrokers", totalBrokers))
for _, broker := range d.Config.MQTTBrokers {
client, err := d.connectToBroker(broker, maxRetries)
if err != nil {
zap.L().Warn("Failed to connect to broker", zap.String("broker", broker.URL), zap.Error(err))
d.BrokerStatus[broker.URL] = false
continue
}
d.MQTTClients = append(d.MQTTClients, client)
d.BrokerStatus[broker.URL] = true
successfulConnections++
}
if successfulConnections == 0 {
return fmt.Errorf("failed to connect to any MQTT brokers")
}
zap.L().Debug("Successfully connected to MQTT brokers",
zap.Int("successfulConnections", successfulConnections),
zap.Int("totalBrokers", totalBrokers))
return nil
}
func (d *Daemon) ConnectClickHouse() error {
settings := clickhouse.Settings{
"max_execution_time": 60,
"async_insert": 1,
"wait_for_async_insert": 1,
"async_insert_busy_timeout_ms": 2500, // 2.5 seconds - flush if buffer is busy for this long
"async_insert_max_data_size": 1048576, // 1MB - flush when buffer reaches this size
"async_insert_max_query_number": 5000, // flush after this many insert queries accumulate
}
conn, err := clickhouse.Open(&clickhouse.Options{
Addr: []string{fmt.Sprintf("%s:%d", d.Config.ClickHouse.Host, d.Config.ClickHouse.Port)},
Auth: clickhouse.Auth{
Database: d.Config.ClickHouse.Database,
Username: d.Config.ClickHouse.Username,
Password: d.Config.ClickHouse.Password,
},
Settings: settings,
Debug: false,
})
if err != nil {
return fmt.Errorf("failed to connect to ClickHouse: %w", err)
}
d.CHConn = conn
zap.L().Info("Connected to ClickHouse",
zap.String("host", d.Config.ClickHouse.Host),
zap.Int("port", d.Config.ClickHouse.Port),
zap.Reflect("settings", settings))
return nil
}
func (d *Daemon) MonitorConnections() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-d.Ctx.Done():
return
case <-ticker.C:
d.checkAndReconnectBrokers()
}
}
}
func (d *Daemon) checkAndReconnectBrokers() {
for i, broker := range d.Config.MQTTBrokers {
if i >= len(d.MQTTClients) {
client, err := d.connectToBroker(broker, 3)
if err != nil {
zap.L().Warn("Background reconnection failed for broker", zap.String("broker", broker.URL), zap.Error(err))
continue
}
d.MQTTClients = append(d.MQTTClients, client)
d.BrokerStatus[broker.URL] = true
zap.L().Debug("Successfully reconnected to broker", zap.String("broker", broker.URL))
continue
}
client := d.MQTTClients[i]
if client == nil {
newClient, err := d.connectToBroker(broker, 3)
if err != nil {
zap.L().Warn("Background reconnection failed for broker", zap.String("broker", broker.URL), zap.Error(err))
d.BrokerStatus[broker.URL] = false
continue
}
d.MQTTClients[i] = newClient
d.BrokerStatus[broker.URL] = true
zap.L().Debug("Successfully reconnected to broker", zap.String("broker", broker.URL))
continue
}
if !client.IsConnected() {
d.BrokerStatus[broker.URL] = false
newClient, err := d.connectToBroker(broker, 3)
if err != nil {
zap.L().Warn("Background reconnection failed for broker", zap.String("broker", broker.URL), zap.Error(err))
continue
}
d.MQTTClients[i] = newClient
d.BrokerStatus[broker.URL] = true
zap.L().Debug("Successfully reconnected to broker", zap.String("broker", broker.URL))
} else {
d.BrokerStatus[broker.URL] = true
}
}
}
func GetEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func GetEnvIntOrDefault(key string, defaultValue int) int {
if value := os.Getenv(key); value != "" {
if intValue, err := strconv.Atoi(value); err == nil {
return intValue
}
}
return defaultValue
}
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"database/sql"
"flag"
"fmt"
"log"
"os"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/pressly/goose/v3"
)
func main() {
var (
host = flag.String("host", "localhost", "ClickHouse host")
port = flag.Int("port", 9000, "ClickHouse port")
database = flag.String("database", "default", "ClickHouse database")
username = flag.String("username", "default", "ClickHouse username")
password = flag.String("password", "", "ClickHouse password")
migrationsPath = flag.String("path", "migrations", "Path to migration files")
action = flag.String("action", "up", "Migration action: up, down, reset, status, version")
version = flag.Int("version", 0, "Version for down action")
)
flag.Parse()
// Create ClickHouse connection
conn := clickhouse.Connector(&clickhouse.Options{
Addr: []string{fmt.Sprintf("%s:%d", *host, *port)},
Auth: clickhouse.Auth{
Database: *database,
Username: *username,
Password: *password,
},
Settings: clickhouse.Settings{
"max_execution_time": 60,
},
Debug: false,
})
// Open database connection
db := sql.OpenDB(conn)
defer db.Close()
// Test connection
if err := db.Ping(); err != nil {
log.Fatalf("Failed to connect to ClickHouse: %v", err)
}
// Set goose database
goose.SetBaseFS(os.DirFS(*migrationsPath))
// Execute migration action
switch *action {
case "up":
if err := goose.Up(db, "."); err != nil {
log.Fatalf("Failed to run migrations up: %v", err)
}
fmt.Println("Migrations applied successfully")
case "down":
if *version == 0 {
// Down one migration
if err := goose.Down(db, "."); err != nil {
log.Fatalf("Failed to run migrations down: %v", err)
}
fmt.Println("Migration rolled back successfully")
} else {
// Down to specific version
if err := goose.DownTo(db, ".", int64(*version)); err != nil {
log.Fatalf("Failed to run migrations down to version %d: %v", *version, err)
}
fmt.Printf("Migrations rolled back to version %d successfully\n", *version)
}
case "reset":
if err := goose.Reset(db, "."); err != nil {
log.Fatalf("Failed to reset migrations: %v", err)
}
fmt.Println("Migrations reset successfully")
case "status":
if err := showStatus(db); err != nil {
log.Fatalf("Failed to show status: %v", err)
}
case "version":
version, err := goose.GetDBVersion(db)
if err != nil {
log.Fatalf("Failed to get migration version: %v", err)
}
fmt.Printf("Current migration version: %d\n", version)
default:
fmt.Printf("Unknown action: %s\n", *action)
fmt.Println("Available actions: up, down, reset, status, version")
os.Exit(1)
}
}
func showStatus(db *sql.DB) error {
// Get current version
currentVersion, err := goose.GetDBVersion(db)
if err != nil {
return err
}
fmt.Println("Migration Status:")
fmt.Println("==================")
fmt.Printf("Current version: %d\n", currentVersion)
// Note: goose doesn't provide a direct way to list all available migrations
// from the source, so we can't show pending migrations without additional file system scanning
fmt.Println("\nNote: Use 'version' action to see current migration state")
return nil
}
@@ -0,0 +1,133 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS meshcore_packets (
ingest_timestamp DateTime64(3) COMMENT 'Timestamp when the packet was ingested into the database',
-- todo change to low cardinality
origin String COMMENT 'Origin field from meshcore/raw',
origin_pubkey String COMMENT 'Public key of the origin node (raw packets) or gateway ID (binary packets)',
broker LowCardinality(String) COMMENT 'Message broker identifier',
topic LowCardinality(String) COMMENT 'Message topic',
mesh_timestamp DateTime64(6) COMMENT 'Timestamp from meshcore/raw',
packet String COMMENT 'Raw MeshCore packet as a string',
-- Extract the first byte as header
header UInt8 ALIAS reinterpretAsUInt8(substring(packet, 1, 1)) COMMENT 'Header byte: routing type, payload type, payload version',
-- Use bitwise operations on header as per MeshCore spec
route_type UInt8 ALIAS bitAnd(header, 0x03) COMMENT 'Route Type (bits 0-1 of header): 0=ROUTE_TYPE_TRANSPORT_FLOOD, 1=ROUTE_TYPE_FLOOD, 2=ROUTE_TYPE_DIRECT, 3=ROUTE_TYPE_TRANSPORT_DIRECT',
payload_type UInt8 ALIAS bitAnd(bitShiftRight(header, 2), 0x0F) COMMENT 'Payload Type (bits 2-5 of header): 0=PAYLOAD_TYPE_REQ, 1=PAYLOAD_TYPE_RESPONSE, 2=PAYLOAD_TYPE_TXT_MSG, 3=PAYLOAD_TYPE_ACK, 4=PAYLOAD_TYPE_ADVERT, 5=PAYLOAD_TYPE_GRP_TXT, 6=PAYLOAD_TYPE_GRP_DATA, 7=PAYLOAD_TYPE_ANON_REQ, 8=PAYLOAD_TYPE_PATH, 9=PAYLOAD_TYPE_TRACE, 15=PAYLOAD_TYPE_RAW_CUSTOM',
payload_version UInt8 ALIAS bitAnd(bitShiftRight(header, 6), 0x03) COMMENT 'Payload Version (bits 6-7 of header): 0=V1, 1=V2, 2=V3, 3=V4',
path_len UInt8 ALIAS reinterpretAsUInt8(substring(packet, 2, 1)) COMMENT 'Length of the path field in bytes (byte 2 of packet)',
path String ALIAS hex(substring(packet, 3, path_len)) COMMENT 'Routing path as hex string (variable length, starts at byte 3, length path_len)',
payload String ALIAS substring(packet, 3 + path_len, length(packet) - 2 - path_len) COMMENT 'Payload (starts after path, up to 184 bytes)',
packet_hash String ALIAS hex(substring(SHA256(concat(
reinterpretAsFixedString(toUInt8(payload_type)),
CASE WHEN payload_type = 9 THEN reinterpretAsFixedString(toUInt32(path_len)) ELSE '' END,
payload
)), 1, 8)) COMMENT 'Packet hash calculated using SHA-256: payload_type + path_len (TRACE only) + payload_data, truncated to 8 bytes'
) ENGINE = ReplacingMergeTree(ingest_timestamp)
ORDER BY (ingest_timestamp, origin, mesh_timestamp, packet)
PARTITION BY toYYYYMM(ingest_timestamp)
COMMENT 'Table for storing MeshCore packets with extracted fields via alias columns.';
-- View to decode node info packets (payload_type = 4)
CREATE OR REPLACE VIEW meshcore_adverts AS
SELECT
ingest_timestamp,
origin,
origin_pubkey,
mesh_timestamp,
packet,
path_len,
path,
broker,
topic,
hex(substring(payload, 1, 32)) AS public_key,
reinterpretAsUInt32(substring(payload, 33, 4)) AS adv_timestamp,
hex(substring(payload, 37, 64)) AS signature,
reinterpretAsUInt8(substring(payload, 101, 1)) AS appdata_flags,
bitAnd(reinterpretAsUInt8(substring(payload, 101, 1)), 0x01) = 0x01 AS is_chat_node,
bitAnd(reinterpretAsUInt8(substring(payload, 101, 1)), 0x02) = 0x02 AS is_repeater,
bitAnd(reinterpretAsUInt8(substring(payload, 101, 1)), 0x03) = 0x03 AS is_room_server,
bitAnd(reinterpretAsUInt8(substring(payload, 101, 1)), 0x10) = 0x10 AS has_location,
bitAnd(reinterpretAsUInt8(substring(payload, 101, 1)), 0x20) = 0x20 AS has_feature1,
bitAnd(reinterpretAsUInt8(substring(payload, 101, 1)), 0x40) = 0x40 AS has_feature2,
bitAnd(reinterpretAsUInt8(substring(payload, 101, 1)), 0x80) = 0x80 AS has_name,
CASE WHEN bitAnd(reinterpretAsUInt8(substring(payload, 101, 1)), 0x10) = 0x10
THEN reinterpretAsInt32(substring(payload, 102, 4))
ELSE NULL
END AS latitude_i,
CASE WHEN bitAnd(reinterpretAsUInt8(substring(payload, 101, 1)), 0x10) = 0x10
THEN reinterpretAsInt32(substring(payload, 106, 4))
ELSE NULL
END AS longitude_i,
latitude_i * 1e-6 AS latitude,
longitude_i * 1e-6 AS longitude,
substring(
payload,
102
+ multiIf(bitAnd(reinterpretAsUInt8(substring(payload, 101, 1)), 0x10) = 0x10, 8, 0)
) AS node_name,
hex(substring(payload, 1, 1)) AS node_hash,
packet_hash
FROM meshcore_packets
WHERE payload_type = 4;
CREATE OR REPLACE VIEW meshcore_adverts_latest AS
SELECT
public_key,
min(ingest_timestamp) AS first_heard,
max(ingest_timestamp) AS last_seen,
argMax(broker, ingest_timestamp) AS broker,
argMax(topic, ingest_timestamp) AS topic,
argMax(origin, ingest_timestamp) AS origin,
argMax(mesh_timestamp, ingest_timestamp) AS mesh_timestamp,
argMax(packet, ingest_timestamp) AS packet,
argMax(path_len, ingest_timestamp) AS path_len,
argMax(path, ingest_timestamp) AS path,
argMax(adv_timestamp, ingest_timestamp) AS adv_timestamp,
argMax(signature, ingest_timestamp) AS signature,
argMax(appdata_flags, ingest_timestamp) AS appdata_flags,
argMax(is_chat_node, ingest_timestamp) AS is_chat_node,
argMax(is_repeater, ingest_timestamp) AS is_repeater,
argMax(is_room_server, ingest_timestamp) AS is_room_server,
argMax(has_location, ingest_timestamp) AS has_location,
argMax(has_feature1, ingest_timestamp) AS has_feature1,
argMax(has_feature2, ingest_timestamp) AS has_feature2,
argMax(has_name, ingest_timestamp) AS has_name,
argMax(latitude_i, ingest_timestamp) AS latitude_i,
argMax(longitude_i, ingest_timestamp) AS longitude_i,
argMax(latitude, ingest_timestamp) AS latitude,
argMax(longitude, ingest_timestamp) AS longitude,
argMax(node_name, ingest_timestamp) AS node_name,
argMax(node_hash, ingest_timestamp) AS node_hash,
argMax(packet_hash, ingest_timestamp) AS packet_hash
FROM meshcore_adverts
GROUP BY public_key
ORDER BY last_seen DESC;
-- View to decode public channel group text messages (payload_type = 5)
CREATE OR REPLACE VIEW meshcore_public_channel_messages AS
SELECT
max(ingest_timestamp) AS ingest_timestamp,
min(mesh_timestamp) AS mesh_timestamp,
groupArray(origin) AS origins,
any(packet) AS packet,
any(path_len) AS path_len,
hex(substring(payload, 1, 1)) AS channel_hash,
hex(substring(payload, 2, 2)) AS mac,
substring(payload, 4) AS encrypted_message,
count() AS message_count,
groupArray((origin, hex(path))) AS origin_path_array, --deprecated
groupArray((origin, hex(origin_pubkey), hex(path))) AS origin_key_path_array, --deprecated
groupArray((broker, topic)) AS topic_broker_array, --deprecated
groupArray((origin, hex(origin_pubkey), hex(path), broker, topic)) AS origin_path_info,
any(packet_hash) AS message_id
FROM meshcore_packets
WHERE payload_type = 5
GROUP BY payload
ORDER BY ingest_timestamp DESC;
-- +goose Down
DROP VIEW IF EXISTS meshcore_public_channel_messages;
DROP VIEW IF EXISTS meshcore_adverts_latest;
DROP VIEW IF EXISTS meshcore_adverts;
DROP TABLE IF EXISTS meshcore_packets;
@@ -0,0 +1,19 @@
-- +goose Up
-- Unified latest node info view.
-- MeshCore is the only supported protocol, so this is a thin projection over
-- meshcore_adverts_latest that the web app (getNodePositions) reads from.
CREATE OR REPLACE VIEW unified_latest_nodeinfo AS
SELECT
public_key AS node_id,
node_name AS name,
substringUTF8(node_name, 1, 1) AS short_name,
latitude AS latitude,
longitude AS longitude,
first_heard AS first_seen,
last_seen,
'meshcore' AS type
FROM meshcore_adverts_latest
ORDER BY last_seen DESC;
-- +goose Down
DROP VIEW IF EXISTS unified_latest_nodeinfo;

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 391 B

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 128 B

After

Width:  |  Height:  |  Size: 128 B

Before

Width:  |  Height:  |  Size: 385 B

After

Width:  |  Height:  |  Size: 385 B

Some files were not shown because too many files have changed in this diff Show More