diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9ba3a3273..cf4966fae 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -241,35 +241,6 @@ jobs: - run: make benchmark - backend-race-tests: - runs-on: ubuntu-latest - needs: frontend - permissions: - contents: read - checks: write - - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - - name: Copy built frontend - uses: actions/download-artifact@v3 - with: - name: frontend - path: internal/http/frontend/dist - - - run: go install github.com/tinylib/msgp - - run: go generate ./... - - - name: Race tests - run: | - go test ./... -race - backend-unit-tests: runs-on: ubuntu-latest needs: frontend diff --git a/docker-compose.yml b/docker-compose.yml index 2902f2bb8..38579323d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,5 +32,6 @@ services: ports: - 3005:3005 - 62031:62031/udp + - 62032:62032/udp volumes: postgres: diff --git a/internal/config/config.go b/internal/config/config.go index ccefc93ba..55543dbf2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,6 +48,7 @@ type Config struct { PasswordSalt string ListenAddr string DMRPort int + OpenBridgePort int HTTPPort int CORSHosts []string TrustedProxies []string @@ -80,6 +81,12 @@ func loadConfig() Config { httpPort = 0 } + portStr = os.Getenv("OPENBRIDGE_PORT") + openBridgePort, err := strconv.ParseInt(portStr, 10, 0) + if err != nil { + openBridgePort = 0 + } + tmpConfig := Config{ RedisHost: os.Getenv("REDIS_HOST"), postgresUser: os.Getenv("PG_USER"), @@ -97,6 +104,7 @@ func loadConfig() Config { InitialAdminUserPassword: os.Getenv("INIT_ADMIN_USER_PASSWORD"), RedisPassword: os.Getenv("REDIS_PASSWORD"), Debug: os.Getenv("DEBUG") != "", + OpenBridgePort: int(openBridgePort), } if tmpConfig.RedisHost == "" { tmpConfig.RedisHost = "localhost:6379" @@ -119,11 +127,11 @@ func loadConfig() Config { tmpConfig.PostgresDSN = "host=" + tmpConfig.postgresHost + " port=" + strconv.FormatInt(int64(tmpConfig.postgresPort), 10) + " user=" + tmpConfig.postgresUser + " dbname=" + tmpConfig.postgresDatabase + " password=" + tmpConfig.postgresPassword if tmpConfig.strSecret == "" { tmpConfig.strSecret = "secret" - logging.GetLogger(logging.Error).Log(loadConfig, "Session secret not set, using INSECURE default") + logging.GetLogger(logging.Error).Log(loadConfig, "SECRET not set, using INSECURE default") } if tmpConfig.PasswordSalt == "" { tmpConfig.PasswordSalt = "salt" - logging.GetLogger(logging.Error).Log(loadConfig, "Password salt not set, using INSECURE default") + logging.GetLogger(logging.Error).Log(loadConfig, "PASSWORD_SALT not set, using INSECURE default") } if tmpConfig.ListenAddr == "" { tmpConfig.ListenAddr = "0.0.0.0" @@ -131,11 +139,14 @@ func loadConfig() Config { if tmpConfig.DMRPort == 0 { tmpConfig.DMRPort = 62031 } + if tmpConfig.OpenBridgePort == 0 { + logging.GetLogger(logging.Error).Log(loadConfig, "OPENBRIDGE_PORT not set, disabling OpenBridge support") + } if tmpConfig.HTTPPort == 0 { tmpConfig.HTTPPort = 3005 } if tmpConfig.InitialAdminUserPassword == "" { - logging.GetLogger(logging.Error).Log(loadConfig, "Initial admin user password not set, using auto-generated password") + logging.GetLogger(logging.Error).Log(loadConfig, "INIT_ADMIN_USER_PASSWORD not set, using auto-generated password") const randLen = 15 const randNums = 4 const randSpecial = 2 @@ -147,7 +158,7 @@ func loadConfig() Config { } if tmpConfig.RedisPassword == "" { tmpConfig.RedisPassword = "password" - logging.GetLogger(logging.Error).Log(loadConfig, "Redis password not set, using INSECURE default") + logging.GetLogger(logging.Error).Log(loadConfig, "REDIS_PASSWORD not set, using INSECURE default") } // CORS_HOSTS is a comma separated list of hosts that are allowed to access the API corsHosts := os.Getenv("CORS_HOSTS") diff --git a/internal/db/db.go b/internal/db/db.go index f8af3ef2f..6a4afdadf 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -58,7 +58,7 @@ func MakeDB() *gorm.DB { } } - err = db.AutoMigrate(&models.AppSettings{}, &models.Call{}, &models.Repeater{}, &models.Talkgroup{}, &models.User{}) + err = db.AutoMigrate(&models.AppSettings{}, &models.Call{}, &models.Peer{}, &models.Repeater{}, &models.Talkgroup{}, &models.User{}) if err != nil { logging.GetLogger(logging.Error).Logf(MakeDB, "Could not migrate database: %s", err) os.Exit(1) diff --git a/internal/db/models/peer.go b/internal/db/models/peer.go new file mode 100644 index 000000000..c7347ada1 --- /dev/null +++ b/internal/db/models/peer.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DMRHub - Run a DMR network server in a single binary +// Copyright (C) 2023 Jacob McSwain +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +// The source code is available at + +package models + +import ( + "encoding/json" + "time" + + "gorm.io/gorm" + "k8s.io/klog/v2" +) + +// Peer is the model for an OpenBridge DMR peer +// +//go:generate msgp +type Peer struct { + ID uint `json:"id" gorm:"primaryKey" msg:"id"` + LastPing time.Time `json:"last_ping_time" msg:"last_ping"` + IP string `json:"-" gorm:"-" msg:"ip"` + Port int `json:"-" gorm:"-" msg:"port"` + Password string `json:"-" msg:"-"` + Owner User `json:"owner" gorm:"foreignKey:OwnerID" msg:"-"` + OwnerID uint `json:"-" msg:"-"` + Ingress bool `json:"ingress" msg:"-"` + Egress bool `json:"egress" msg:"-"` + CreatedAt time.Time `json:"created_at" msg:"-"` + UpdatedAt time.Time `json:"-" msg:"-"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index" msg:"-"` +} + +func (p *Peer) String() string { + jsn, err := json.Marshal(p) + if err != nil { + klog.Errorf("Failed to marshal peer to json: %s", err) + return "" + } + return string(jsn) +} + +func ListPeers(db *gorm.DB) []Peer { + var peers []Peer + db.Preload("Owner").Order("id asc").Find(&peers) + return peers +} + +func CountPeers(db *gorm.DB) int { + var count int64 + db.Model(&Peer{}).Count(&count) + return int(count) +} + +func GetUserPeers(db *gorm.DB, id uint) []Peer { + var peers []Peer + db.Preload("Owner").Where("owner_id = ?", id).Order("id asc").Find(&peers) + return peers +} + +func CountUserPeers(db *gorm.DB, id uint) int { + var count int64 + db.Model(&Peer{}).Where("owner_id = ?", id).Count(&count) + return int(count) +} + +func FindPeerByID(db *gorm.DB, id uint) Peer { + var peer Peer + db.Preload("Owner").First(&peer, id) + return peer +} + +func PeerExists(db *gorm.DB, peer Peer) bool { + var count int64 + db.Model(&Peer{}).Where("id = ?", peer.ID).Limit(1).Count(&count) + return count > 0 +} + +func PeerIDExists(db *gorm.DB, id uint) bool { + var count int64 + db.Model(&Peer{}).Where("id = ?", id).Limit(1).Count(&count) + return count > 0 +} + +func DeletePeer(db *gorm.DB, id uint) { + tx := db.Unscoped().Delete(&Peer{ID: id}) + if tx.Error != nil { + klog.Errorf("Error deleting repeater: %s", tx.Error) + } +} diff --git a/internal/dmr/servers/openbridge/redis.go b/internal/dmr/servers/openbridge/redis.go new file mode 100644 index 000000000..f5292501a --- /dev/null +++ b/internal/dmr/servers/openbridge/redis.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DMRHub - Run a DMR network server in a single binary +// Copyright (C) 2023 Jacob McSwain +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +// The source code is available at + +package openbridge + +import ( + "context" + "errors" + "fmt" + + "github.com/USA-RedDragon/DMRHub/internal/db/models" + "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + "k8s.io/klog/v2" +) + +type redisClient struct { + Redis *redis.Client + Tracer trace.Tracer +} + +var ( + errNoSuchPeer = errors.New("no such peer") + errUnmarshalPeer = errors.New("unmarshal peer") + errCastPeer = errors.New("unable to cast peer id") +) + +func makeRedisClient(redis *redis.Client) redisClient { + return redisClient{ + Redis: redis, + Tracer: otel.Tracer("openbridge-redis"), + } +} + +func (s *redisClient) getPeer(ctx context.Context, peerID uint) (models.Peer, error) { + ctx, span := otel.Tracer("DMRHub").Start(ctx, "Server.handlePacket") + defer span.End() + + peerBits, err := s.Redis.Get(ctx, fmt.Sprintf("openbridge:peer:%d", peerID)).Result() + if err != nil { + klog.Errorf("Error getting peer from redis", err) + return models.Peer{}, errNoSuchPeer + } + var peer models.Peer + _, err = peer.UnmarshalMsg([]byte(peerBits)) + if err != nil { + klog.Errorf("Error unmarshalling peer", err) + return models.Peer{}, errUnmarshalPeer + } + return peer, nil +} diff --git a/internal/dmr/servers/openbridge/server.go b/internal/dmr/servers/openbridge/server.go new file mode 100644 index 000000000..be6511c31 --- /dev/null +++ b/internal/dmr/servers/openbridge/server.go @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DMRHub - Run a DMR network server in a single binary +// Copyright (C) 2023 Jacob McSwain +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +// The source code is available at + +package openbridge + +import ( + "context" + "crypto/hmac" + "crypto/sha1" //#nosec G505 -- False positive, used for a protocol + "encoding/binary" + "net" + + "github.com/USA-RedDragon/DMRHub/internal/config" + "github.com/USA-RedDragon/DMRHub/internal/db/models" + "github.com/USA-RedDragon/DMRHub/internal/dmr/calltracker" + "github.com/USA-RedDragon/DMRHub/internal/dmr/utils" + "github.com/USA-RedDragon/DMRHub/internal/dmrconst" + "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + "gorm.io/gorm" + "k8s.io/klog/v2" +) + +const packetLength = 73 +const largestMessageSize = 73 +const bufferSize = 1000000 // 1MB + +// OpenBridge is the same as HBRP, but with a single packet type. +type Server struct { + Buffer []byte + SocketAddress net.UDPAddr + Server *net.UDPConn + Tracer trace.Tracer + + DB *gorm.DB + Redis redisClient + + CallTracker *calltracker.CallTracker +} + +// MakeServer creates a new DMR server. +func MakeServer(db *gorm.DB, redis *redis.Client, callTracker *calltracker.CallTracker) Server { + return Server{ + Buffer: make([]byte, largestMessageSize), + SocketAddress: net.UDPAddr{ + IP: net.ParseIP(config.GetConfig().ListenAddr), + Port: config.GetConfig().OpenBridgePort, + }, + DB: db, + Redis: makeRedisClient(redis), + CallTracker: callTracker, + Tracer: otel.Tracer("dmr-openbridge-server"), + } +} + +// Start starts the DMR server. +func (s *Server) Start(ctx context.Context) { + ctx, span := otel.Tracer("DMRHub").Start(ctx, "Server.Start") + defer span.End() + + server, err := net.ListenUDP("udp", &s.SocketAddress) + if err != nil { + klog.Exitf("Error opening UDP Socket", err) + } + + err = server.SetReadBuffer(bufferSize) + if err != nil { + klog.Exitf("Error opening UDP Socket", err) + } + err = server.SetWriteBuffer(bufferSize) + if err != nil { + klog.Exitf("Error opening UDP Socket", err) + } + + s.Server = server + + klog.Infof("OpenBridge Server listening at %s on port %d", s.SocketAddress.IP.String(), s.SocketAddress.Port) + + go s.listen(ctx) + go s.subcribeOutgoing(ctx) + + go func() { + for { + length, remoteaddr, err := s.Server.ReadFromUDP(s.Buffer) + if config.GetConfig().Debug { + klog.Infof("Read a message from %v\n", remoteaddr) + } + if err != nil { + klog.Warningf("Error reading from UDP Socket, Swallowing Error: %v", err) + continue + } + go func() { + p := models.RawDMRPacket{ + Data: s.Buffer[:length], + RemoteIP: remoteaddr.IP.String(), + RemotePort: remoteaddr.Port, + } + packedBytes, err := p.MarshalMsg(nil) + if err != nil { + klog.Errorf("Error marshalling packet", err) + return + } + s.Redis.Redis.Publish(ctx, "openbridge:incoming", packedBytes) + }() + } + }() +} + +// Stop stops the DMR server. +func (s *Server) Stop(ctx context.Context) { + ctx, span := otel.Tracer("DMRHub").Start(ctx, "Server.Stop") + defer span.End() +} + +func (s *Server) listen(ctx context.Context) { + ctx, span := otel.Tracer("DMRHub").Start(ctx, "Server.listen") + defer span.End() + + pubsub := s.Redis.Redis.Subscribe(ctx, "openbridge:incoming") + defer func() { + err := pubsub.Close() + if err != nil { + klog.Errorf("Error closing pubsub", err) + } + }() + for msg := range pubsub.Channel() { + var packet models.RawDMRPacket + _, err := packet.UnmarshalMsg([]byte(msg.Payload)) + if err != nil { + klog.Errorf("Error unmarshalling packet", err) + continue + } + s.handlePacket(ctx, &net.UDPAddr{ + IP: net.ParseIP(packet.RemoteIP), + Port: packet.RemotePort, + }, packet.Data) + } +} + +func (s *Server) subcribeOutgoing(ctx context.Context) { + pubsub := s.Redis.Redis.Subscribe(ctx, "openbridge:outgoing") + defer func() { + err := pubsub.Close() + if err != nil { + klog.Errorf("Error closing pubsub", err) + } + }() + for msg := range pubsub.Channel() { + packet, ok := models.UnpackPacket([]byte(msg.Payload)) + if !ok { + klog.Errorf("Error unpacking packet") + continue + } + peer, err := s.Redis.getPeer(ctx, packet.Repeater) + if err != nil { + klog.Errorf("Error getting peer %d from redis", packet.Repeater) + continue + } + _, err = s.Server.WriteToUDP(packet.Encode(), &net.UDPAddr{ + IP: net.ParseIP(peer.IP), + Port: peer.Port, + }) + if err != nil { + klog.Errorf("Error sending packet", err) + } + } +} + +func (s *Server) sendPacket(ctx context.Context, repeaterIDBytes uint, packet models.Packet) { + if packet.Signature != string(dmrconst.CommandDMRD) { + klog.Errorf("Invalid packet type: %s", packet.Signature) + return + } + + if config.GetConfig().Debug { + klog.Infof("Sending Packet: %s\n", packet.String()) + klog.Infof("Sending DMR packet to Repeater ID: %d", repeaterIDBytes) + } + repeater, err := s.Redis.getPeer(ctx, repeaterIDBytes) + if err != nil { + klog.Errorf("Error getting repeater from Redis", err) + return + } + p := models.RawDMRPacket{ + Data: packet.Encode(), + RemoteIP: repeater.IP, + RemotePort: repeater.Port, + } + packedBytes, err := p.MarshalMsg(nil) + if err != nil { + klog.Errorf("Error marshalling packet", err) + return + } + s.Redis.Redis.Publish(ctx, "openbridge:outgoing", packedBytes) +} + +func (s *Server) validateHMAC(ctx context.Context, packetBytes []byte, hmacBytes []byte, peer models.Peer) bool { + ctx, span := otel.Tracer("DMRHub").Start(ctx, "Server.validateHMAC") + defer span.End() + + h := hmac.New(sha1.New, []byte(peer.Password)) + _, err := h.Write(packetBytes) + if err != nil { + klog.Warningf("Error hashing OpenBridge packet: %s", err) + return false + } + if !hmac.Equal(h.Sum(nil), hmacBytes) { + klog.Warningf("Invalid OpenBridge HMAC") + return false + } + return true +} + +func (s *Server) handlePacket(ctx context.Context, remoteAddr *net.UDPAddr, data []byte) { + ctx, span := otel.Tracer("DMRHub").Start(ctx, "Server.handlePacket") + defer span.End() + + const signatureLength = 4 + + if len(data) != packetLength { + klog.Warningf("Invalid OpenBridge packet length: %d", len(data)) + return + } + + if dmrconst.Command(data[:signatureLength]) != dmrconst.CommandDMRD { + klog.Warningf("Unknown command: %s", data[:signatureLength]) + return + } + + packetBytes := data[:dmrconst.HBRPPacketLength] + hmacBytes := data[dmrconst.HBRPPacketLength:packetLength] + + packet, ok := models.UnpackPacket(packetBytes) + if !ok { + klog.Warningf("Invalid OpenBridge packet") + return + } + + if config.GetConfig().Debug { + klog.Infof("DMRD packet: %s", packet.String()) + } + + if packet.Slot { + // Drop TS2 packets on OpenBridge + klog.Warningf("Dropping TS2 packet from OpenBridge") + return + } + + peerIDBytes := data[11:15] + peerID := uint(binary.BigEndian.Uint32(peerIDBytes)) + if config.GetConfig().Debug { + klog.Infof("DMR Data from Peer ID: %d", peerID) + } + + if !models.PeerIDExists(s.DB, peerID) { + klog.Warningf("Unknown peer ID: %d", peerID) + return + } + + peer := models.FindPeerByID(s.DB, peerID) + + if !s.validateHMAC(ctx, packetBytes, hmacBytes, peer) { + klog.Warningf("Invalid OpenBridge HMAC") + return + } + + isVoice, _ := utils.CheckPacketType(packet) + + if packet.Dst == 0 { + return + } + + if peer.Egress { + s.TrackCall(ctx, packet, isVoice) + s.sendPacket(ctx, packet.Dst, packet) + } +} + +func (s *Server) TrackCall(ctx context.Context, packet models.Packet, isVoice bool) { + ctx, span := otel.Tracer("DMRHub").Start(ctx, "Server.TrackCall") + defer span.End() + + // Don't call track unlink + if isVoice { + go func() { + if !s.CallTracker.IsCallActive(ctx, packet) { + s.CallTracker.StartCall(ctx, packet) + } + if s.CallTracker.IsCallActive(ctx, packet) { + s.CallTracker.ProcessCallPacket(ctx, packet) + if packet.FrameType == dmrconst.FrameDataSync && dmrconst.DataType(packet.DTypeOrVSeq) == dmrconst.DTypeVoiceTerm { + s.CallTracker.EndCall(ctx, packet) + } + } + }() + } +} diff --git a/internal/dmr/servers/openbridge/server_test.go b/internal/dmr/servers/openbridge/server_test.go new file mode 100644 index 000000000..a5e4ca285 --- /dev/null +++ b/internal/dmr/servers/openbridge/server_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DMRHub - Run a DMR network server in a single binary +// Copyright (C) 2023 Jacob McSwain +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +// The source code is available at + +package openbridge_test + +import ( + "testing" +) + +func TestNoop(t *testing.T) { + t.Parallel() + t.Log("Noop") +} diff --git a/internal/dmr/servers/openbridge/subscriptions_manager.go b/internal/dmr/servers/openbridge/subscriptions_manager.go new file mode 100644 index 000000000..b0e6f0408 --- /dev/null +++ b/internal/dmr/servers/openbridge/subscriptions_manager.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DMRHub - Run a DMR network server in a single binary +// Copyright (C) 2023 Jacob McSwain +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +// The source code is available at + +package openbridge + +import ( + "context" + "sync" + + "github.com/USA-RedDragon/DMRHub/internal/config" + "github.com/USA-RedDragon/DMRHub/internal/db/models" + "github.com/redis/go-redis/v9" + "go.opentelemetry.io/otel" + "k8s.io/klog/v2" +) + +var subscriptionManager *SubscriptionManager //nolint:golint,gochecknoglobals + +type SubscriptionManager struct { + subscriptions map[uint]context.CancelFunc + subscriptionsMutex *sync.RWMutex + subscriptionCancelMutex map[uint]*sync.RWMutex +} + +func GetSubscriptionManager() *SubscriptionManager { + if subscriptionManager == nil { + subscriptionManager = &SubscriptionManager{ + subscriptions: make(map[uint]context.CancelFunc), + subscriptionsMutex: &sync.RWMutex{}, + subscriptionCancelMutex: make(map[uint]*sync.RWMutex), + } + } + return subscriptionManager +} + +func (m *SubscriptionManager) CancelSubscription(p models.Peer) { + m.subscriptionsMutex.RLock() + m.subscriptionCancelMutex[p.ID].RLock() + cancel, ok := m.subscriptions[p.ID] + m.subscriptionCancelMutex[p.ID].RUnlock() + m.subscriptionsMutex.RUnlock() + if ok { + m.subscriptionsMutex.Lock() + m.subscriptionCancelMutex[p.ID].Lock() + delete(m.subscriptions, p.ID) + m.subscriptionCancelMutex[p.ID].Unlock() + delete(m.subscriptionCancelMutex, p.ID) + m.subscriptionsMutex.Unlock() + cancel() + } +} + +func (m *SubscriptionManager) Subscribe(ctx context.Context, redis *redis.Client, p models.Peer) { + ctx, span := otel.Tracer("DMRHub").Start(ctx, "Server.handlePacket") + defer span.End() + + if !p.Ingress { + return + } + m.subscriptionsMutex.RLock() + _, ok := m.subscriptions[p.ID] + m.subscriptionsMutex.RUnlock() + if !ok { + newCtx, cancel := context.WithCancel(context.Background()) + m.subscriptionsMutex.Lock() + _, ok = m.subscriptionCancelMutex[p.ID] + if !ok { + m.subscriptionCancelMutex[p.ID] = &sync.RWMutex{} + } + m.subscriptionCancelMutex[p.ID].Lock() + m.subscriptions[p.ID] = cancel + m.subscriptionCancelMutex[p.ID].Unlock() + m.subscriptionsMutex.Unlock() + go m.subscribe(newCtx, redis, p) //nolint:golint,contextcheck + } +} + +func (m *SubscriptionManager) subscribe(ctx context.Context, redis *redis.Client, p models.Peer) { + if config.GetConfig().Debug { + klog.Infof("Listening for calls on peer %d", p.ID) + } + pubsub := redis.Subscribe(ctx, "openbridge:packets") + defer func() { + err := pubsub.Unsubscribe(ctx, "openbridge:packets") + if err != nil { + klog.Errorf("Error unsubscribing from openbridge:packets: %s", err) + } + err = pubsub.Close() + if err != nil { + klog.Errorf("Error closing pubsub connection: %s", err) + } + }() + pubsubChannel := pubsub.Channel() + + for { + select { + case <-ctx.Done(): + if config.GetConfig().Debug { + klog.Info("Context canceled, stopping subscription to openbridge:packets") + } + m.subscriptionsMutex.Lock() + _, ok := m.subscriptionCancelMutex[p.ID] + if ok { + m.subscriptionCancelMutex[p.ID].Lock() + } + delete(m.subscriptions, p.ID) + if ok { + m.subscriptionCancelMutex[p.ID].Unlock() + delete(m.subscriptionCancelMutex, p.ID) + } + m.subscriptionsMutex.Unlock() + return + case msg := <-pubsubChannel: + rawPacket := models.RawDMRPacket{} + _, err := rawPacket.UnmarshalMsg([]byte(msg.Payload)) + if err != nil { + klog.Errorf("Failed to unmarshal raw packet: %s", err) + continue + } + packet, ok := models.UnpackPacket(rawPacket.Data) + if !ok { + klog.Errorf("Failed to unpack packet: %s", err) + continue + } + + if packet.Repeater == p.ID { + continue + } + + packet.Repeater = p.ID + packet.Slot = false + redis.Publish(ctx, "openbridge:outgoing", packet.Encode()) + } + } +} diff --git a/internal/http/api/apimodels/peer.go b/internal/http/api/apimodels/peer.go new file mode 100644 index 000000000..bf2083d69 --- /dev/null +++ b/internal/http/api/apimodels/peer.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DMRHub - Run a DMR network server in a single binary +// Copyright (C) 2023 Jacob McSwain +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +// The source code is available at + +package apimodels + +type PeerPost struct { + ID uint `json:"id" binding:"required"` + Ingress bool `json:"ingress"` + Egress bool `json:"egress"` +} diff --git a/internal/http/api/controllers/v1/peers/peers.go b/internal/http/api/controllers/v1/peers/peers.go new file mode 100644 index 000000000..86804e3e1 --- /dev/null +++ b/internal/http/api/controllers/v1/peers/peers.go @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DMRHub - Run a DMR network server in a single binary +// Copyright (C) 2023 Jacob McSwain +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +// The source code is available at + +package peers + +import ( + "net/http" + "strconv" + + "github.com/USA-RedDragon/DMRHub/internal/db/models" + "github.com/USA-RedDragon/DMRHub/internal/dmr/servers/openbridge" + "github.com/USA-RedDragon/DMRHub/internal/http/api/apimodels" + "github.com/USA-RedDragon/DMRHub/internal/http/api/utils" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" + "k8s.io/klog/v2" +) + +const ( + LinkTypeDynamic = "dynamic" + LinkTypeStatic = "static" +) + +func GETPeers(c *gin.Context) { + db, ok := c.MustGet("PaginatedDB").(*gorm.DB) + if !ok { + klog.Errorf("Unable to get DB from context") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"}) + return + } + cDb, ok := c.MustGet("DB").(*gorm.DB) + if !ok { + klog.Errorf("Unable to get DB from context") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"}) + return + } + peers := models.ListPeers(db) + count := models.CountPeers(cDb) + c.JSON(http.StatusOK, gin.H{"total": count, "peers": peers}) +} + +func GETMyPeers(c *gin.Context) { + db, ok := c.MustGet("PaginatedDB").(*gorm.DB) + if !ok { + klog.Errorf("Unable to get DB from context") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"}) + return + } + cDb, ok := c.MustGet("DB").(*gorm.DB) + if !ok { + klog.Errorf("Unable to get DB from context") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"}) + return + } + session := sessions.Default(c) + + userID := session.Get("user_id") + if userID == nil { + klog.Error("userID not found") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + return + } + + uid, ok := userID.(uint) + if !ok { + klog.Errorf("Unable to convert userID to uint: %v", userID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"}) + return + } + + // Get all peers owned by user + peers := models.GetUserPeers(db, uid) + if db.Error != nil { + klog.Errorf("Error getting peers owned by user %d: %v", userID, db.Error) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting peers owned by user"}) + return + } + + count := models.CountUserPeers(cDb, uid) + + c.JSON(http.StatusOK, gin.H{"total": count, "peers": peers}) +} + +func GETPeer(c *gin.Context) { + db, ok := c.MustGet("DB").(*gorm.DB) + if !ok { + klog.Errorf("Unable to get DB from context") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"}) + return + } + id := c.Param("id") + // Convert string id into uint + peerID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid peer ID"}) + return + } + if models.PeerIDExists(db, uint(peerID)) { + peer := models.FindPeerByID(db, uint(peerID)) + c.JSON(http.StatusOK, peer) + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Peer does not exist"}) + } +} + +func DELETEPeer(c *gin.Context) { + db, ok := c.MustGet("DB").(*gorm.DB) + if !ok { + klog.Errorf("Unable to get DB from context") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"}) + return + } + idUint64, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid peer ID"}) + return + } + models.DeletePeer(db, uint(idUint64)) + if db.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": db.Error.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Peer deleted"}) +} + +func POSTPeer(c *gin.Context) { + session := sessions.Default(c) + usID := session.Get("user_id") + if usID == nil { + klog.Error("userID not found") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + } + userID, ok := usID.(uint) + if !ok { + klog.Error("userID cast failed") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + } + db, ok := c.MustGet("DB").(*gorm.DB) + if !ok { + klog.Error("DB cast failed") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"}) + return + } + redis, ok := c.MustGet("Redis").(*redis.Client) + if !ok { + klog.Error("Redis cast failed") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Try again later"}) + return + } + + var user models.User + db.First(&user, userID) + if db.Error != nil { + klog.Errorf("Error getting user %d: %v", userID, db.Error) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Error getting user"}) + return + } + + var json apimodels.PeerPost + err := c.ShouldBindJSON(&json) + if err != nil { + klog.Errorf("POSTPeer: JSON data is invalid: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "JSON data is invalid"}) + } else { + if models.PeerIDExists(db, json.ID) { + klog.Errorf("POSTPeer: Peer ID already exists: %v", json.ID) + c.JSON(http.StatusBadRequest, gin.H{"error": "Peer ID already exists"}) + return + } + + var peer models.Peer + + peer.Egress = json.Egress + peer.Ingress = json.Ingress + + // Peer validated to fit within a 4 byte integer + if json.ID <= 0 || json.ID > 4294967295 { + klog.Errorf("POSTPeer: Peer ID is invalid: %v", json.ID) + c.JSON(http.StatusBadRequest, gin.H{"error": "Peer ID is invalid"}) + return + } + + peer.ID = json.ID + + // Generate a random password of 12 characters + const randLen = 12 + const randNum = 1 + const randSpecial = 2 + peer.Password, err = utils.RandomPassword(randLen, randNum, randSpecial) + if err != nil { + klog.Errorf("Failed to generate a peer password %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to generate a peer password"}) + return + } + + // Find user by userID + peer.Owner = user + peer.OwnerID = user.ID + db.Preload("Owner").Create(&peer) + if db.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": db.Error.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Peer created", "password": peer.Password}) + go openbridge.GetSubscriptionManager().Subscribe(c.Request.Context(), redis, peer) + } +} diff --git a/internal/http/api/controllers/v1/peers/peers_test.go b/internal/http/api/controllers/v1/peers/peers_test.go new file mode 100644 index 000000000..284839f75 --- /dev/null +++ b/internal/http/api/controllers/v1/peers/peers_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DMRHub - Run a DMR network server in a single binary +// Copyright (C) 2023 Jacob McSwain +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +// The source code is available at + +package peers_test + +import ( + "testing" +) + +func TestNoop(t *testing.T) { + t.Parallel() + t.Log("Noop") +} diff --git a/internal/http/api/middleware/auth.go b/internal/http/api/middleware/auth.go index 9cf9c1f63..3d97fab6c 100644 --- a/internal/http/api/middleware/auth.go +++ b/internal/http/api/middleware/auth.go @@ -269,6 +269,65 @@ func RequireLogin() gin.HandlerFunc { } } +func RequirePeerOwnerOrAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + session := sessions.Default(c) + id := c.Param("id") + userID := session.Get("user_id") + if userID == nil { + if config.GetConfig().Debug { + klog.Error("RequirePeerOwnerOrAdmin: Failed to get user_id from session") + } + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + return + } + uid, ok := userID.(uint) + if !ok { + klog.Error("RequirePeerOwnerOrAdmin: Unable to convert user_id to uint") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + return + } + ctx := c.Request.Context() + span := trace.SpanFromContext(ctx) + if span.IsRecording() { + span.SetAttributes( + attribute.String("http.auth", "RequirePeerOwnerOrAdmin"), + attribute.Int("user.id", int(uid)), + ) + } + + valid := false + db, ok := c.MustGet("DB").(*gorm.DB) + if !ok { + klog.Error("RequirePeerOwnerOrAdmin: Unable to get DB from context") + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + return + } + db = db.WithContext(ctx) + // Open up the DB and check if the user is an admin or if they own peer with id = id + var user models.User + db.Find(&user, "id = ?", uid) + if span.IsRecording() { + span.SetAttributes( + attribute.Bool("user.admin", user.Admin), + ) + } + if user.Approved && !user.Suspended && user.Admin { + valid = true + } else { + var peer models.Peer + db.Find(&peer, "radio_id = ?", id) + if peer.OwnerID == user.ID && !user.Suspended && user.Approved { + valid = true + } + } + + if !valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + } + } +} + func RequireRepeaterOwnerOrAdmin() gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) diff --git a/internal/http/api/routes.go b/internal/http/api/routes.go index ce317665e..f036080b7 100644 --- a/internal/http/api/routes.go +++ b/internal/http/api/routes.go @@ -23,6 +23,7 @@ import ( v1Controllers "github.com/USA-RedDragon/DMRHub/internal/http/api/controllers/v1" v1AuthControllers "github.com/USA-RedDragon/DMRHub/internal/http/api/controllers/v1/auth" v1LastheardControllers "github.com/USA-RedDragon/DMRHub/internal/http/api/controllers/v1/lastheard" + v1PeersControllers "github.com/USA-RedDragon/DMRHub/internal/http/api/controllers/v1/peers" v1RepeatersControllers "github.com/USA-RedDragon/DMRHub/internal/http/api/controllers/v1/repeaters" v1TalkgroupsControllers "github.com/USA-RedDragon/DMRHub/internal/http/api/controllers/v1/talkgroups" v1UsersControllers "github.com/USA-RedDragon/DMRHub/internal/http/api/controllers/v1/users" @@ -85,6 +86,15 @@ func v1(group *gin.RouterGroup, userSuspension gin.HandlerFunc) { v1Users.PATCH("/:id", middleware.RequireSelfOrAdmin(), userSuspension, v1UsersControllers.PATCHUser) v1Users.DELETE("/:id", middleware.RequireSuperAdmin(), userSuspension, v1UsersControllers.DELETEUser) + v1Peers := group.Group("/peers") + // Paginated + v1Peers.GET("", middleware.RequireAdmin(), v1PeersControllers.GETPeers) + // Paginated + v1Peers.GET("/my", middleware.RequireLogin(), v1PeersControllers.GETMyPeers) + v1Peers.POST("", middleware.RequireLogin(), v1PeersControllers.POSTPeer) + v1Peers.GET("/:id", middleware.RequireLogin(), v1PeersControllers.GETPeer) + v1Peers.DELETE("/:id", middleware.RequirePeerOwnerOrAdmin(), v1PeersControllers.DELETEPeer) + v1Lastheard := group.Group("/lastheard") // Returns the lastheard data for the server, adds personal data if logged in // Paginated diff --git a/internal/http/frontend/cypress/support/e2e.js b/internal/http/frontend/cypress/support/e2e.js index eab64c703..8c3152479 100644 --- a/internal/http/frontend/cypress/support/e2e.js +++ b/internal/http/frontend/cypress/support/e2e.js @@ -14,6 +14,9 @@ // *********************************************************** import '@cypress/code-coverage/support'; import 'cypress-mochawesome-reporter/register'; +import { registerCommand } from 'cypress-wait-for-stable-dom'; + +registerCommand(); // Import commands.js using ES2015 syntax: import './commands'; diff --git a/internal/http/frontend/package-lock.json b/internal/http/frontend/package-lock.json index 89150a87c..8854b9849 100644 --- a/internal/http/frontend/package-lock.json +++ b/internal/http/frontend/package-lock.json @@ -30,6 +30,7 @@ "cypress": "^12.6.0", "cypress-mochawesome-reporter": "^3.3.0", "cypress-multi-reporters": "^1.6.2", + "cypress-wait-for-stable-dom": "^0.1.0", "eslint": "^8.34.0", "eslint-config-google": "^0.14.0", "eslint-plugin-cypress": "^2.12.1", @@ -4219,6 +4220,12 @@ "mocha": ">=3.1.2" } }, + "node_modules/cypress-wait-for-stable-dom": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cypress-wait-for-stable-dom/-/cypress-wait-for-stable-dom-0.1.0.tgz", + "integrity": "sha512-iVJc6CDzlu1xUnTcZph+zbkOlImaDelpvRv4G+3naugvjkF6b9EFpDmRCC/16xL1pqpkFq4rFyfhuNw4C3PQjw==", + "dev": true + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -12675,6 +12682,12 @@ "lodash": "^4.17.15" } }, + "cypress-wait-for-stable-dom": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cypress-wait-for-stable-dom/-/cypress-wait-for-stable-dom-0.1.0.tgz", + "integrity": "sha512-iVJc6CDzlu1xUnTcZph+zbkOlImaDelpvRv4G+3naugvjkF6b9EFpDmRCC/16xL1pqpkFq4rFyfhuNw4C3PQjw==", + "dev": true + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", diff --git a/internal/http/frontend/package.json b/internal/http/frontend/package.json index cb4140ea4..8259fec06 100644 --- a/internal/http/frontend/package.json +++ b/internal/http/frontend/package.json @@ -6,6 +6,7 @@ "dev": "vite --host", "build": "vite build", "preview": "vite preview", + "screenshot": "start-server-and-test preview :4173 \"cypress run --e2e --headed --browser chrome --config excludeSpecPattern='**/no-op' --spec tests/e2e/screenshots/**.cy.js\"", "test:e2e": "start-server-and-test preview :4173 'cypress run --e2e --headed'", "test:e2e:chrome": "start-server-and-test preview :4173 'cypress run --e2e --headed --browser chrome'", "test:e2e:firefox": "start-server-and-test preview :4173 'cypress run --e2e --headed --browser firefox'", @@ -39,6 +40,7 @@ "cypress": "^12.6.0", "cypress-mochawesome-reporter": "^3.3.0", "cypress-multi-reporters": "^1.6.2", + "cypress-wait-for-stable-dom": "^0.1.0", "eslint": "^8.34.0", "eslint-config-google": "^0.14.0", "eslint-plugin-cypress": "^2.12.1", @@ -53,4 +55,4 @@ "vite": "^4.1.4", "vite-plugin-istanbul": "^4.0.0" } -} +} \ No newline at end of file diff --git a/internal/http/frontend/src/components/AppHeader.vue b/internal/http/frontend/src/components/AppHeader.vue index 585a4411a..7454bab8e 100644 --- a/internal/http/frontend/src/components/AppHeader.vue +++ b/internal/http/frontend/src/components/AppHeader.vue @@ -27,9 +27,53 @@ Home - Repeaters + Repeaters + + + + + + {{ item.label }} + + + + . + + The source code is available at +--> + + + + + + + + + + + + + + + + {{ slotProps.data.last_ping_time.fromNow() }} + + Never + + + + + + + + + + + + + + {{ + slotProps.data.created_at.fromNow() + }} + + + + + + + + + + + + + diff --git a/internal/http/frontend/src/router/routes.mjs b/internal/http/frontend/src/router/routes.mjs index ff820c87b..80d5093ee 100644 --- a/internal/http/frontend/src/router/routes.mjs +++ b/internal/http/frontend/src/router/routes.mjs @@ -26,32 +26,42 @@ export default [ { path: '/login', name: 'Login', - component: () => import('../views/LoginPage.vue'), + component: () => import('../views/auth/LoginPage.vue'), }, { path: '/register', name: 'Register', - component: () => import('../views/RegisterPage.vue'), + component: () => import('../views/auth/RegisterPage.vue'), }, { path: '/repeaters', name: 'Repeaters', - component: () => import('../views/RepeatersPage.vue'), + component: () => import('../views/repeaters/RepeatersPage.vue'), }, { path: '/repeaters/new', name: 'NewRepeater', - component: () => import('../views/NewRepeaterPage.vue'), + component: () => import('../views/repeaters/NewRepeaterPage.vue'), + }, + { + path: '/repeaters/peers', + name: 'Peers', + component: () => import('../views/peers/PeersPage.vue'), + }, + { + path: '/repeaters/peers/new', + name: 'NewPeer', + component: () => import('../views/peers/NewPeerPage.vue'), }, { path: '/talkgroups', name: 'Talkgroups', - component: () => import('../views/TalkgroupsPage.vue'), + component: () => import('../views/talkgroups/TalkgroupsPage.vue'), }, { path: '/talkgroups/owned', name: 'OwnedTalkgroups', - component: () => import('../views/OwnedTalkgroupsPage.vue'), + component: () => import('../views/talkgroups/OwnedTalkgroupsPage.vue'), }, { path: '/admin/repeaters', diff --git a/internal/http/frontend/src/views/LoginPage.vue b/internal/http/frontend/src/views/auth/LoginPage.vue similarity index 100% rename from internal/http/frontend/src/views/LoginPage.vue rename to internal/http/frontend/src/views/auth/LoginPage.vue diff --git a/internal/http/frontend/src/views/RegisterPage.vue b/internal/http/frontend/src/views/auth/RegisterPage.vue similarity index 100% rename from internal/http/frontend/src/views/RegisterPage.vue rename to internal/http/frontend/src/views/auth/RegisterPage.vue diff --git a/internal/http/frontend/src/views/peers/NewPeerPage.vue b/internal/http/frontend/src/views/peers/NewPeerPage.vue new file mode 100644 index 000000000..9208f937b --- /dev/null +++ b/internal/http/frontend/src/views/peers/NewPeerPage.vue @@ -0,0 +1,216 @@ + + + + + + + + + + You will need to use this password configuration to connect to the + network. + Save this now, as you will not be able to retrieve it + again. + Your Peer password is: + {{ slotProps.message.message }} + + + + + + + New Peer + + + + Peer ID + + + + {{ error.$message }} + + + + + {{ v$.id.required.$message.replace("Value", "Peer ID") }} + + + + + Peer should receive traffic from the server? + + + + + + Peer should send traffic to the server? + + + + + + + + + + + + + + + diff --git a/internal/http/frontend/src/views/peers/PeersPage.vue b/internal/http/frontend/src/views/peers/PeersPage.vue new file mode 100644 index 000000000..7c65af302 --- /dev/null +++ b/internal/http/frontend/src/views/peers/PeersPage.vue @@ -0,0 +1,56 @@ + + + + + + + + OpenBridge Peers + + + + + + + + + + diff --git a/internal/http/frontend/src/views/NewRepeaterPage.vue b/internal/http/frontend/src/views/repeaters/NewRepeaterPage.vue similarity index 99% rename from internal/http/frontend/src/views/NewRepeaterPage.vue rename to internal/http/frontend/src/views/repeaters/NewRepeaterPage.vue index 055669f7f..dbf2fe4d2 100644 --- a/internal/http/frontend/src/views/NewRepeaterPage.vue +++ b/internal/http/frontend/src/views/repeaters/NewRepeaterPage.vue @@ -184,7 +184,7 @@ export default { this.$toast.add({ summary: 'Error', severity: 'error', - detail: `Error deleting repeater`, + detail: 'Error deleting repeater', life: 3000, }); } diff --git a/internal/http/frontend/src/views/RepeaterDetailsPage.vue b/internal/http/frontend/src/views/repeaters/RepeaterDetailsPage.vue similarity index 100% rename from internal/http/frontend/src/views/RepeaterDetailsPage.vue rename to internal/http/frontend/src/views/repeaters/RepeaterDetailsPage.vue diff --git a/internal/http/frontend/src/views/RepeatersPage.vue b/internal/http/frontend/src/views/repeaters/RepeatersPage.vue similarity index 100% rename from internal/http/frontend/src/views/RepeatersPage.vue rename to internal/http/frontend/src/views/repeaters/RepeatersPage.vue diff --git a/internal/http/frontend/src/views/OwnedTalkgroupsPage.vue b/internal/http/frontend/src/views/talkgroups/OwnedTalkgroupsPage.vue similarity index 100% rename from internal/http/frontend/src/views/OwnedTalkgroupsPage.vue rename to internal/http/frontend/src/views/talkgroups/OwnedTalkgroupsPage.vue diff --git a/internal/http/frontend/src/views/TalkgroupsPage.vue b/internal/http/frontend/src/views/talkgroups/TalkgroupsPage.vue similarity index 100% rename from internal/http/frontend/src/views/TalkgroupsPage.vue rename to internal/http/frontend/src/views/talkgroups/TalkgroupsPage.vue diff --git a/internal/http/frontend/tests/e2e/screenshots/home.cy.js b/internal/http/frontend/tests/e2e/screenshots/home.cy.js new file mode 100644 index 000000000..c103ce818 --- /dev/null +++ b/internal/http/frontend/tests/e2e/screenshots/home.cy.js @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// DMRHub - Run a DMR network server in a single binary +// Copyright (C) 2023 Jacob McSwain +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +// +// The source code is available at + +import moment from "moment"; + +// https://docs.cypress.io/api/introduction/api.html + +// DMR radio IDs lifted randomly from radioid.net +const radioIds = [ + { + id: 3110691, + callsign: "KF6FM", + }, + { + id: 2353426, + callsign: "MW6ABC", + }, + { + id: 3163099, + callsign: "KO4CVD", + }, + { + id: 2626282, + callsign: "DK4FC", + }, +]; + +function generateUser(lastUser) { + const radioId = radioIds[Math.floor(Math.random() * radioIds.length)]; + + if (lastUser && lastUser.id === radioId.id) { + return generateUser(lastUser); + } + + return { + id: radioId.id, + callsign: radioId.callsign, + }; +} + +function generateCall(id, callTime, user) { + var dst = Math.floor(Math.random() * 2) + 1; + var slot = Math.floor(Math.random() * 2) === 0; + return { + id, + active: false, + time_slot: slot, + group_call: true, + start_time: callTime.start, + duration: callTime.duration, + user, + is_to_talkgroup: true, + to_talkgroup: { + id: dst, + }, + destination_id: dst, + loss: Math.random() * 0.032, + jitter: Math.random() * 6 - 3, + ber: Math.random() * 0.1, + rssi: Math.random() * 9 + 32, + }; +} + +// Generates an array of calls to be used in the lastheard API +function generateCalls(count) { + const calls = []; + var lastStart; + var lastDuration = moment().subtract(2, "seconds").toISOString(); + var lastUser = generateUser(null); + + if (count > 10) { + lastStart = moment().subtract(3, "hours").toISOString(); + + for (let i = 0; i < count - 10; i++) { + var callTime = generateCallTime(lastStart, lastDuration, calls); + var user = generateUser(lastUser); + + calls.push(generateCall(i, callTime, user)); + lastStart = callTime.start; + lastDuration = callTime.duration; + lastUser = user; + } + + lastStart = moment().subtract(10, "minutes").toISOString(); + + for (let i = count + 0; i < count + 10; i++) { + callTime = generateCallTime(lastStart, lastDuration, calls); + user = generateUser(lastUser); + + calls.push(generateCall(i, callTime, user)); + lastStart = callTime.start; + lastDuration = callTime.duration; + lastUser = user; + } + } else { + lastStart = moment().subtract(3, "hours").toISOString(); + + for (let i = 0; i < count; i++) { + callTime = generateCallTime(lastStart, lastDuration, calls); + user = generateUser(lastUser); + + calls.push(generateCall(i, callTime, user)); + lastStart = callTime.start; + lastDuration = callTime.duration; + lastUser = user; + } + } + + // Reverse the array so the calls are in order + return calls.reverse(); +} + +// Generate call time generates a random time since lastStart + lastDuration +// It returns an object with start and duration +// The start time should not be closer than 3 seconds to lastStart + lastDuration +// Calls should roughly be 3 seconds to 2 minutes long but weighted towards minimum +function generateCallTime(lastStart, lastDuration) { + var start, duration; + + // Parse lastStart into a Moment object + var lastStartObj = moment(lastStart); + + // Convert lastDuration from nanoseconds to seconds + var lastDurationSeconds = Math.floor(lastDuration / (1000 * 1000 * 1000)); + + // Calculate the minimum start time as lastStart + lastDuration + 3 seconds + var minStartTimeMoment = moment(lastStartObj).add( + lastDurationSeconds + 3, + "seconds" + ); + + // Generate a random start time between minStartTime and now + var maxStartTimeMoment = moment(); + var startMoment = moment + .duration( + Math.random() * + (maxStartTimeMoment.diff(minStartTimeMoment, "milliseconds") + 1), + "milliseconds" + ) + .add(minStartTimeMoment); + + // Ensure the start time is at least 3 seconds after lastStart + lastDuration + var earliestStartMoment = moment(lastStartObj).add( + lastDurationSeconds + 3, + "seconds" + ); + startMoment = moment.max(startMoment, earliestStartMoment); + + start = startMoment.toISOString(); + + const minDuration = 1.2; // minimum duration in seconds + const maxDuration = 120; // maximum duration in seconds + const lambda = 0.042; // rate parameter for the exponential distribution + var randomDuration = -Math.log(1 - Math.random()) / lambda; + duration = + Math.max(minDuration, Math.min(maxDuration, randomDuration)) * + 1000 * + 1000 * + 1000; + + return { start, duration }; +} + +beforeEach(() => { + cy.intercept("/api/v1/users/me", { + id: 3191868, + callsign: "KI5VMF", + username: "USA-RedDragon", + admin: true, + approved: true, + suspended: false, + created_at: "2023-01-27T21:50:34.154146-06:00", + }); + cy.intercept("/api/v1/version", { + body: "1.1.0", + }); + cy.intercept( + "/api/v1/lastheard?page=1&limit=10", + JSON.stringify({ + total: 50, + calls: generateCalls(50), + }) + ); +}); + +describe("Screenshotter", () => { + it("visits the app root url while not signed in", () => { + cy.intercept("/api/v1/users/me", { + statusCode: 401, + }); + cy.visit("/"); + cy.screenshot({ + onBeforeScreenshot: () => { + cy.get("#app").waitForStableDOM({ pollInterval: 1000, timeout: 10000 }); + }, + }); + }); + it("visits the app root url while signed in", () => { + cy.visit("/"); + cy.screenshot({ + onBeforeScreenshot: () => { + cy.get("#app").waitForStableDOM({ pollInterval: 1000, timeout: 10000 }); + }, + }); + }); + it("clicks the theme picker", () => { + cy.visit("/"); + cy.get(".pi-palette").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("be.visible"); + cy.screenshot({ + onBeforeScreenshot: () => { + cy.get("#app").waitForStableDOM({ pollInterval: 1000, timeout: 10000 }); + }, + }); + }); + it("clicks the theme picker and makes the size smaller", () => { + cy.visit("/"); + cy.get(".pi-palette").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("be.visible"); + cy.get(".p-sidebar-content div .p-button .pi-minus").click(); + cy.get(".p-sidebar-close-icon").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("not.exist"); + cy.screenshot({ + onBeforeScreenshot: () => { + cy.get("#app").waitForStableDOM({ pollInterval: 1000, timeout: 10000 }); + }, + }); + }); + it("clicks the theme picker and makes the size even smaller", () => { + cy.visit("/"); + cy.get(".pi-palette").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("be.visible"); + cy.get(".p-sidebar-content div .p-button .pi-minus").click(); + cy.get(".p-sidebar-content div .p-button .pi-minus").click(); + cy.get(".p-sidebar-close-icon").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("not.exist"); + cy.screenshot({ + onBeforeScreenshot: () => { + cy.get("#app").waitForStableDOM({ pollInterval: 1000, timeout: 10000 }); + }, + }); + }); + it("clicks the theme picker and makes the size the smallest", () => { + cy.visit("/"); + cy.get(".pi-palette").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("be.visible"); + cy.get(".p-sidebar-content div .p-button .pi-minus").click(); + cy.get(".p-sidebar-content div .p-button .pi-minus").click(); + cy.get(".p-sidebar-content div .p-button .pi-minus").click(); + cy.get(".p-sidebar-close-icon").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("not.exist"); + cy.screenshot({ + onBeforeScreenshot: () => { + cy.get("#app").waitForStableDOM({ pollInterval: 1000, timeout: 10000 }); + }, + }); + }); + it("clicks the theme picker and makes the size bigger", () => { + cy.visit("/"); + cy.get(".pi-palette").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("be.visible"); + cy.get(".p-sidebar-content div .p-button .pi-plus").click(); + cy.get(".p-sidebar-close-icon").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("not.exist"); + cy.screenshot({ + onBeforeScreenshot: () => { + cy.get("#app").waitForStableDOM({ pollInterval: 1000, timeout: 10000 }); + }, + }); + }); + it("clicks the theme picker and makes the size even bigger", () => { + cy.visit("/"); + cy.get(".pi-palette").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("be.visible"); + cy.get(".p-sidebar-content div .p-button .pi-plus").click(); + cy.get(".p-sidebar-content div .p-button .pi-plus").click(); + cy.get(".p-sidebar-close-icon").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("not.exist"); + cy.screenshot({ + onBeforeScreenshot: () => { + cy.get("#app").waitForStableDOM({ pollInterval: 1000, timeout: 10000 }); + }, + }); + }); + it("clicks the theme picker and makes the size the biggest", () => { + cy.visit("/"); + cy.get(".pi-palette").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("be.visible"); + cy.get(".p-sidebar-content div .p-button .pi-plus").click(); + cy.get(".p-sidebar-content div .p-button .pi-plus").click(); + cy.get(".p-sidebar-content div .p-button .pi-plus").click(); + cy.get(".p-sidebar-close-icon").click(); + cy.get(".layout-config-sidebar", { timeout: 6000 }).should("not.exist"); + cy.screenshot({ + onBeforeScreenshot: () => { + cy.get("#app").waitForStableDOM({ pollInterval: 1000, timeout: 10000 }); + }, + }); + }); + it("is different themes", () => { + const themes = [ + "arya-blue", + "arya-green", + "arya-orange", + "arya-purple", + "bootstrap4-dark-blue", + "bootstrap4-dark-purple", + "bootstrap4-light-blue", + "bootstrap4-light-purple", + "fluent-light", + "lara-dark-blue", + "lara-dark-indigo", + "lara-dark-purple", + "lara-dark-teal", + "lara-light-blue", + "lara-light-indigo", + "lara-light-purple", + "lara-light-teal", + "luna-amber", + "luna-blue", + "luna-green", + "luna-pink", + "mdc-dark-deeppurple", + "mdc-dark-indigo", + "mdc-light-deeppurple", + "mdc-light-indigo", + "md-dark-deeppurple", + "md-dark-indigo", + "md-light-deeppurple", + "md-light-indigo", + "nova", + "nova-accent", + "nova-alt", + "nova-vue", + "rhea", + "saga-blue", + "saga-green", + "saga-orange", + "saga-purple", + "tailwind-light", + "vela-blue", + "vela-green", + "vela-orange", + "vela-purple", + ]; + themes.forEach((theme) => { + cy.visit("/", { + onBeforeLoad: function (window) { + window.localStorage.setItem("theme", theme); + }, + }); + cy.screenshot(theme, { + onBeforeScreenshot: () => { + cy.get("#app").waitForStableDOM({ + pollInterval: 1000, + timeout: 10000, + }); + }, + }); + }); + }); +}); diff --git a/internal/http/websocket/ws.go b/internal/http/websocket/ws.go index 8b1a77dfb..e9545643a 100644 --- a/internal/http/websocket/ws.go +++ b/internal/http/websocket/ws.go @@ -42,6 +42,9 @@ type WSHandler struct { database *gorm.DB } +const ping = "PING" +const pong = "PONG" + const bufferSize = 1024 func CreateHandler(db *gorm.DB, redis *redis.Client) *WSHandler { @@ -100,8 +103,53 @@ func (h *WSHandler) repeaterHandler(ctx context.Context, _ sessions.Session, w h readFailed <- "read failed" break } - if string(msg) == "PING" { - msg = []byte("PONG") + if string(msg) == ping { + msg = []byte(pong) + err = conn.WriteMessage(t, msg) + if err != nil { + readFailed <- "write failed" + break + } + continue + } + + err = conn.WriteMessage(t, msg) + if err != nil { + readFailed <- "write failed" + break + } + } + }() + + select { + case <-ctx.Done(): + case <-readFailed: + } +} + +func (h *WSHandler) peerHandler(ctx context.Context, _ sessions.Session, w http.ResponseWriter, r *http.Request) { + conn, err := h.wsUpgrader.Upgrade(w, r, nil) + if err != nil { + klog.Errorf("Failed to set websocket upgrade: %v", err) + return + } + defer func() { + err := conn.Close() + if err != nil { + klog.Errorf("Failed to close websocket: %v", err) + } + }() + + readFailed := make(chan string) + go func() { + for { + t, msg, err := conn.ReadMessage() + if err != nil { + readFailed <- "read failed" + break + } + if string(msg) == ping { + msg = []byte(pong) err = conn.WriteMessage(t, msg) if err != nil { readFailed <- "write failed" @@ -169,8 +217,8 @@ func (h *WSHandler) callHandler(ctx context.Context, session sessions.Session, w readFailed <- "read failed" break } - if string(msg) == "PING" { - msg = []byte("PONG") + if string(msg) == ping { + msg = []byte(pong) err = conn.WriteMessage(t, msg) if err != nil { readFailed <- "write failed" @@ -203,6 +251,11 @@ func (h *WSHandler) ApplyRoutes(r *gin.Engine, ratelimit gin.HandlerFunc, userSu h.repeaterHandler(c.Request.Context(), session, c.Writer, c.Request) }) + r.GET("/ws/peers", middleware.RequireLogin(), ratelimit, userSuspension, func(c *gin.Context) { + session := sessions.Default(c) + h.peerHandler(c.Request.Context(), session, c.Writer, c.Request) + }) + r.GET("/ws/calls", ratelimit, func(c *gin.Context) { session := sessions.Default(c) h.callHandler(c.Request.Context(), session, c.Writer, c.Request) diff --git a/internal/sdk/version.go b/internal/sdk/version.go index 4743e0cf5..7f4c0e0f4 100644 --- a/internal/sdk/version.go +++ b/internal/sdk/version.go @@ -30,5 +30,5 @@ var ( GitCommit string // Version of the program - Version = "1.0.16" //nolint:golint,gochecknoglobals + Version = "1.1.0" //nolint:golint,gochecknoglobals ) diff --git a/main.go b/main.go index f2c7f5e6d..68ac33900 100644 --- a/main.go +++ b/main.go @@ -32,6 +32,7 @@ import ( "github.com/USA-RedDragon/DMRHub/internal/db/models" "github.com/USA-RedDragon/DMRHub/internal/dmr/calltracker" "github.com/USA-RedDragon/DMRHub/internal/dmr/servers/hbrp" + "github.com/USA-RedDragon/DMRHub/internal/dmr/servers/openbridge" "github.com/USA-RedDragon/DMRHub/internal/http" "github.com/USA-RedDragon/DMRHub/internal/logging" "github.com/USA-RedDragon/DMRHub/internal/repeaterdb" @@ -198,6 +199,21 @@ func start() int { return nil }) + if config.GetConfig().OpenBridgePort != 0 { + // Start the OpenBridge server + openbridgeServer := openbridge.MakeServer(database, redis, callTracker) + openbridgeServer.Start(ctx) + defer openbridgeServer.Stop(ctx) + + go func() { + // For each peer in the DB, start a gofunc to listen for calls + peers := models.ListPeers(database) + for _, peer := range peers { + go openbridge.GetSubscriptionManager().Subscribe(ctx, redis, peer) + } + }() + } + http := http.MakeServer(database, redis) http.Start() defer http.Stop()
+ You will need to use this password configuration to connect to the + network. + Save this now, as you will not be able to retrieve it + again. + Your Peer password is: + {{ slotProps.message.message }} +
{{ slotProps.message.message }}