mirror of
https://github.com/la5nta/pat
synced 2026-08-13 17:46:54 -04:00
514 lines
14 KiB
Go
514 lines
14 KiB
Go
// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved.
|
|
// Use of this source code is governed by the MIT-license that can be
|
|
// found in the LICENSE file.
|
|
|
|
// Package app implements the core functionality shared by cli and api.
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/harenber/ptc-go/v2/pactor"
|
|
"github.com/la5nta/pat/api/types"
|
|
"github.com/la5nta/pat/cfg"
|
|
"github.com/la5nta/pat/internal/buildinfo"
|
|
"github.com/la5nta/pat/internal/debug"
|
|
"github.com/la5nta/pat/internal/directories"
|
|
"github.com/la5nta/pat/internal/forms"
|
|
"github.com/la5nta/pat/internal/propagation"
|
|
"github.com/la5nta/wl2k-go/fbb"
|
|
"github.com/la5nta/wl2k-go/mailbox"
|
|
"github.com/la5nta/wl2k-go/rigcontrol/hamlib"
|
|
"github.com/la5nta/wl2k-go/transport"
|
|
"github.com/la5nta/wl2k-go/transport/ardop"
|
|
"github.com/la5nta/wl2k-go/transport/ax25"
|
|
"github.com/la5nta/wl2k-go/transport/ax25/agwpe"
|
|
"github.com/n8jja/Pat-Vara/vara"
|
|
)
|
|
|
|
const (
|
|
MethodArdop = "ardop"
|
|
MethodTelnet = "telnet"
|
|
MethodPactor = "pactor"
|
|
MethodVaraHF = "varahf"
|
|
MethodVaraFM = "varafm"
|
|
|
|
MethodAX25 = "ax25"
|
|
MethodAX25AGWPE = MethodAX25 + "+agwpe"
|
|
MethodAX25Linux = MethodAX25 + "+linux"
|
|
MethodAX25SerialTNC = MethodAX25 + "+serial-tnc"
|
|
|
|
// TODO: Remove after some release cycles (2023-05-21)
|
|
MethodSerialTNCDeprecated = "serial-tnc"
|
|
)
|
|
|
|
type Options struct {
|
|
IgnoreBusy bool // Move to connect?
|
|
SendOnly bool // Move to connect?
|
|
RadioOnly bool
|
|
|
|
Robust bool
|
|
MyCall string
|
|
Listen string
|
|
MailboxPath string
|
|
ConfigPath string
|
|
PrehooksPath string
|
|
LogPath string
|
|
EventLogPath string
|
|
FormsPath string
|
|
}
|
|
|
|
type App struct {
|
|
options Options
|
|
config cfg.Config
|
|
OnReload func() error
|
|
|
|
mbox *mailbox.DirHandler
|
|
formsMgr *forms.Manager
|
|
|
|
exchangeChan chan ex // The channel that the exchange loop is listening on
|
|
exchangeConn net.Conn // Pointer to the active session connection (exchange)
|
|
dialing *transport.URL // The connect URL currently being dialed (if any)
|
|
dialCancelFunc func() // Context cancellation function for aborting while dialing.
|
|
listenHub *ListenerHub
|
|
|
|
promptHub *PromptHub
|
|
websocketHub WSHub
|
|
|
|
// Persistent modem connections
|
|
ardop *ardop.TNC
|
|
agwpe *agwpe.TNCPort
|
|
pactor *pactor.Modem
|
|
varaHF *vara.Modem
|
|
varaFM *vara.Modem
|
|
|
|
rigs map[string]rig
|
|
|
|
predictor propagation.Predictor
|
|
|
|
eventLog *EventLogger
|
|
termWriter io.WriteCloser // termWriter writes to both stdout and the log file (web gui echoes log file)
|
|
}
|
|
|
|
// A rig holds a VFO and a closer for the underlying rig connection.
|
|
type rig struct {
|
|
hamlib.VFO
|
|
io.Closer
|
|
}
|
|
|
|
func New(opts Options) *App {
|
|
opts.MailboxPath = filepath.Clean(opts.MailboxPath)
|
|
opts.FormsPath = filepath.Clean(opts.FormsPath)
|
|
opts.ConfigPath = filepath.Clean(opts.ConfigPath)
|
|
opts.LogPath = filepath.Clean(opts.LogPath)
|
|
opts.EventLogPath = filepath.Clean(opts.EventLogPath)
|
|
|
|
return &App{options: opts, websocketHub: noopWSSocket{}}
|
|
}
|
|
|
|
func (a *App) Mailbox() *mailbox.DirHandler { return a.mbox }
|
|
|
|
func (a *App) FormsManager() *forms.Manager { return a.formsMgr }
|
|
|
|
func (a *App) Config() cfg.Config { return a.config }
|
|
|
|
func (a *App) Options() Options { return a.options }
|
|
|
|
func (a *App) PromptHub() *PromptHub { return a.promptHub }
|
|
|
|
func (a *App) Reload() error { return a.OnReload() }
|
|
|
|
func (a *App) VFOForRig(rig string) (hamlib.VFO, bool) { r, ok := a.rigs[rig]; return r, ok }
|
|
|
|
// Locator implements forms.LocatorProvider
|
|
func (a *App) Locator() string { return a.config.Locator }
|
|
|
|
func (a *App) VFOForTransport(transport string) (vfo hamlib.VFO, rigName string, ok bool, err error) {
|
|
var rig string
|
|
switch {
|
|
case transport == MethodArdop:
|
|
rig = a.config.Ardop.Rig
|
|
case transport == MethodAX25, strings.HasPrefix(transport, MethodAX25+"+"):
|
|
rig = a.config.AX25.Rig
|
|
case transport == MethodPactor:
|
|
rig = a.config.Pactor.Rig
|
|
case transport == MethodVaraHF:
|
|
rig = a.config.VaraHF.Rig
|
|
case transport == MethodVaraFM:
|
|
rig = a.config.VaraFM.Rig
|
|
default:
|
|
return vfo, "", false, fmt.Errorf("not supported with transport '%s'", transport)
|
|
}
|
|
if rig == "" {
|
|
return vfo, "", false, fmt.Errorf("missing rig reference in config section for %s", transport)
|
|
}
|
|
vfo, ok = a.VFOForRig(rig)
|
|
return vfo, rig, ok, nil
|
|
}
|
|
|
|
func (a *App) EnableWebSocket(ctx context.Context, wsHub WSHub) error {
|
|
a.websocketHub = wsHub
|
|
a.promptHub.AddPrompter(wsHub)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) Run(ctx context.Context, cmd Command, args []string) {
|
|
debug.Printf("Version: %s", buildinfo.VersionString())
|
|
debug.Printf("Command: %s %v", cmd.Str, args)
|
|
debug.Printf("Mailbox dir is\t'%s'", a.options.MailboxPath)
|
|
debug.Printf("Forms dir is\t'%s'", a.options.FormsPath)
|
|
debug.Printf("Config file is\t'%s'", a.options.ConfigPath)
|
|
debug.Printf("Log file is \t'%s'", a.options.LogPath)
|
|
debug.Printf("Event log file is\t'%s'", a.options.EventLogPath)
|
|
directories.MigrateLegacyDataDir()
|
|
|
|
a.listenHub = NewListenerHub(a)
|
|
a.listenHub.websocketHub = a.websocketHub
|
|
a.promptHub = NewPromptHub()
|
|
|
|
// Skip initialization for some commands
|
|
switch cmd.Str {
|
|
case "configure", "version":
|
|
cmd.HandleFunc(ctx, a, args)
|
|
return
|
|
}
|
|
|
|
// Enable the GZIP extension experiment by default
|
|
if _, ok := os.LookupEnv("GZIP_EXPERIMENT"); !ok {
|
|
os.Setenv("GZIP_EXPERIMENT", "1")
|
|
}
|
|
|
|
os.Setenv("PATH", fmt.Sprintf(`%s%c%s`, a.options.PrehooksPath, os.PathListSeparator, os.Getenv("PATH")))
|
|
|
|
// Parse configuration file
|
|
var err error
|
|
a.config, err = LoadConfig(a.options.ConfigPath, cfg.DefaultConfig)
|
|
if err != nil {
|
|
log.Fatalf("Unable to load/write config: %s", err)
|
|
}
|
|
|
|
// Initialize logger
|
|
f, err := os.Create(a.options.LogPath)
|
|
if err != nil {
|
|
log.Fatalf("Unable to create log file at %s: %v", a.options.LogPath, err)
|
|
}
|
|
a.termWriter = struct {
|
|
io.Writer
|
|
io.Closer
|
|
}{io.MultiWriter(f, os.Stdout), f}
|
|
log.SetOutput(io.MultiWriter(f, os.Stderr)) // web gui echoes the log file
|
|
|
|
a.eventLog, err = NewEventLogger(a.options.EventLogPath)
|
|
if err != nil {
|
|
log.Fatal("Unable to open event log file:", err)
|
|
}
|
|
|
|
// Read command line options from config if unset
|
|
if a.options.MyCall == "" && a.config.MyCall == "" {
|
|
fmt.Fprint(os.Stderr, "Missing mycall\n")
|
|
if cmd.Str != "http" {
|
|
os.Exit(1)
|
|
}
|
|
} else if a.options.MyCall == "" {
|
|
a.options.MyCall = a.config.MyCall
|
|
}
|
|
|
|
// Ensure mycall is all upper case.
|
|
a.options.MyCall = strings.ToUpper(a.options.MyCall)
|
|
|
|
// Don't use config password if we don't use config mycall
|
|
if !strings.EqualFold(a.options.MyCall, a.config.MyCall) {
|
|
a.config.SecureLoginPassword = ""
|
|
}
|
|
|
|
if a.options.Listen == "" && len(a.config.Listen) > 0 {
|
|
a.options.Listen = strings.Join(a.config.Listen, ",")
|
|
}
|
|
|
|
// Initialize HF prediction engine (VOACAP)
|
|
switch p := a.config.Prediction; p.Engine {
|
|
case cfg.PredictionEngineVOACAP, cfg.PredictionEngineAuto:
|
|
voacap, err := propagation.NewVOACAPPredictor(p.VOACAP.Executable, p.VOACAP.DataDir)
|
|
if err != nil {
|
|
// Only log error if engine is set explicitly
|
|
if p.Engine == cfg.PredictionEngineVOACAP {
|
|
log.Println("Failed to initialize VOACAP:", err)
|
|
} else {
|
|
debug.Printf("Failed to initialize VOACAP: %v", err)
|
|
}
|
|
break
|
|
}
|
|
debug.Printf("Prediction engine: %s (%q)", p.Engine, voacap.Version())
|
|
a.predictor = propagation.WithCaching(voacap)
|
|
case cfg.PredictionEngineVOACAPAPI:
|
|
voacap := propagation.NewVOACAPAPIPredictor(p.VOACAPAPI.BaseURL)
|
|
debug.Printf("Prediction engine: %s (%q)", p.Engine, voacap.Version())
|
|
a.predictor = propagation.WithCaching(voacap)
|
|
}
|
|
|
|
// init forms subsystem
|
|
a.formsMgr = forms.NewManager(forms.Config{
|
|
FormsPath: a.options.FormsPath,
|
|
SequencePath: filepath.Join(directories.StateDir(), "template-sequence-number.json"),
|
|
SequenceFormat: "%03d",
|
|
MyCall: a.options.MyCall,
|
|
AppVersion: buildinfo.AppName + " " + buildinfo.VersionStringShort(),
|
|
UserAgent: buildinfo.UserAgent(),
|
|
GPSd: a.config.GPSd,
|
|
LocatorProvider: a,
|
|
})
|
|
|
|
// Load the mailbox handler
|
|
a.mbox = mailbox.NewDirHandler(
|
|
filepath.Join(a.options.MailboxPath, a.options.MyCall),
|
|
a.options.SendOnly,
|
|
)
|
|
// Ensure the mailbox handler is ready
|
|
if err := a.mbox.Prepare(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
if cmd.MayConnect {
|
|
a.loadHamlibRigs(a.config.HamlibRigs)
|
|
a.exchangeChan = a.exchangeLoop(ctx)
|
|
|
|
go func() {
|
|
if a.config.VersionReportingDisabled {
|
|
return
|
|
}
|
|
for {
|
|
a.postVersionUpdate() // 24 hour hold on success
|
|
a.checkPasswordRecoveryEmailIsSet(ctx) // 14 day hold on success
|
|
select {
|
|
case <-time.After(6 * time.Hour): // Retry every 6 hours
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
if cmd.LongLived {
|
|
if a.options.Listen != "" {
|
|
a.Listen(a.options.Listen)
|
|
}
|
|
if a.config.GPSd.UpdateLocator {
|
|
go a.gpsdLocatorUpdater(ctx)
|
|
}
|
|
}
|
|
|
|
// Start command execution
|
|
cmd.HandleFunc(ctx, a, args)
|
|
}
|
|
|
|
type Heard struct {
|
|
Callsign string `json:"callsign"`
|
|
Time time.Time `json:"time"`
|
|
}
|
|
|
|
func (a *App) ActiveListeners() []string {
|
|
slice := []string{}
|
|
for _, tl := range a.listenHub.Active() {
|
|
slice = append(slice, tl.Name())
|
|
}
|
|
sort.Strings(slice)
|
|
return slice
|
|
}
|
|
|
|
func (a *App) Heard() map[string][]Heard {
|
|
heard := make(map[string][]Heard)
|
|
if a.ardop != nil {
|
|
for callsign, time := range a.ardop.Heard() {
|
|
heard[MethodArdop] = append(heard[MethodArdop], Heard{
|
|
Callsign: callsign,
|
|
Time: time,
|
|
})
|
|
}
|
|
}
|
|
|
|
if ax25, err := ax25.Heard(a.Config().AX25Linux.Port); err == nil {
|
|
for callsign, time := range ax25 {
|
|
heard[MethodAX25Linux] = append(heard[MethodAX25Linux], Heard{
|
|
Callsign: callsign,
|
|
Time: time,
|
|
})
|
|
}
|
|
}
|
|
|
|
return heard
|
|
}
|
|
|
|
func (a *App) GetStatus() types.Status {
|
|
configHash := func(c cfg.Config) string {
|
|
h := sha1.New()
|
|
if err := json.NewEncoder(h).Encode(c); err != nil {
|
|
panic(err)
|
|
}
|
|
return fmt.Sprintf("%x", h.Sum(nil))
|
|
}
|
|
|
|
status := types.Status{
|
|
ActiveListeners: a.ActiveListeners(),
|
|
Dialing: a.dialing != nil,
|
|
Connected: a.exchangeConn != nil,
|
|
HTTPClients: a.websocketHub.ClientAddrs(),
|
|
ConfigHash: configHash(a.config),
|
|
}
|
|
|
|
if a.exchangeConn != nil {
|
|
addr := a.exchangeConn.RemoteAddr()
|
|
status.RemoteAddr = fmt.Sprintf("%s:%s", addr.Network(), addr)
|
|
}
|
|
|
|
return status
|
|
}
|
|
|
|
func (a *App) Close() {
|
|
debug.Printf("Starting cleanup")
|
|
defer func() {
|
|
debug.Printf("Cleanup done")
|
|
if a.termWriter != nil {
|
|
a.termWriter.Close()
|
|
}
|
|
}()
|
|
|
|
debug.Printf("Closing active connection and/or listeners")
|
|
a.AbortActiveConnection(false)
|
|
a.listenHub.Close()
|
|
|
|
debug.Printf("Closing modems")
|
|
if a.ardop != nil {
|
|
if err := a.ardop.Close(); err != nil {
|
|
log.Printf("Failure to close ardop TNC: %s", err)
|
|
}
|
|
}
|
|
if a.pactor != nil {
|
|
if err := a.pactor.Close(); err != nil {
|
|
log.Printf("Failure to close pactor modem: %s", err)
|
|
}
|
|
}
|
|
if a.varaFM != nil {
|
|
if err := a.varaFM.Close(); err != nil {
|
|
log.Printf("Failure to close varafm modem: %s", err)
|
|
}
|
|
}
|
|
if a.varaHF != nil {
|
|
if err := a.varaHF.Close(); err != nil {
|
|
log.Printf("Failure to close varahf modem: %s", err)
|
|
}
|
|
}
|
|
if a.agwpe != nil {
|
|
if err := a.agwpe.Close(); err != nil {
|
|
log.Printf("Failure to close AGWPE TNC: %s", err)
|
|
}
|
|
}
|
|
|
|
// Close rigs
|
|
debug.Printf("Closing rigs")
|
|
for name, r := range a.rigs {
|
|
if err := r.Close(); err != nil {
|
|
log.Printf("Failure to close rig %s: %s", name, err)
|
|
}
|
|
}
|
|
|
|
a.promptHub.Close()
|
|
a.websocketHub.Close()
|
|
a.eventLog.Close()
|
|
a.formsMgr.Close()
|
|
}
|
|
|
|
func (a *App) onServiceMessageReceived(msg *fbb.Message) {
|
|
// Recover any panic here, as we really REALLY don't want the user to lose system messages due to panics.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Println(r)
|
|
}
|
|
}()
|
|
|
|
// Write all service messages to the log
|
|
body, _ := msg.Body()
|
|
subject := msg.Subject()
|
|
fmt.Fprintln(a.termWriter)
|
|
fmt.Fprintln(a.termWriter, strings.Repeat("=", (60-len(subject))/2), subject, strings.Repeat("=", (60-len(subject))/2))
|
|
fmt.Fprintln(a.termWriter, strings.TrimSpace(body))
|
|
fmt.Fprintln(a.termWriter, strings.Repeat("=", 62))
|
|
fmt.Fprintln(a.termWriter)
|
|
|
|
// Handle account activation email
|
|
if isActivation, password := isAccountActivationMessage(msg); isActivation && password != "" {
|
|
fmt.Fprintln(a.termWriter, "DO NOT LOSE YOUR PASSWORD:", password)
|
|
fmt.Fprintln(a.termWriter)
|
|
}
|
|
}
|
|
|
|
func (a *App) loadHamlibRigs(rigsConfig map[string]cfg.HamlibConfig) {
|
|
a.rigs = make(map[string]rig, len(rigsConfig))
|
|
for name, conf := range rigsConfig {
|
|
if conf.Address == "" {
|
|
log.Printf("Missing address-field for rig '%s', skipping.", name)
|
|
continue
|
|
}
|
|
if conf.Network == "" {
|
|
conf.Network = "tcp"
|
|
}
|
|
|
|
r, err := hamlib.Open(conf.Network, conf.Address)
|
|
if err != nil {
|
|
log.Printf("Initialization hamlib rig %s failed: %s.", name, err)
|
|
continue
|
|
}
|
|
|
|
var vfo hamlib.VFO
|
|
switch strings.ToUpper(conf.VFO) {
|
|
case "A", "VFOA":
|
|
vfo, err = r.VFOA()
|
|
case "B", "VFOB":
|
|
vfo, err = r.VFOB()
|
|
case "":
|
|
vfo = r.CurrentVFO()
|
|
default:
|
|
log.Printf("Cannot load rig '%s': Unrecognized VFO identifier '%s'", name, conf.VFO)
|
|
r.Close() // Close rig if we can't use it
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
log.Printf("Cannot load rig '%s': Unable to select VFO: %s", name, err)
|
|
r.Close() // Close rig if we can't use it
|
|
continue
|
|
}
|
|
|
|
f, err := vfo.GetFreq()
|
|
if err != nil {
|
|
log.Printf("Unable to get frequency from rig %s: %s.", name, err)
|
|
} else {
|
|
log.Printf("%s ready. Dial frequency is %s.", name, Frequency(f))
|
|
}
|
|
|
|
a.rigs[name] = rig{VFO: vfo, Closer: r}
|
|
}
|
|
}
|
|
|
|
func (a *App) SetConnectAliases(aliases map[string]string) error {
|
|
onDisk, err := LoadConfig(a.options.ConfigPath, cfg.DefaultConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
onDisk.ConnectAliases = aliases
|
|
if err := WriteConfig(onDisk, a.options.ConfigPath); err != nil {
|
|
return err
|
|
}
|
|
a.config.ConnectAliases = aliases
|
|
return nil
|
|
}
|