From 32f3fcf3c7a22a24d77fe9d8cd0ea9dff72c0faf Mon Sep 17 00:00:00 2001 From: Jim Ancona Date: Thu, 15 Jan 2026 12:39:48 -0500 Subject: [PATCH 1/8] Working minimal Discord bridge --- cmd/bridge/bridge.go | 155 +++++++++++++++++++ encoding.go | 5 +- encoding_test.go | 40 +++++ go.mod | 17 ++- go.sum | 55 ++++++- relay.go => internet.go | 327 +++++++++++++++++++++++++++++++++++++--- module.go | 96 ++++++++++++ 7 files changed, 670 insertions(+), 25 deletions(-) create mode 100644 cmd/bridge/bridge.go rename relay.go => internet.go (52%) create mode 100644 module.go diff --git a/cmd/bridge/bridge.go b/cmd/bridge/bridge.go new file mode 100644 index 0000000..6f639bb --- /dev/null +++ b/cmd/bridge/bridge.go @@ -0,0 +1,155 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "log" + "os" + + "github.com/hashicorp/logutils" + "github.com/jancona/m17" + "gopkg.in/ini.v1" +) + +type config struct { + name string + listenAddress string + listenPort string + modules map[byte]*ini.Section + logLevel string + logPath string + logRoot string +} + +func loadConfig(iniFile string) (*config, error) { + log.Printf("[INFO] Loading settings from '%s'", iniFile) + cfg, err := ini.Load(iniFile) + if err != nil { + log.Fatalf("Fail to read config from %s: %v", iniFile, err) + } + name := cfg.Section("General").Key("Name").String() + listenAddress := cfg.Section("General").Key("ListenAddress").String() + listenPort := cfg.Section("General").Key("ListenPort").String() + mods := cfg.Section("General").Key("Modules").String() + modules := map[byte]*ini.Section{} + for _, m := range []byte(mods) { + s := cfg.Section("Module-" + string(m)) + if s == nil { + return nil, fmt.Errorf("missing configuration section [Module-%s]", string(m)) + } + modules[m] = s + } + + logLevel := cfg.Section("Log").Key("Level").String() + logPath := cfg.Section("Log").Key("Path").String() + logRoot := cfg.Section("Log").Key("Root").String() + var logLevelErr error + if logLevel != "ERROR" && logLevel != "INFO" && logLevel != "DEBUG" { + logLevelErr = fmt.Errorf("configured Log Level must be one of ERROR, INFO or DEBUG") + } + + err = errors.Join( + logLevelErr, + ) + + c := config{ + name: name, + listenAddress: listenAddress, + listenPort: listenPort, + modules: modules, + logLevel: logLevel, + logPath: logPath, + logRoot: logRoot, + } + return &c, err +} + +var ( + configFile *string = flag.String("config", "./bridge.ini", "Configuration file") + helpArg *bool = flag.Bool("h", false, "Print arguments") +) + +func main() { + var err error + + flag.Parse() + + if *helpArg { + flag.Usage() + return + } + cfg, err := loadConfig(*configFile) + if err != nil { + log.Fatalf("Bad configuration: %v", err) + } + + setupLogging(cfg) + + var b *Bridge + log.Printf("[DEBUG] Creating Bridge cfg: %#v", cfg) + b, err = NewBridge(cfg) + if err != nil { + log.Fatalf("Error creating Bridge: %v", err) + } + defer b.Close() + b.Run() +} + +func setupLogging(c *config) { + var err error + minLogLevel := c.logLevel + logWriter := os.Stderr + + if c.logRoot != "" { + logWriter, err = os.OpenFile(c.logPath+"/"+c.logRoot+".log", os.O_WRONLY|os.O_CREATE|os.O_SYNC, 0644) + if err != nil { + log.Fatalf("Error opening server output, exiting: %v", err) + } + } + + filter := &logutils.LevelFilter{ + Levels: []logutils.LogLevel{"DEBUG", "INFO", "ERROR"}, + MinLevel: logutils.LogLevel(minLogLevel), + Writer: logWriter, + } + log.SetOutput(filter) + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + // log.SetFlags(0) + log.Print("[DEBUG] Debug is on") +} + +type Bridge struct { + server *m17.Server +} + +func NewBridge(cfg *config) (*Bridge, error) { + var err error + ret := Bridge{} + modules := map[byte]m17.Module{} + ret.server = m17.NewServer(cfg.name, cfg.listenAddress+":"+cfg.listenPort, modules) + for k, m := range cfg.modules { + switch m.Key("Type").String() { + case "Discord": + modules[k], err = m17.NewDiscordModule( + k, + ret.server, + m.Key("ChannelName").String(), + m.Key("WebhookURL").String(), + m.Key("BotToken").String(), + ) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("unknown module type '%s'", m.Key("Type").String()) + } + } + return &ret, nil +} +func (b *Bridge) Run() { + b.server.Start() +} +func (b *Bridge) Close() { + b.server.Close() +} diff --git a/encoding.go b/encoding.go index e1f22f7..7075460 100644 --- a/encoding.go +++ b/encoding.go @@ -20,7 +20,8 @@ var EncodedDestinationAllBytes = EncodedCallsign{0xff, 0xff, 0xff, 0xff, 0xff, 0 const m17Chars = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/." -var callsignRegex = regexp.MustCompile(`^([0-9]?[A-Z]{1,2}[0-9]{0,2}/)?[0-9]?[A-Z]{1,2}[0-9]{1,2}[A-Z]{1,4}([ -/\.][A-Z0-9 -/\.]*)?$`) +var exactCallsignRegex = regexp.MustCompile(`^([0-9]?[A-Z]{1,2}[0-9]{0,2}/)?[0-9]?[A-Z]{1,2}[0-9]{1,2}[A-Z]{1,4}([ -/\.][A-Z0-9 -/\.]*)?$`) +var csRegex = regexp.MustCompile(`([a-zA-Z0-9]{1,3}/)?[a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z](/[a-zA-Z0-9]{1,3})?`) var reflectorRegex = regexp.MustCompile(`^[A-Z][-A-Z0-9]{5,6}( +[ A-Z])?$`) var roomRegex = regexp.MustCompile(`^#[A-Z0-9 -/\.]+$`) @@ -39,7 +40,7 @@ func EncodeCallsign(callsign string) (*EncodedCallsign, error) { return nil, fmt.Errorf("room name '%s' is not valid", callsign) } } else { - if !callsignRegex.MatchString(callsign) && !reflectorRegex.MatchString(callsign) { + if !exactCallsignRegex.MatchString(callsign) && !reflectorRegex.MatchString(callsign) { return nil, fmt.Errorf("callsign '%s' is not valid", callsign) } } diff --git a/encoding_test.go b/encoding_test.go index 2e9e1b8..e4fc9ba 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -213,3 +213,43 @@ func TestNormalizeCallsignModule(t *testing.T) { }) } } + +func TestCSRegex(t *testing.T) { + type args struct { + callsign string + } + tests := []struct { + name string + args args + want []int + }{ + {name: "N1ADJ", + args: args{callsign: "N1ADJ"}, + want: []int{0, 5}, + }, + {name: "N1ADJ B", + args: args{callsign: "N1ADJ B"}, + want: []int{0, 5}, + }, + {name: "N1ADJ - Jim", + args: args{callsign: "N1ADJ - Jim"}, + want: []int{0, 5}, + }, + {name: "Jim - N1ADJ", + args: args{callsign: "Jim - N1ADJ"}, + want: []int{6, 11}, + }, + {name: "SP5/N1ADJ", + args: args{callsign: "SP5/N1ADJ"}, + want: []int{0, 9}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := csRegex.FindStringIndex(tt.args.callsign) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("callsignRegex.FindStringIndex() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod index 33dc1c2..d202813 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/jancona/m17 -go 1.24 +go 1.24.0 + +toolchain go1.24.3 require github.com/hashicorp/logutils v1.0.0 @@ -8,6 +10,8 @@ require github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 require ( fyne.io/fyne/v2 v2.5.5 + github.com/StalkR/discordgo-bridge v1.0.10 + github.com/bwmarrin/discordgo v0.29.0 github.com/go-zeromq/zmq4 v0.17.0 github.com/icza/gog v0.0.0-20241010132004-5da24f18211d github.com/warthog618/go-gpiocdev v0.9.1 @@ -34,8 +38,10 @@ require ( github.com/go-zeromq/goczmq/v4 v4.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/text v0.2.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -44,11 +50,12 @@ require ( github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/crypto v0.47.0 // indirect golang.org/x/image v0.24.0 // indirect golang.org/x/mobile v0.0.0-20250106192035-c31d5b91ecc3 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 96e2103..213f1ae 100644 --- a/go.sum +++ b/go.sum @@ -45,12 +45,18 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/StalkR/discordgo-bridge v1.0.10 h1:tazF32fQxQ6gtJ0pXQlb6ATCDZk5vJD9mfw5DoKalAw= +github.com/StalkR/discordgo-bridge v1.0.10/go.mod h1:Rhl8kMNGefKCyZRvnGYdZlPtMO1Isx4kVMer44mwNKo= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= +github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= +github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= @@ -64,6 +70,7 @@ github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -77,6 +84,7 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= +github.com/fluffle/goirc v1.0.3/go.mod h1:SqQ+D/FJYnNf6btZfM1NsOkESlVw39Q5bvjjRUUQ2Ho= github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= @@ -179,6 +187,11 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= @@ -220,8 +233,9 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= @@ -302,6 +316,7 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= @@ -326,7 +341,14 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -370,8 +392,11 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -407,8 +432,13 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -432,8 +462,12 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -479,9 +513,20 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -490,8 +535,14 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -548,6 +599,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/relay.go b/internet.go similarity index 52% rename from relay.go rename to internet.go index 405e797..ec8d834 100644 --- a/relay.go +++ b/internet.go @@ -7,20 +7,21 @@ import ( "log" "net" "os" + "sync" "time" ) const ( magicLen = 4 - magicACKN = "ACKN" - magicCONN = "CONN" - magicDISC = "DISC" - // magicLSTN = "LSTN" + magicACKN = "ACKN" + magicCONN = "CONN" + magicDISC = "DISC" + magicLSTN = "LSTN" magicNACK = "NACK" magicPING = "PING" magicPONG = "PONG" - magicM17Voice = "M17 " + magicM17Stream = "M17 " magicM17Packet = "M17P" maxRetries = 10 @@ -50,11 +51,11 @@ func NewRelay(name string, server string, port uint, module string, callsign str if err != nil { return nil, fmt.Errorf("bad callsign %s: %w", callsign, err) } - n := NormalizeCallsignModule(name + " " + module) - encodedName, err := EncodeCallsign(n) - if err != nil { - return nil, fmt.Errorf("bad name/module %s: %w", n, err) - } + // n := NormalizeCallsignModule(name + " " + module) + // encodedName, err := EncodeCallsign(n) + // if err != nil { + // return nil, fmt.Errorf("bad name/module %s: %w", n, err) + // } var m byte switch { case len(module) == 0: @@ -66,11 +67,11 @@ func NewRelay(name string, server string, port uint, module string, callsign str } var r *Relay r = &Relay{ - Name: name, - Server: server, - Port: port, - Module: m, - EncodedName: encodedName, + Name: name, + Server: server, + Port: port, + Module: m, + // EncodedName: encodedName, callsign: callsign, encodedCallsign: cs, packetHandler: packetHandler, @@ -188,7 +189,7 @@ func (r *Relay) handle() { r.sendPONG() r.pingTimer.Reset(30 * time.Second) // case magicINFO: - case magicM17Voice: // M17 voice stream + case magicM17Stream: // M17 voice stream // log.Printf("[DEBUG] stream buffer: % 2x", buffer) if r.streamHandler != nil { sd, err := NewStreamDatagramFromBytes(buffer) @@ -317,7 +318,7 @@ func NewStreamDatagram(streamID uint16, frameNumber uint16, lsf *LSF, payload [] func (sd StreamDatagram) ToBytes() []byte { buf := make([]byte, 0, 54) - buf = append(buf, []byte(magicM17Voice)...) + buf = append(buf, []byte(magicM17Stream)...) buf, _ = binary.Append(buf, binary.BigEndian, sd.StreamID) buf = append(buf, sd.LSF.ToLSDBytes()...) buf, _ = binary.Append(buf, binary.BigEndian, sd.FrameNumber) @@ -336,3 +337,295 @@ func (sd StreamDatagram) String() string { Payload: [% 2x], }`, sd.StreamID, sd.FrameNumber, sd.LastFrame, sd.LSF, sd.Payload) } + +type Server struct { + Name string + InterfaceAddr string + conn *net.UDPConn + modules map[byte]Module + running bool + mutex sync.Mutex + clients map[string]*client +} + +func NewServer(name string, addr string, modules map[byte]Module) *Server { + s := Server{ + Name: name, + InterfaceAddr: addr, + modules: modules, + clients: map[string]*client{}, + } + return &s +} +func (s *Server) Start() error { + udpAddr, err := net.ResolveUDPAddr("udp", s.InterfaceAddr) + if err != nil { + log.Printf("[ERROR] Failed to resolve address %s", s.InterfaceAddr) + return err + } + + s.conn, err = net.ListenUDP("udp", udpAddr) + if err != nil { + log.Printf("[ERROR] Failed to listen on %v", udpAddr) + return err + } + log.Printf("[INFO] Listening on: %s", s.InterfaceAddr) + + s.handle() + + return nil +} +func (s *Server) handle() { + log.Print("[INFO] Server is ready") + for { + buf := make([]byte, 1024) + s.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) + n, addr, err := s.conn.ReadFromUDP(buf) + if err != nil { + if ne, ok := err.(*net.OpError); ok && ne.Timeout() { + continue + } + if ne, ok := err.(*net.OpError); ok && ne.Op == "read" && ne.Err.Error() == "use of closed network connection" { + log.Print("[DEBUG] Socket closed, exiting listen loop.", nil) + return + } + log.Printf("[ERROR] Error reading packet: %v", err) + continue + } + buf = buf[:n] + if n < magicLen { + log.Printf("[DEBUG] Ignoring short packet from %s: [% x]", addr, buf) + continue + } + magic := string(buf[:magicLen]) + if magic != magicPING && magic != magicPONG { + log.Printf("[DEBUG] Received packet from %s: magic: %s, [% x]", addr, magic, buf) + } + switch magic { + case magicACKN: + // s.recvACKN(buf) + case magicCONN: + s.recvConnect(buf, addr, false) + case magicDISC: + if n != 10 { + log.Printf("[INFO] Bad DISC packet length %d, should be 19", n) + } else { + c := s.lookupClient(addr) + if c != nil { + log.Printf("[INFO] Disconnecting client %s", addr.String()) + c.pongTimer.Stop() + c.pingTimer.Stop() + s.removeClient(c) + } + } + case magicLSTN: + s.recvConnect(buf, addr, true) + case magicNACK: + // s.recvNACK(buf) + case magicPING: + fallthrough + case magicPONG: + if n != 10 { + log.Printf("[INFO] Bad PING/PONG packet length %d, should be 10", n) + } else { + c := s.lookupClient(addr) + if c != nil { + // log.Printf("[DEBUG] Received PING/PONG from client %v", *c) + c.pongTimer.Reset(30 * time.Second) + } + } + case magicM17Stream: + log.Printf("[DEBUG] Server received stream message: % 2x", buf) + sd, err := NewStreamDatagramFromBytes(buf) + if err != nil { + log.Printf("[INFO] Dropping bad stream datagram: %v", err) + s.sendNACK(addr) + } else { + log.Printf("[DEBUG] Server received StreamDatagram: %s", sd) + c := s.lookupClient(addr) + if c != nil { + err := c.module.HandleStreamDatagram(sd) + if err != nil { + log.Printf("[ERROR] Error calling streamHandler: %v", err) + s.sendNACK(addr) + } + // Send the datagram to other clients of the module + for _, cl := range s.lookupClientsByModule(c.module.Name()) { + if c != cl { + s.SendDatagram(&sd, cl.addr) + } + } + } + } + case magicM17Packet: + p := NewPacketFromBytes(buf[4:]) + log.Printf("[DEBUG] Server received packet: %s", p.String()) + c := s.lookupClient(addr) + if c != nil { + c.module.HandlePacket(p) + // Send the packet to other clients of the module, becuase the module won't send it back + // Should this be here or in the module? + for _, cl := range s.lookupClientsByModule(c.module.Name()) { + if c != cl { + s.SendPacket(&p, cl.addr) + } + } + } + } + } +} + +func (s *Server) Close() { + s.conn.Close() +} + +func (s *Server) recvConnect(buf []byte, addr *net.UDPAddr, listenOnly bool) { + if len(buf) != 11 { + s.sendNACK(addr) + log.Printf("[INFO] Bad CONN packet length %d, should be 11", len(buf)) + return + } + module := s.modules[buf[10]] + if module == nil { + s.sendNACK(addr) + log.Printf("[INFO] Invalid module '%s'", string(buf[10])) + return + } + c := s.newClient(buf[4:10], module, addr, listenOnly) + log.Printf("[INFO] Connecting client %s", addr.String()) + s.addClient(c) + s.sendACKN(addr) +} + +func (s *Server) sendACKN(addr *net.UDPAddr) error { + // log.Print("[DEBUG] Sending ACKN") + cmd := make([]byte, 10) + copy(cmd, []byte(magicACKN)) + _, err := s.conn.WriteToUDP(cmd, addr) + if err != nil { + return fmt.Errorf("error sending ACKN: %w", err) + } + return nil +} + +func (s *Server) sendNACK(addr *net.UDPAddr) error { + // log.Print("[DEBUG] Sending NACK") + cmd := make([]byte, 10) + copy(cmd, []byte(magicNACK)) + _, err := s.conn.WriteToUDP(cmd, addr) + if err != nil { + return fmt.Errorf("error sending NACK: %w", err) + } + return nil +} + +func (s *Server) sendPING(encodedCallsign []byte, addr *net.UDPAddr) error { + // log.Print("[DEBUG] Sending PING") + cmd := make([]byte, 10) + copy(cmd, []byte(magicPING)) + copy(cmd[4:10], encodedCallsign) + _, err := s.conn.WriteToUDP(cmd, addr) + if err != nil { + return fmt.Errorf("error sending PONG: %w", err) + } + return nil +} + +// func (s *Server) sendDISC(encodedCallsign []byte, addr *net.UDPAddr) error { +// cmd := make([]byte, 10) +// copy(cmd, []byte(magicDISC)) +// copy(cmd[4:10], encodedCallsign[:]) +// log.Printf("[DEBUG] Sending DISC cmd: %#v", cmd) +// _, err := s.conn.WriteToUDP(cmd, addr) +// if err != nil { +// return fmt.Errorf("error sending DISC: %w", err) +// } +// return nil +// } + +func (s *Server) SendPacket(p *Packet, addr *net.UDPAddr) error { + cmd := []byte("M17P") + cmd = append(cmd, p.ToBytes()...) + log.Printf("[DEBUG] Sending Packet: %#v", cmd) + _, err := s.conn.WriteToUDP(cmd, addr) + if err != nil { + return fmt.Errorf("error sending DISC: %w", err) + } + return nil +} + +func (s *Server) SendDatagram(sd *StreamDatagram, addr *net.UDPAddr) error { + cmd := []byte("M17 ") + cmd = append(cmd, sd.ToBytes()...) + log.Printf("[DEBUG] Sending StreamDatagram: %#v to %s", cmd, addr.String()) + _, err := s.conn.WriteToUDP(cmd, addr) + if err != nil { + return fmt.Errorf("error sending DISC: %w", err) + } + return nil +} + +type client struct { + encodedCallsign []byte + callsign string + module Module + addr *net.UDPAddr + pongTimer *time.Timer + pingTimer *time.Timer + listenOnly bool +} + +func (s *Server) newClient(callsign []byte, module Module, addr *net.UDPAddr, listenOnly bool) *client { + var c client + cs, _ := DecodeCallsign(callsign) + c = client{ + encodedCallsign: callsign, + callsign: cs, + module: module, + addr: addr, + listenOnly: listenOnly, + pongTimer: time.AfterFunc(30*time.Second, func() { + log.Printf("[DEBUG] No PONGs received in > 30 seconds. Disconnecting.") + c.pongTimer.Stop() + c.pingTimer.Stop() + s.removeClient(&c) + }), + pingTimer: time.AfterFunc(3*time.Second, func() { + // log.Printf("[DEBUG] Sending PING to %s at %s", c.callsign, c.addr.String()) + s.sendPING(c.encodedCallsign, c.addr) + c.pingTimer.Reset(3 * time.Second) + }), + } + return &c +} + +func (s *Server) addClient(c *client) { + s.mutex.Lock() + s.clients[c.addr.String()] = c + s.mutex.Unlock() +} + +func (s *Server) removeClient(c *client) { + s.mutex.Lock() + delete(s.clients, c.addr.String()) + s.mutex.Unlock() +} + +func (s *Server) lookupClient(addr *net.UDPAddr) *client { + key := addr.String() + s.mutex.Lock() + c := s.clients[key] + s.mutex.Unlock() + // log.Printf("[DEBUG] lookupClient(%s): %v", key, c) + return c +} + +func (s *Server) lookupClientsByModule(m byte) []*client { + ret := []*client{} + for _, c := range s.clients { + if c.module.Name() == m { + ret = append(ret, c) + } + } + return ret +} diff --git a/module.go b/module.go new file mode 100644 index 0000000..1c35e4b --- /dev/null +++ b/module.go @@ -0,0 +1,96 @@ +package m17 + +import ( + "fmt" + "log" + "strings" + + bridge "github.com/StalkR/discordgo-bridge" + "github.com/bwmarrin/discordgo" +) + +type Module interface { + Name() byte + HandlePacket(Packet) error + HandleStreamDatagram(StreamDatagram) error +} + +type DiscordModule struct { + name byte + server *Server + channel *bridge.Channel + bot *bridge.Bot + session *discordgo.Session +} + +func NewDiscordModule(name byte, server *Server, channelName string, webhookURL string, botToken string) (*DiscordModule, error) { + log.Printf("[DEBUG] NewDiscordModule(%s, %s, %s, %s)", string(name), channelName, webhookURL, botToken) + m := DiscordModule{ + name: name, + server: server, + } + c := bridge.NewChannel("#"+channelName, webhookURL, m.recvMessage) + m.channel = c + m.bot = bridge.NewBot(botToken, c) + err := m.bot.Start() + if err != nil { + log.Printf("[ERROR] Error starting bot: %v", err) + return nil, err + } + m.session, err = discordgo.New("Bot " + botToken) + if err != nil { + return nil, fmt.Errorf("error creating session: %v", err) + } + err = m.session.Open() + if err != nil { + return nil, fmt.Errorf("error opening session: %v", err) + } + log.Printf("[DEBUG] NewDiscordModule: %#v", m) + return &m, nil +} + +func (m *DiscordModule) Name() byte { + return m.name +} + +func (m *DiscordModule) HandlePacket(p Packet) error { + log.Printf("[DEBUG] Received packet: %s", p.String()) + err := m.channel.Send(p.LSF.Src.Callsign(), string(p.Payload)) + if err != nil { + log.Printf("[ERROR] Unable to send message to Discord: %v", err) + } + return err +} +func (m *DiscordModule) HandleStreamDatagram(sd StreamDatagram) error { + log.Printf("[DEBUG] Ignoring StreamDatagram: %v", sd) + return nil +} + +func (m *DiscordModule) recvMessage(nick string, text string) { + log.Printf("[DEBUG] Received nick: %s, message: %s", nick, text) + // member, err := m.session.GuildMembersSearch(m.session.State.Guilds[0].ID, nick, 1) + // if err != nil { + // log.Printf("[ERROR] GuildMember: %v", err) + // } + // log.Printf("[DEBUG] member: %v", member) + // log.Printf("[DEBUG] member: %v", member[0].DisplayName()) + // nick = strings.ToUpper(member[0].DisplayName()) + nick = strings.ToUpper(nick) + loc := csRegex.FindStringIndex(nick) + if loc == nil || loc[1] == 0 { + log.Printf("[INFO] No callsign found in nick: %s", nick) + return + } + callsign := nick[loc[0]:loc[1]] + log.Printf("[DEBUG] loc: %v, callsign: %s", loc, callsign) + msg := append([]byte(text), 0) + p, err := NewPacket("@ALL", callsign, PacketTypeSMS, msg) + if err != nil { + log.Printf("[INFO] Error building packet: %v", err) + return + } + clients := m.server.lookupClientsByModule(m.Name()) + for _, c := range clients { + m.server.SendPacket(p, c.addr) + } +} From 8d536f271dc1e03f7d55f5dfe318b8849c16f763 Mon Sep 17 00:00:00 2001 From: Jim Ancona Date: Wed, 21 Jan 2026 10:25:38 -0500 Subject: [PATCH 2/8] Add basic IRC support to bridge --- .gitignore | 3 + README.md | 24 +- cmd/bridge/bridge.go | 30 ++- cmd/m17-gateway/gateway.go | 26 +- cmd/m17-message/config.go | 2 +- cmd/m17-message/m17server.go | 28 +- cmd/m17-text-cli/textclient.go | 6 +- encoding.go | 3 +- encoding_test.go | 2 +- go.mod | 1 + go.sum | 14 +- internet.go | 369 +++----------------------- module.go => server/discord_module.go | 19 +- server/inet_server.go | 303 +++++++++++++++++++++ server/irc_module.go | 114 ++++++++ 15 files changed, 536 insertions(+), 408 deletions(-) rename module.go => server/discord_module.go (80%) create mode 100644 server/inet_server.go create mode 100644 server/irc_module.go diff --git a/.gitignore b/.gitignore index b6e529a..2465b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +cmd/bridge/bridge +cmd/bridge/bridge.ini cmd/m17-text-cli/m17-text-cli cmd/m17-gateway/m17-gateway cmd/m17-gateway/m17-gateway.ini @@ -10,3 +12,4 @@ cmd/m17-message/m17-message.apk cmd/modem-emulator/modem-emulator misc/ .DS_Store +.vscode/ diff --git a/README.md b/README.md index 6faef38..8d3906a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # M17 library, gateway and clients, written in Go -M17 Packet Mode is defined in the [spec](https://spec.m17project.org), and messaging is one obvious application. This project started as a set of tools to jumpstart messaging and data communications using the [M17 ham radio mode](https://m17foundation.org/). It has evolved to provide more general tool and library support for M17. +M17 Packet Mode is defined in the [spec](https://spec.m17project.org), and messaging is one obvious application. This project started as a set of tools to jump-start messaging and data communications using the [M17 ham radio mode](https://m17foundation.org/). It has evolved to provide more general tool and library support for M17. There are several tools and a library here: @@ -8,7 +8,7 @@ There are several tools and a library here: ### M17 Gateway -[m17-gateway](./cmd/m17-gateway/) bridges between RF clients and relays/reflectors. It currently supports the [CC1200 Pi HAT](https://github.com/M17-Project/CC1200_HAT-hw). When run on a Raspberry Pi with a CC1200 HAT, it can forward M17 voice and packet traffic to and from a reflector/relay, making the Pi/CC1200 HAT an M17 voice and packet hotspot. +[m17-gateway](./cmd/m17-gateway/) makes allows a computer and modem to act as a repeater/hotspot. It also connects RF clients to Internet services such as reflectors. It currently supports the [CC1200 Pi HAT](https://github.com/M17-Project/CC1200_HAT-hw) and MMDVM-compatible hotspots and modem. When run on a Raspberry Pi with a CC1200 HAT, it can forward M17 voice and packet traffic to and from a reflector, making the Pi/CC1200 HAT an M17 voice and packet hotspot. The easiest way to get a working CC1200 hotspot, including `m17-gateway` and a [web dashboard](https://github.com/M17-Project/rpi-dashboard) is using DK1MI's [excellent installer script](https://github.com/DK1MI/cc1200-hotspot-installer). Highly recommended! @@ -17,24 +17,12 @@ Another way to install just the gateway is using the APT package from a release 1. Copy the URL for the latest `deb` package from https://github.com/jancona/m17/releases 2. From a shell on the Pi do: ``` -wget +wget sudo dpkg -i m17-gateway__arm64.deb ``` To build it just run `go build` in the `m17-gateway` directory. Because Go natively supports cross-compilation, you can build a Raspberry Pi executable on any machine by running `GOOS=linux GOARCH=arm64 go build`, the using `scp` to copy the resulting executable to the Pi. - - ``` Usage of gateway: -config string @@ -52,7 +40,7 @@ Bu default, the gateway looks for configuration in `gateway.ini` in the working ### GUI Messaging Client -[m17-message](./cmd/m17-message/) is a cross-platform GUI network messaging client. It's based on [Fybro](https://github.com/andydotxyz/fybro), a messaging app built using [Fyne](https://fyne.io/), a fraemwork for building multi-platform GUI apps in Go. To build the client just run `go build` in the `m17-message` directory. For more packaging options, see the [Fyne docs](https://docs.fyne.io/started/packaging). +[m17-message](./cmd/m17-message/) is a cross-platform GUI network messaging client. It's based on [Fybro](https://github.com/andydotxyz/fybro), a messaging app built using [Fyne](https://fyne.io/), a framework for building multi-platform GUI apps in Go. To build the client just run `go build` in the `m17-message` directory. For more packaging options, see the [Fyne docs](https://docs.fyne.io/started/packaging). ### CLI Messaging Client @@ -60,13 +48,13 @@ Bu default, the gateway looks for configuration in `gateway.ini` in the working To build the client just run `go build` in the `m17-text-cli` directory. -Example: `./m17-text-cli -server relay.kc1awv.net -callsign N1ADJ` +Example: `./m17-text-cli -server m17.openquad.net -callsign N1ADJ` The program will respond with a prompt `> `. To send a message, enter `callsign: message`. Incoming messages for you will appear starting with `< `. To quit, enter `/quit`. Sample session: ``` -$ ./m17-text-cli -server relay.kc1awv.net +$ ./m17-text-cli -server m17.openquad.net > N1ADJ: Hi from my other window! > 2025-02-06 14:45:45 N0CALL>@ALL: Hi back diff --git a/cmd/bridge/bridge.go b/cmd/bridge/bridge.go index 6f639bb..2e21fdc 100644 --- a/cmd/bridge/bridge.go +++ b/cmd/bridge/bridge.go @@ -8,7 +8,7 @@ import ( "os" "github.com/hashicorp/logutils" - "github.com/jancona/m17" + "github.com/jancona/m17/server" "gopkg.in/ini.v1" ) @@ -120,18 +120,18 @@ func setupLogging(c *config) { } type Bridge struct { - server *m17.Server + server *server.InetServer } func NewBridge(cfg *config) (*Bridge, error) { var err error ret := Bridge{} - modules := map[byte]m17.Module{} - ret.server = m17.NewServer(cfg.name, cfg.listenAddress+":"+cfg.listenPort, modules) + modules := map[byte]server.Module{} + ret.server = server.NewInetServer(cfg.name, cfg.listenAddress+":"+cfg.listenPort, modules) for k, m := range cfg.modules { switch m.Key("Type").String() { case "Discord": - modules[k], err = m17.NewDiscordModule( + modules[k], err = server.NewDiscordModule( k, ret.server, m.Key("ChannelName").String(), @@ -141,6 +141,26 @@ func NewBridge(cfg *config) (*Bridge, error) { if err != nil { return nil, err } + case "IRC": + port, err := m.Key("Port").Uint() + if err != nil { + return nil, err + } + useTLS, err := m.Key("UseTLS").Bool() + if err != nil { + return nil, err + } + modules[k], err = server.NewIRCModule( + k, + ret.server, + m.Key("Server").String(), + port, + useTLS, + m.Key("ServerPassword").String(), + ) + if err != nil { + return nil, err + } default: return nil, fmt.Errorf("unknown module type '%s'", m.Key("Type").String()) } diff --git a/cmd/m17-gateway/gateway.go b/cmd/m17-gateway/gateway.go index 7f4485f..1ecae1d 100644 --- a/cmd/m17-gateway/gateway.go +++ b/cmd/m17-gateway/gateway.go @@ -324,7 +324,7 @@ type Gateway struct { modem m17.Modem in *os.File out *os.File - relay *m17.Relay + inetClient *m17.InetClient duplex bool done bool dashLog *m17.DashboardLogger @@ -376,11 +376,11 @@ func NewGateway(cfg config, modem m17.Modem) (*Gateway, error) { g.Server = h.Server g.Port = h.Port log.Printf("[DEBUG] Connecting to %s, %s:%d, module %s", g.Name, g.Server, g.Port, g.Module) - g.relay, err = m17.NewRelay(g.Name, g.Server, g.Port, g.Module, cfg.callsign, g.dashLog, g.TransmitPacket, g.TransmitVoiceStream) + g.inetClient, err = m17.NewInetClient(g.Name, g.Server, g.Port, g.Module, cfg.callsign, m17.NewDashboardLogger(cfg.dashboardLogger), g.TransmitPacket, g.TransmitVoiceStream) if err != nil { - return nil, fmt.Errorf("error creating relay: %v", err) + return nil, fmt.Errorf("error creating client: %v", err) } - err = g.relay.Connect() + err = g.inetClient.Connect() if err != nil { return nil, fmt.Errorf("error connecting to %s %s:%d %s: %v", g.Name, g.Server, g.Port, g.Module, err) } @@ -391,7 +391,7 @@ func NewGateway(cfg config, modem m17.Modem) (*Gateway, error) { } func (g *Gateway) TransmitPacket(p m17.Packet) error { - // log.Printf("[DEBUG] received packet from relay: %#v", p) + // log.Printf("[DEBUG] received packet from server: %#v", p) if p.Type == m17.PacketTypeSMS && len(p.Payload) > 0 { msg := string(p.Payload[0 : len(p.Payload)-1]) g.dashLog.LogFrame(p.LSF, "Internet", "Packet", "packetType", p.Type, "smsMessage", msg) @@ -402,13 +402,13 @@ func (g *Gateway) TransmitPacket(p m17.Packet) error { // Replace META with Extended Callsign Data // Don't swap Src for Packet - p.LSF.SetECD(&g.encodedCallsign, g.relay.EncodedName) + p.LSF.SetECD(&g.encodedCallsign, g.inetClient.EncodedName) // p.LSF.Src = g.encodedCallsign return g.modem.TransmitPacket(p) } func (g *Gateway) TransmitVoiceStream(sd m17.StreamDatagram) error { - // log.Printf("[DEBUG] received voice stream data from relay: %#v", sd) + // log.Printf("[DEBUG] received voice stream data from server: %#v", sd) if g.lastStreamID != sd.StreamID { if g.lastFrameTimer != nil { g.lastFrameTimer.Stop() @@ -444,7 +444,7 @@ func (g *Gateway) TransmitVoiceStream(sd m17.StreamDatagram) error { // Shouldn't need the next line with modern reflectors // sd.LSF.Dst = *callsignAll // Replace META with Extended Callsign Data - sd.LSF.SetECD(&sd.LSF.Src, g.relay.EncodedName) + sd.LSF.SetECD(&sd.LSF.Src, g.inetClient.EncodedName) sd.LSF.Src = g.encodedCallsign sd.LSF.CalcCRC() err := g.modem.TransmitVoiceStream(sd) @@ -498,7 +498,7 @@ func (g *Gateway) receivedRFStreamFrame(lsf m17.LSF, payload []byte, sid, fn uin case Echo: g.echoStreamRecord(sd) case RFStreamRX: - err = g.relay.SendStream(sd) + err = g.inetClient.SendStream(sd) if g.duplex { // Replace META with Extended Callsign Data sd.LSF.SetECD(&sd.LSF.Src, nil) @@ -523,7 +523,7 @@ func (g *Gateway) receivedRFStreamEOT(lsf m17.LSF, sid, fn uint16, ber float64) case LocalCommand: switch lsf.Dst.Callsign() { case "/INFO", "#INFO": - go g.playMessage("welcome", "callsign", "is_linked_to", g.relay.Name+" "+string(g.relay.Module)) + go g.playMessage("welcome", "callsign", "is_linked_to", g.inetClient.Name+" "+string(g.inetClient.Module)) } case RFStreamRX: log.Printf("[DEBUG] receivedRFStreamEOT() setState(Idle)") @@ -553,7 +553,7 @@ func (g *Gateway) receivedRFPacket(lsf m17.LSF, payload []byte, ber float64) err go g.infoPacket(p) default: log.Printf("[DEBUG] receivedRFPacket() packet dst: %s", lsf.Dst.Callsign()) - err = g.relay.SendPacket(p) + err = g.inetClient.SendPacket(p) if err == nil && g.duplex { // Replace META with Extended Callsign Data // Don't swap Src for packet @@ -592,7 +592,7 @@ func (g *Gateway) Run() { func (g *Gateway) Close() { log.Print("[DEBUG] Gateway.Close()") g.done = true - g.relay.Close() + g.inetClient.Close() if g.modem != nil { g.modem.Close() } @@ -622,7 +622,7 @@ func (g *Gateway) infoPacket(p m17.Packet) error { p.LSF.Dst = p.LSF.Src p.LSF.Src = g.encodedCallsign p.LSF.CalcCRC() - msg := g.callsign + " is linked to " + g.relay.Name + " " + string(g.relay.Module) + msg := g.callsign + " is linked to " + g.inetClient.Name + " " + string(g.inetClient.Module) p.Payload = append(([]byte)(msg), 0) // NULL terminate the string p.CalcCRC() err = g.modem.TransmitPacket(p) diff --git a/cmd/m17-message/config.go b/cmd/m17-message/config.go index 51c3e4d..dddbfac 100644 --- a/cmd/m17-message/config.go +++ b/cmd/m17-message/config.go @@ -68,7 +68,7 @@ func (u *ui) loginContent(a fyne.App) fyne.CanvasObject { }) list.OnSelected = func(id widget.ListItemID) { opt := opts[id] - title.SetText(fmt.Sprintf("Add %s reflector/relay", strings.Title(opt.id))) + title.SetText(fmt.Sprintf("Add %s reflector", strings.Title(opt.id))) details.Objects = []fyne.CanvasObject{ opts[id].content, } diff --git a/cmd/m17-message/m17server.go b/cmd/m17-message/m17server.go index 881b86c..24bb09f 100644 --- a/cmd/m17-message/m17server.go +++ b/cmd/m17-message/m17server.go @@ -36,14 +36,14 @@ func init() { } type m17Server struct { - ID string - app fyne.App - callsign string - name string - host string - port uint - module string - relay *m17.Relay + ID string + app fyne.App + callsign string + name string + host string + port uint + module string + inetClient *m17.InetClient } func initM17Server(a fyne.App) service { @@ -105,8 +105,8 @@ func (s *m17Server) configure(u *ui) (fyne.CanvasObject, func(prefix string, a f } func (s *m17Server) disconnect() { - if s.relay != nil { - s.relay.Close() + if s.inetClient != nil { + s.inetClient.Close() } } @@ -163,7 +163,7 @@ func (s *m17Server) disconnect() { // } func (s *m17Server) send(ch *channel, text string) { - // s.relay.SendSMS(ch.name, s.callsign, text) + // s.inetClient.SendSMS(ch.name, s.callsign, text) // Add a trailing NUL msg := append([]byte(text), 0) p, err := m17.NewPacket(ch.name, s.callsign, m17.PacketTypeSMS, []byte(msg)) @@ -171,7 +171,7 @@ func (s *m17Server) send(ch *channel, text string) { fmt.Printf("Error creating Packet: %v\n", err) return } - err = s.relay.SendPacket(*p) + err = s.inetClient.SendPacket(*p) if err != nil { fmt.Printf("Error sending message: %v\n", err) return @@ -242,11 +242,11 @@ func (s *m17Server) login(prefix string, u *ui) { func (s *m17Server) doConnect(name string, server string, port uint, module string, u *ui) { var err error log.Printf("Connecting to %s, %s:%d %s, callsign %s", name, server, port, module, s.callsign) - s.relay, err = m17.NewRelay(name, server, port, module, s.callsign, nil, s.handleM17, nil) + s.inetClient, err = m17.NewInetClient(name, server, port, module, s.callsign, nil, s.handleM17, nil) if err != nil { log.Printf("fail to connect create client: %v", err) } - err = s.relay.Connect() + err = s.inetClient.Connect() if err != nil { fmt.Printf("Error connecting to %s:%d %s: %v", server, port, module, err) } diff --git a/cmd/m17-text-cli/textclient.go b/cmd/m17-text-cli/textclient.go index aaf1137..1ba6b8e 100644 --- a/cmd/m17-text-cli/textclient.go +++ b/cmd/m17-text-cli/textclient.go @@ -14,7 +14,7 @@ import ( ) var ( - serverArg *string = flag.String("server", "", "Reflector server address (e.g. relay.n1adj.net)") + serverArg *string = flag.String("server", "", "Reflector server address (e.g. m17.openquad.net)") portArg *uint = flag.Uint("port", 17000, "Port the reflector listens on") moduleArg *string = flag.String("module", "P", "Module to connect to") callsignArg *string = flag.String("callsign", "N0CALL", "Client user's callsign (e.g. N1ADJ)") @@ -37,7 +37,7 @@ func main() { } // TODO: Add name argument and hostfile lookup - r, err := m17.NewRelay(*serverArg, *serverArg, *portArg, *moduleArg, *callsignArg, nil, handleM17, nil) + r, err := m17.NewInetClient(*serverArg, *serverArg, *portArg, *moduleArg, *callsignArg, nil, handleM17, nil) if err != nil { fmt.Printf("Error creating client: %v", err) os.Exit(1) @@ -75,7 +75,7 @@ func handleM17(p m17.Packet) error { // keep watching for console input // send the "message" command to the chat server when we have some -func handleConsoleInput(c *m17.Relay) { +func handleConsoleInput(c *m17.InetClient) { var done bool reader := bufio.NewReader(os.Stdin) diff --git a/encoding.go b/encoding.go index 7075460..57a394b 100644 --- a/encoding.go +++ b/encoding.go @@ -21,10 +21,11 @@ var EncodedDestinationAllBytes = EncodedCallsign{0xff, 0xff, 0xff, 0xff, 0xff, 0 const m17Chars = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-/." var exactCallsignRegex = regexp.MustCompile(`^([0-9]?[A-Z]{1,2}[0-9]{0,2}/)?[0-9]?[A-Z]{1,2}[0-9]{1,2}[A-Z]{1,4}([ -/\.][A-Z0-9 -/\.]*)?$`) -var csRegex = regexp.MustCompile(`([a-zA-Z0-9]{1,3}/)?[a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z](/[a-zA-Z0-9]{1,3})?`) var reflectorRegex = regexp.MustCompile(`^[A-Z][-A-Z0-9]{5,6}( +[ A-Z])?$`) var roomRegex = regexp.MustCompile(`^#[A-Z0-9 -/\.]+$`) +var CallsignRegex = regexp.MustCompile(`([a-zA-Z0-9]{1,3}/)?[a-zA-Z0-9]{1,3}[0-9][a-zA-Z0-9]{0,3}[a-zA-Z](/[a-zA-Z0-9]{1,3})?`) + func EncodeCallsign(callsign string) (*EncodedCallsign, error) { if len(callsign) > MaxCallsignLen { return nil, fmt.Errorf("callsign '%s' too long, max %d", callsign, MaxCallsignLen) diff --git a/encoding_test.go b/encoding_test.go index e4fc9ba..b0658c6 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -246,7 +246,7 @@ func TestCSRegex(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := csRegex.FindStringIndex(tt.args.callsign) + got := CallsignRegex.FindStringIndex(tt.args.callsign) if !reflect.DeepEqual(got, tt.want) { t.Errorf("callsignRegex.FindStringIndex() = %v, want %v", got, tt.want) } diff --git a/go.mod b/go.mod index d202813..927b477 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( fyne.io/fyne/v2 v2.5.5 github.com/StalkR/discordgo-bridge v1.0.10 github.com/bwmarrin/discordgo v0.29.0 + github.com/ergochat/irc-go v0.5.0 github.com/go-zeromq/zmq4 v0.17.0 github.com/icza/gog v0.0.0-20241010132004-5da24f18211d github.com/warthog618/go-gpiocdev v0.9.1 diff --git a/go.sum b/go.sum index 213f1ae..27332eb 100644 --- a/go.sum +++ b/go.sum @@ -53,7 +53,6 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= -github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= @@ -81,6 +80,8 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw= +github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= @@ -188,7 +189,6 @@ github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNY github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -345,8 +345,6 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -435,8 +433,6 @@ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -464,8 +460,6 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -518,8 +512,6 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -539,8 +531,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internet.go b/internet.go index ec8d834..6e47f06 100644 --- a/internet.go +++ b/internet.go @@ -7,27 +7,26 @@ import ( "log" "net" "os" - "sync" "time" ) const ( - magicLen = 4 - - magicACKN = "ACKN" - magicCONN = "CONN" - magicDISC = "DISC" - magicLSTN = "LSTN" - magicNACK = "NACK" - magicPING = "PING" - magicPONG = "PONG" - magicM17Stream = "M17 " - magicM17Packet = "M17P" + MagicLen = 4 + + MagicACKN = "ACKN" + MagicCONN = "CONN" + MagicDISC = "DISC" + MagicLSTN = "LSTN" + MagicNACK = "NACK" + MagicPING = "PING" + MagicPONG = "PONG" + MagicM17Stream = "M17 " + MagicM17Packet = "M17P" maxRetries = 10 ) -type Relay struct { +type InetClient struct { Name string Server string Port uint @@ -46,7 +45,7 @@ type Relay struct { dashLog *DashboardLogger } -func NewRelay(name string, server string, port uint, module string, callsign string, dashLog *DashboardLogger, packetHandler func(Packet) error, streamHandler func(StreamDatagram) error) (*Relay, error) { +func NewInetClient(name string, server string, port uint, module string, callsign string, dashLog *DashboardLogger, packetHandler func(Packet) error, streamHandler func(StreamDatagram) error) (*InetClient, error) { cs, err := EncodeCallsign(callsign) if err != nil { return nil, fmt.Errorf("bad callsign %s: %w", callsign, err) @@ -65,8 +64,8 @@ func NewRelay(name string, server string, port uint, module string, callsign str case len(module) == 1: m = []byte(module)[0] } - var r *Relay - r = &Relay{ + var r *InetClient + r = &InetClient{ Name: name, Server: server, Port: port, @@ -109,13 +108,13 @@ func NewRelay(name string, server string, port uint, module string, callsign str return r, nil } -func (r *Relay) Connect() error { +func (r *InetClient) Connect() error { addr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", r.Server, r.Port)) if err != nil { return fmt.Errorf("failed to resolve address: %w", err) } - // Dial UDP connection to relay/reflector + // Dial UDP connection to server/reflector r.conn, err = net.DialUDP("udp", nil, addr) if err != nil { return fmt.Errorf("failed to connect: %w", err) @@ -130,8 +129,8 @@ func (r *Relay) Connect() error { go r.handle() return nil } -func (r *Relay) Close() error { - log.Print("[DEBUG] Relay.Close()") +func (r *InetClient) Close() error { + log.Print("[DEBUG] InetClient.Close()") r.running = false r.pingTimer.Stop() r.sendDISC() @@ -139,7 +138,7 @@ func (r *Relay) Close() error { return r.conn.Close() } -func (r *Relay) handle() { +func (r *InetClient) handle() { r.running = true for r.connected || r.connecting { r.conn.SetDeadline(time.Now().Add(10 * time.Second)) @@ -151,7 +150,7 @@ func (r *Relay) handle() { log.Printf("[DEBUG] Reflector read timed out") continue } - log.Printf("[DEBUG] Relay.Handle(): error reading from UDP: %v", err) + log.Printf("[DEBUG] InetClient.Handle(): error reading from UDP: %v", err) r.running = false break } @@ -167,29 +166,29 @@ func (r *Relay) handle() { // log.Printf("[DEBUG] Packet received, len: %d:\n%#v\n%s\n", l, buffer, string(buffer[:4])) // } switch magic { - case magicACKN: + case MagicACKN: r.connected = true r.connecting = false r.dashLog.Log("Reflector", "Connect", "name", r.Name, "module", string(r.Module)) r.pingTimer.Reset(30 * time.Second) log.Printf("[DEBUG] Received ACKN") - case magicNACK: + case MagicNACK: r.connected = false r.connecting = false log.Print("[INFO] Received NACK, disconnecting") r.dashLog.Log("Reflector", "Disconnect", "name", r.Name, "module", string(r.Module)) // r.done = true - case magicDISC: + case MagicDISC: r.connected = false r.connecting = false log.Print("[INFO] Received DISC, disconnecting") r.dashLog.Log("Reflector", "Disconnect", "name", r.Name, "module", string(r.Module)) // r.done = true - case magicPING: + case MagicPING: r.sendPONG() r.pingTimer.Reset(30 * time.Second) // case magicINFO: - case magicM17Stream: // M17 voice stream + case MagicM17Stream: // M17 voice stream // log.Printf("[DEBUG] stream buffer: % 2x", buffer) if r.streamHandler != nil { sd, err := NewStreamDatagramFromBytes(buffer) @@ -200,7 +199,7 @@ func (r *Relay) handle() { r.streamHandler(sd) } } - case magicM17Packet: // M17 packet + case MagicM17Packet: // M17 packet if r.packetHandler != nil { p := NewPacketFromBytes(buffer[4:]) // log.Printf("[DEBUG] Received packet from reflector. buffer: % 02x, buffer len: %d, p: %v", buffer[4:], len(buffer[4:]), p) @@ -211,10 +210,10 @@ func (r *Relay) handle() { r.running = false } -func (r *Relay) SendPacket(p Packet) error { +func (r *InetClient) SendPacket(p Packet) error { b := p.ToBytes() - cmd := make([]byte, 0, magicLen+len(b)) - cmd = append(cmd, []byte(magicM17Packet)...) + cmd := make([]byte, 0, MagicLen+len(b)) + cmd = append(cmd, []byte(MagicM17Packet)...) cmd = append(cmd, b...) // log.Printf("[DEBUG] p: %#v, cmd: %#v", p, cmd) @@ -225,7 +224,7 @@ func (r *Relay) SendPacket(p Packet) error { return nil } -func (r *Relay) SendStream(sd StreamDatagram) error { +func (r *InetClient) SendStream(sd StreamDatagram) error { // log.Printf("[DEBUG] Send StreamDatagram: %s", sd) _, err := r.conn.Write(sd.ToBytes()) if err != nil { @@ -234,9 +233,9 @@ func (r *Relay) SendStream(sd StreamDatagram) error { return nil } -func (r *Relay) sendCONN() error { +func (r *InetClient) sendCONN() error { cmd := make([]byte, 11) - copy(cmd, []byte(magicCONN)) + copy(cmd, []byte(MagicCONN)) copy(cmd[4:10], r.encodedCallsign[:]) cmd[10] = r.Module log.Printf("[DEBUG] Sending CONN callsign: %s, module %s, cmd: %#v", r.callsign, string(r.Module), cmd) @@ -246,10 +245,10 @@ func (r *Relay) sendCONN() error { } return nil } -func (r *Relay) sendPONG() error { +func (r *InetClient) sendPONG() error { // log.Print("[DEBUG] Sending PONG") cmd := make([]byte, 10) - copy(cmd, []byte(magicPONG)) + copy(cmd, []byte(MagicPONG)) copy(cmd[4:10], r.encodedCallsign[:]) _, err := r.conn.Write(cmd) if err != nil { @@ -257,9 +256,9 @@ func (r *Relay) sendPONG() error { } return nil } -func (r *Relay) sendDISC() error { +func (r *InetClient) sendDISC() error { cmd := make([]byte, 10) - copy(cmd, []byte(magicDISC)) + copy(cmd, []byte(MagicDISC)) copy(cmd[4:10], r.encodedCallsign[:]) log.Printf("[DEBUG] Sending DISC cmd: %#v", cmd) _, err := r.conn.Write(cmd) @@ -318,7 +317,7 @@ func NewStreamDatagram(streamID uint16, frameNumber uint16, lsf *LSF, payload [] func (sd StreamDatagram) ToBytes() []byte { buf := make([]byte, 0, 54) - buf = append(buf, []byte(magicM17Stream)...) + buf = append(buf, []byte(MagicM17Stream)...) buf, _ = binary.Append(buf, binary.BigEndian, sd.StreamID) buf = append(buf, sd.LSF.ToLSDBytes()...) buf, _ = binary.Append(buf, binary.BigEndian, sd.FrameNumber) @@ -337,295 +336,3 @@ func (sd StreamDatagram) String() string { Payload: [% 2x], }`, sd.StreamID, sd.FrameNumber, sd.LastFrame, sd.LSF, sd.Payload) } - -type Server struct { - Name string - InterfaceAddr string - conn *net.UDPConn - modules map[byte]Module - running bool - mutex sync.Mutex - clients map[string]*client -} - -func NewServer(name string, addr string, modules map[byte]Module) *Server { - s := Server{ - Name: name, - InterfaceAddr: addr, - modules: modules, - clients: map[string]*client{}, - } - return &s -} -func (s *Server) Start() error { - udpAddr, err := net.ResolveUDPAddr("udp", s.InterfaceAddr) - if err != nil { - log.Printf("[ERROR] Failed to resolve address %s", s.InterfaceAddr) - return err - } - - s.conn, err = net.ListenUDP("udp", udpAddr) - if err != nil { - log.Printf("[ERROR] Failed to listen on %v", udpAddr) - return err - } - log.Printf("[INFO] Listening on: %s", s.InterfaceAddr) - - s.handle() - - return nil -} -func (s *Server) handle() { - log.Print("[INFO] Server is ready") - for { - buf := make([]byte, 1024) - s.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) - n, addr, err := s.conn.ReadFromUDP(buf) - if err != nil { - if ne, ok := err.(*net.OpError); ok && ne.Timeout() { - continue - } - if ne, ok := err.(*net.OpError); ok && ne.Op == "read" && ne.Err.Error() == "use of closed network connection" { - log.Print("[DEBUG] Socket closed, exiting listen loop.", nil) - return - } - log.Printf("[ERROR] Error reading packet: %v", err) - continue - } - buf = buf[:n] - if n < magicLen { - log.Printf("[DEBUG] Ignoring short packet from %s: [% x]", addr, buf) - continue - } - magic := string(buf[:magicLen]) - if magic != magicPING && magic != magicPONG { - log.Printf("[DEBUG] Received packet from %s: magic: %s, [% x]", addr, magic, buf) - } - switch magic { - case magicACKN: - // s.recvACKN(buf) - case magicCONN: - s.recvConnect(buf, addr, false) - case magicDISC: - if n != 10 { - log.Printf("[INFO] Bad DISC packet length %d, should be 19", n) - } else { - c := s.lookupClient(addr) - if c != nil { - log.Printf("[INFO] Disconnecting client %s", addr.String()) - c.pongTimer.Stop() - c.pingTimer.Stop() - s.removeClient(c) - } - } - case magicLSTN: - s.recvConnect(buf, addr, true) - case magicNACK: - // s.recvNACK(buf) - case magicPING: - fallthrough - case magicPONG: - if n != 10 { - log.Printf("[INFO] Bad PING/PONG packet length %d, should be 10", n) - } else { - c := s.lookupClient(addr) - if c != nil { - // log.Printf("[DEBUG] Received PING/PONG from client %v", *c) - c.pongTimer.Reset(30 * time.Second) - } - } - case magicM17Stream: - log.Printf("[DEBUG] Server received stream message: % 2x", buf) - sd, err := NewStreamDatagramFromBytes(buf) - if err != nil { - log.Printf("[INFO] Dropping bad stream datagram: %v", err) - s.sendNACK(addr) - } else { - log.Printf("[DEBUG] Server received StreamDatagram: %s", sd) - c := s.lookupClient(addr) - if c != nil { - err := c.module.HandleStreamDatagram(sd) - if err != nil { - log.Printf("[ERROR] Error calling streamHandler: %v", err) - s.sendNACK(addr) - } - // Send the datagram to other clients of the module - for _, cl := range s.lookupClientsByModule(c.module.Name()) { - if c != cl { - s.SendDatagram(&sd, cl.addr) - } - } - } - } - case magicM17Packet: - p := NewPacketFromBytes(buf[4:]) - log.Printf("[DEBUG] Server received packet: %s", p.String()) - c := s.lookupClient(addr) - if c != nil { - c.module.HandlePacket(p) - // Send the packet to other clients of the module, becuase the module won't send it back - // Should this be here or in the module? - for _, cl := range s.lookupClientsByModule(c.module.Name()) { - if c != cl { - s.SendPacket(&p, cl.addr) - } - } - } - } - } -} - -func (s *Server) Close() { - s.conn.Close() -} - -func (s *Server) recvConnect(buf []byte, addr *net.UDPAddr, listenOnly bool) { - if len(buf) != 11 { - s.sendNACK(addr) - log.Printf("[INFO] Bad CONN packet length %d, should be 11", len(buf)) - return - } - module := s.modules[buf[10]] - if module == nil { - s.sendNACK(addr) - log.Printf("[INFO] Invalid module '%s'", string(buf[10])) - return - } - c := s.newClient(buf[4:10], module, addr, listenOnly) - log.Printf("[INFO] Connecting client %s", addr.String()) - s.addClient(c) - s.sendACKN(addr) -} - -func (s *Server) sendACKN(addr *net.UDPAddr) error { - // log.Print("[DEBUG] Sending ACKN") - cmd := make([]byte, 10) - copy(cmd, []byte(magicACKN)) - _, err := s.conn.WriteToUDP(cmd, addr) - if err != nil { - return fmt.Errorf("error sending ACKN: %w", err) - } - return nil -} - -func (s *Server) sendNACK(addr *net.UDPAddr) error { - // log.Print("[DEBUG] Sending NACK") - cmd := make([]byte, 10) - copy(cmd, []byte(magicNACK)) - _, err := s.conn.WriteToUDP(cmd, addr) - if err != nil { - return fmt.Errorf("error sending NACK: %w", err) - } - return nil -} - -func (s *Server) sendPING(encodedCallsign []byte, addr *net.UDPAddr) error { - // log.Print("[DEBUG] Sending PING") - cmd := make([]byte, 10) - copy(cmd, []byte(magicPING)) - copy(cmd[4:10], encodedCallsign) - _, err := s.conn.WriteToUDP(cmd, addr) - if err != nil { - return fmt.Errorf("error sending PONG: %w", err) - } - return nil -} - -// func (s *Server) sendDISC(encodedCallsign []byte, addr *net.UDPAddr) error { -// cmd := make([]byte, 10) -// copy(cmd, []byte(magicDISC)) -// copy(cmd[4:10], encodedCallsign[:]) -// log.Printf("[DEBUG] Sending DISC cmd: %#v", cmd) -// _, err := s.conn.WriteToUDP(cmd, addr) -// if err != nil { -// return fmt.Errorf("error sending DISC: %w", err) -// } -// return nil -// } - -func (s *Server) SendPacket(p *Packet, addr *net.UDPAddr) error { - cmd := []byte("M17P") - cmd = append(cmd, p.ToBytes()...) - log.Printf("[DEBUG] Sending Packet: %#v", cmd) - _, err := s.conn.WriteToUDP(cmd, addr) - if err != nil { - return fmt.Errorf("error sending DISC: %w", err) - } - return nil -} - -func (s *Server) SendDatagram(sd *StreamDatagram, addr *net.UDPAddr) error { - cmd := []byte("M17 ") - cmd = append(cmd, sd.ToBytes()...) - log.Printf("[DEBUG] Sending StreamDatagram: %#v to %s", cmd, addr.String()) - _, err := s.conn.WriteToUDP(cmd, addr) - if err != nil { - return fmt.Errorf("error sending DISC: %w", err) - } - return nil -} - -type client struct { - encodedCallsign []byte - callsign string - module Module - addr *net.UDPAddr - pongTimer *time.Timer - pingTimer *time.Timer - listenOnly bool -} - -func (s *Server) newClient(callsign []byte, module Module, addr *net.UDPAddr, listenOnly bool) *client { - var c client - cs, _ := DecodeCallsign(callsign) - c = client{ - encodedCallsign: callsign, - callsign: cs, - module: module, - addr: addr, - listenOnly: listenOnly, - pongTimer: time.AfterFunc(30*time.Second, func() { - log.Printf("[DEBUG] No PONGs received in > 30 seconds. Disconnecting.") - c.pongTimer.Stop() - c.pingTimer.Stop() - s.removeClient(&c) - }), - pingTimer: time.AfterFunc(3*time.Second, func() { - // log.Printf("[DEBUG] Sending PING to %s at %s", c.callsign, c.addr.String()) - s.sendPING(c.encodedCallsign, c.addr) - c.pingTimer.Reset(3 * time.Second) - }), - } - return &c -} - -func (s *Server) addClient(c *client) { - s.mutex.Lock() - s.clients[c.addr.String()] = c - s.mutex.Unlock() -} - -func (s *Server) removeClient(c *client) { - s.mutex.Lock() - delete(s.clients, c.addr.String()) - s.mutex.Unlock() -} - -func (s *Server) lookupClient(addr *net.UDPAddr) *client { - key := addr.String() - s.mutex.Lock() - c := s.clients[key] - s.mutex.Unlock() - // log.Printf("[DEBUG] lookupClient(%s): %v", key, c) - return c -} - -func (s *Server) lookupClientsByModule(m byte) []*client { - ret := []*client{} - for _, c := range s.clients { - if c.module.Name() == m { - ret = append(ret, c) - } - } - return ret -} diff --git a/module.go b/server/discord_module.go similarity index 80% rename from module.go rename to server/discord_module.go index 1c35e4b..76cb4c5 100644 --- a/module.go +++ b/server/discord_module.go @@ -1,4 +1,4 @@ -package m17 +package server import ( "fmt" @@ -7,23 +7,24 @@ import ( bridge "github.com/StalkR/discordgo-bridge" "github.com/bwmarrin/discordgo" + "github.com/jancona/m17" ) type Module interface { Name() byte - HandlePacket(Packet) error - HandleStreamDatagram(StreamDatagram) error + HandlePacket(m17.Packet) error + HandleStreamDatagram(m17.StreamDatagram) error } type DiscordModule struct { name byte - server *Server + server *InetServer channel *bridge.Channel bot *bridge.Bot session *discordgo.Session } -func NewDiscordModule(name byte, server *Server, channelName string, webhookURL string, botToken string) (*DiscordModule, error) { +func NewDiscordModule(name byte, server *InetServer, channelName string, webhookURL string, botToken string) (*DiscordModule, error) { log.Printf("[DEBUG] NewDiscordModule(%s, %s, %s, %s)", string(name), channelName, webhookURL, botToken) m := DiscordModule{ name: name, @@ -53,7 +54,7 @@ func (m *DiscordModule) Name() byte { return m.name } -func (m *DiscordModule) HandlePacket(p Packet) error { +func (m *DiscordModule) HandlePacket(p m17.Packet) error { log.Printf("[DEBUG] Received packet: %s", p.String()) err := m.channel.Send(p.LSF.Src.Callsign(), string(p.Payload)) if err != nil { @@ -61,7 +62,7 @@ func (m *DiscordModule) HandlePacket(p Packet) error { } return err } -func (m *DiscordModule) HandleStreamDatagram(sd StreamDatagram) error { +func (m *DiscordModule) HandleStreamDatagram(sd m17.StreamDatagram) error { log.Printf("[DEBUG] Ignoring StreamDatagram: %v", sd) return nil } @@ -76,7 +77,7 @@ func (m *DiscordModule) recvMessage(nick string, text string) { // log.Printf("[DEBUG] member: %v", member[0].DisplayName()) // nick = strings.ToUpper(member[0].DisplayName()) nick = strings.ToUpper(nick) - loc := csRegex.FindStringIndex(nick) + loc := m17.CallsignRegex.FindStringIndex(nick) if loc == nil || loc[1] == 0 { log.Printf("[INFO] No callsign found in nick: %s", nick) return @@ -84,7 +85,7 @@ func (m *DiscordModule) recvMessage(nick string, text string) { callsign := nick[loc[0]:loc[1]] log.Printf("[DEBUG] loc: %v, callsign: %s", loc, callsign) msg := append([]byte(text), 0) - p, err := NewPacket("@ALL", callsign, PacketTypeSMS, msg) + p, err := m17.NewPacket("@ALL", callsign, m17.PacketTypeSMS, msg) if err != nil { log.Printf("[INFO] Error building packet: %v", err) return diff --git a/server/inet_server.go b/server/inet_server.go new file mode 100644 index 0000000..0d7646f --- /dev/null +++ b/server/inet_server.go @@ -0,0 +1,303 @@ +package server + +import ( + "fmt" + "log" + "net" + "sync" + "time" + + "github.com/jancona/m17" +) + +type InetServer struct { + Name string + InterfaceAddr string + conn *net.UDPConn + modules map[byte]Module + running bool + mutex sync.Mutex + clients map[string]*client +} + +func NewInetServer(name string, addr string, modules map[byte]Module) *InetServer { + s := InetServer{ + Name: name, + InterfaceAddr: addr, + modules: modules, + clients: map[string]*client{}, + } + return &s +} +func (s *InetServer) Start() error { + udpAddr, err := net.ResolveUDPAddr("udp", s.InterfaceAddr) + if err != nil { + log.Printf("[ERROR] Failed to resolve address %s", s.InterfaceAddr) + return err + } + + s.conn, err = net.ListenUDP("udp", udpAddr) + if err != nil { + log.Printf("[ERROR] Failed to listen on %v", udpAddr) + return err + } + log.Printf("[INFO] Listening on: %s", s.InterfaceAddr) + + s.handle() + + return nil +} +func (s *InetServer) handle() { + log.Print("[INFO] InetServer is ready") + for { + buf := make([]byte, 1024) + s.conn.SetReadDeadline(time.Now().Add(1 * time.Second)) + n, addr, err := s.conn.ReadFromUDP(buf) + if err != nil { + if ne, ok := err.(*net.OpError); ok && ne.Timeout() { + continue + } + if ne, ok := err.(*net.OpError); ok && ne.Op == "read" && ne.Err.Error() == "use of closed network connection" { + log.Print("[DEBUG] Socket closed, exiting listen loop.", nil) + return + } + log.Printf("[ERROR] Error reading packet: %v", err) + continue + } + buf = buf[:n] + if n < m17.MagicLen { + log.Printf("[DEBUG] Ignoring short packet from %s: [% x]", addr, buf) + continue + } + magic := string(buf[:m17.MagicLen]) + if magic != m17.MagicPING && magic != m17.MagicPONG { + log.Printf("[DEBUG] Received packet from %s: magic: %s, [% x]", addr, magic, buf) + } + switch magic { + case m17.MagicACKN: + // s.recvACKN(buf) + case m17.MagicCONN: + s.recvConnect(buf, addr, false) + case m17.MagicDISC: + if n != 10 { + log.Printf("[INFO] Bad DISC packet length %d, should be 19", n) + } else { + c := s.lookupClient(addr) + if c != nil { + log.Printf("[INFO] Disconnecting client %s", addr.String()) + c.pongTimer.Stop() + c.pingTimer.Stop() + s.removeClient(c) + } + } + case m17.MagicLSTN: + s.recvConnect(buf, addr, true) + case m17.MagicNACK: + // s.recvNACK(buf) + case m17.MagicPING: + fallthrough + case m17.MagicPONG: + if n != 10 { + log.Printf("[INFO] Bad PING/PONG packet length %d, should be 10", n) + } else { + c := s.lookupClient(addr) + if c != nil { + // log.Printf("[DEBUG] Received PING/PONG from client %v", *c) + c.pongTimer.Reset(30 * time.Second) + } + } + case m17.MagicM17Stream: + log.Printf("[DEBUG] InetServer received stream message: % 2x", buf) + sd, err := m17.NewStreamDatagramFromBytes(buf) + if err != nil { + log.Printf("[INFO] Dropping bad stream datagram: %v", err) + s.sendNACK(addr) + } else { + log.Printf("[DEBUG] InetServer received StreamDatagram: %s", sd) + c := s.lookupClient(addr) + if c != nil { + err := c.module.HandleStreamDatagram(sd) + if err != nil { + log.Printf("[ERROR] Error calling streamHandler: %v", err) + s.sendNACK(addr) + } + // Send the datagram to other clients of the module + for _, cl := range s.lookupClientsByModule(c.module.Name()) { + if c != cl { + s.SendDatagram(&sd, cl.addr) + } + } + } + } + case m17.MagicM17Packet: + p := m17.NewPacketFromBytes(buf[4:]) + log.Printf("[DEBUG] InetServer received packet: %s", p.String()) + c := s.lookupClient(addr) + if c != nil { + c.module.HandlePacket(p) + // Send the packet to other clients of the module, becuase the module won't send it back + // Should this be here or in the module? + for _, cl := range s.lookupClientsByModule(c.module.Name()) { + if c != cl { + s.SendPacket(&p, cl.addr) + } + } + } + } + } +} + +func (s *InetServer) Close() { + s.conn.Close() +} + +func (s *InetServer) recvConnect(buf []byte, addr *net.UDPAddr, listenOnly bool) { + if len(buf) != 11 { + s.sendNACK(addr) + log.Printf("[INFO] Bad CONN packet length %d, should be 11", len(buf)) + return + } + module := s.modules[buf[10]] + if module == nil { + s.sendNACK(addr) + log.Printf("[INFO] Invalid module '%s'", string(buf[10])) + return + } + c := s.newClient(buf[4:10], module, addr, listenOnly) + log.Printf("[INFO] Connecting client %s", addr.String()) + s.addClient(c) + s.sendACKN(addr) +} + +func (s *InetServer) sendACKN(addr *net.UDPAddr) error { + // log.Print("[DEBUG] Sending ACKN") + cmd := make([]byte, 10) + copy(cmd, []byte(m17.MagicACKN)) + _, err := s.conn.WriteToUDP(cmd, addr) + if err != nil { + return fmt.Errorf("error sending ACKN: %w", err) + } + return nil +} + +func (s *InetServer) sendNACK(addr *net.UDPAddr) error { + // log.Print("[DEBUG] Sending NACK") + cmd := make([]byte, 10) + copy(cmd, []byte(m17.MagicNACK)) + _, err := s.conn.WriteToUDP(cmd, addr) + if err != nil { + return fmt.Errorf("error sending NACK: %w", err) + } + return nil +} + +func (s *InetServer) sendPING(encodedCallsign []byte, addr *net.UDPAddr) error { + // log.Print("[DEBUG] Sending PING") + cmd := make([]byte, 10) + copy(cmd, []byte(m17.MagicPING)) + copy(cmd[4:10], encodedCallsign) + _, err := s.conn.WriteToUDP(cmd, addr) + if err != nil { + return fmt.Errorf("error sending PONG: %w", err) + } + return nil +} + +// func (s *InetServer) sendDISC(encodedCallsign []byte, addr *net.UDPAddr) error { +// cmd := make([]byte, 10) +// copy(cmd, []byte(m17.MagicDISC)) +// copy(cmd[4:10], encodedCallsign[:]) +// log.Printf("[DEBUG] Sending DISC cmd: %#v", cmd) +// _, err := s.conn.WriteToUDP(cmd, addr) +// if err != nil { +// return fmt.Errorf("error sending DISC: %w", err) +// } +// return nil +// } + +func (s *InetServer) SendPacket(p *m17.Packet, addr *net.UDPAddr) error { + cmd := []byte("M17P") + cmd = append(cmd, p.ToBytes()...) + log.Printf("[DEBUG] Sending Packet: %#v", cmd) + _, err := s.conn.WriteToUDP(cmd, addr) + if err != nil { + return fmt.Errorf("error sending DISC: %w", err) + } + return nil +} + +func (s *InetServer) SendDatagram(sd *m17.StreamDatagram, addr *net.UDPAddr) error { + cmd := []byte("M17 ") + cmd = append(cmd, sd.ToBytes()...) + log.Printf("[DEBUG] Sending StreamDatagram: %#v to %s", cmd, addr.String()) + _, err := s.conn.WriteToUDP(cmd, addr) + if err != nil { + return fmt.Errorf("error sending DISC: %w", err) + } + return nil +} + +type client struct { + encodedCallsign []byte + callsign string + module Module + addr *net.UDPAddr + pongTimer *time.Timer + pingTimer *time.Timer + listenOnly bool +} + +func (s *InetServer) newClient(callsign []byte, module Module, addr *net.UDPAddr, listenOnly bool) *client { + var c client + cs, _ := m17.DecodeCallsign(callsign) + c = client{ + encodedCallsign: callsign, + callsign: cs, + module: module, + addr: addr, + listenOnly: listenOnly, + pongTimer: time.AfterFunc(30*time.Second, func() { + log.Printf("[DEBUG] No PONGs received in > 30 seconds. Disconnecting.") + c.pongTimer.Stop() + c.pingTimer.Stop() + s.removeClient(&c) + }), + pingTimer: time.AfterFunc(3*time.Second, func() { + // log.Printf("[DEBUG] Sending PING to %s at %s", c.callsign, c.addr.String()) + s.sendPING(c.encodedCallsign, c.addr) + c.pingTimer.Reset(3 * time.Second) + }), + } + return &c +} + +func (s *InetServer) addClient(c *client) { + s.mutex.Lock() + s.clients[c.addr.String()] = c + s.mutex.Unlock() +} + +func (s *InetServer) removeClient(c *client) { + s.mutex.Lock() + delete(s.clients, c.addr.String()) + s.mutex.Unlock() +} + +func (s *InetServer) lookupClient(addr *net.UDPAddr) *client { + key := addr.String() + s.mutex.Lock() + c := s.clients[key] + s.mutex.Unlock() + // log.Printf("[DEBUG] lookupClient(%s): %v", key, c) + return c +} + +func (s *InetServer) lookupClientsByModule(m byte) []*client { + ret := []*client{} + for _, c := range s.clients { + if c.module.Name() == m { + ret = append(ret, c) + } + } + return ret +} diff --git a/server/irc_module.go b/server/irc_module.go new file mode 100644 index 0000000..60f5087 --- /dev/null +++ b/server/irc_module.go @@ -0,0 +1,114 @@ +package server + +import ( + "log" + "strconv" + "strings" + "time" + + "github.com/ergochat/irc-go/ircevent" + "github.com/ergochat/irc-go/ircmsg" + + "github.com/jancona/m17" +) + +type IRCModule struct { + name byte + server *InetServer + serverName string + port uint + useTLS bool + serverPassword string + users map[string]*ircUser +} +type ircUser struct { + conn ircevent.Connection + lastGet time.Time +} + +func NewIRCModule(name byte, server *InetServer, serverName string, port uint, useTLS bool, serverPassword string) (*IRCModule, error) { + log.Printf("[DEBUG] NewIRCModule(%s, %s, %d, %v)", string(name), serverName, port, useTLS) + m := IRCModule{ + name: name, + server: server, + serverName: serverName, + port: port, + useTLS: useTLS, + serverPassword: serverPassword, + users: map[string]*ircUser{}, + } + return &m, nil +} + +func (m *IRCModule) Name() byte { + return m.name +} + +func (m *IRCModule) HandlePacket(p m17.Packet) error { + log.Printf("[DEBUG] Received packet: %s", p.String()) + u := m.getIRCUser(p.LSF.Src.Callsign()) + if u != nil { + msg := strings.ReplaceAll(string(p.Payload), "\x00", "") + err := u.conn.Privmsg(p.LSF.Dst.Callsign(), msg) + if err != nil { + log.Printf("[ERROR] Unable to send message: %v", err) + } + } + return nil +} +func (m *IRCModule) HandleStreamDatagram(sd m17.StreamDatagram) error { + log.Printf("[DEBUG] Ignoring StreamDatagram: %v", sd) + return nil +} + +func (m *IRCModule) getIRCUser(callsign string) *ircUser { + u, ok := m.users[callsign] + if !ok { + u = &ircUser{ + conn: ircevent.Connection{ + Server: m.serverName + ":" + strconv.Itoa(int(m.port)), + UseTLS: m.useTLS, + Nick: callsign, + Debug: true, + Password: m.serverPassword, + // RequestCaps: []string{"server-time", "message-tags"}, + }, + } + // u.conn.AddConnectCallback(func(e ircmsg.Message) {}) + // u.conn.AddCallback("JOIN", func(e ircmsg.Message) {}) // TODO try to rejoin if we *don't* get this + u.conn.AddCallback("PRIVMSG", func(e ircmsg.Message) { + log.Printf("[DEBUG] PRIVMSG callback: %#v", e) + if len(e.Params) < 2 { + return + } + text := e.Params[1] + srcNick := strings.ToUpper(e.Source) + loc := m17.CallsignRegex.FindStringIndex(srcNick) + if loc == nil || loc[1] == 0 { + log.Printf("[INFO] No callsign found in nick: %s", srcNick) + return + } + srcCallsign := srcNick[loc[0]:loc[1]] + log.Printf("[DEBUG] loc: %v, callsign: %s", loc, srcCallsign) + msg := append([]byte(text), 0) + p, err := m17.NewPacket(callsign, srcCallsign, m17.PacketTypeSMS, msg) + if err != nil { + log.Printf("[INFO] Error building packet: %v", err) + return + } + clients := m.server.lookupClientsByModule(m.Name()) + for _, c := range clients { + m.server.SendPacket(p, c.addr) + } + }) + err := u.conn.Connect() + if err != nil { + log.Printf("[INFO] Failed to connect to ircd, dropping message: %v", err) + return nil + } + go u.conn.Loop() + m.users[callsign] = u + } + u.lastGet = time.Now() + return u +} From 714eafdb5fb92ada102097f43083acd2140b3c8e Mon Sep 17 00:00:00 2001 From: Jim Ancona Date: Tue, 3 Feb 2026 21:50:00 -0500 Subject: [PATCH 3/8] Minimal send/receive APRS messages. Send APRS position reports. --- cmd/bridge/bridge.go | 11 +++ go.mod | 2 + go.sum | 8 ++ server/aprs_module.go | 224 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 server/aprs_module.go diff --git a/cmd/bridge/bridge.go b/cmd/bridge/bridge.go index 2e21fdc..600e79b 100644 --- a/cmd/bridge/bridge.go +++ b/cmd/bridge/bridge.go @@ -161,6 +161,17 @@ func NewBridge(cfg *config) (*Bridge, error) { if err != nil { return nil, err } + case "APRS": + modules[k], err = server.NewAPRSModule( + k, + ret.server, + m.Key("Server").String(), + m.Key("Callsign").String(), + m.Key("Symbol").String(), + ) + if err != nil { + return nil, err + } default: return nil, fmt.Errorf("unknown module type '%s'", m.Key("Type").String()) } diff --git a/go.mod b/go.mod index 927b477..3269b98 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( fyne.io/fyne/v2 v2.5.5 github.com/StalkR/discordgo-bridge v1.0.10 github.com/bwmarrin/discordgo v0.29.0 + github.com/ebarkie/aprs v1.0.4 github.com/ergochat/irc-go v0.5.0 github.com/go-zeromq/zmq4 v0.17.0 github.com/icza/gog v0.0.0-20241010132004-5da24f18211d @@ -27,6 +28,7 @@ require ( fyne.io/systray v1.11.0 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ebarkie/weatherlink v1.0.9 // indirect github.com/fredbi/uri v1.1.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fyne-io/gl-js v0.0.0-20230506162202-1fdaa286a934 // indirect diff --git a/go.sum b/go.sum index 27332eb..2dae1ed 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,12 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebarkie/aprs v1.0.4 h1:Jw2Ta2gTCqswU4Xfr1Gx0WnhY3hmvPkoI+pUOtDRVLI= +github.com/ebarkie/aprs v1.0.4/go.mod h1:DGhO4rvpN8oNnexuJldM10Xo5ZHJSRthNACVpHtbU/k= +github.com/ebarkie/weatherlink v1.0.2 h1:+sj1JQDlmU9mrV7ppCZEswR3N0N6VmYsilQ64B5l/dA= +github.com/ebarkie/weatherlink v1.0.2/go.mod h1:PS5QOQWlOtW8czp7fIe15AfPscdw511cOFngx90QbVg= +github.com/ebarkie/weatherlink v1.0.9 h1:uPDC4EiEpAgXUf2dulfCPBo5189CVRDB8QoA3Z+kYzg= +github.com/ebarkie/weatherlink v1.0.9/go.mod h1:PyxCfM38gqkEk11AEisc71c1QXhCRINu5HC0I3iyjgE= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -267,6 +273,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pkg/term v0.0.0-20180730021639-bffc007b7fd5/go.mod h1:eCbImbZ95eXtAUIbLAuAVnBnwf83mjf6QIVH8SHYwqQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= @@ -475,6 +482,7 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191009170203-06d7bd2c5f4f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/server/aprs_module.go b/server/aprs_module.go new file mode 100644 index 0000000..d1408d9 --- /dev/null +++ b/server/aprs_module.go @@ -0,0 +1,224 @@ +package server + +import ( + "context" + "fmt" + "log" + "math" + "strings" + "time" + + "github.com/ebarkie/aprs" + + "github.com/jancona/m17" +) + +const ( + clientDefinedFilterPort = ":14580" + deviceID = "APZ001" // Experimental + metersToFeet = 3.280839895 + mpsToKnots = 1.94384 +) + +type APRSModule struct { + name byte + server *InetServer + serverName string + aprsSymbol string + users map[string]*aprsUser +} + +type aprsUser struct { + module *APRSModule + aprsCallsign aprs.Addr + passcode uint16 + ctx context.Context + frames <-chan aprs.Frame + lastGet time.Time + lastPositionReport time.Time // limit how often we send position reports +} + +func (u *aprsUser) sendGNSSFrame(s *m17.GNSS) error { + p := aprs.PositionReport{ // create a position report + Lat: float64(s.Latitude), + Lon: float64(s.Longitude), + Symbol: u.module.aprsSymbol, + MessageCapable: true, // all our clients can receive messages + } + if s.ValidAltitude { + p.Altitude = int(math.Round(float64(s.Altitude) * metersToFeet)) + } + if s.ValidBearingSpeed { + p.CSExtension(int(s.Bearing), int(math.Round(float64(s.Speed)*mpsToKnots)), 0, 0) + } + + f := aprs.Frame{} + f.Src = u.aprsCallsign + f.Dst.FromString(deviceID) + f.Path.FromString("WIDE1-1,WIDE2-1") + f.Text = p.String() + log.Printf("[DEBUG] Sending GNSS position report: %v, f: %#v", s, f) + return f.SendTCP(u.module.serverName+clientDefinedFilterPort, int(u.passcode)) +} + +func NewAPRSModule(name byte, server *InetServer, serverName string, aprsCallsign string, aprsSymbol string) (*APRSModule, error) { + log.Printf("[DEBUG] NewAPRSModule(%s, %s)", string(name), serverName) + if len(aprsSymbol) != 2 { + return nil, fmt.Errorf("Bad APRS symbol '%s'", aprsSymbol) + } + m := APRSModule{ + name: name, + server: server, + serverName: serverName, + aprsSymbol: aprsSymbol, + users: map[string]*aprsUser{}, + } + return &m, nil +} + +func (m *APRSModule) Name() byte { + return m.name +} + +func (m *APRSModule) HandlePacket(p m17.Packet) error { + log.Printf("[DEBUG] Received packet: %s", p.String()) + u := m.getAPRSUser(p.LSF.Src.Callsign()) + if u != nil { + f := aprs.Frame{} + f.Src = u.aprsCallsign + f.Dst.FromString(deviceID) + f.Text = fmt.Sprintf(":%-9s:", aprsCallsign(p.LSF.Dst.Callsign())) + strings.ReplaceAll(string(p.Payload), "\x00", "") + log.Printf("[DEBUG] Sending frame: '%s' to %s, passcode: %d", f.String(), m.serverName+clientDefinedFilterPort, u.passcode) + err := f.SendTCP(m.serverName+clientDefinedFilterPort, int(u.passcode)) + if err != nil { + log.Printf("[INFO] Unable to send message: %v", err) + } + if p.LSF.GNSS() != nil && p.LSF.GNSS().ValidLatLon { + if time.Since(u.lastPositionReport) > 5*time.Minute { + err := u.sendGNSSFrame(p.LSF.GNSS()) + if err != nil { + log.Printf("[INFO] Unable to send location report: %v", err) + } + u.lastPositionReport = time.Now() + } + } + } + return nil +} +func (m *APRSModule) HandleStreamDatagram(sd m17.StreamDatagram) error { + if sd.LSF.GNSS() != nil && sd.LSF.GNSS().ValidLatLon { + u := m.getAPRSUser(sd.LSF.Src.Callsign()) + if u != nil { + if time.Since(u.lastPositionReport) > 5*time.Minute { + err := u.sendGNSSFrame(sd.LSF.GNSS()) + if err != nil { + log.Printf("[INFO] Unable to send location report: %v", err) + } + u.lastPositionReport = time.Now() + } + } + } else { + log.Printf("[DEBUG] Ignoring StreamDatagram: %v", sd) + } + return nil +} + +func (m *APRSModule) getAPRSUser(callsign string) *aprsUser { + u, ok := m.users[callsign] + + if !ok { + u = &aprsUser{ + module: m, + } + + aprsCallsign := aprsCallsign(callsign) + filter := "g/" + aprsCallsign + if !strings.Contains(aprsCallsign, "-") { + filter += "*" + } + u.aprsCallsign.FromString(aprsCallsign) + u.passcode = aprs.GenPass(aprsCallsign) + u.ctx = context.Background() + go func() { + for { + log.Printf("[DEBUG] Calling RecvIS(ctx, %s, %v, %d, %s)", m.serverName+clientDefinedFilterPort, u.aprsCallsign, int(u.passcode), filter) + u.frames = aprs.RecvIS(u.ctx, m.serverName+clientDefinedFilterPort, u.aprsCallsign, int(u.passcode), filter) + for f := range u.frames { + log.Printf("[DEBUG] received frame: %#v", f) + dst, msgText, ack := decodeMsg(f.Text) + log.Printf("[DEBUG] dst: %s, msgText: %s, ack: %s, aprsCallsign: %s", dst, msgText, ack, aprsCallsign) + if ack != "" { + // Send ack + a := aprs.Frame{} + a.Src = u.aprsCallsign + a.Dst.FromString(deviceID) + c := f.Src.Call + if f.Src.SSID > 0 { + c += fmt.Sprintf("-%d", f.Src.SSID) + } + a.Text = fmt.Sprintf(":%-9s:", c) + "ack" + ack[1:] + log.Printf("[DEBUG] Sending frame: '%s' to %s, passcode: %d", a.String(), m.serverName+clientDefinedFilterPort, u.passcode) + err := a.SendTCP(m.serverName+clientDefinedFilterPort, int(u.passcode)) + if err != nil { + log.Printf("[INFO] Unable to send message: %v", err) + } + } + if dst == aprsCallsign { + msg := append([]byte(msgText), 0) + p, err := m17.NewPacket(callsign, strings.ReplaceAll(f.Src.String(), "-", " "), m17.PacketTypeSMS, msg) + if err != nil { + log.Printf("[INFO] Error building packet: %v", err) + return + } + clients := m.server.lookupClientsByModule(m.Name()) + for _, c := range clients { + m.server.SendPacket(p, c.addr) + } + } + } + log.Printf("[DEBUG] Exiting RecvIS loop for %s", u.aprsCallsign) + } + }() + m.users[callsign] = u + } + + u.lastGet = time.Now() + return u +} + +func decodeMsg(text string) (dst string, msg string, ack string) { + if len(text) == 0 || text[0] != ':' { + // Not an APRS message + return + } + text = text[1:] + parts := strings.SplitN(text, ":", 2) + switch len(parts) { + case 1: // No ":"'s + dst = strings.TrimSpace(parts[0]) + case 2: + dst = strings.TrimSpace(parts[0]) + i := strings.LastIndex(parts[1], "{") + if i == -1 { + msg = parts[1] + } else { + msg = parts[1][:i] + ack = parts[1][i:] + } + } + return +} +func aprsCallsign(callsign string) string { + // build APRS callsign by removing non-numeric suffix + parts := strings.Split(callsign, " ") + aprsCallsign := parts[0] + suffix := parts[len(parts)-1] + if len(suffix) != 1 || suffix[0] < '1' || suffix[0] > '9' { + suffix = "" + } + if suffix != "" { + aprsCallsign = aprsCallsign + "-" + suffix + } + log.Printf("[DEBUG] APRS callsign: %s", aprsCallsign) + return aprsCallsign +} From 07eec6dfb1cd9429672a7b2a48e7c8d4fb72ee1b Mon Sep 17 00:00:00 2001 From: Jim Ancona Date: Thu, 5 Feb 2026 07:50:34 -0500 Subject: [PATCH 4/8] Build and packaging of m17-bridge --- .github/workflows/m17-gateway.yml | 70 ++++++++++++++++--- cmd/{bridge => m17-bridge}/bridge.go | 3 +- cmd/m17-bridge/packaging/debian/control | 11 +++ cmd/m17-bridge/packaging/debian/postinst | 30 ++++++++ cmd/m17-bridge/packaging/debian/postrm | 19 +++++ cmd/m17-bridge/packaging/debian/prerm | 14 ++++ .../packaging/m17-bridge.ini.sample | 28 ++++++++ cmd/m17-bridge/packaging/m17-bridge.service | 24 +++++++ cmd/m17-bridge/packaging/scripts/build-deb.sh | 49 +++++++++++++ go.mod | 2 +- go.sum | 3 +- server/aprs_module.go | 2 +- 12 files changed, 241 insertions(+), 14 deletions(-) rename cmd/{bridge => m17-bridge}/bridge.go (97%) create mode 100644 cmd/m17-bridge/packaging/debian/control create mode 100644 cmd/m17-bridge/packaging/debian/postinst create mode 100644 cmd/m17-bridge/packaging/debian/postrm create mode 100644 cmd/m17-bridge/packaging/debian/prerm create mode 100644 cmd/m17-bridge/packaging/m17-bridge.ini.sample create mode 100644 cmd/m17-bridge/packaging/m17-bridge.service create mode 100755 cmd/m17-bridge/packaging/scripts/build-deb.sh diff --git a/.github/workflows/m17-gateway.yml b/.github/workflows/m17-gateway.yml index aab0740..25d36cb 100644 --- a/.github/workflows/m17-gateway.yml +++ b/.github/workflows/m17-gateway.yml @@ -46,7 +46,7 @@ jobs: - name: Run tests run: go test . - - name: Build for Raspberry Pi (ARM64) + - name: Build m17-gateway for Raspberry Pi (ARM64) run: | cd cmd/m17-gateway echo "Building M17 Gateway for ARM64 architecture (64-bit Raspberry Pi OS)..." @@ -55,32 +55,64 @@ jobs: ls -la m17-gateway file m17-gateway - - name: Make build script executable + - name: Make m17-gateway build script executable run: | cd cmd/m17-gateway chmod +x ./packaging/scripts/build-deb.sh - - name: Build .deb package + - name: Build m17-gateway .deb package run: | cd cmd/m17-gateway ./packaging/scripts/build-deb.sh ${{ steps.version.outputs.VERSION }} - - name: List build artifacts + - name: List m17-gateway build artifacts run: | cd cmd/m17-gateway ls -la build/ - - name: Upload .deb package + - name: Upload m17-gateway .deb package uses: actions/upload-artifact@v4 with: name: m17-gateway-debian-package path: cmd/m17-gateway/build/*.deb + - name: Build m17-bridge for Raspberry Pi (ARM64) + run: | + cd cmd/m17-bridge + echo "Building M17 Gateway for ARM64 architecture (64-bit Raspberry Pi OS)..." + GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o m17-bridge . + echo "Build completed successfully" + ls -la m17-bridge + file m17-bridge + + - name: Make m17-bridge build script executable + run: | + cd cmd/m17-bridge + chmod +x ./packaging/scripts/build-deb.sh + + - name: Build m17-bridge .deb package + run: | + cd cmd/m17-bridge + ./packaging/scripts/build-deb.sh ${{ steps.version.outputs.VERSION }} + + - name: List m17-bridge build artifacts + run: | + cd cmd/m17-bridge + ls -la build/ + + - name: Upload m17-bridge .deb package + uses: actions/upload-artifact@v4 + with: + name: m17-bridge-debian-package + path: cmd/m17-bridge/build/*.deb + - name: Create Release if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v1 with: - files: cmd/m17-gateway/build/*.deb + files: | + cmd/m17-gateway/build/*.deb + cmd/m17-bridge/build/*.deb generate_release_notes: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -92,20 +124,40 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Download .deb package + - name: Download m17-gateway .deb package uses: actions/download-artifact@v4 with: name: m17-gateway-debian-package path: ./ - - name: Test package info + - name: Test m17-gateway package info + run: | + # Install dpkg tools + sudo apt-get update + sudo apt-get install -y dpkg-dev + + # Check package info + DEB_FILE=$(ls m17-gateway*.deb) + echo "Package contents:" + dpkg -c "$DEB_FILE" + + echo "Package info:" + dpkg -I "$DEB_FILE" + + - name: Download m17-bridge .deb package + uses: actions/download-artifact@v4 + with: + name: m17-bridge-debian-package + path: ./ + + - name: Test m17-bridge package info run: | # Install dpkg tools sudo apt-get update sudo apt-get install -y dpkg-dev # Check package info - DEB_FILE=$(ls *.deb) + DEB_FILE=$(ls m17-bridge*.deb) echo "Package contents:" dpkg -c "$DEB_FILE" diff --git a/cmd/bridge/bridge.go b/cmd/m17-bridge/bridge.go similarity index 97% rename from cmd/bridge/bridge.go rename to cmd/m17-bridge/bridge.go index 600e79b..a50d7dc 100644 --- a/cmd/bridge/bridge.go +++ b/cmd/m17-bridge/bridge.go @@ -66,7 +66,7 @@ func loadConfig(iniFile string) (*config, error) { } var ( - configFile *string = flag.String("config", "./bridge.ini", "Configuration file") + configFile *string = flag.String("config", "./m17-bridge.ini", "Configuration file") helpArg *bool = flag.Bool("h", false, "Print arguments") ) @@ -166,7 +166,6 @@ func NewBridge(cfg *config) (*Bridge, error) { k, ret.server, m.Key("Server").String(), - m.Key("Callsign").String(), m.Key("Symbol").String(), ) if err != nil { diff --git a/cmd/m17-bridge/packaging/debian/control b/cmd/m17-bridge/packaging/debian/control new file mode 100644 index 0000000..8d7fbc8 --- /dev/null +++ b/cmd/m17-bridge/packaging/debian/control @@ -0,0 +1,11 @@ +Package: m17-bridge +Version: VERSION_PLACEHOLDER +Section: utils +Priority: optional +Architecture: arm64 +Depends: systemd +Maintainer: Jim Ancona, N1ADJ +Description: M17 Messaging Bridge for Raspberry Pi + M17 Messaging Bridge that runs as a systemd service on Raspberry Pi. + This package includes the binary, configuration file, and systemd service configuration. + Built for 64-bit Raspberry Pi OS (Bookworm or later). diff --git a/cmd/m17-bridge/packaging/debian/postinst b/cmd/m17-bridge/packaging/debian/postinst new file mode 100644 index 0000000..f694ca8 --- /dev/null +++ b/cmd/m17-bridge/packaging/debian/postinst @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +# Create service user and groups if they don't exist +if ! getent group m17-bridge >/dev/null; then + groupadd --system m17-bridge +fi + +if ! getent passwd m17-bridge >/dev/null; then + useradd --system --gid m17-bridge --shell /bin/false m17-bridge +fi + +# Make binary executable +chmod +x /usr/bin/m17-bridge + +# Set config file ownership and permissions (readable by service user, writable by service control group) +chown m17-bridge:m17-bridge /etc/m17-bridge.ini +chmod 664 /etc/m17-bridge.ini + +# Reload systemd and enable the service +systemctl daemon-reload +systemctl enable m17-bridge.service + +echo +echo "M17 Messaging Bridge installed successfully!" +echo "Configuration file: /etc/m17-bridge.ini" +echo "View logs with: journalctl -u m17-bridge -o cat -f" +echo "Start with: sudo systemctl start m17-bridge" +echo "Check status with: systemctl status m17-bridge" diff --git a/cmd/m17-bridge/packaging/debian/postrm b/cmd/m17-bridge/packaging/debian/postrm new file mode 100644 index 0000000..7a9efc4 --- /dev/null +++ b/cmd/m17-bridge/packaging/debian/postrm @@ -0,0 +1,19 @@ +#!/bin/bash + +set -e + +if [ "$1" = "purge" ]; then + # Remove service user and group + if getent passwd m17-bridge >/dev/null; then + userdel m17-bridge + fi + + if getent group m17-bridge >/dev/null; then + groupdel m17-bridge + fi + + # Remove config + rm -f /etc/m17-bridge.ini + + echo "M17 Messaging Bridge service and user data removed." +fi diff --git a/cmd/m17-bridge/packaging/debian/prerm b/cmd/m17-bridge/packaging/debian/prerm new file mode 100644 index 0000000..5764955 --- /dev/null +++ b/cmd/m17-bridge/packaging/debian/prerm @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e + +# Stop and disable the service +if systemctl is-active --quiet m17-bridge.service; then + systemctl stop m17-bridge.service +fi + +if systemctl is-enabled --quiet m17-bridge.service; then + systemctl disable m17-bridge.service +fi + +systemctl daemon-reload \ No newline at end of file diff --git a/cmd/m17-bridge/packaging/m17-bridge.ini.sample b/cmd/m17-bridge/packaging/m17-bridge.ini.sample new file mode 100644 index 0000000..8225547 --- /dev/null +++ b/cmd/m17-bridge/packaging/m17-bridge.ini.sample @@ -0,0 +1,28 @@ +[General] +Name=TestBridge +ListenAddress=127.0.0.1 +ListenPort=17000 +Modules=DIA + +[Log] +# Logging levels: ERROR, INFO, DEBUG +Level=DEBUG + +[Module-D] +Type=Discord +ChannelName=ham-only +WebhookURL="" +BotToken= + +[Module-I] +Type=IRC +Server=127.0.0.1 +Port=6667 +UseTLS=false +ServerPassword= + +[Module-A] +Type=APRS +# Server for your area. See http://aprs2.net/ +Server=noam.aprs2.net +Symbol=// # APRS symbol. See https://www.aprs.org/symbols.html diff --git a/cmd/m17-bridge/packaging/m17-bridge.service b/cmd/m17-bridge/packaging/m17-bridge.service new file mode 100644 index 0000000..7f0dd0d --- /dev/null +++ b/cmd/m17-bridge/packaging/m17-bridge.service @@ -0,0 +1,24 @@ +[Unit] +Description=M17 Messaging Bridge +After=network.target +Wants=network.target + +[Service] +Type=simple +User=m17-bridge +Group=m17-bridge +# WorkingDirectory=/opt/m17/m17-bridge +ExecStart=/usr/bin/m17-bridge -config /etc/m17-bridge.ini +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal + +# Security settings +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=full +ProtectHome=yes + +[Install] +WantedBy=multi-user.target diff --git a/cmd/m17-bridge/packaging/scripts/build-deb.sh b/cmd/m17-bridge/packaging/scripts/build-deb.sh new file mode 100755 index 0000000..6d6589d --- /dev/null +++ b/cmd/m17-bridge/packaging/scripts/build-deb.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +set -e + +VERSION=${1:-1.0.0} +PACKAGE_NAME="m17-bridge" +ARCH="arm64" + +echo "Building .deb package for ${PACKAGE_NAME} version ${VERSION} (${ARCH})" + +# Create build directory +BUILD_DIR="build/${PACKAGE_NAME}_${VERSION}_${ARCH}" +mkdir -p "${BUILD_DIR}" + +# Create directory structure +mkdir -p "${BUILD_DIR}/usr/bin" +mkdir -p "${BUILD_DIR}/etc/systemd/system" +mkdir -p "${BUILD_DIR}/DEBIAN" + +# Copy binary +cp "${PACKAGE_NAME}" "${BUILD_DIR}/usr/bin/" + +cd packaging + +# Copy configuration file +cp "${PACKAGE_NAME}.ini.sample" "../${BUILD_DIR}/etc/${PACKAGE_NAME}.ini" + +# Copy systemd service file +cp "${PACKAGE_NAME}.service" "../${BUILD_DIR}/etc/systemd/system/" + +# Copy control file and replace version +sed "s/VERSION_PLACEHOLDER/${VERSION}/g" debian/control > "../${BUILD_DIR}/DEBIAN/control" + +# Copy debian scripts +cp debian/postinst "../${BUILD_DIR}/DEBIAN/" +cp debian/prerm "../${BUILD_DIR}/DEBIAN/" +cp debian/postrm "../${BUILD_DIR}/DEBIAN/" + +cd .. + +# Make scripts executable +chmod +x "${BUILD_DIR}/DEBIAN/postinst" +chmod +x "${BUILD_DIR}/DEBIAN/prerm" +chmod +x "${BUILD_DIR}/DEBIAN/postrm" + +# Build the package +dpkg-deb --build "${BUILD_DIR}" + +echo "Package built: ${BUILD_DIR}.deb" diff --git a/go.mod b/go.mod index 3269b98..ba00612 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( fyne.io/fyne/v2 v2.5.5 github.com/StalkR/discordgo-bridge v1.0.10 github.com/bwmarrin/discordgo v0.29.0 - github.com/ebarkie/aprs v1.0.4 + github.com/ebarkie/aprs v1.0.5 github.com/ergochat/irc-go v0.5.0 github.com/go-zeromq/zmq4 v0.17.0 github.com/icza/gog v0.0.0-20241010132004-5da24f18211d diff --git a/go.sum b/go.sum index 2dae1ed..62582bd 100644 --- a/go.sum +++ b/go.sum @@ -75,7 +75,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ebarkie/aprs v1.0.4 h1:Jw2Ta2gTCqswU4Xfr1Gx0WnhY3hmvPkoI+pUOtDRVLI= github.com/ebarkie/aprs v1.0.4/go.mod h1:DGhO4rvpN8oNnexuJldM10Xo5ZHJSRthNACVpHtbU/k= -github.com/ebarkie/weatherlink v1.0.2 h1:+sj1JQDlmU9mrV7ppCZEswR3N0N6VmYsilQ64B5l/dA= +github.com/ebarkie/aprs v1.0.5 h1:nmP0WzS3g8aDjbFpa+tCkOqL6rkOXH6sLyDfnB1PR/0= +github.com/ebarkie/aprs v1.0.5/go.mod h1:yJdxbayy/w88jxDh9TxY3D7/887y5kJsywLVs6V10CU= github.com/ebarkie/weatherlink v1.0.2/go.mod h1:PS5QOQWlOtW8czp7fIe15AfPscdw511cOFngx90QbVg= github.com/ebarkie/weatherlink v1.0.9 h1:uPDC4EiEpAgXUf2dulfCPBo5189CVRDB8QoA3Z+kYzg= github.com/ebarkie/weatherlink v1.0.9/go.mod h1:PyxCfM38gqkEk11AEisc71c1QXhCRINu5HC0I3iyjgE= diff --git a/server/aprs_module.go b/server/aprs_module.go index d1408d9..6b87151 100644 --- a/server/aprs_module.go +++ b/server/aprs_module.go @@ -61,7 +61,7 @@ func (u *aprsUser) sendGNSSFrame(s *m17.GNSS) error { return f.SendTCP(u.module.serverName+clientDefinedFilterPort, int(u.passcode)) } -func NewAPRSModule(name byte, server *InetServer, serverName string, aprsCallsign string, aprsSymbol string) (*APRSModule, error) { +func NewAPRSModule(name byte, server *InetServer, serverName string, aprsSymbol string) (*APRSModule, error) { log.Printf("[DEBUG] NewAPRSModule(%s, %s)", string(name), serverName) if len(aprsSymbol) != 2 { return nil, fmt.Errorf("Bad APRS symbol '%s'", aprsSymbol) From 4807f3b85d6fd07faa46702996832fa4372e8ce4 Mon Sep 17 00:00:00 2001 From: Jim Ancona Date: Tue, 17 Feb 2026 13:10:54 -0500 Subject: [PATCH 5/8] Update README --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d3906a..eacf731 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,18 @@ There are several tools and a library here: ## Tools +### M17 Messaging Bridge + +[m17-bridge](./cmd/m17-bridge/) is an experimental network service that bridges M17 SMS messages to and from other messaging protocols. It speaks the [M17 Internet protocol](https://github.com/M17-Project/M17_inet/blob/main/M17%20Internet%20Interface.md), so it looks like a reflector to hotspots/repeaters that connect to it. + +It bridges to these systems: +* **Discord:** An M17 module letter is connected to a Discord channel. All M17 messages are send to the channel and all channel messages are sent to connected hotspots/repeaters. Since all received traffic is sent over the air, membertship in the Discord channels should be restricted to licensed hams. +* **IRC:** An M17 module letter is connected to an IRC server and allows sending and receiving private messages via M17 SMS. Because received traffic is sent over the air, accounts on the server should probably be restricted to licensed hams. +* **APRS:** An M17 module letter is connected to an APRS-IS server. Radios can use M17 SMS messaging to send APRS messages. When it sees M17 GNSS data, the bridge will also send periodiic APRS position reports. The bridge will send APRS messages to connected radios. A radio must send a message in order to register to receive APRS messages. + ### M17 Gateway -[m17-gateway](./cmd/m17-gateway/) makes allows a computer and modem to act as a repeater/hotspot. It also connects RF clients to Internet services such as reflectors. It currently supports the [CC1200 Pi HAT](https://github.com/M17-Project/CC1200_HAT-hw) and MMDVM-compatible hotspots and modem. When run on a Raspberry Pi with a CC1200 HAT, it can forward M17 voice and packet traffic to and from a reflector, making the Pi/CC1200 HAT an M17 voice and packet hotspot. +[m17-gateway](./cmd/m17-gateway/) allows a computer and modem to act as a repeater/hotspot. It also connects RF clients to Internet services such as reflectors. It currently supports the [CC1200 Pi HAT](https://github.com/M17-Project/CC1200_HAT-hw) and MMDVM-compatible hotspots and modem. When run on a Raspberry Pi with a CC1200 HAT, it can forward M17 voice and packet traffic to and from a reflector, making the Pi/CC1200 HAT an M17 voice and packet hotspot. The easiest way to get a working CC1200 hotspot, including `m17-gateway` and a [web dashboard](https://github.com/M17-Project/rpi-dashboard) is using DK1MI's [excellent installer script](https://github.com/DK1MI/cc1200-hotspot-installer). Highly recommended! From a85d00995741d541fc99d6175a5d3b34673b7786 Mon Sep 17 00:00:00 2001 From: Jim Ancona Date: Tue, 17 Feb 2026 17:15:09 -0500 Subject: [PATCH 6/8] Switch to different APRS library --- go.mod | 7 +- go.sum | 11 +-- server/aprs_module.go | 180 +++++++++++++++++++++++------------------- 3 files changed, 104 insertions(+), 94 deletions(-) diff --git a/go.mod b/go.mod index ba00612..9be0f34 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/jancona/m17 -go 1.24.0 - -toolchain go1.24.3 +go 1.26 require github.com/hashicorp/logutils v1.0.0 @@ -12,9 +10,9 @@ require ( fyne.io/fyne/v2 v2.5.5 github.com/StalkR/discordgo-bridge v1.0.10 github.com/bwmarrin/discordgo v0.29.0 - github.com/ebarkie/aprs v1.0.5 github.com/ergochat/irc-go v0.5.0 github.com/go-zeromq/zmq4 v0.17.0 + github.com/hessu/go-aprs-fap v0.0.5 github.com/icza/gog v0.0.0-20241010132004-5da24f18211d github.com/warthog618/go-gpiocdev v0.9.1 go.bug.st/serial v1.6.2 @@ -28,7 +26,6 @@ require ( fyne.io/systray v1.11.0 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/ebarkie/weatherlink v1.0.9 // indirect github.com/fredbi/uri v1.1.0 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fyne-io/gl-js v0.0.0-20230506162202-1fdaa286a934 // indirect diff --git a/go.sum b/go.sum index 62582bd..7100067 100644 --- a/go.sum +++ b/go.sum @@ -73,13 +73,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ebarkie/aprs v1.0.4 h1:Jw2Ta2gTCqswU4Xfr1Gx0WnhY3hmvPkoI+pUOtDRVLI= -github.com/ebarkie/aprs v1.0.4/go.mod h1:DGhO4rvpN8oNnexuJldM10Xo5ZHJSRthNACVpHtbU/k= -github.com/ebarkie/aprs v1.0.5 h1:nmP0WzS3g8aDjbFpa+tCkOqL6rkOXH6sLyDfnB1PR/0= -github.com/ebarkie/aprs v1.0.5/go.mod h1:yJdxbayy/w88jxDh9TxY3D7/887y5kJsywLVs6V10CU= -github.com/ebarkie/weatherlink v1.0.2/go.mod h1:PS5QOQWlOtW8czp7fIe15AfPscdw511cOFngx90QbVg= -github.com/ebarkie/weatherlink v1.0.9 h1:uPDC4EiEpAgXUf2dulfCPBo5189CVRDB8QoA3Z+kYzg= -github.com/ebarkie/weatherlink v1.0.9/go.mod h1:PyxCfM38gqkEk11AEisc71c1QXhCRINu5HC0I3iyjgE= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -222,6 +215,8 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hessu/go-aprs-fap v0.0.5 h1:Bsb//n+cVikaa+Q5ArDTe1ZNZe+O1RUBFuUV+WzkMXM= +github.com/hessu/go-aprs-fap v0.0.5/go.mod h1:ijCZO2LBfaOgvcCjmV086Z3ku8dVh7lH40IXiARWmuM= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/icza/gog v0.0.0-20241010132004-5da24f18211d h1:ZA56WhTKshNVDTPwit0Opb4FXRQUd5kcABgjAOa68ro= @@ -274,7 +269,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pkg/term v0.0.0-20180730021639-bffc007b7fd5/go.mod h1:eCbImbZ95eXtAUIbLAuAVnBnwf83mjf6QIVH8SHYwqQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= @@ -483,7 +477,6 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191009170203-06d7bd2c5f4f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/server/aprs_module.go b/server/aprs_module.go index 6b87151..3789171 100644 --- a/server/aprs_module.go +++ b/server/aprs_module.go @@ -1,14 +1,13 @@ package server import ( - "context" "fmt" "log" - "math" "strings" + "sync" "time" - "github.com/ebarkie/aprs" + fap "github.com/hessu/go-aprs-fap" "github.com/jancona/m17" ) @@ -16,8 +15,9 @@ import ( const ( clientDefinedFilterPort = ":14580" deviceID = "APZ001" // Experimental - metersToFeet = 3.280839895 - mpsToKnots = 1.94384 + mpsToKmh = 3.6 + readTimeout = 5 * time.Minute + reconnectDelay = 10 * time.Second ) type APRSModule struct { @@ -30,35 +30,46 @@ type APRSModule struct { type aprsUser struct { module *APRSModule - aprsCallsign aprs.Addr - passcode uint16 - ctx context.Context - frames <-chan aprs.Frame + aprsCallsign string + passcode int16 + mu sync.Mutex + conn *fap.Conn lastGet time.Time lastPositionReport time.Time // limit how often we send position reports } func (u *aprsUser) sendGNSSFrame(s *m17.GNSS) error { - p := aprs.PositionReport{ // create a position report - Lat: float64(s.Latitude), - Lon: float64(s.Longitude), - Symbol: u.module.aprsSymbol, - MessageCapable: true, // all our clients can receive messages + var speed, course, altitude *float64 + if s.ValidBearingSpeed { + sp := float64(s.Speed) * mpsToKmh + speed = &sp + c := float64(s.Bearing) + course = &c } if s.ValidAltitude { - p.Altitude = int(math.Round(float64(s.Altitude) * metersToFeet)) + alt := float64(s.Altitude) + altitude = &alt } - if s.ValidBearingSpeed { - p.CSExtension(int(s.Bearing), int(math.Round(float64(s.Speed)*mpsToKnots)), 0, 0) + + posStr, err := fap.MakePosition( + float64(s.Latitude), + float64(s.Longitude), + speed, course, altitude, + u.module.aprsSymbol, + nil, + ) + if err != nil { + return fmt.Errorf("making position report: %w", err) } - f := aprs.Frame{} - f.Src = u.aprsCallsign - f.Dst.FromString(deviceID) - f.Path.FromString("WIDE1-1,WIDE2-1") - f.Text = p.String() - log.Printf("[DEBUG] Sending GNSS position report: %v, f: %#v", s, f) - return f.SendTCP(u.module.serverName+clientDefinedFilterPort, int(u.passcode)) + frame := fmt.Sprintf("%s>%s,WIDE1-1,WIDE2-1:%s", u.aprsCallsign, deviceID, posStr) + log.Printf("[DEBUG] Sending GNSS position report: %v, frame: %s", s, frame) + u.mu.Lock() + defer u.mu.Unlock() + if u.conn == nil { + return fmt.Errorf("APRS-IS connection not available for %s", u.aprsCallsign) + } + return u.conn.SendLine(frame) } func NewAPRSModule(name byte, server *InetServer, serverName string, aprsSymbol string) (*APRSModule, error) { @@ -84,12 +95,13 @@ func (m *APRSModule) HandlePacket(p m17.Packet) error { log.Printf("[DEBUG] Received packet: %s", p.String()) u := m.getAPRSUser(p.LSF.Src.Callsign()) if u != nil { - f := aprs.Frame{} - f.Src = u.aprsCallsign - f.Dst.FromString(deviceID) - f.Text = fmt.Sprintf(":%-9s:", aprsCallsign(p.LSF.Dst.Callsign())) + strings.ReplaceAll(string(p.Payload), "\x00", "") - log.Printf("[DEBUG] Sending frame: '%s' to %s, passcode: %d", f.String(), m.serverName+clientDefinedFilterPort, u.passcode) - err := f.SendTCP(m.serverName+clientDefinedFilterPort, int(u.passcode)) + dst := aprsCallsign(p.LSF.Dst.Callsign()) + msgText := strings.ReplaceAll(string(p.Payload), "\x00", "") + frame := fmt.Sprintf("%s>%s::%-9s:%s", u.aprsCallsign, deviceID, dst, msgText) + log.Printf("[DEBUG] Sending frame: '%s', passcode: %d", frame, u.passcode) + u.mu.Lock() + err := u.conn.SendLine(frame) + u.mu.Unlock() if err != nil { log.Printf("[INFO] Unable to send message: %v", err) } @@ -131,41 +143,59 @@ func (m *APRSModule) getAPRSUser(callsign string) *aprsUser { module: m, } - aprsCallsign := aprsCallsign(callsign) - filter := "g/" + aprsCallsign - if !strings.Contains(aprsCallsign, "-") { + ac := aprsCallsign(callsign) + filter := "g/" + ac + if !strings.Contains(ac, "-") { filter += "*" } - u.aprsCallsign.FromString(aprsCallsign) - u.passcode = aprs.GenPass(aprsCallsign) - u.ctx = context.Background() + u.aprsCallsign = ac + u.passcode = fap.AprsPasscode(ac) + passcodeStr := fmt.Sprintf("%d", u.passcode) + log.Printf("[DEBUG] Calling Dial(%s, %s, %s, m17-bridge, 0.1, %s)", m.serverName+clientDefinedFilterPort, u.aprsCallsign, passcodeStr, filter) + conn, err := fap.Dial(m.serverName+clientDefinedFilterPort, u.aprsCallsign, passcodeStr, "m17-bridge", "0.1", filter) + if err != nil { + log.Printf("[INFO] Unable to connect to APRS-IS: %v", err) + return nil + } + u.conn = conn + log.Printf("[DEBUG] u.conn: %#v", u.conn) go func() { for { - log.Printf("[DEBUG] Calling RecvIS(ctx, %s, %v, %d, %s)", m.serverName+clientDefinedFilterPort, u.aprsCallsign, int(u.passcode), filter) - u.frames = aprs.RecvIS(u.ctx, m.serverName+clientDefinedFilterPort, u.aprsCallsign, int(u.passcode), filter) - for f := range u.frames { - log.Printf("[DEBUG] received frame: %#v", f) - dst, msgText, ack := decodeMsg(f.Text) - log.Printf("[DEBUG] dst: %s, msgText: %s, ack: %s, aprsCallsign: %s", dst, msgText, ack, aprsCallsign) - if ack != "" { + for { + raw, err := conn.ReadPacket(readTimeout) + if err != nil { + log.Printf("[DEBUG] ReadPacket error: %v", err) + u.mu.Lock() + conn.Close() + u.conn = nil + u.mu.Unlock() + break + } + log.Printf("[DEBUG] received packet: %s", raw) + pkt, err := fap.Parse(raw) + if err != nil { + log.Printf("[DEBUG] Parse error: %v", err) + continue + } + if pkt.Type != fap.PacketTypeMessage || pkt.Message == nil { + continue + } + msg := pkt.Message + log.Printf("[DEBUG] dst: %s, msgText: %s, msgID: %s, aprsCallsign: %s", msg.Destination, msg.Text, msg.ID, ac) + if msg.ID != "" { // Send ack - a := aprs.Frame{} - a.Src = u.aprsCallsign - a.Dst.FromString(deviceID) - c := f.Src.Call - if f.Src.SSID > 0 { - c += fmt.Sprintf("-%d", f.Src.SSID) - } - a.Text = fmt.Sprintf(":%-9s:", c) + "ack" + ack[1:] - log.Printf("[DEBUG] Sending frame: '%s' to %s, passcode: %d", a.String(), m.serverName+clientDefinedFilterPort, u.passcode) - err := a.SendTCP(m.serverName+clientDefinedFilterPort, int(u.passcode)) + ackFrame := fmt.Sprintf("%s>%s::%-9s:ack%s", u.aprsCallsign, deviceID, pkt.SrcCallsign, msg.ID) + log.Printf("[DEBUG] Sending ack frame: '%s'", ackFrame) + u.mu.Lock() + err := conn.SendLine(ackFrame) + u.mu.Unlock() if err != nil { - log.Printf("[INFO] Unable to send message: %v", err) + log.Printf("[INFO] Unable to send ack: %v", err) } } - if dst == aprsCallsign { - msg := append([]byte(msgText), 0) - p, err := m17.NewPacket(callsign, strings.ReplaceAll(f.Src.String(), "-", " "), m17.PacketTypeSMS, msg) + if msg.Destination == ac { + msgBytes := append([]byte(msg.Text), 0) + p, err := m17.NewPacket(callsign, strings.ReplaceAll(pkt.SrcCallsign, "-", " "), m17.PacketTypeSMS, msgBytes) if err != nil { log.Printf("[INFO] Error building packet: %v", err) return @@ -176,7 +206,19 @@ func (m *APRSModule) getAPRSUser(callsign string) *aprsUser { } } } - log.Printf("[DEBUG] Exiting RecvIS loop for %s", u.aprsCallsign) + log.Printf("[DEBUG] Reconnecting APRS-IS for %s", u.aprsCallsign) + time.Sleep(reconnectDelay) + passcodeStr := fmt.Sprintf("%d", u.passcode) + log.Printf("[DEBUG] Calling Dial(%s, %s, %s, m17-bridge, 0.1, %s)", m.serverName+clientDefinedFilterPort, u.aprsCallsign, passcodeStr, filter) + newConn, err := fap.Dial(m.serverName+clientDefinedFilterPort, u.aprsCallsign, passcodeStr, "m17-bridge", "0.1", filter) + if err != nil { + log.Printf("[INFO] Unable to reconnect to APRS-IS: %v", err) + continue + } + u.mu.Lock() + conn = newConn + u.conn = conn + u.mu.Unlock() } }() m.users[callsign] = u @@ -186,28 +228,6 @@ func (m *APRSModule) getAPRSUser(callsign string) *aprsUser { return u } -func decodeMsg(text string) (dst string, msg string, ack string) { - if len(text) == 0 || text[0] != ':' { - // Not an APRS message - return - } - text = text[1:] - parts := strings.SplitN(text, ":", 2) - switch len(parts) { - case 1: // No ":"'s - dst = strings.TrimSpace(parts[0]) - case 2: - dst = strings.TrimSpace(parts[0]) - i := strings.LastIndex(parts[1], "{") - if i == -1 { - msg = parts[1] - } else { - msg = parts[1][:i] - ack = parts[1][i:] - } - } - return -} func aprsCallsign(callsign string) string { // build APRS callsign by removing non-numeric suffix parts := strings.Split(callsign, " ") From 216fc4c42e5f2346904611a350e330a7e18359f8 Mon Sep 17 00:00:00 2001 From: Jim Ancona Date: Wed, 18 Feb 2026 09:20:54 -0500 Subject: [PATCH 7/8] Update go-aprs-fap library --- go.mod | 2 +- go.sum | 4 ++++ server/aprs_module.go | 16 ++++++++++++---- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 9be0f34..bc56edb 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/bwmarrin/discordgo v0.29.0 github.com/ergochat/irc-go v0.5.0 github.com/go-zeromq/zmq4 v0.17.0 - github.com/hessu/go-aprs-fap v0.0.5 + github.com/hessu/go-aprs-fap v0.0.7 github.com/icza/gog v0.0.0-20241010132004-5da24f18211d github.com/warthog618/go-gpiocdev v0.9.1 go.bug.st/serial v1.6.2 diff --git a/go.sum b/go.sum index 7100067..7ed1210 100644 --- a/go.sum +++ b/go.sum @@ -217,6 +217,10 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hessu/go-aprs-fap v0.0.5 h1:Bsb//n+cVikaa+Q5ArDTe1ZNZe+O1RUBFuUV+WzkMXM= github.com/hessu/go-aprs-fap v0.0.5/go.mod h1:ijCZO2LBfaOgvcCjmV086Z3ku8dVh7lH40IXiARWmuM= +github.com/hessu/go-aprs-fap v0.0.6 h1:EKK34a0m4Ao8iESPXPAukg6ooKg7z6OH3UwJAAryPu4= +github.com/hessu/go-aprs-fap v0.0.6/go.mod h1:ijCZO2LBfaOgvcCjmV086Z3ku8dVh7lH40IXiARWmuM= +github.com/hessu/go-aprs-fap v0.0.7 h1:xMducGNObNk7itPUAnhjT5I571gDbr4KZZ0S0IT+Oe8= +github.com/hessu/go-aprs-fap v0.0.7/go.mod h1:ijCZO2LBfaOgvcCjmV086Z3ku8dVh7lH40IXiARWmuM= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/icza/gog v0.0.0-20241010132004-5da24f18211d h1:ZA56WhTKshNVDTPwit0Opb4FXRQUd5kcABgjAOa68ro= diff --git a/server/aprs_module.go b/server/aprs_module.go index 3789171..8ee73fb 100644 --- a/server/aprs_module.go +++ b/server/aprs_module.go @@ -51,7 +51,7 @@ func (u *aprsUser) sendGNSSFrame(s *m17.GNSS) error { altitude = &alt } - posStr, err := fap.MakePosition( + posStr, err := fap.EncodePosition( float64(s.Latitude), float64(s.Longitude), speed, course, altitude, @@ -97,10 +97,18 @@ func (m *APRSModule) HandlePacket(p m17.Packet) error { if u != nil { dst := aprsCallsign(p.LSF.Dst.Callsign()) msgText := strings.ReplaceAll(string(p.Payload), "\x00", "") - frame := fmt.Sprintf("%s>%s::%-9s:%s", u.aprsCallsign, deviceID, dst, msgText) - log.Printf("[DEBUG] Sending frame: '%s', passcode: %d", frame, u.passcode) + body, err := fap.EncodeMessage(&fap.Message{ + Destination: dst, + Text: msgText, + }) + if err != nil { + log.Printf("[INFO] Unable to encode APRS message: %v", err) + return err + } + packet := fmt.Sprintf("%s>%s:%s", u.aprsCallsign, deviceID, body) + log.Printf("[DEBUG] Sending packet: '%s', passcode: %d", packet, u.passcode) u.mu.Lock() - err := u.conn.SendLine(frame) + err = u.conn.SendLine(packet) u.mu.Unlock() if err != nil { log.Printf("[INFO] Unable to send message: %v", err) From fdbb74632a1f61bd1f2fc696d971435f4ab2b1fb Mon Sep 17 00:00:00 2001 From: Jim Ancona Date: Wed, 18 Feb 2026 10:55:30 -0500 Subject: [PATCH 8/8] Refactor APRS-IS to single shared connection; add stale user eviction Replace per-user APRS-IS connections with a single shared connection using a server-side g/ filter updated as users are added or removed. This reduces resource usage and simplifies reconnection logic. Also track lastHeard on each aprsUser and periodically evict users that haven't been heard from within a configurable staleTimeout. The timeout is read from StaleMinutes in the APRS module INI section (default: 60 minutes). Set StaleMinutes = 0 to disable cleanup. Co-Authored-By: Claude Sonnet 4.6 --- cmd/m17-bridge/bridge.go | 4 + server/aprs_module.go | 444 ++++++++++++++++++++++++--------------- 2 files changed, 277 insertions(+), 171 deletions(-) diff --git a/cmd/m17-bridge/bridge.go b/cmd/m17-bridge/bridge.go index a50d7dc..bcb7cb0 100644 --- a/cmd/m17-bridge/bridge.go +++ b/cmd/m17-bridge/bridge.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "time" "github.com/hashicorp/logutils" "github.com/jancona/m17/server" @@ -162,11 +163,14 @@ func NewBridge(cfg *config) (*Bridge, error) { return nil, err } case "APRS": + staleMinutes := m.Key("StaleMinutes").MustInt(60) modules[k], err = server.NewAPRSModule( k, ret.server, m.Key("Server").String(), + m.Key("Callsign").String(), m.Key("Symbol").String(), + time.Duration(staleMinutes)*time.Minute, ) if err != nil { return nil, err diff --git a/server/aprs_module.go b/server/aprs_module.go index 8ee73fb..8e21c6e 100644 --- a/server/aprs_module.go +++ b/server/aprs_module.go @@ -21,121 +21,287 @@ const ( ) type APRSModule struct { - name byte - server *InetServer - serverName string - aprsSymbol string - users map[string]*aprsUser + name byte + server *InetServer + serverName string + callsign string + passcode string + aprsSymbol string + staleTimeout time.Duration + + mu sync.Mutex // protects conn and users + conn *fap.Conn + users map[string]*aprsUser } type aprsUser struct { - module *APRSModule aprsCallsign string - passcode int16 - mu sync.Mutex - conn *fap.Conn - lastGet time.Time - lastPositionReport time.Time // limit how often we send position reports + lastPositionReport time.Time + lastHeard time.Time } -func (u *aprsUser) sendGNSSFrame(s *m17.GNSS) error { - var speed, course, altitude *float64 - if s.ValidBearingSpeed { - sp := float64(s.Speed) * mpsToKmh - speed = &sp - c := float64(s.Bearing) - course = &c +func NewAPRSModule(name byte, server *InetServer, serverName string, callsign string, aprsSymbol string, staleTimeout time.Duration) (*APRSModule, error) { + log.Printf("[DEBUG] NewAPRSModule(%s, %s, %s)", string(name), serverName, callsign) + if len(aprsSymbol) != 2 { + return nil, fmt.Errorf("Bad APRS symbol '%s'", aprsSymbol) } - if s.ValidAltitude { - alt := float64(s.Altitude) - altitude = &alt + if callsign == "" { + return nil, fmt.Errorf("APRS module requires a Callsign") + } + passcode := fmt.Sprintf("%d", fap.AprsPasscode(callsign)) + m := &APRSModule{ + name: name, + server: server, + serverName: serverName, + callsign: callsign, + passcode: passcode, + aprsSymbol: aprsSymbol, + staleTimeout: staleTimeout, + users: map[string]*aprsUser{}, + } + if err := m.connect(); err != nil { + return nil, fmt.Errorf("connecting to APRS-IS: %w", err) + } + go m.readLoop() + if staleTimeout > 0 { + go m.staleUserLoop() } + return m, nil +} - posStr, err := fap.EncodePosition( - float64(s.Latitude), - float64(s.Longitude), - speed, course, altitude, - u.module.aprsSymbol, - nil, - ) +func (m *APRSModule) Name() byte { + return m.name +} + +// connect dials the APRS-IS server with a filter for all known users. +// Must be called with m.mu held. +func (m *APRSModule) connect() error { + filter := m.buildFilter() + log.Printf("[DEBUG] Calling Dial(%s, %s, %s, m17-bridge, 0.1, %s)", m.serverName+clientDefinedFilterPort, m.callsign, m.passcode, filter) + conn, err := fap.Dial(m.serverName+clientDefinedFilterPort, m.callsign, m.passcode, "m17-bridge", "0.1", filter) if err != nil { - return fmt.Errorf("making position report: %w", err) + return err } + m.conn = conn + return nil +} - frame := fmt.Sprintf("%s>%s,WIDE1-1,WIDE2-1:%s", u.aprsCallsign, deviceID, posStr) - log.Printf("[DEBUG] Sending GNSS position report: %v, frame: %s", s, frame) - u.mu.Lock() - defer u.mu.Unlock() - if u.conn == nil { - return fmt.Errorf("APRS-IS connection not available for %s", u.aprsCallsign) +// buildFilter constructs a g/ filter string covering all known users. +func (m *APRSModule) buildFilter() string { + if len(m.users) == 0 { + return "" + } + parts := make([]string, 0, len(m.users)) + for _, u := range m.users { + ac := u.aprsCallsign + if !strings.Contains(ac, "-") { + ac += "*" + } + parts = append(parts, ac) } - return u.conn.SendLine(frame) + return "g/" + strings.Join(parts, "/") } -func NewAPRSModule(name byte, server *InetServer, serverName string, aprsSymbol string) (*APRSModule, error) { - log.Printf("[DEBUG] NewAPRSModule(%s, %s)", string(name), serverName) - if len(aprsSymbol) != 2 { - return nil, fmt.Errorf("Bad APRS symbol '%s'", aprsSymbol) +// updateFilter sends a #filter command to update the server-side filter +// for the current connection. Must be called with m.mu held. +func (m *APRSModule) updateFilter() { + if m.conn == nil { + return } - m := APRSModule{ - name: name, - server: server, - serverName: serverName, - aprsSymbol: aprsSymbol, - users: map[string]*aprsUser{}, + filter := m.buildFilter() + if filter == "" { + return + } + line := "#filter " + filter + log.Printf("[DEBUG] Updating APRS-IS filter: %s", line) + if err := m.conn.SendLine(line); err != nil { + log.Printf("[INFO] Unable to update APRS-IS filter: %v", err) } - return &m, nil } -func (m *APRSModule) Name() byte { - return m.name +// sendLine sends a line on the APRS-IS connection. +// Acquires m.mu internally. +func (m *APRSModule) sendLine(line string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.conn == nil { + return fmt.Errorf("APRS-IS connection not available") + } + return m.conn.SendLine(line) } -func (m *APRSModule) HandlePacket(p m17.Packet) error { - log.Printf("[DEBUG] Received packet: %s", p.String()) - u := m.getAPRSUser(p.LSF.Src.Callsign()) - if u != nil { - dst := aprsCallsign(p.LSF.Dst.Callsign()) - msgText := strings.ReplaceAll(string(p.Payload), "\x00", "") - body, err := fap.EncodeMessage(&fap.Message{ - Destination: dst, - Text: msgText, - }) - if err != nil { - log.Printf("[INFO] Unable to encode APRS message: %v", err) - return err +func (m *APRSModule) readLoop() { + for { + m.mu.Lock() + conn := m.conn + m.mu.Unlock() + if conn == nil { + time.Sleep(reconnectDelay) + m.mu.Lock() + err := m.connect() + m.mu.Unlock() + if err != nil { + log.Printf("[INFO] Unable to reconnect to APRS-IS: %v", err) + continue + } + m.mu.Lock() + conn = m.conn + m.mu.Unlock() + } + for { + raw, err := conn.ReadPacket(readTimeout) + if err != nil { + log.Printf("[DEBUG] ReadPacket error: %v", err) + m.mu.Lock() + conn.Close() + m.conn = nil + m.mu.Unlock() + break + } + log.Printf("[DEBUG] received packet: %s", raw) + pkt, err := fap.Parse(raw) + if err != nil { + log.Printf("[DEBUG] Parse error: %v", err) + continue + } + if pkt.Type != fap.PacketTypeMessage || pkt.Message == nil { + continue + } + m.handleAPRSMessage(pkt) } - packet := fmt.Sprintf("%s>%s:%s", u.aprsCallsign, deviceID, body) - log.Printf("[DEBUG] Sending packet: '%s', passcode: %d", packet, u.passcode) - u.mu.Lock() - err = u.conn.SendLine(packet) - u.mu.Unlock() + log.Printf("[DEBUG] Reconnecting APRS-IS") + time.Sleep(reconnectDelay) + m.mu.Lock() + err := m.connect() + m.mu.Unlock() if err != nil { - log.Printf("[INFO] Unable to send message: %v", err) + log.Printf("[INFO] Unable to reconnect to APRS-IS: %v", err) + } + } +} + +func (m *APRSModule) handleAPRSMessage(pkt *fap.Packet) { + msg := pkt.Message + dst := msg.Destination + log.Printf("[DEBUG] dst: %s, msgText: %s, msgID: %s", dst, msg.Text, msg.ID) + if msg.ID != "" { + // Send ack using the destination callsign as the source + ackFrame := fmt.Sprintf("%s>%s::%-9s:ack%s", dst, deviceID, pkt.SrcCallsign, msg.ID) + log.Printf("[DEBUG] Sending ack frame: '%s'", ackFrame) + if err := m.sendLine(ackFrame); err != nil { + log.Printf("[INFO] Unable to send ack: %v", err) } - if p.LSF.GNSS() != nil && p.LSF.GNSS().ValidLatLon { - if time.Since(u.lastPositionReport) > 5*time.Minute { - err := u.sendGNSSFrame(p.LSF.GNSS()) - if err != nil { - log.Printf("[INFO] Unable to send location report: %v", err) - } - u.lastPositionReport = time.Now() + } + // Find which M17 callsign this APRS destination maps to + m.mu.Lock() + var m17Callsign string + for cs, u := range m.users { + if u.aprsCallsign == dst { + m17Callsign = cs + break + } + } + m.mu.Unlock() + if m17Callsign == "" { + log.Printf("[DEBUG] No M17 user found for APRS destination %s", dst) + return + } + msgBytes := append([]byte(msg.Text), 0) + p, err := m17.NewPacket(m17Callsign, strings.ReplaceAll(pkt.SrcCallsign, "-", " "), m17.PacketTypeSMS, msgBytes) + if err != nil { + log.Printf("[INFO] Error building packet: %v", err) + return + } + clients := m.server.lookupClientsByModule(m.Name()) + for _, c := range clients { + m.server.SendPacket(p, c.addr) + } +} + +// getOrAddUser returns the aprsUser for the given M17 callsign, +// creating one and updating the APRS-IS filter if needed. +// It always updates lastHeard to the current time. +func (m *APRSModule) getOrAddUser(callsign string) *aprsUser { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.users[callsign] + if !ok { + ac := aprsCallsign(callsign) + u = &aprsUser{ + aprsCallsign: ac, + } + m.users[callsign] = u + m.updateFilter() + } + u.lastHeard = time.Now() + return u +} + +// staleUserLoop periodically removes users that haven't been heard from +// within m.staleTimeout. It runs as a background goroutine. +func (m *APRSModule) staleUserLoop() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for range ticker.C { + m.removeStaleUsers() + } +} + +// removeStaleUsers deletes any users whose lastHeard time exceeds staleTimeout +// and updates the APRS-IS filter if any were removed. +func (m *APRSModule) removeStaleUsers() { + m.mu.Lock() + defer m.mu.Unlock() + removed := 0 + for cs, u := range m.users { + if time.Since(u.lastHeard) > m.staleTimeout { + log.Printf("[INFO] Removing stale APRS user %s (last heard %v ago)", cs, time.Since(u.lastHeard).Round(time.Second)) + delete(m.users, cs) + removed++ + } + } + if removed > 0 { + m.updateFilter() + } +} + +func (m *APRSModule) HandlePacket(p m17.Packet) error { + log.Printf("[DEBUG] Received packet: %s", p.String()) + u := m.getOrAddUser(p.LSF.Src.Callsign()) + dst := aprsCallsign(p.LSF.Dst.Callsign()) + msgText := strings.ReplaceAll(string(p.Payload), "\x00", "") + body, err := fap.EncodeMessage(&fap.Message{ + Destination: dst, + Text: msgText, + }) + if err != nil { + log.Printf("[INFO] Unable to encode APRS message: %v", err) + return err + } + packet := fmt.Sprintf("%s>%s:%s", u.aprsCallsign, deviceID, body) + log.Printf("[DEBUG] Sending packet: '%s'", packet) + if err := m.sendLine(packet); err != nil { + log.Printf("[INFO] Unable to send message: %v", err) + } + if p.LSF.GNSS() != nil && p.LSF.GNSS().ValidLatLon { + if time.Since(u.lastPositionReport) > 5*time.Minute { + if err := m.sendGNSSFrame(u, p.LSF.GNSS()); err != nil { + log.Printf("[INFO] Unable to send location report: %v", err) } + u.lastPositionReport = time.Now() } } return nil } + func (m *APRSModule) HandleStreamDatagram(sd m17.StreamDatagram) error { if sd.LSF.GNSS() != nil && sd.LSF.GNSS().ValidLatLon { - u := m.getAPRSUser(sd.LSF.Src.Callsign()) - if u != nil { - if time.Since(u.lastPositionReport) > 5*time.Minute { - err := u.sendGNSSFrame(sd.LSF.GNSS()) - if err != nil { - log.Printf("[INFO] Unable to send location report: %v", err) - } - u.lastPositionReport = time.Now() + u := m.getOrAddUser(sd.LSF.Src.Callsign()) + if time.Since(u.lastPositionReport) > 5*time.Minute { + if err := m.sendGNSSFrame(u, sd.LSF.GNSS()); err != nil { + log.Printf("[INFO] Unable to send location report: %v", err) } + u.lastPositionReport = time.Now() } } else { log.Printf("[DEBUG] Ignoring StreamDatagram: %v", sd) @@ -143,97 +309,33 @@ func (m *APRSModule) HandleStreamDatagram(sd m17.StreamDatagram) error { return nil } -func (m *APRSModule) getAPRSUser(callsign string) *aprsUser { - u, ok := m.users[callsign] - - if !ok { - u = &aprsUser{ - module: m, - } +func (m *APRSModule) sendGNSSFrame(u *aprsUser, s *m17.GNSS) error { + var speed, course, altitude *float64 + if s.ValidBearingSpeed { + sp := float64(s.Speed) * mpsToKmh + speed = &sp + c := float64(s.Bearing) + course = &c + } + if s.ValidAltitude { + alt := float64(s.Altitude) + altitude = &alt + } - ac := aprsCallsign(callsign) - filter := "g/" + ac - if !strings.Contains(ac, "-") { - filter += "*" - } - u.aprsCallsign = ac - u.passcode = fap.AprsPasscode(ac) - passcodeStr := fmt.Sprintf("%d", u.passcode) - log.Printf("[DEBUG] Calling Dial(%s, %s, %s, m17-bridge, 0.1, %s)", m.serverName+clientDefinedFilterPort, u.aprsCallsign, passcodeStr, filter) - conn, err := fap.Dial(m.serverName+clientDefinedFilterPort, u.aprsCallsign, passcodeStr, "m17-bridge", "0.1", filter) - if err != nil { - log.Printf("[INFO] Unable to connect to APRS-IS: %v", err) - return nil - } - u.conn = conn - log.Printf("[DEBUG] u.conn: %#v", u.conn) - go func() { - for { - for { - raw, err := conn.ReadPacket(readTimeout) - if err != nil { - log.Printf("[DEBUG] ReadPacket error: %v", err) - u.mu.Lock() - conn.Close() - u.conn = nil - u.mu.Unlock() - break - } - log.Printf("[DEBUG] received packet: %s", raw) - pkt, err := fap.Parse(raw) - if err != nil { - log.Printf("[DEBUG] Parse error: %v", err) - continue - } - if pkt.Type != fap.PacketTypeMessage || pkt.Message == nil { - continue - } - msg := pkt.Message - log.Printf("[DEBUG] dst: %s, msgText: %s, msgID: %s, aprsCallsign: %s", msg.Destination, msg.Text, msg.ID, ac) - if msg.ID != "" { - // Send ack - ackFrame := fmt.Sprintf("%s>%s::%-9s:ack%s", u.aprsCallsign, deviceID, pkt.SrcCallsign, msg.ID) - log.Printf("[DEBUG] Sending ack frame: '%s'", ackFrame) - u.mu.Lock() - err := conn.SendLine(ackFrame) - u.mu.Unlock() - if err != nil { - log.Printf("[INFO] Unable to send ack: %v", err) - } - } - if msg.Destination == ac { - msgBytes := append([]byte(msg.Text), 0) - p, err := m17.NewPacket(callsign, strings.ReplaceAll(pkt.SrcCallsign, "-", " "), m17.PacketTypeSMS, msgBytes) - if err != nil { - log.Printf("[INFO] Error building packet: %v", err) - return - } - clients := m.server.lookupClientsByModule(m.Name()) - for _, c := range clients { - m.server.SendPacket(p, c.addr) - } - } - } - log.Printf("[DEBUG] Reconnecting APRS-IS for %s", u.aprsCallsign) - time.Sleep(reconnectDelay) - passcodeStr := fmt.Sprintf("%d", u.passcode) - log.Printf("[DEBUG] Calling Dial(%s, %s, %s, m17-bridge, 0.1, %s)", m.serverName+clientDefinedFilterPort, u.aprsCallsign, passcodeStr, filter) - newConn, err := fap.Dial(m.serverName+clientDefinedFilterPort, u.aprsCallsign, passcodeStr, "m17-bridge", "0.1", filter) - if err != nil { - log.Printf("[INFO] Unable to reconnect to APRS-IS: %v", err) - continue - } - u.mu.Lock() - conn = newConn - u.conn = conn - u.mu.Unlock() - } - }() - m.users[callsign] = u + posStr, err := fap.EncodePosition( + float64(s.Latitude), + float64(s.Longitude), + speed, course, altitude, + m.aprsSymbol, + nil, + ) + if err != nil { + return fmt.Errorf("making position report: %w", err) } - u.lastGet = time.Now() - return u + frame := fmt.Sprintf("%s>%s,WIDE1-1,WIDE2-1:%s", u.aprsCallsign, deviceID, posStr) + log.Printf("[DEBUG] Sending GNSS position report: %v, frame: %s", s, frame) + return m.sendLine(frame) } func aprsCallsign(callsign string) string {