package hamview import ( "database/sql" "encoding/base64" "encoding/hex" "errors" "fmt" "net" "net/http" "os" "slices" "strconv" "strings" "time" echologrus "github.com/cemkiy/echo-logrus" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "git.maze.io/go/ham/protocol/meshcore" "git.maze.io/go/ham/protocol/meshcore/crypto" ) const DefaultServerListen = ":8073" type ServerConfig struct { Listen string `yaml:"listen"` } type Server struct { listen string listenAddr *net.TCPAddr db *sql.DB } func NewServer(serverConfig *ServerConfig, databaseConfig *DatabaseConfig) (*Server, error) { if serverConfig.Listen == "" { serverConfig.Listen = DefaultServerListen } listenAddr, err := net.ResolveTCPAddr("tcp", serverConfig.Listen) if err != nil { return nil, fmt.Errorf("hamview: invalid listen address %q: %v", serverConfig.Listen, err) } db, err := sql.Open(databaseConfig.Type, databaseConfig.Conf) if err != nil { return nil, err } return &Server{ listen: serverConfig.Listen, listenAddr: listenAddr, db: db, }, nil } func (server *Server) Run() error { echologrus.Logger = Logger e := echo.New() e.Logger = echologrus.GetEchoLogger() e.Use(echologrus.Hook()) e.Use(middleware.RequestLogger()) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{"*"}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept}, })) e.GET("/api/v1/meshcore/nodes", server.apiGetMeshCoreNodes) e.GET("/api/v1/meshcore/packets", server.apiGetMeshCorePackets) e.GET("/api/v1/meshcore/path/:origin/:path", server.apiGetMeshCorePath) e.GET("/api/v1/meshcore/sources", server.apiGetMeshCoreSources) if server.listenAddr.IP == nil || server.listenAddr.IP.Equal(net.ParseIP("0.0.0.0")) || server.listenAddr.IP.Equal(net.ParseIP("::")) { Logger.Infof("server: listening on http://127.0.0.1:%d", server.listenAddr.Port) } else { Logger.Infof("server: listening on http://%s:%d", server.listenAddr.IP, server.listenAddr.Port) } return e.Start(server.listen) } func (server *Server) apiError(ctx echo.Context, err error, status ...int) error { Logger.Warnf("server: error serving %s %s: %v", ctx.Request().Method, ctx.Request().URL.Path, err) if len(status) > 0 { return ctx.JSON(status[0], map[string]any{ "error": err.Error(), }) } switch { case os.IsNotExist(err): return ctx.JSON(http.StatusNotFound, nil) case os.IsPermission(err): return ctx.JSON(http.StatusUnauthorized, nil) default: return ctx.JSON(http.StatusInternalServerError, map[string]any{ "error": err.Error(), }) } } type meshCoreNodeResponse struct { SNR float64 `json:"snr"` RSSI int8 `json:"rssi"` Name string `json:"name"` PublicKey []byte `json:"public_key"` Prefix byte `json:"prefix"` NodeType meshcore.NodeType `json:"node_type"` FirstHeard time.Time `json:"first_heard"` LastHeard time.Time `json:"last_heard"` Position *meshcore.Position `json:"position"` } func (server *Server) apiGetMeshCoreNodes(ctx echo.Context) error { /* nodeTypes := getQueryInts(ctx, "type") if len(nodeTypes) == 0 { nodeTypes = []int{ int(meshcore.Repeater), } } */ nodeType := meshcore.Repeater if ctx.QueryParam("type") != "" { nodeType = meshcore.NodeType(getQueryInt(ctx, "type")) } rows, err := server.db.Query(sqlSelectMeshCoreNodesLastPosition, nodeType, 25) if err != nil { return server.apiError(ctx, err) } var ( response []meshCoreNodeResponse prefix []byte ) for rows.Next() { var ( row meshCoreNodeResponse lat, lng *float64 ) if err := rows.Scan( &row.SNR, &row.RSSI, &row.Name, &row.PublicKey, &prefix, &row.NodeType, &row.FirstHeard, &row.LastHeard, &lat, &lng, ); err != nil { return server.apiError(ctx, err) } if lat != nil && lng != nil { row.Position = &meshcore.Position{ Latitude: *lat, Longitude: *lng, } } if len(prefix) > 0 { row.Prefix = prefix[0] } response = append(response, row) } return ctx.JSON(http.StatusOK, response) } type meshCorePacketResponse struct { SNR float64 `json:"snr"` RSSI int8 `json:"rssi"` Hash []byte `json:"hash"` RouteType byte `json:"route_type"` PayloadType byte `json:"payload_type"` Path []byte `json:"path"` ReceivedAt time.Time `json:"received_at"` Raw []byte `json:"raw"` Parsed []byte `json:"parsed"` } func (server *Server) apiGetMeshCorePackets(ctx echo.Context) error { var ( query string limit = 25 args []any ) if hashParam := ctx.QueryParam("hash"); hashParam != "" { var ( hash []byte err error ) switch len(hashParam) { case base64.URLEncoding.EncodedLen(8): hash, err = base64.URLEncoding.DecodeString(hashParam) case hex.EncodedLen(8): hash, err = hex.DecodeString(hashParam) default: err = errors.New("invalid encoding") } if err != nil { return server.apiError(ctx, err, http.StatusBadRequest) } query = sqlSelectMeshCorePacketsByHash args = []any{hash} } else { query = sqlSelectMeshCorePackets args = []any{limit} } rows, err := server.db.Query(query, args...) if err != nil { return server.apiError(ctx, err) } var response []meshCorePacketResponse for rows.Next() { var row meshCorePacketResponse if err := rows.Scan( &row.SNR, &row.RSSI, &row.Hash, &row.RouteType, &row.PayloadType, &row.Path, &row.ReceivedAt, &row.Raw, &row.Parsed, ); err != nil { return server.apiError(ctx, err) } response = append(response, row) } return ctx.JSON(http.StatusOK, response) } type meshCorePathResponse struct { Origin *meshCoreNode `json:"origin"` Path []*meshCoreNodeDistance `json:"path"` } func (server *Server) apiGetMeshCorePath(ctx echo.Context) error { origin, err := hex.DecodeString(ctx.Param("origin")) if err != nil || len(origin) != crypto.PublicKeySize { return ctx.JSON(http.StatusBadRequest, map[string]any{ "error": "invalid origin", }) } path, err := hex.DecodeString(ctx.Param("path")) if err != nil || len(path) == 0 { return ctx.JSON(http.StatusBadRequest, map[string]any{ "error": "invalid path", }) } var ( node meshCoreNodeDistance prefix []byte latitude, longitude *float64 ) if err := server.db.QueryRow(` SELECT n.name, n.public_key, n.prefix, n.first_heard, n.last_heard, n.last_latitude, n.last_longitude FROM meshcore_node n WHERE n.node_type = 2 AND n.public_key = $1 `, origin, ).Scan( &node.Name, &node.PublicKey, &prefix, &node.FirstHeard, &node.LastHeard, &latitude, &longitude, ); err != nil { return server.apiError(ctx, err) } node.Prefix = MeshCorePrefix(prefix[0]) if latitude == nil || longitude == nil { return ctx.JSON(http.StatusNotFound, map[string]any{ "error": "origin has no known position", }) } node.Position = &meshcore.Position{ Latitude: *latitude, Longitude: *longitude, } var ( current = &node trace []*meshCoreNodeDistance ) slices.Reverse(path) for _, prefix := range path { if prefix != byte(current.Prefix) { var hop *meshCoreNodeDistance if hop, err = meshCoreRepeaterWithPrefixCloseTo(server.db, MeshCorePrefix(prefix), current.Position); err != nil { if !os.IsNotExist(err) { return server.apiError(ctx, err) } current = &meshCoreNodeDistance{ meshCoreNode: meshCoreNode{ Prefix: MeshCorePrefix(prefix), Position: current.Position, }, } } else { current = hop } } trace = append(trace, current) } /* if path[len(path)-1] == node.Prefix { path = path[:len(path)-2] } */ var response = meshCorePathResponse{ Origin: &node.meshCoreNode, Path: trace, } return ctx.JSON(http.StatusOK, response) } type meshCoreSourcesResponse struct { Window time.Time `json:"time"` Packets map[string]int `json:"packets"` } func (server *Server) apiGetMeshCoreSources(ctx echo.Context) error { var ( now = time.Now().UTC() windows = map[string]struct { Interval int Since time.Duration }{ "24h": {900, time.Hour * 24}, "1w": {3600, time.Hour * 24 * 7}, } window = ctx.QueryParam("window") ) if window == "" { window = "24h" } params, ok := windows[window] if !ok { return server.apiError(ctx, os.ErrNotExist) } rows, err := server.db.Query(sqlSelectMeshCorePacketsByRepeaterWindowed, params.Interval, now.Add(-params.Since)) if err != nil { return server.apiError(ctx, err) } var ( response []*meshCoreSourcesResponse buckets = make(map[int64]*meshCoreSourcesResponse) ) for rows.Next() { var result struct { Window time.Time Repeater string Packets int } if err := rows.Scan(&result.Window, &result.Repeater, &result.Packets); err != nil { return server.apiError(ctx, err) } if result.Packets <= 10 { continue // ignore } if bucket, ok := buckets[result.Window.Unix()]; ok { bucket.Packets[result.Repeater] = result.Packets } else { bucket = &meshCoreSourcesResponse{ Window: result.Window, Packets: map[string]int{ result.Repeater: result.Packets, }, } response = append(response, bucket) buckets[result.Window.Unix()] = bucket } } return ctx.JSON(http.StatusOK, response) } func getQueryInt(ctx echo.Context, param string) int { v, _ := strconv.Atoi(ctx.QueryParam(param)) return v } func getQueryInts(ctx echo.Context, param string) []int { var values []int if keys := strings.Split(ctx.QueryParam(param), ","); len(keys) > 0 { for _, value := range keys { if v, err := strconv.Atoi(value); err == nil { values = append(values, v) } } } return values }