Initial import
This commit is contained in:
423
server.go
Normal file
423
server.go
Normal file
@@ -0,0 +1,423 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user