635 lines
16 KiB
Go
635 lines
16 KiB
Go
package stats
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
mqtt "github.com/mochi-mqtt/server/v2"
|
|
"github.com/mochi-mqtt/server/v2/packets"
|
|
"google.golang.org/protobuf/encoding/protowire"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Meshtastic Protobuf 手动解析 (简化版)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// MeshPacket 简化版,只包含解密所需字段
|
|
type MeshPacket struct {
|
|
Id uint64
|
|
From uint32
|
|
WhichPayloadVariant int // 10=decoded, 11=encrypted
|
|
Encrypted []byte
|
|
DecryptedPayload []byte // field 7: decoded.payload
|
|
PkiEncrypted bool
|
|
}
|
|
|
|
// ServiceEnvelope 简化版
|
|
type ServiceEnvelope struct {
|
|
ChannelId string
|
|
GatewayId string
|
|
Packet *MeshPacket
|
|
}
|
|
|
|
// ParseServiceEnvelope 解析 ServiceEnvelope 二进制 protobuf
|
|
func ParseServiceEnvelope(data []byte) (*ServiceEnvelope, error) {
|
|
env := &ServiceEnvelope{}
|
|
|
|
pos := 0
|
|
for pos < len(data) {
|
|
fieldNum, wireType, n := protowire.ConsumeTag(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid wire format at pos %d", pos)
|
|
}
|
|
pos += n
|
|
|
|
switch {
|
|
case int(fieldNum) == 1 && wireType == protowire.BytesType:
|
|
msgData, n := protowire.ConsumeBytes(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid bytes at pos %d", pos)
|
|
}
|
|
pos += n
|
|
packet, err := parseMeshPacket(msgData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse packet: %w", err)
|
|
}
|
|
env.Packet = packet
|
|
|
|
case int(fieldNum) == 2 && wireType == protowire.BytesType:
|
|
val, n := protowire.ConsumeBytes(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid bytes at pos %d", pos)
|
|
}
|
|
env.ChannelId = string(val)
|
|
pos += n
|
|
|
|
case int(fieldNum) == 3 && wireType == protowire.BytesType:
|
|
val, n := protowire.ConsumeBytes(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid bytes at pos %d", pos)
|
|
}
|
|
env.GatewayId = string(val)
|
|
pos += n
|
|
|
|
default:
|
|
n, ok := skipField(data[pos:], int(fieldNum), wireType)
|
|
if !ok {
|
|
return nil, fmt.Errorf("skip failed at pos %d", pos)
|
|
}
|
|
pos += n
|
|
}
|
|
}
|
|
|
|
return env, nil
|
|
}
|
|
|
|
// parseMeshPacket 解析 MeshPacket
|
|
func parseMeshPacket(data []byte) (*MeshPacket, error) {
|
|
packet := &MeshPacket{}
|
|
|
|
pos := 0
|
|
for pos < len(data) {
|
|
fieldNum, wireType, n := protowire.ConsumeTag(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid wire format at pos %d", pos)
|
|
}
|
|
pos += n
|
|
|
|
switch {
|
|
case int(fieldNum) == 1 && wireType == protowire.VarintType:
|
|
val, n := protowire.ConsumeVarint(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid varint at pos %d", pos)
|
|
}
|
|
packet.Id = val
|
|
pos += n
|
|
|
|
case int(fieldNum) == 3 && wireType == protowire.VarintType:
|
|
val, n := protowire.ConsumeVarint(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid varint at pos %d", pos)
|
|
}
|
|
packet.From = uint32(val)
|
|
pos += n
|
|
|
|
case int(fieldNum) == 8 && wireType == protowire.VarintType:
|
|
val, n := protowire.ConsumeVarint(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid varint at pos %d", pos)
|
|
}
|
|
packet.WhichPayloadVariant = int(val)
|
|
pos += n
|
|
|
|
case int(fieldNum) == 11 && wireType == protowire.BytesType:
|
|
// encrypted 字段 (variant 11)
|
|
val, n := protowire.ConsumeBytes(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid bytes at pos %d", pos)
|
|
}
|
|
packet.Encrypted = val
|
|
pos += n
|
|
|
|
case int(fieldNum) == 7 && wireType == protowire.BytesType:
|
|
// decoded.payload 字段 (variant 10) - 已经是解密的数据
|
|
val, n := protowire.ConsumeBytes(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid bytes at pos %d", pos)
|
|
}
|
|
packet.DecryptedPayload = val
|
|
pos += n
|
|
|
|
case int(fieldNum) == 15 && wireType == protowire.VarintType:
|
|
val, n := protowire.ConsumeVarint(data[pos:])
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("invalid varint at pos %d", pos)
|
|
}
|
|
packet.PkiEncrypted = val != 0
|
|
pos += n
|
|
|
|
default:
|
|
skipped, ok := skipField(data[pos:], int(fieldNum), wireType)
|
|
if !ok {
|
|
return nil, fmt.Errorf("skip failed at pos %d", pos)
|
|
}
|
|
pos += skipped
|
|
}
|
|
}
|
|
|
|
return packet, nil
|
|
}
|
|
|
|
// skipField 跳过未知 protobuf 字段
|
|
func skipField(data []byte, fieldNum int, wireType protowire.Type) (int, bool) {
|
|
switch wireType {
|
|
case protowire.VarintType:
|
|
_, n := protowire.ConsumeVarint(data)
|
|
if n < 0 {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
case protowire.Fixed32Type:
|
|
if len(data) < 4 {
|
|
return 0, false
|
|
}
|
|
return 4, true
|
|
case protowire.Fixed64Type:
|
|
if len(data) < 8 {
|
|
return 0, false
|
|
}
|
|
return 8, true
|
|
case protowire.BytesType:
|
|
_, n := protowire.ConsumeBytes(data)
|
|
if n < 0 {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
case protowire.StartGroupType, protowire.EndGroupType:
|
|
return 0, false
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 数据结构
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ClientInfo 当前在线客户端信息
|
|
type ClientInfo struct {
|
|
ID string `json:"id"`
|
|
RemoteAddr string `json:"remote_addr"`
|
|
Username string `json:"username"`
|
|
ConnectedAt time.Time `json:"connected_at"`
|
|
SubsCount int `json:"subs_count"`
|
|
}
|
|
|
|
// Stats 当前统计快照
|
|
type Stats struct {
|
|
Connections int64 `json:"connections"`
|
|
MessagesTotal int64 `json:"messages_total"`
|
|
MessagesMsh int64 `json:"messages_msh"`
|
|
Uptime int64 `json:"uptime"`
|
|
Clients []ClientInfo `json:"clients"`
|
|
Topics map[string]int64 `json:"topics"`
|
|
}
|
|
|
|
// DecryptedMessage 解密后的消息结构
|
|
type DecryptedMessage struct {
|
|
ChannelId string `json:"channel_id"`
|
|
GatewayId string `json:"gateway_id"`
|
|
PacketId uint64 `json:"packet_id"`
|
|
From uint32 `json:"from"`
|
|
PortNum uint32 `json:"port_num"`
|
|
Payload []byte `json:"payload"`
|
|
}
|
|
|
|
// MessageRecord 一条MQTT消息记录
|
|
type MessageRecord struct {
|
|
Topic string `json:"topic"`
|
|
Payload string `json:"payload"`
|
|
Time time.Time `json:"time"`
|
|
Decrypted *DecryptedMessage `json:"decrypted,omitempty"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 全局统计
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var (
|
|
connections atomic.Int64
|
|
messagesTotal atomic.Int64
|
|
messagesMsh atomic.Int64
|
|
startTime = time.Now()
|
|
clientsMu sync.RWMutex
|
|
clients = make(map[string]ClientInfo)
|
|
subs = make(map[string][]string)
|
|
topicsMu sync.RWMutex
|
|
topics = make(map[string]int64)
|
|
msgMu sync.RWMutex
|
|
msgBuf []MessageRecord
|
|
)
|
|
|
|
// GetStats 返回当前统计快照
|
|
func GetStats() Stats {
|
|
clientsMu.RLock()
|
|
clientList := make([]ClientInfo, 0, len(clients))
|
|
for id, info := range clients {
|
|
info.SubsCount = len(subs[id])
|
|
clientList = append(clientList, info)
|
|
}
|
|
clientsMu.RUnlock()
|
|
|
|
topicsMu.RLock()
|
|
topicsCopy := make(map[string]int64, len(topics))
|
|
for k, v := range topics {
|
|
topicsCopy[k] = v
|
|
}
|
|
topicsMu.RUnlock()
|
|
|
|
return Stats{
|
|
Connections: connections.Load(),
|
|
MessagesTotal: messagesTotal.Load(),
|
|
MessagesMsh: messagesMsh.Load(),
|
|
Uptime: int64(time.Since(startTime).Seconds()),
|
|
Clients: clientList,
|
|
Topics: topicsCopy,
|
|
}
|
|
}
|
|
|
|
// GetClient 返回指定客户端的详细信息
|
|
func GetClient(id string) *ClientInfo {
|
|
clientsMu.RLock()
|
|
defer clientsMu.RUnlock()
|
|
info, ok := clients[id]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
info.SubsCount = len(subs[id])
|
|
return &info
|
|
}
|
|
|
|
// GetClientSubs 返回指定客户端的订阅主题列表
|
|
func GetClientSubs(id string) []string {
|
|
clientsMu.RLock()
|
|
defer clientsMu.RUnlock()
|
|
return subs[id]
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hook 实现
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Hook struct {
|
|
mqtt.HookBase
|
|
}
|
|
|
|
func (h *Hook) ID() string { return "meshgo-stats" }
|
|
|
|
func (h *Hook) Provides(b byte) bool {
|
|
return bytes.Contains([]byte{
|
|
mqtt.OnSessionEstablished,
|
|
mqtt.OnDisconnect,
|
|
mqtt.OnPublish,
|
|
mqtt.OnSubscribe,
|
|
mqtt.OnUnsubscribe,
|
|
}, []byte{b})
|
|
}
|
|
|
|
func (h *Hook) OnSessionEstablished(cl *mqtt.Client, pk packets.Packet) {
|
|
username := string(pk.Connect.Username)
|
|
if username == "" {
|
|
username = "(anonymous)"
|
|
}
|
|
clientsMu.Lock()
|
|
clients[cl.ID] = ClientInfo{
|
|
ID: cl.ID,
|
|
RemoteAddr: cl.Net.Remote,
|
|
Username: username,
|
|
ConnectedAt: time.Now(),
|
|
}
|
|
subs[cl.ID] = []string{}
|
|
clientsMu.Unlock()
|
|
connections.Add(1)
|
|
}
|
|
|
|
func (h *Hook) OnDisconnect(cl *mqtt.Client, err error, expire bool) {
|
|
clientsMu.Lock()
|
|
delete(clients, cl.ID)
|
|
delete(subs, cl.ID)
|
|
clientsMu.Unlock()
|
|
connections.Add(-1)
|
|
}
|
|
|
|
func (h *Hook) OnPublish(cl *mqtt.Client, pk packets.Packet) (packets.Packet, error) {
|
|
messagesTotal.Add(1)
|
|
if len(pk.TopicName) >= 4 && pk.TopicName[:4] == "msh/" {
|
|
messagesMsh.Add(1)
|
|
addMessage(pk.TopicName, pk.Payload)
|
|
}
|
|
topicsMu.Lock()
|
|
topics[pk.TopicName]++
|
|
topicsMu.Unlock()
|
|
return pk, nil
|
|
}
|
|
|
|
func (h *Hook) OnSubscribe(cl *mqtt.Client, pk packets.Packet) packets.Packet {
|
|
clientsMu.Lock()
|
|
for _, f := range pk.Filters {
|
|
subs[cl.ID] = append(subs[cl.ID], f.Filter)
|
|
}
|
|
clientsMu.Unlock()
|
|
return pk
|
|
}
|
|
|
|
func (h *Hook) OnUnsubscribe(cl *mqtt.Client, pk packets.Packet) packets.Packet {
|
|
clientsMu.Lock()
|
|
for _, f := range pk.Filters {
|
|
filters := subs[cl.ID]
|
|
for i, ff := range filters {
|
|
if ff == f.Filter {
|
|
subs[cl.ID] = append(filters[:i], filters[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
clientsMu.Unlock()
|
|
return pk
|
|
}
|
|
|
|
var _ mqtt.Hook = (*Hook)(nil)
|
|
|
|
func addMessage(topic string, payload []byte) {
|
|
rec := MessageRecord{
|
|
Topic: topic,
|
|
Payload: base64.StdEncoding.EncodeToString(payload),
|
|
Time: time.Now(),
|
|
}
|
|
msgMu.Lock()
|
|
defer msgMu.Unlock()
|
|
msgBuf = append(msgBuf, rec)
|
|
if len(msgBuf) > 200 {
|
|
msgBuf = msgBuf[len(msgBuf)-200:]
|
|
}
|
|
}
|
|
|
|
func GetMessages() []MessageRecord {
|
|
msgMu.RLock()
|
|
defer msgMu.RUnlock()
|
|
out := make([]MessageRecord, len(msgBuf))
|
|
copy(out, msgBuf)
|
|
return out
|
|
}
|
|
|
|
// ParseServiceEnvelopeDebug 解析但不解密(用于调试)
|
|
func ParseServiceEnvelopeDebug(payloadB64 string) (*ServiceEnvelope, error) {
|
|
data, err := base64.StdEncoding.DecodeString(payloadB64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode base64: %w", err)
|
|
}
|
|
return ParseServiceEnvelope(data)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Meshtastic AES-CTR 解密
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// 默认 PSK (索引1 = 不变)
|
|
var DefaultPSK = []byte{0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59, 0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01}
|
|
|
|
// 默认 PSK 索引 (1-8)
|
|
var defaultPSKIndex byte = 1
|
|
|
|
// Payload variant tags
|
|
const (
|
|
MeshPacket_decoded_tag = 10
|
|
MeshPacket_encrypted_tag = 11
|
|
)
|
|
|
|
// ExpandPSK 将 1 字节 PSK 索引扩展为 16 字节 AES128 密钥
|
|
func ExpandPSK(pskIndex byte) ([]byte, error) {
|
|
if pskIndex == 0 {
|
|
return nil, nil // 无加密
|
|
}
|
|
if pskIndex > 8 {
|
|
return nil, fmt.Errorf("PSK index must be 0-8, got %d", pskIndex)
|
|
}
|
|
|
|
key := make([]byte, 16)
|
|
copy(key, DefaultPSK)
|
|
|
|
// 索引1不变,索引2-8在最后一位累加
|
|
if pskIndex > 1 {
|
|
key[15] += pskIndex - 1
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
// buildNonce 构建 AES-CTR 用的 nonce (16字节)
|
|
// nonce 结构: packetId(8字节小端) + fromNode(4字节小端) + counter(4字节,通常为0)
|
|
func buildNonce(packetId uint64, fromNode uint32) [16]byte {
|
|
var nonce [16]byte
|
|
// packetId: 8字节,小端序
|
|
nonce[0] = byte(packetId)
|
|
nonce[1] = byte(packetId >> 8)
|
|
nonce[2] = byte(packetId >> 16)
|
|
nonce[3] = byte(packetId >> 24)
|
|
nonce[4] = byte(packetId >> 32)
|
|
nonce[5] = byte(packetId >> 40)
|
|
nonce[6] = byte(packetId >> 48)
|
|
nonce[7] = byte(packetId >> 56)
|
|
// fromNode: 4字节,小端序
|
|
nonce[8] = byte(fromNode)
|
|
nonce[9] = byte(fromNode >> 8)
|
|
nonce[10] = byte(fromNode >> 16)
|
|
nonce[11] = byte(fromNode >> 24)
|
|
// counter: 4字节,默认为0 (nonce[12-15] 已经是0)
|
|
return nonce
|
|
}
|
|
|
|
// decryptAESCtr 使用 AES-CTR 解密
|
|
func decryptAESCtr(key []byte, nonce [16]byte, ciphertext []byte) ([]byte, error) {
|
|
if len(key) != 16 {
|
|
return nil, fmt.Errorf("key must be 16 bytes, got %d", len(key))
|
|
}
|
|
if len(ciphertext) == 0 {
|
|
return nil, fmt.Errorf("ciphertext is empty")
|
|
}
|
|
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stream := cipher.NewCTR(block, nonce[:])
|
|
plaintext := make([]byte, len(ciphertext))
|
|
stream.XORKeyStream(plaintext, ciphertext)
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
// DecryptMeshPacket 解密 MeshPacket
|
|
func DecryptMeshPacket(psk []byte, packetId uint64, fromNode uint32, encrypted []byte) ([]byte, error) {
|
|
if psk == nil {
|
|
return nil, fmt.Errorf("no PSK configured")
|
|
}
|
|
if len(encrypted) == 0 {
|
|
return nil, fmt.Errorf("encrypted payload is empty")
|
|
}
|
|
|
|
nonce := buildNonce(packetId, fromNode)
|
|
return decryptAESCtr(psk, nonce, encrypted)
|
|
}
|
|
|
|
// SetDefaultPSKIndex 设置默认 PSK 索引
|
|
func SetDefaultPSKIndex(index byte) {
|
|
defaultPSKIndex = index
|
|
}
|
|
|
|
// GetDefaultPSK 返回当前 PSK 的 16 字节密钥
|
|
func GetDefaultPSK() []byte {
|
|
key, _ := ExpandPSK(defaultPSKIndex)
|
|
return key
|
|
}
|
|
|
|
// GetDefaultPSKIndex 返回当前 PSK 索引
|
|
func GetDefaultPSKIndex() byte {
|
|
return defaultPSKIndex
|
|
}
|
|
|
|
// TryDecryptServiceEnvelope 尝试解密 ServiceEnvelope
|
|
func TryDecryptServiceEnvelope(data []byte, pskIndex byte) (*DecryptedMessage, error) {
|
|
psk, err := ExpandPSK(pskIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
env, err := ParseServiceEnvelope(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if env.Packet == nil {
|
|
return nil, fmt.Errorf("ServiceEnvelope has no packet")
|
|
}
|
|
|
|
msg := &DecryptedMessage{
|
|
ChannelId: env.ChannelId,
|
|
GatewayId: env.GatewayId,
|
|
PacketId: env.Packet.Id,
|
|
From: env.Packet.From,
|
|
}
|
|
|
|
// variant 10: 已经解密的数据 (decoded.payload)
|
|
if env.Packet.WhichPayloadVariant == MeshPacket_decoded_tag {
|
|
if len(env.Packet.DecryptedPayload) > 0 {
|
|
msg.Payload = env.Packet.DecryptedPayload
|
|
// portnum 是解密数据的第一个字节
|
|
msg.PortNum = uint32(env.Packet.DecryptedPayload[0])
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
// variant 11: 加密数据,需要解密
|
|
if env.Packet.WhichPayloadVariant == MeshPacket_encrypted_tag && !env.Packet.PkiEncrypted {
|
|
plaintext, err := DecryptMeshPacket(psk, env.Packet.Id, env.Packet.From, env.Packet.Encrypted)
|
|
if err != nil {
|
|
return msg, fmt.Errorf("decryption failed: %w", err)
|
|
}
|
|
msg.Payload = plaintext
|
|
|
|
// 解析 portnum (第一个字节)
|
|
if len(plaintext) > 0 {
|
|
msg.PortNum = uint32(plaintext[0])
|
|
}
|
|
} else if env.Packet.WhichPayloadVariant == MeshPacket_encrypted_tag && env.Packet.PkiEncrypted {
|
|
return msg, fmt.Errorf("PKI encrypted packet (not supported)")
|
|
} else {
|
|
return msg, fmt.Errorf("unknown packet variant: %d", env.Packet.WhichPayloadVariant)
|
|
}
|
|
|
|
return msg, nil
|
|
}
|
|
|
|
// TryDecryptMessage 尝试解密消息
|
|
func TryDecryptMessage(payloadB64 string, pskIndex byte) (*DecryptedMessage, error) {
|
|
data, err := base64.StdEncoding.DecodeString(payloadB64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode base64: %w", err)
|
|
}
|
|
|
|
return TryDecryptServiceEnvelope(data, pskIndex)
|
|
}
|
|
|
|
// PortNumName 返回 PortNum 对应的名称
|
|
func PortNumName(portNum uint32) string {
|
|
names := map[uint32]string{
|
|
0: "Reserved",
|
|
1: "TEXT_MESSAGE_APP",
|
|
2: "REMOTE_HARDWARE_APP",
|
|
3: "POSITION_APP",
|
|
4: "NODEINFO_APP",
|
|
5: "ROUTING_APP",
|
|
6: "ADMIN_APP",
|
|
7: "TEXT_MESSAGE_APP2",
|
|
8: "WAYPOINT_APP",
|
|
9: "WIFI_APP",
|
|
10: "MXT_AI_APP",
|
|
11: "RANGE_TEST_APP",
|
|
12: "DETECTION_SENSOR_APP",
|
|
13: "REPLY_APP",
|
|
14: "IP_TUNNEL_APP",
|
|
15: "SERIAL_APP",
|
|
16: "STORE_FORWARD_APP",
|
|
17: "TELEMETRY_APP",
|
|
18: "ZPS_APP",
|
|
19: "SIMULATOR_APP",
|
|
20: "TRACEROUTE_APP",
|
|
21: "NEIGHBORINFO_APP",
|
|
22: "AUDIO_APP",
|
|
23: "DUPLICATE_MESSAGES_APP",
|
|
24: "ACKNOWLEDGEMENT_APP",
|
|
25: "CONFIG_APP",
|
|
26: "IPLY_CONFIG_APP",
|
|
27: "MAP_REPORT_APP",
|
|
28: "PaxCounter_APP",
|
|
32: "PRIVATE_APP",
|
|
256: "ATAK_PLUGIN",
|
|
257: "HALP",
|
|
258: "RPC_APP",
|
|
259: "XMPP_APP",
|
|
260: "STREAM_APP",
|
|
261: "TUNNEL_APP",
|
|
}
|
|
if name, ok := names[portNum]; ok {
|
|
return name
|
|
}
|
|
return fmt.Sprintf("Unknown(%d)", portNum)
|
|
}
|