pax_global_header 0000666 0000000 0000000 00000000064 14534256521 0014521 g ustar 00root root 0000000 0000000 52 comment=073d2ac2f761439d2132a2c1e34a42f1d873ec12
pat-0.15.1/ 0000775 0000000 0000000 00000000000 14534256521 0012371 5 ustar 00root root 0000000 0000000 pat-0.15.1/.github/ 0000775 0000000 0000000 00000000000 14534256521 0013731 5 ustar 00root root 0000000 0000000 pat-0.15.1/.github/workflows/ 0000775 0000000 0000000 00000000000 14534256521 0015766 5 ustar 00root root 0000000 0000000 pat-0.15.1/.github/workflows/docker.yaml 0000664 0000000 0000000 00000002310 14534256521 0020115 0 ustar 00root root 0000000 0000000 name: docker-push
on:
push:
branches:
- 'ci-test/*'
- 'release/*'
tags:
- 'v*'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: la5nta/pat
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/386,linux/arm64/v8,linux/arm/v7,linux/arm/v6
push: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
pat-0.15.1/.github/workflows/go.yaml 0000664 0000000 0000000 00000002405 14534256521 0017260 0 ustar 00root root 0000000 0000000 name: build
on:
push:
pull_request:
types: [ review_requested ]
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
go-version: [ '1.x' ]
include:
- os: ubuntu-latest
go-version: '1.19'
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Setup Go ${{ matrix.go-version }}
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
check-latest: true
cache: true
- if: ${{ matrix.os == 'ubuntu-latest' }}
name: Cache libax25
id: cache-libax25
uses: actions/cache@v3
env:
cache-name: cache-libax25
with:
path: .build
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.go-version }}-${{ hashFiles('make.bash') }}
restore-keys: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.go-version }}-
- if: ${{ matrix.os == 'ubuntu-latest' && steps.cache-libax25.outputs.cache-hit != 'true' }}
name: Setup libax25
run: ./make.bash libax25
- name: Display Go version
run: go version
- name: Vet
run: go vet ./...
- name: Build
run: ./make.bash
pat-0.15.1/.gitignore 0000664 0000000 0000000 00000000042 14534256521 0014355 0 ustar 00root root 0000000 0000000 .build/
pat
pat*.pkg
docker-data/
pat-0.15.1/.gitmodules 0000664 0000000 0000000 00000000000 14534256521 0014534 0 ustar 00root root 0000000 0000000 pat-0.15.1/CONTRIBUTING.md 0000664 0000000 0000000 00000006251 14534256521 0014626 0 ustar 00root root 0000000 0000000 # Contributing to Pat
We welcome contributions to Pat of any kind including documentation, tutorials, bug reports, issues, feature requests, feature implementation, pull requests, answering questions on the mailing list, helping to manage issues, etc.
If you have any questions about how to contribute or what to contribute, please ask on the [pat-users](https://groups.google.com/group/pat-users) list.
## Issue tracker Guidelines
We use github's [issue tracker](https://github.com/la5nta/pat/issues) for keeping track of bugs, features and technical development discussions.
To keep the issue tracker nice and tidy, we ask for the following:
- Keep one issue per topic:
- Don't report multiple bugs in the same issue unless they closely relates to each other.
- Open one issue per feature request.
- When reporting a bug, please add the following:
- Output of pat version (including the SHA).
- Operating system and architecture.
- What you expected to happen.
- What actually happened (including full stack trace and/or error message).
- Issues should not be closed until they are either discarded or deployed. This means that code changing issues should not be closed until the changes have been merged to the master branch.
## Code Contribution Guideline
We welcome your contributions.
To make the process as seamless as possible, we ask for the following:
- Go ahead and fork the project and make your changes. We encourage pull requests to discuss code changes.
- When you’re ready to create a pull request, be sure to:
- Run `go fmt`
- Consider squashing your commits into a single commit. `git rebase -i`. It's okay to force update your pull request.
- **Write a good commit message.** This [blog article](http://chris.beams.io/posts/git-commit/) is a good resource for learning how to write good commit messages, the most important part being that each commit message should have a title/subject in imperative mood starting with a capital letter and no trailing period: *"Return error on wrong use of the Paginator"*, **NOT** *"returning some error."* Also, if your commit references one or more GitHub issues, always end your commit message body with *See #1234* or *Fixes #1234*. Replace *1234* with the GitHub issue ID. The last example will close the issue when the commit is merged into *master*.
- Make sure `go test ./...` passes, and `go build` completes. Our [Travis CI loop](https://app.travis-ci.com/github/la5nta/pat) (Linux and OS X) will catch most things that are missing.
## The release process
New releases of Pat is done by these steps:
1. All issues targeted by the next release are moved into a milestone with the corresponding version name.
2. A release/*-branch is prepared and VERSION.go is updated.
3. A pull request to *master* is opened.
4. The release-branch is built and tested on *all targeted platforms*.
5. If all status checks (Travis CI) passes, the release-branch is merged into *master* and tagged.
6. Issues in the targeted milestone is either closed or moved to another milestone. The milestone is closed.
7. The various binary packages are built and uploaded to [releases/](https://github.com/la5nta/Pat/releases).
pat-0.15.1/Dockerfile 0000664 0000000 0000000 00000001211 14534256521 0014356 0 ustar 00root root 0000000 0000000 FROM golang:alpine as builder
RUN apk add --no-cache git ca-certificates
WORKDIR /src
ADD go.mod go.sum ./
RUN go mod download
ADD . .
RUN go build -o /src/pat
FROM scratch
LABEL org.opencontainers.image.source=https://github.com/la5nta/pat
LABEL org.opencontainers.image.description="Pat - A portable Winlink client for amateur radio email"
LABEL org.opencontainers.image.licenses=MIT
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
COPY --from=builder /src/pat /bin/pat
USER 65534:65534
WORKDIR /app
ENV XDG_CONFIG_HOME=/app
ENV XDG_DATA_HOME=/app
ENV XDG_STATE_HOME=/app
ENV PAT_HTTPADDR=:8080
EXPOSE 8080
ENTRYPOINT ["/bin/pat"]
CMD ["http"]
pat-0.15.1/LICENSE 0000664 0000000 0000000 00000002112 14534256521 0013372 0 ustar 00root root 0000000 0000000 The MIT License (MIT)
Copyright (c) 2020 Martin Hebnes Pedersen (LA5NTA)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
pat-0.15.1/README.md 0000664 0000000 0000000 00000006655 14534256521 0013664 0 ustar 00root root 0000000 0000000
[](https://github.com/la5nta/pat/actions)
[](https://goreportcard.com/report/github.com/la5nta/pat)
[](https://liberapay.com/la5nta)
## Overview
Pat is a cross platform Winlink client with basic messaging capabilities.
It is the primary sandbox/prototype application for the [wl2k-go](https://github.com/la5nta/wl2k-go) project, and provides both a command line interface and a responsive (mobile-friendly) web interface.
It is mainly developed for Linux, but is also known to run on OS X, Windows and Android.
#### Features
* Message composer/reader (basic mailbox functionality).
* Auto-shrink image attachments.
* Post position reports with location from local GPS, browser location or manual entry.
* Rig control (using hamlib).
* CRON-like syntax for execution of scheduled commands (e.g. QSY or connect).
* Built in http-server with web interface (mobile friendly).
* Git style command line interface.
* Listen for P2P connections using multiple modes concurrently.
* AX.25, telnet, PACTOR and ARDOP support.
* Experimental gzip message compression (See "Gzip experiment" below).
##### Example
```
martinhpedersen@duo:~$ pat interactive
> listen winmor,telnet-p2p,ax25
2015/02/03 10:33:10 Listening for incoming traffic (winmor,telnet-p2p,ax25)...
> connect winmor:///LA3F
2015/02/03 10:34:28 Connecting to winmor:LA3F...
2015/02/03 10:34:33 Connected to WINMOR:LA3F
RMS Trimode 1.3.3.0 Follo.SE Oslo. Pactor & Winmor Hybrid Gateway
LA5NTA has 117 minutes remaining with LA3F
[WL2K-2.8.4.8-B2FWIHJM$]
Wien CMS via LA3F >
>FF
FC EM FOYNU8AKXX59 260 221 0
F> 68
1 proposal(s) received
Accepting FOYNU8AKXX59
Receiving [//WL2K test til linux] [offset 0]
>FF
FQ
Waiting for remote node to close the connection...
> _
```
### Gzip experiment
Gzip message compression has been added as an experimental B2F extension. The extension is implemented as a backwards compatible alternative to the ancient LZHUF compression.
This experiment is enabled by default and sessions between two Pat nodes (or other software supporting this B2F extension) will use gzip compression when transferring messages.
For more information, see .
## Copyright/License
Copyright (c) 2020 Martin Hebnes Pedersen LA5NTA
### Contributors (alphabetical)
* DL1THM - Torsten Harenberg
* HB9GPA - Matthias Renner
* K0RET - Ryan Turner
* K0SWE - Chris Keller
* KD8DRX - Will Davidson
* KE8HMG - Andrew Huebner
* KI7RMJ - Rainer Grosskopf
* LA3QMA - Kai Günter Brandt
* LA4TTA - Erlend Grimseid
* LA5NTA - Martin Hebnes Pedersen
* N2YGK - Alan Crosswell
* VE7GNU - Doug Collinge
* W6IPA - JC Martin
* WY2K - Benjamin Seidenberg
## Thanks to
The JNOS developers for the properly maintained lzhuf implementation, as well as the original author Haruyasu Yoshizaki.
The paclink-unix team (Nicholas S. Castellano N2QZ and others) - reference implementation
Amateur Radio Safety Foundation, Inc. - The Winlink 2000 project
F6FBB Jean-Paul ROUBELAT - the FBB forwarding protocol
_Pat/wl2k-go is not affiliated with The Winlink Development Team nor the Winlink 2000 project [http://winlink.org]._
pat-0.15.1/cfg/ 0000775 0000000 0000000 00000000000 14534256521 0013130 5 ustar 00root root 0000000 0000000 pat-0.15.1/cfg/ax25_engine.go 0000664 0000000 0000000 00000001025 14534256521 0015561 0 ustar 00root root 0000000 0000000 package cfg
import (
"encoding/json"
"fmt"
)
const (
AX25EngineAGWPE AX25Engine = "agwpe"
AX25EngineLinux = "linux"
AX25EngineSerialTNC = "serial-tnc"
)
type AX25Engine string
func (a *AX25Engine) UnmarshalJSON(p []byte) error {
var str string
if err := json.Unmarshal(p, &str); err != nil {
return err
}
switch v := AX25Engine(str); v {
case AX25EngineLinux, AX25EngineAGWPE, AX25EngineSerialTNC:
*a = v
return nil
default:
return fmt.Errorf("invalid AX.25 engine '%s'", v)
}
}
pat-0.15.1/cfg/ax25_engine_libax25.go 0000664 0000000 0000000 00000000162 14534256521 0017110 0 ustar 00root root 0000000 0000000 //go:build libax25
// +build libax25
package cfg
func DefaultAX25Engine() AX25Engine { return AX25EngineLinux }
pat-0.15.1/cfg/ax25_engine_other.go 0000664 0000000 0000000 00000000164 14534256521 0016765 0 ustar 00root root 0000000 0000000 //go:build !libax25
// +build !libax25
package cfg
func DefaultAX25Engine() AX25Engine { return AX25EngineAGWPE }
pat-0.15.1/cfg/config.go 0000664 0000000 0000000 00000025442 14534256521 0014733 0 ustar 00root root 0000000 0000000 // 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 cfg
import (
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"github.com/la5nta/wl2k-go/transport/ardop"
)
const (
PlaceholderMycall = "{mycall}"
)
type AuxAddr struct {
Address string
Password *string
}
func (a AuxAddr) MarshalJSON() ([]byte, error) {
if a.Password == nil {
return json.Marshal(a.Address)
}
return json.Marshal(a.Address + ":" + *a.Password)
}
func (a *AuxAddr) UnmarshalJSON(p []byte) error {
var str string
if err := json.Unmarshal(p, &str); err != nil {
return err
}
parts := strings.SplitN(str, ":", 2)
a.Address = parts[0]
if len(parts) > 1 {
a.Password = &parts[1]
}
return nil
}
type Config struct {
// This station's callsign.
MyCall string `json:"mycall"`
// Secure login password used when a secure login challenge is received.
//
// The user is prompted if this is undefined.
SecureLoginPassword string `json:"secure_login_password"`
// Auxiliary callsigns to fetch email on behalf of.
//
// Passwords can optionally be specified by appending :MYPASS (e.g. EMCOMM-1:MyPassw0rd).
// If no password is specified, the SecureLoginPassword is used.
AuxAddrs []AuxAddr `json:"auxiliary_addresses"`
// Maidenhead grid square (e.g. JP20qe).
Locator string `json:"locator"`
// List of service codes for rmslist (defaults to PUBLIC)
ServiceCodes []string `json:"service_codes"`
// Default HTTP listen address (for web UI).
//
// Use ":8080" to listen on any device, port 8080.
HTTPAddr string `json:"http_addr"`
// Handshake comment lines sent to remote node on incoming connections.
//
// Example: ["QTH: Hagavik, Norway. Operator: Martin", "Rig: FT-897 with Signalink USB"]
MOTD []string `json:"motd"`
// Connect aliases
//
// Example: {"LA1B-10": "ax25:///LD5GU/LA1B-10", "LA1B": "ardop://LA3F?freq=5350"}
// Any occurrence of the substring "{mycall}" will be replaced with user's callsign.
ConnectAliases map[string]string `json:"connect_aliases"`
// Methods to listen for incoming P2P connections by default.
//
// Example: ["ax25", "telnet", "ardop"]
Listen []string `json:"listen"`
// Hamlib rigs available (with reference name) for ptt and frequency control.
HamlibRigs map[string]HamlibConfig `json:"hamlib_rigs"`
AX25 AX25Config `json:"ax25"` // See AX25Config.
AX25Linux AX25LinuxConfig `json:"ax25_linux"` // See AX25LinuxConfig.
AGWPE AGWPEConfig `json:"agwpe"` // See AGWPEConfig.
SerialTNC SerialTNCConfig `json:"serial-tnc"` // See SerialTNCConfig.
Ardop ArdopConfig `json:"ardop"` // See ArdopConfig.
Pactor PactorConfig `json:"pactor"` // See PactorConfig.
Telnet TelnetConfig `json:"telnet"` // See TelnetConfig.
VaraHF VaraConfig `json:"varahf"` // See VaraConfig.
VaraFM VaraConfig `json:"varafm"` // See VaraConfig.
// See GPSdConfig.
GPSd GPSdConfig `json:"gpsd"`
// Legacy support for old config files only. This field is deprecated!
// Please use "Addr" field in GPSd config struct (GPSd.Addr)
GPSdAddrLegacy string `json:"gpsd_addr,omitempty"`
// Command schedule (cron-like syntax).
//
// Examples:
// # Connect to telnet once every hour
// "@hourly": "connect telnet"
//
// # Change ardop listen frequency based on hour of day
// "00 10 * * *": "freq ardop:7350.000", # 40m from 10:00
// "00 18 * * *": "freq ardop:5347.000", # 60m from 18:00
// "00 22 * * *": "freq ardop:3602.000" # 80m from 22:00
Schedule map[string]string `json:"schedule"`
// By default, Pat posts your callsign and running version to the Winlink CMS Web Services
//
// Set to true if you don't want your information sent.
VersionReportingDisabled bool `json:"version_reporting_disabled"`
}
type HamlibConfig struct {
// The network type ("serial" or "tcp"). Use 'tcp' for rigctld (default).
//
// (For serial support: build with "-tags libhamlib".)
Network string `json:"network,omitempty"`
// The rig address.
//
// For tcp (rigctld): "address:port" (e.g. localhost:4532).
// For serial: "/path/to/tty?model=&baudrate=" (e.g. /dev/ttyS0?model=123&baudrate=4800).
Address string `json:"address,omitempty"`
// The rig's VFO to control ("A" or "B"). If empty, the current active VFO is used.
VFO string `json:"VFO"`
}
type ArdopConfig struct {
// Network address of the Ardop TNC (e.g. localhost:8515).
Addr string `json:"addr"`
// Default/listen ARQ bandwidth (200/500/1000/2000 MAX/FORCED).
ARQBandwidth ardop.Bandwidth `json:"arq_bandwidth"`
// (optional) Reference name to the Hamlib rig to control frequency and ptt.
Rig string `json:"rig"`
// Set to true if hamlib should control PTT (SignaLink=false, most rigexpert=true).
PTTControl bool `json:"ptt_ctrl"`
// (optional) Send ID frame at a regular interval when the listener is active (unit is seconds)
BeaconInterval int `json:"beacon_interval"`
// Send FSK CW ID after an ID frame.
CWID bool `json:"cwid_enabled"`
}
type VaraConfig struct {
// Network host of the VARA modem (defaults to localhost:8300).
Addr string `json:"addr"`
// Default/listen bandwidth (HF: 500/2300/2750 Hz).
Bandwidth int `json:"bandwidth"`
// (optional) Reference name to the Hamlib rig to control frequency and ptt.
Rig string `json:"rig"`
// Set to true if hamlib should control PTT (SignaLink=false, most rigexpert=true).
PTTControl bool `json:"ptt_ctrl"`
}
// UnmarshalJSON implements VaraConfig JSON unmarshalling with support for legacy format.
func (v *VaraConfig) UnmarshalJSON(b []byte) error {
type newFormat VaraConfig
legacy := struct {
newFormat
Host string `json:"host"`
CmdPort int `json:"cmdPort"`
DataPort int `json:"dataPort"`
}{}
if err := json.Unmarshal(b, &legacy); err != nil {
return err
}
if legacy.newFormat.Addr == "" && legacy.Host != "" {
legacy.newFormat.Addr = fmt.Sprintf("%s:%d", legacy.Host, legacy.CmdPort)
}
*v = VaraConfig(legacy.newFormat)
if !v.IsZero() && v.CmdPort() <= 0 {
return fmt.Errorf("invalid addr format")
}
return nil
}
func (v VaraConfig) IsZero() bool { return v == (VaraConfig{}) }
func (v VaraConfig) Host() string {
host, _, _ := net.SplitHostPort(v.Addr)
return host
}
func (v VaraConfig) CmdPort() int {
_, portStr, _ := net.SplitHostPort(v.Addr)
port, _ := strconv.Atoi(portStr)
return port
}
func (v VaraConfig) DataPort() int { return v.CmdPort() + 1 }
type PactorConfig struct {
// Path/port to TNC device (e.g. /dev/ttyUSB0 or COM1).
Path string `json:"path"`
// Baudrate for the serial port (e.g. 57600).
Baudrate int `json:"baudrate"`
// (optional) Reference name to the Hamlib rig for frequency control.
Rig string `json:"rig"`
// (optional) Path to custom TNC initialization script.
InitScript string `json:"custom_init_script"`
}
type TelnetConfig struct {
// Network address (and port) to listen for telnet-p2p connections (e.g. :8774).
ListenAddr string `json:"listen_addr"`
// Telnet-p2p password.
Password string `json:"password"`
}
type SerialTNCConfig struct {
// Serial port (e.g. /dev/ttyUSB0 or COM1).
Path string `json:"path"`
// SerialBaud is the serial port's baudrate (e.g. 57600).
SerialBaud int `json:"serial_baud"`
// HBaud is the the packet connection's baudrate (1200 or 9600).
HBaud int `json:"hbaud"`
// Baudrate of the packet connection.
// Deprecated: Use HBaud instead.
BaudrateLegacy int `json:"baudrate,omitempty"`
// Type of TNC (currently only 'kenwood').
Type string `json:"type"`
// (optional) Reference name to the Hamlib rig for frequency control.
Rig string `json:"rig"`
}
type AGWPEConfig struct {
// The TCP address of the TNC.
Addr string `json:"addr"`
// The AGWPE "radio port" (0-3).
RadioPort int `json:"radio_port"`
}
type AX25Config struct {
// The AX.25 engine to be used.
//
// Valid options are:
// - linux
// - agwpe
// - serial-tnc
Engine AX25Engine `json:"engine"`
// (optional) Reference name to the Hamlib rig for frequency control.
Rig string `json:"rig"`
// DEPRECATED: See AX25Linux.Port.
AXPort string `json:"port,omitempty"`
// Optional beacon when listening for incoming packet-p2p connections.
Beacon BeaconConfig `json:"beacon"`
}
type AX25LinuxConfig struct {
// axport to use (as defined in /etc/ax25/axports). Only applicable to ax25 engine 'linux'.
Port string `json:"port"`
}
type BeaconConfig struct {
// Beacon interval in seconds (e.g. 3600 for once every 1 hour)
Every int `json:"every"` // (seconds)
// Beacon data/message
Message string `json:"message"`
// Beacon destination (e.g. IDENT)
Destination string `json:"destination"`
}
type GPSdConfig struct {
// Enable GPSd proxy for HTTP (web GUI)
//
// Caution: Your GPS position will be accessible to any network device able to access Pat's HTTP interface.
EnableHTTP bool `json:"enable_http"`
// Allow Winlink forms to use GPSd for aquiring your position.
//
// Caution: Your current GPS position will be automatically injected, without your explicit consent, into forms requesting such information.
AllowForms bool `json:"allow_forms"`
// Use server time instead of timestamp provided by GPSd (e.g for older GPS device with week roll-over issue).
UseServerTime bool `json:"use_server_time"`
// Address and port of GPSd server (e.g. localhost:2947).
Addr string `json:"addr"`
}
var DefaultConfig = Config{
MOTD: []string{"Open source Winlink client - getpat.io"},
AuxAddrs: []AuxAddr{},
ServiceCodes: []string{"PUBLIC"},
ConnectAliases: map[string]string{
"telnet": "telnet://{mycall}:CMSTelnet@cms.winlink.org:8772/wl2k",
},
Listen: []string{},
HTTPAddr: "localhost:8080",
AX25: AX25Config{
Engine: DefaultAX25Engine(),
Beacon: BeaconConfig{
Every: 3600,
Message: "Winlink P2P",
Destination: "IDENT",
},
},
AX25Linux: AX25LinuxConfig{
Port: "wl2k",
},
SerialTNC: SerialTNCConfig{
Path: "/dev/ttyUSB0",
SerialBaud: 9600,
HBaud: 1200,
Type: "Kenwood",
},
AGWPE: AGWPEConfig{
Addr: "localhost:8000",
RadioPort: 0,
},
Ardop: ArdopConfig{
Addr: "localhost:8515",
ARQBandwidth: ardop.Bandwidth500Max,
CWID: true,
},
Pactor: PactorConfig{
Path: "/dev/ttyUSB0",
Baudrate: 57600,
},
Telnet: TelnetConfig{
ListenAddr: ":8774",
Password: "",
},
VaraHF: VaraConfig{
Addr: "localhost:8300",
Bandwidth: 2300,
},
VaraFM: VaraConfig{
Addr: "localhost:8300",
},
GPSd: GPSdConfig{
EnableHTTP: false, // Default to false to help protect privacy of unknowing users (see github.com//issues/146)
AllowForms: false, // Default to false to help protect location privacy of unknowing users
UseServerTime: false,
Addr: "localhost:2947", // Default listen address for GPSd
},
GPSdAddrLegacy: "",
Schedule: map[string]string{},
HamlibRigs: map[string]HamlibConfig{},
}
pat-0.15.1/cli_composer.go 0000664 0000000 0000000 00000020302 14534256521 0015373 0 ustar 00root root 0000000 0000000 // 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.
// A portable Winlink client for amateur radio email.
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/la5nta/pat/internal/buildinfo"
"github.com/la5nta/wl2k-go/fbb"
"github.com/spf13/pflag"
)
func composeMessageHeader(replyMsg *fbb.Message) *fbb.Message {
msg := fbb.NewMessage(fbb.Private, fOptions.MyCall)
fmt.Printf(`From [%s]: `, fOptions.MyCall)
from := readLine()
if from == "" {
from = fOptions.MyCall
}
msg.SetFrom(from)
fmt.Print(`To`)
if replyMsg != nil {
fmt.Printf(" [%s]", replyMsg.From())
}
fmt.Printf(": ")
to := readLine()
if to == "" && replyMsg != nil {
msg.AddTo(replyMsg.From().String())
} else {
for _, addr := range strings.FieldsFunc(to, SplitFunc) {
msg.AddTo(addr)
}
}
ccCand := make([]fbb.Address, 0)
if replyMsg != nil {
for _, addr := range append(replyMsg.To(), replyMsg.Cc()...) {
if !addr.EqualString(fOptions.MyCall) {
ccCand = append(ccCand, addr)
}
}
}
fmt.Printf("Cc")
if replyMsg != nil {
fmt.Printf(" %s", ccCand)
}
fmt.Print(`: `)
cc := readLine()
if cc == "" && replyMsg != nil {
for _, addr := range ccCand {
msg.AddCc(addr.String())
}
} else {
for _, addr := range strings.FieldsFunc(cc, SplitFunc) {
msg.AddCc(addr)
}
}
switch len(msg.Receivers()) {
case 1:
fmt.Print("P2P only [y/N]: ")
ans := readLine()
if strings.EqualFold("y", ans) {
msg.Header.Set("X-P2POnly", "true")
}
case 0:
fmt.Println("Message must have at least one recipient")
os.Exit(1)
}
fmt.Print(`Subject: `)
if replyMsg != nil {
subject := strings.TrimSpace(strings.TrimPrefix(replyMsg.Subject(), "Re:"))
subject = fmt.Sprintf("Re:%s", subject)
fmt.Println(subject)
msg.SetSubject(subject)
} else {
msg.SetSubject(readLine())
}
// A message without subject is not valid, so let's use a sane default
if msg.Subject() == "" {
msg.SetSubject("")
}
return msg
}
func composeMessage(ctx context.Context, args []string) {
set := pflag.NewFlagSet("compose", pflag.ExitOnError)
// From default is --mycall but it can be overriden with -r
from := set.StringP("from", "r", fOptions.MyCall, "")
subject := set.StringP("subject", "s", "", "")
attachments := set.StringArrayP("attachment", "a", nil, "")
ccs := set.StringArrayP("cc", "c", nil, "")
p2pOnly := set.BoolP("p2p-only", "", false, "")
set.Parse(args)
// Remaining args are recipients
recipients := []string{}
for _, r := range set.Args() {
// Filter out empty args (this actually happens)
if strings.TrimSpace(r) == "" {
continue
}
recipients = append(recipients, r)
}
// Check if any args are set. If so, go non-interactive
// Otherwise, interactive
if (len(*subject) + len(*attachments) + len(*ccs) + len(recipients)) > 0 {
noninteractiveComposeMessage(*from, *subject, *attachments, *ccs, recipients, *p2pOnly)
} else {
interactiveComposeMessage(nil)
}
}
func noninteractiveComposeMessage(from string, subject string, attachments []string, ccs []string, recipients []string, p2pOnly bool) {
// We have to verify the args here. Follow the same pattern as main()
// We'll allow a missing recipient if CC is present (or vice versa)
if len(recipients)+len(ccs) <= 0 {
fmt.Fprint(os.Stderr, "ERROR: Missing recipients in non-interactive mode!\n")
os.Exit(1)
}
// Subject is optional. Print a mailx style warning
if subject == "" {
fmt.Fprint(os.Stderr, "Warning: missing subject; hope that's OK\n")
}
msg := fbb.NewMessage(fbb.Private, fOptions.MyCall)
msg.SetFrom(from)
for _, to := range recipients {
msg.AddTo(to)
}
for _, cc := range ccs {
msg.AddCc(cc)
}
msg.SetSubject(subject)
// Handle Attachments. Since we're not interactive, treat errors as fatal so the user can fix
for _, filename := range attachments {
file, err := readAttachment(filename)
if err != nil {
fmt.Fprint(os.Stderr, err.Error()+"\nAborting! (Message not posted)\n")
os.Exit(1)
}
msg.AddFile(file)
}
// Read the message body from stdin
body, _ := ioutil.ReadAll(os.Stdin)
if len(body) == 0 {
// Yeah, I've spent way too much time using mail(1)
fmt.Fprint(os.Stderr, "Null message body; hope that's ok\n")
}
msg.SetBody(string(body))
if p2pOnly {
msg.Header.Set("X-P2POnly", "true")
}
postMessage(msg)
}
// This is currently an alias for interactiveComposeMessage but keeping as a separate
// call path for the future
func composeReplyMessage(replyMsg *fbb.Message) {
interactiveComposeMessage(replyMsg)
}
func interactiveComposeMessage(replyMsg *fbb.Message) {
msg := composeMessageHeader(replyMsg)
// Read body
fmt.Printf(`Press ENTER to start composing the message body. `)
readLine()
f, err := ioutil.TempFile("", strings.ToLower(fmt.Sprintf("%s_new_%d.txt", buildinfo.AppName, time.Now().Unix())))
if err != nil {
log.Fatalf("Unable to prepare temporary file for body: %s", err)
}
if replyMsg != nil {
fmt.Fprintf(f, "--- %s %s wrote: ---\n", replyMsg.Date(), replyMsg.From().Addr)
body, _ := replyMsg.Body()
orig := ">" + strings.ReplaceAll(
strings.TrimSpace(body),
"\n",
"\n>",
) + "\n"
f.Write([]byte(orig))
f.Sync()
}
// Windows fix: Avoid 'cannot access the file because it is being used by another process' error.
// Close the file before opening the editor.
f.Close()
cmd := exec.Command(EditorName(), f.Name())
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
log.Fatalf("Unable to start body editor: %s", err)
}
f, err = os.OpenFile(f.Name(), os.O_RDWR, 0o666)
if err != nil {
log.Fatalf("Unable to read temporary file from editor: %s", err)
}
var buf bytes.Buffer
io.Copy(&buf, f)
msg.SetBody(buf.String())
f.Close()
os.Remove(f.Name())
// An empty message body is illegal. Let's set a sane default.
if msg.BodySize() == 0 {
msg.SetBody("\n")
}
// END Read body
fmt.Print("\n")
for {
fmt.Print(`Attachment [empty when done]: `)
path := readLine()
if path == "" {
break
}
file, err := readAttachment(path)
if err != nil {
log.Println(err)
continue
}
msg.AddFile(file)
}
fmt.Println(msg)
postMessage(msg)
}
func readAttachment(path string) (*fbb.File, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
name := filepath.Base(path)
var resizeImage bool
if isConvertableImageMediaType(name, "") {
fmt.Print("This seems to be an image. Auto resize? [Y/n]: ")
ans := readLine()
resizeImage = ans == "" || strings.EqualFold("y", ans)
}
var data []byte
data, err = ioutil.ReadAll(f)
if resizeImage {
data, err = convertImage(data)
ext := filepath.Ext(name)
name = name[:len(name)-len(ext)] + ".jpg"
}
return fbb.NewFile(name, data), err
}
var stdin *bufio.Reader
func readLine() string {
if stdin == nil {
stdin = bufio.NewReader(os.Stdin)
}
str, _ := stdin.ReadString('\n')
return strings.TrimSpace(str)
}
func composeFormReport(ctx context.Context, args []string) {
var tmplPathArg string
set := pflag.NewFlagSet("form", pflag.ExitOnError)
set.StringVar(&tmplPathArg, "template", "ICS USA Forms/ICS213", "")
set.Parse(args)
msg := composeMessageHeader(nil)
formMsg, err := formsMgr.ComposeForm(tmplPathArg, msg.Subject())
if err != nil {
log.Printf("failed to compose message for template: %v", err)
return
}
msg.SetSubject(formMsg.Subject)
fmt.Println("================================================================")
fmt.Print("To: ")
fmt.Println(msg.To())
fmt.Print("Cc: ")
fmt.Println(msg.Cc())
fmt.Print("From: ")
fmt.Println(msg.From())
fmt.Println("Subject: " + msg.Subject())
fmt.Println(formMsg.Body)
fmt.Println("================================================================")
fmt.Println("Press ENTER to post this message in the outbox, Ctrl-C to abort.")
fmt.Println("================================================================")
readLine()
msg.SetBody(formMsg.Body)
if xml := formMsg.AttachmentXML; xml != "" {
attachmentFile := fbb.NewFile(formMsg.AttachmentName, []byte(xml))
msg.AddFile(attachmentFile)
}
postMessage(msg)
}
pat-0.15.1/config.go 0000664 0000000 0000000 00000013112 14534256521 0014163 0 ustar 00root root 0000000 0000000 // 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 main
import (
"encoding/json"
"fmt"
"log"
"os"
"path"
"strings"
"github.com/kelseyhightower/envconfig"
"github.com/la5nta/pat/cfg"
"github.com/la5nta/pat/internal/buildinfo"
"github.com/la5nta/pat/internal/debug"
)
func LoadConfig(cfgPath string, fallback cfg.Config) (config cfg.Config, err error) {
config, err = ReadConfig(cfgPath)
switch {
case os.IsNotExist(err):
config = fallback
if err := WriteConfig(config, cfgPath); err != nil {
return config, err
}
case err != nil:
return config, err
}
// Environment variables overrides values from the config file
if err := envconfig.Process(buildinfo.AppName, &config); err != nil {
return config, err
}
// Environment variables for hamlib rigs (custom syntax not handled by envconfig)
if err := readRigsFromEnv(&config.HamlibRigs); err != nil {
return config, err
}
// Ensure the alias "telnet" exists
if config.ConnectAliases == nil {
config.ConnectAliases = make(map[string]string)
}
if _, exists := config.ConnectAliases["telnet"]; !exists {
config.ConnectAliases["telnet"] = cfg.DefaultConfig.ConnectAliases["telnet"]
}
// TODO: Remove after some release cycles (2023-05-21)
// Rewrite deprecated serial-tnc:// aliases to ax25-serial-tnc://
var deprecatedAliases []string
for k, v := range config.ConnectAliases {
if !strings.HasPrefix(v, MethodSerialTNCDeprecated+"://") {
continue
}
deprecatedAliases = append(deprecatedAliases, k)
config.ConnectAliases[k] = strings.Replace(v, MethodSerialTNCDeprecated, MethodAX25SerialTNC, 1)
}
if len(deprecatedAliases) > 0 {
log.Printf("Alias(es) %s uses deprecated transport scheme %s://. Please use %s:// instead.", strings.Join(deprecatedAliases, ", "), MethodSerialTNCDeprecated, MethodAX25SerialTNC)
}
// Ensure ServiceCodes has a default value
if len(config.ServiceCodes) == 0 {
config.ServiceCodes = cfg.DefaultConfig.ServiceCodes
}
// Ensure we have a default AX.25 engine
if config.AX25.Engine == "" {
config.AX25.Engine = cfg.DefaultAX25Engine()
}
// Ensure we have a default AGWPE config
if config.AGWPE == (cfg.AGWPEConfig{}) {
config.AGWPE = cfg.DefaultConfig.AGWPE
}
// Ensure we have a default AX.25 Linux config
if config.AX25Linux == (cfg.AX25LinuxConfig{}) {
config.AX25Linux = cfg.DefaultConfig.AX25Linux
}
// TODO: Remove after some release cycles (2023-04-30)
if v := config.AX25.AXPort; v != "" && v != config.AX25Linux.Port {
log.Println("Using deprecated configuration option ax25.port. Please set ax25_linux.port instead.")
config.AX25Linux.Port = v
}
// Ensure Pactor has a default value
if config.Pactor == (cfg.PactorConfig{}) {
config.Pactor = cfg.DefaultConfig.Pactor
}
// Ensure VARA FM and VARA HF has default values
if config.VaraHF.IsZero() {
config.VaraHF = cfg.DefaultConfig.VaraHF
}
if config.VaraFM.IsZero() {
config.VaraFM = cfg.DefaultConfig.VaraFM
}
// Ensure GPSd has a default value
if config.GPSd == (cfg.GPSdConfig{}) {
config.GPSd = cfg.DefaultConfig.GPSd
}
// TODO: Remove after some release cycles (2019-09-29)
if v := config.GPSdAddrLegacy; v != "" && v != config.GPSd.Addr {
log.Println("Using deprecated configuration option gpsd_addr. Please set gpsd.addr instead.")
config.GPSd.Addr = v
}
// Ensure SerialTNC has a default hbaud and serialbaud
if config.SerialTNC.HBaud == 0 {
config.SerialTNC.HBaud = cfg.DefaultConfig.SerialTNC.HBaud
}
if config.SerialTNC.SerialBaud == 0 {
config.SerialTNC.SerialBaud = cfg.DefaultConfig.SerialTNC.SerialBaud
}
// Compatibility for the old baudrate field for serial-tnc
if v := config.SerialTNC.BaudrateLegacy; v != 0 && v != config.SerialTNC.HBaud {
// Since we changed the default value from 9600 to 1200, we can't warn about this without causing confusion.
debug.Printf("Legacy serial_tnc.baudrate config detected (%d). Translating to serial_tnc.hbaud.", v)
config.SerialTNC.HBaud = v
}
return config, nil
}
// readRigsFromEnv reads hamlib rigs config from environment.
// Syntax: PAT_HAMLIB_RIGS_{rig name}_{ATTRIBUTE}
// _{ATTRIBUTE} is optional (defaults to _ADDRESS).
// Examples:
// - PAT_HAMLIB_RIGS_rig1_NETWORK=tcp
// - PAT_HAMLIB_RIGS_rig1_ADDRESS=localhost:8080
// - PAT_HAMLIB_RIGS_rig1_VFO=A
// - PAT_HAMLIB_RIGS_rig2=localhost:8080
func readRigsFromEnv(rigs *map[string]cfg.HamlibConfig) error {
prefix := strings.ToUpper(buildinfo.AppName) + "_HAMLIB_RIGS_"
for _, env := range os.Environ() {
attribute, value, _ := strings.Cut(env, "=")
if !strings.HasPrefix(attribute, prefix) {
continue
}
attribute = strings.TrimPrefix(attribute, prefix)
name, attribute, _ := strings.Cut(attribute, "_")
if *rigs == nil {
*rigs = make(map[string]cfg.HamlibConfig)
}
rig := (*rigs)[name]
switch attribute {
case "ADDRESS", "":
rig.Address = value
case "NETWORK":
rig.Network = value
case "VFO":
rig.VFO = value
default:
return fmt.Errorf("invalid attribute '%s' for rig '%s'", attribute, name)
}
(*rigs)[name] = rig
}
return nil
}
func ReadConfig(path string) (config cfg.Config, err error) {
data, err := os.ReadFile(path)
if err != nil {
return
}
err = json.Unmarshal(data, &config)
return
}
func WriteConfig(config cfg.Config, filePath string) error {
b, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
// Add trailing new-line
b = append(b, '\n')
// Ensure path dir is available
os.Mkdir(path.Dir(filePath), os.ModePerm|os.ModeDir)
return os.WriteFile(filePath, b, 0o600)
}
pat-0.15.1/config_test.go 0000664 0000000 0000000 00000003010 14534256521 0015216 0 ustar 00root root 0000000 0000000 package main
import (
"os"
"strings"
"testing"
"github.com/la5nta/pat/cfg"
)
func TestReadRigsFromEnv(t *testing.T) {
const prefix = "PAT_HAMLIB_RIGS"
unset := func() {
for _, env := range os.Environ() {
key, _, _ := strings.Cut(env, "=")
if strings.HasPrefix(key, prefix) {
os.Unsetenv(key)
}
}
}
t.Run("simple", func(t *testing.T) {
defer unset()
var rigs map[string]cfg.HamlibConfig
os.Setenv(prefix+"_rig", "localhost:4532")
if err := readRigsFromEnv(&rigs); err != nil {
t.Fatal(err)
}
if got := rigs["rig"]; (got != cfg.HamlibConfig{Address: "localhost:4532"}) {
t.Fatalf("Got unexpected config: %#v", got)
}
})
t.Run("with VFO", func(t *testing.T) {
defer unset()
var rigs map[string]cfg.HamlibConfig
os.Setenv(prefix+"_rig", "localhost:4532")
os.Setenv(prefix+"_rig_VFO", "A")
if err := readRigsFromEnv(&rigs); err != nil {
t.Fatal(err)
}
if got := rigs["rig"]; (got != cfg.HamlibConfig{Address: "localhost:4532", VFO: "A"}) {
t.Fatalf("Got unexpected config: %#v", got)
}
})
t.Run("full", func(t *testing.T) {
defer unset()
var rigs map[string]cfg.HamlibConfig
os.Setenv(prefix+"_rig_ADDRESS", "/dev/ttyS0")
os.Setenv(prefix+"_rig_NETWORK", "serial")
os.Setenv(prefix+"_rig_VFO", "B")
if err := readRigsFromEnv(&rigs); err != nil {
t.Fatal(err)
}
expect := cfg.HamlibConfig{
Address: "/dev/ttyS0",
Network: "serial",
VFO: "B",
}
if got := rigs["rig"]; got != expect {
t.Fatalf("Got unexpected config: %#v", got)
}
})
}
pat-0.15.1/connect.go 0000664 0000000 0000000 00000023041 14534256521 0014351 0 ustar 00root root 0000000 0000000 // 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 main
import (
"context"
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"github.com/la5nta/pat/cfg"
"github.com/la5nta/pat/internal/debug"
"github.com/harenber/ptc-go/v2/pactor"
"github.com/la5nta/wl2k-go/transport"
"github.com/la5nta/wl2k-go/transport/ardop"
"github.com/la5nta/wl2k-go/transport/ax25/agwpe"
"github.com/n8jja/Pat-Vara/vara"
// Register stateless dialers
_ "github.com/la5nta/wl2k-go/transport/ax25"
_ "github.com/la5nta/wl2k-go/transport/telnet"
)
var (
dialing *transport.URL // The connect URL currently being dialed (if any)
adTNC *ardop.TNC // Pointer to the ARDOP TNC used by Listen and Connect
agwpeTNC *agwpe.TNCPort // Pointer to the AGWPE TNC combined TNC and Port
pModem *pactor.Modem
varaHFModem *vara.Modem
varaFMModem *vara.Modem
// Context cancellation function for aborting while dialing.
dialCancelFunc func() = func() {}
)
func hasSSID(str string) bool { return strings.Contains(str, "-") }
func connectAny(connectStr ...string) bool {
for _, str := range connectStr {
if Connect(str) {
return true
}
}
return false
}
func Connect(connectStr string) (success bool) {
if connectStr == "" {
return false
} else if aliased, ok := config.ConnectAliases[connectStr]; ok {
return Connect(aliased)
}
// Hack around bug in frontend which may occur if the status updates too quickly.
if websocketHub != nil {
defer func() { time.Sleep(time.Second); websocketHub.UpdateStatus() }()
}
debug.Printf("connectStr: %s", connectStr)
url, err := transport.ParseURL(connectStr)
if err != nil {
log.Println(err)
return false
}
// TODO: Remove after some release cycles (2023-05-21)
// Rewrite legacy serial-tnc scheme.
if url.Scheme == MethodSerialTNCDeprecated {
log.Printf("Transport scheme %s:// is deprecated, use %s:// instead.", MethodSerialTNCDeprecated, MethodAX25SerialTNC)
url.Scheme = MethodAX25SerialTNC
}
// Rewrite the generic ax25:// scheme to use a specified AX.25 engine.
if url.Scheme == MethodAX25 {
url.Scheme = defaultAX25Method()
}
// Init TNCs
switch url.Scheme {
case MethodAX25AGWPE:
if err := initAGWPE(); err != nil {
log.Println(err)
return
}
case MethodArdop:
if err := initArdopTNC(); err != nil {
log.Println(err)
return
}
case MethodPactor:
ptCmdInit := ""
if val, ok := url.Params["init"]; ok {
ptCmdInit = strings.Join(val, "\n")
}
if err := initPactorModem(ptCmdInit); err != nil {
log.Println(err)
return
}
case MethodVaraHF:
if err := initVaraHFModem(); err != nil {
log.Println(err)
return
}
case MethodVaraFM:
if err := initVaraFMModem(); err != nil {
log.Println(err)
return
}
}
// Set default userinfo (mycall)
if url.User == nil {
url.SetUser(fOptions.MyCall)
}
// Set default host interface address
if url.Host == "" {
switch url.Scheme {
case MethodAX25Linux:
url.Host = config.AX25Linux.Port
case MethodAX25SerialTNC:
url.Host = config.SerialTNC.Path
if hbaud := config.SerialTNC.HBaud; hbaud > 0 {
url.Params.Set("hbaud", fmt.Sprint(hbaud))
}
if sbaud := config.SerialTNC.SerialBaud; sbaud > 0 {
url.Params.Set("serial_baud", fmt.Sprint(sbaud))
}
}
}
// Radio Only?
radioOnly := fOptions.RadioOnly
if v := url.Params.Get("radio_only"); v != "" {
radioOnly, _ = strconv.ParseBool(v)
}
if radioOnly {
if hasSSID(fOptions.MyCall) {
log.Println("Radio Only does not support callsign with SSID")
return
}
if strings.HasPrefix(url.Scheme, MethodAX25) {
log.Printf("Radio-Only is not available for %s", url.Scheme)
return
}
url.SetUser(url.User.Username() + "-T")
}
// QSY
var revertFreq func()
if freq := url.Params.Get("freq"); freq != "" {
revertFreq, err = qsy(url.Scheme, freq)
if err != nil {
log.Printf("Unable to QSY: %s", err)
return
}
defer revertFreq()
}
var currFreq Frequency
if vfo, _, ok, _ := VFOForTransport(url.Scheme); ok {
f, _ := vfo.GetFreq()
currFreq = Frequency(f)
}
// Wait for a clear channel
switch url.Scheme {
case MethodArdop:
waitBusy(adTNC)
}
ctx, cancel := context.WithCancel(context.Background())
dialCancelFunc = func() { dialing = nil; cancel() }
defer dialCancelFunc()
// Signal web gui that we are dialing a connection
dialing = url
websocketHub.UpdateStatus()
log.Printf("Connecting to %s (%s)...", url.Target, url.Scheme)
conn, err := transport.DialURLContext(ctx, url)
// Signal web gui that we are no longer dialing
dialing = nil
websocketHub.UpdateStatus()
eventLog.LogConn("connect "+connectStr, currFreq, conn, err)
switch {
case errors.Is(err, context.Canceled):
log.Printf("Connect cancelled")
return
case err != nil:
log.Printf("Unable to establish connection to remote: %s", err)
return
}
err = exchange(conn, url.Target, false)
if err != nil {
log.Printf("Exchange failed: %s", err)
} else {
log.Println("Disconnected.")
success = true
}
return
}
func qsy(method, addr string) (revert func(), err error) {
noop := func() {}
rig, rigName, ok, err := VFOForTransport(method)
if err != nil {
return noop, err
} else if !ok {
return noop, fmt.Errorf("hamlib rig '%s' not loaded", rigName)
}
log.Printf("QSY %s: %s", method, addr)
_, oldFreq, err := setFreq(rig, addr)
if err != nil {
return noop, err
}
time.Sleep(3 * time.Second)
return func() {
time.Sleep(time.Second)
log.Printf("QSX %s: %.3f", method, float64(oldFreq)/1e3)
rig.SetFreq(oldFreq)
}, nil
}
func waitBusy(b transport.BusyChannelChecker) {
printed := false
for b.Busy() {
if !printed && fOptions.IgnoreBusy {
log.Println("Ignoring busy channel!")
break
} else if !printed {
log.Println("Waiting for clear channel...")
printed = true
}
time.Sleep(300 * time.Millisecond)
}
}
func initArdopTNC() error {
if adTNC != nil && adTNC.Ping() == nil {
return nil
}
if adTNC != nil {
adTNC.Close()
}
var err error
adTNC, err = ardop.OpenTCP(config.Ardop.Addr, fOptions.MyCall, config.Locator)
if err != nil {
return fmt.Errorf("ARDOP TNC initialization failed: %w", err)
}
if !config.Ardop.ARQBandwidth.IsZero() {
if err := adTNC.SetARQBandwidth(config.Ardop.ARQBandwidth); err != nil {
return fmt.Errorf("unable to set ARQ bandwidth for ardop TNC: %w", err)
}
}
if err := adTNC.SetCWID(config.Ardop.CWID); err != nil {
return fmt.Errorf("unable to configure CWID for ardop TNC: %w", err)
}
if v, err := adTNC.Version(); err != nil {
return fmt.Errorf("ARDOP TNC initialization failed: %s", err)
} else {
log.Printf("ARDOP TNC (%s) initialized", v)
}
transport.RegisterDialer(MethodArdop, adTNC)
if !config.Ardop.PTTControl {
return nil
}
rig, ok := rigs[config.Ardop.Rig]
if !ok {
return fmt.Errorf("unable to set PTT rig '%s': Not defined or not loaded", config.Ardop.Rig)
}
adTNC.SetPTT(rig)
return nil
}
func initPactorModem(cmdlineinit string) error {
if pModem != nil {
pModem.Close()
}
var err error
pModem, err = pactor.OpenModem(config.Pactor.Path, config.Pactor.Baudrate, fOptions.MyCall, config.Pactor.InitScript, cmdlineinit)
if err != nil || pModem == nil {
return fmt.Errorf("pactor initialization failed: %w", err)
}
transport.RegisterDialer(MethodPactor, pModem)
return nil
}
func initVaraHFModem() error {
if varaHFModem != nil && varaHFModem.Ping() {
return nil
}
if varaHFModem != nil {
varaHFModem.Close()
}
m, err := initVaraModem(MethodVaraHF, config.VaraHF)
if err != nil {
return err
}
if bw := config.VaraHF.Bandwidth; bw != 0 {
if err := m.SetBandwidth(fmt.Sprint(bw)); err != nil {
m.Close()
return err
}
}
varaHFModem = m
return nil
}
func initVaraFMModem() error {
if varaFMModem != nil && varaFMModem.Ping() {
return nil
}
if varaFMModem != nil {
varaFMModem.Close()
}
m, err := initVaraModem(MethodVaraFM, config.VaraFM)
if err != nil {
return err
}
varaFMModem = m
return nil
}
func initVaraModem(scheme string, conf cfg.VaraConfig) (*vara.Modem, error) {
vConf := vara.ModemConfig{
Host: conf.Host(),
CmdPort: conf.CmdPort(),
DataPort: conf.DataPort(),
}
m, err := vara.NewModem(scheme, fOptions.MyCall, vConf)
if err != nil {
return nil, fmt.Errorf("vara initialization failed: %w", err)
}
transport.RegisterDialer(scheme, m)
if conf.PTTControl {
rig, ok := rigs[conf.Rig]
if !ok {
m.Close()
return nil, fmt.Errorf("unable to set PTT rig '%s': not defined or not loaded", conf.Rig)
}
m.SetPTT(rig)
}
v, _ := m.Version()
log.Printf("VARA modem (%s) initialized", v)
return m, nil
}
func initAGWPE() error {
if agwpeTNC != nil && agwpeTNC.Ping() == nil {
return nil
}
if agwpeTNC != nil {
agwpeTNC.Close()
}
var err error
agwpeTNC, err = agwpe.OpenPortTCP(config.AGWPE.Addr, config.AGWPE.RadioPort, fOptions.MyCall)
if err != nil {
return fmt.Errorf("AGWPE TNC initialization failed: %w", err)
}
if v, err := agwpeTNC.Version(); err != nil {
return fmt.Errorf("AGWPE TNC initialization failed: %w", err)
} else {
log.Printf("AGWPE TNC (%s) initialized", v)
}
transport.RegisterContextDialer(MethodAX25AGWPE, agwpeTNC)
return nil
}
// defaultAX25Method resolves the generic ax25:// scheme to a implementation specific scheme.
func defaultAX25Method() string {
switch config.AX25.Engine {
case cfg.AX25EngineAGWPE:
return MethodAX25AGWPE
case cfg.AX25EngineSerialTNC:
return MethodAX25SerialTNC
case cfg.AX25EngineLinux:
return MethodAX25Linux
default:
panic(fmt.Sprintf("invalid ax25 engine: %s", config.AX25.Engine))
}
}
pat-0.15.1/convert_image.go 0000664 0000000 0000000 00000002253 14534256521 0015544 0 ustar 00root root 0000000 0000000 // 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 main
import (
"bytes"
"image"
_ "image/gif"
"image/jpeg"
_ "image/png"
"mime"
"path"
"strings"
"github.com/nfnt/resize"
)
func isConvertableImageMediaType(filename, contentType string) bool {
var mediaType string
if contentType != "" {
mediaType, _, _ = mime.ParseMediaType(contentType)
}
if mediaType == "" {
mediaType = mime.TypeByExtension(path.Ext(filename))
}
switch mediaType {
case "image/svg+xml":
// This is a text file
return false
default:
return strings.HasPrefix(mediaType, "image/")
}
}
func convertImage(orig []byte) ([]byte, error) {
img, _, err := image.Decode(bytes.NewReader(orig))
if err != nil {
return nil, err
}
// Scale down
if img.Bounds().Dx() > 600 {
img = resize.Resize(600, 0, img, resize.NearestNeighbor)
}
// Re-encode as low quality jpeg
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 40}); err != nil {
return orig, err
}
if buf.Len() >= len(orig) {
return orig, nil
}
return buf.Bytes(), nil
}
pat-0.15.1/debian/ 0000775 0000000 0000000 00000000000 14534256521 0013613 5 ustar 00root root 0000000 0000000 pat-0.15.1/debian/.gitignore 0000664 0000000 0000000 00000000053 14534256521 0015601 0 ustar 00root root 0000000 0000000 pat/
files
pat.debhelper.log
pat.substvars
pat-0.15.1/debian/changelog 0000664 0000000 0000000 00000037717 14534256521 0015504 0 ustar 00root root 0000000 0000000 pat (0.15.1) stable; urgency=medium
* Support config overrides using env variables
* Only attach Forms XML if a viewer file is defined
* Fix handling of SVG attachments (don't attempt auto resize)
* VARA: Silence "got a vara command I wasn't expecting..." messages
* VARA: Fix leak when re-initializing the modem connection
* hamlib: Fix compatibility with rigctld in VFO Mode
* hamlib: Fix tests and compilation when statically linking libhamlib
* New experiment FW_AUX_ONLY_EXPERIMENT (disabled by default)
* Require Go 1.19 or later (due to updated dependencies)
-- Martin Hebnes Pedersen Sun, 5 Nov 2023 12:44:08 +0100
pat (0.15.0) stable; urgency=medium
* Restore previous connect parameters from browser's local storage
* Add missing AX.25 schemes to connect modal's transport dropdown
* Fix clearing of To/Cc fields after message is posted to outbox
* Fix alignment of connect modal input fields
* Improve the dirty disconnect feature
* Add deprecation warning for newly deprecated config options
* Remove support for previously deprecated config options
* AGWPE: Add support for QtSoundModem
* AGWPE: Wait for modem ack on dial cancellation
* VARA: Add support for inbound (P2P) connections
* VARA: Improved throughput, various bug fixes and other improvements
* ARDOP: Experimental FSKONLY support (with ARDOP_FSKONLY_EXPERIMENT=1)
-- Martin Hebnes Pedersen Sat, 10 Jun 2023 13:07:32 +0200
pat (0.14.1) stable; urgency=medium
* VARA: Fix panic on 32-bit builds
-- Martin Hebnes Pedersen Web, 02 May 2023 08:34:42 +0200
pat (0.14.0) stable; urgency=medium
* AX.25: Implement ability to switch between different AX.25 engines
* AX.25: Add AGWPE support (use Direwolf directly over TCP on all platforms)
* Winlink HTML Forms: Various compatibility fixes
* VARA: Switch to more idiomatic config fields
* VARA: Improved progress report on outbound traffic
* VARA: Add support for dial cancellation
* VARA: Reject inbound P2P sessions (listener not supported yet)
-- Martin Hebnes Pedersen Web, 19 Apr 2023 21:09:12 +0200
pat (0.13.1) stable; urgency=medium
* Fix panic when using unregistered VARA instances
* Use VARA HF/FM defaults if undefined in config
* Fix case sensitive matching when resolving aux addresses' passwords
-- Martin Hebnes Pedersen Sat, 17 Sep 2022 09:05:48 +0200
pat (0.13.0) stable; urgency=medium
* Add support for VARAHF and VARAFM
* Add support for setting the ARDOP ARQ bandwidth when dialing a connection
* Include linux/arm64 deb package in releases
* Remove support for WINMOR TNCs
* Add generic support for dial cancellation
* Implement dial cancellation for ax25:// and telnet://
* Improved non-interactive CLI compose command
* Improved shutdown behavior
* Improved FBB protocol compatibility with BPQ Mail
* Minor improvements and bug fixes to the PACTOR and serial-tnc transports
* Add a build system and package management for the Web GUI
-- Martin Hebnes Pedersen Sat, 20 Aug 2022 21:42:05 +0200
pat (0.12.1) stable; urgency=medium
* Add support for configurable telnet dial timeout (for Iridium GO users)
* Add support for scriptable message composition
* Add CLI command `env` for retrieving related environment variables (for scripting)
* More reliable Forms updates by using a new API for retrieving latest version and archive URL
* Improve websocket handling
* Fix bug in Forms update procedure that would delete the OS temp directory in rare cases
* Fix bug with pactor serial communication on macOS (Darwin)
* Fix bug with Web GUI and Message IDs containing the hash (`#`) symbol
-- Martin Hebnes Pedersen Sat, 11 Dec 2021 15:14:22 +0100
pat (0.12.0) stable; urgency=medium
* Follow the XDG Base Directory Specification
* Add support for sending in precedence order
* Add new serial-tnc baudrate configuration options
* Fix bug in forms parsing leading to missing forms
* Fix permissions issue when updating forms
* Fix FBB protocol handshake issue
* Improve fsnotify handling for mailbox events
* More descriptive error on premature disconnect
* Add basic debug logging capabilities
* Various dependency updates and refactoring
-- Martin Hebnes Pedersen Sun, 31 Oct 2021 17:28:02 +0100
pat (0.11.0) stable; urgency=medium
* Add support for Winlink HTML Forms
* Add support for individual passords for auxiliary addresses
* Add ability to abort ongoing dialing/connection in Web GUI
* Add systemd unit file for rigctld
* Improve version reporting to Winlink API
* Improve websocket handling
* Improve visibility of QSY errors in Web GUI
* Improve 'reply' and 'forward' functionality in Web GUI
* Fix issue with azimuth calculation when distance is zero
* Fix incorrect transport URI scheme for packet nodes
* Fix build on FreeBSD and macOS.
* Avoid truncating rmslist cache on refresh failure
* Avoid recompressing images where the resulting file size increases
* Require Go 1.16 or later
-- Martin Hebnes Pedersen Wed, 30 Jun 2021 21:13:40 +0100
pat (0.10.0) stable; urgency=medium
* Add support for P4 Dragon modems
* Add RMS list viewer in Web GUI's connect modal
* Add support for additional connect parameters for pactor
* New max length of message attachment filenames (255 characters)
-- Martin Hebnes Pedersen Thu, 08 Sep 2020 19:39:40 +0100
pat (0.9.0) stable; urgency=medium
* Less aggressive websocket timeout
* Add column sorting in Web GUI
* Require Go 1.10 or later
* Fix GPSd config bug introduced in v0.8.0
* Fix (mainly macOS) bug related to many open file descriptors
-- Martin Hebnes Pedersen Wed, 19 Feb 2020 20:13:18 +0100
pat (0.8.0) stable; urgency=medium
* GPSd support in Web GUI
* User configurable Service Code
* High Accuracy HTML5 Geolocation
* Minor PACTOR enhancements and bug fixes
* Fixed ARDOP listener issue
-- Martin Hebnes Pedersen Thu, 03 Oct 2019 21:48:51 +0200
pat (0.7.0) stable; urgency=medium
* Support PACTOR PTC-II and PTC-III (https://github.com/la5nta/pat/issues/40)
* Fix QSY frequency rounding error (https://github.com/la5nta/pat/issues/147)
* Fix panic on ARDOP TNC connection teardown (https://github.com/la5nta/pat/issues/137)
* Fix ARDOP compatibility issue (https://github.com/la5nta/pat/issues/139)
-- Martin Hebnes Pedersen Wed, 18 Sep 2019 21:56:17 +0200
pat (0.6.1) stable; urgency=medium
* Add deb package `dist` as conflicting package (https://github.com/la5nta/pat/issues/131)
* Include systemd unit file for ARDOPc (https://github.com/la5nta/pat/issues/130)
* Set correct URL parameter for serial-tnc.Baudrate (https://github.com/la5nta/pat/issues/129)
* Fix Go 1.10 compatibility issue (https://github.com/la5nta/pat/issues/121)
-- Martin Hebnes Pedersen Sun, 21 Apr 2018 11:23:40 +0200
pat (0.6.0) stable; urgency=high
* Support Winlink's new mixed-case password scheme (https://github.com/la5nta/pat/issues/113)
* Support for distance and azumuth in rmslist (https://github.com/la5nta/pat/pull/112)
* Improved ARDOP ID-frame parser
-- Martin Hebnes Pedersen Mon, 22 Jan 2018 21:41:13 +0100
pat (0.5.1) stable; urgency=medium
* Support ARDOP >= v1.0 (https://github.com/la5nta/pat/issues/108)
* Add rmslist support for ARDOP nodes
* Switch to the new Winlink rest API (https://github.com/la5nta/pat/issues/110)
* Fix bug which caused WINMOR connection failure when dialing the (non-idle) TNC
-- Martin Hebnes Pedersen Tue, 12 Dec 2017 19:03:04 +0100
pat (0.5.0) stable; urgency=high
* Fix XSS vulnerability when serving attachments over HTTP (https://github.com/la5nta/pat/issues/105)
* Gracefully recover/initialize failed external devices (https://github.com/la5nta/pat/issues/88)
* Switch to the new Winlink CMS and API hostname (https://github.com/la5nta/pat/issues/104)
* Add config option for WINMOR's Drive Level parameter (https://github.com/la5nta/pat/issues/99)
* Add password prompt in web GUI (https://github.com/la5nta/pat/issues/90)
* Include man pages in deb and pkg packages (https://github.com/la5nta/pat/pull/91)
* Various minor web GUI improvements (https://github.com/la5nta/pat/issues/97)
-- Martin Hebnes Pedersen Sat, 18 Nov 2017 11:40:28 +0100
pat (0.4.0) stable; urgency=medium
* Desktop notifications for web GUI users (https://github.com/la5nta/pat/issues/85)
* New status indicator in web GUI for display of various alerts and info (https://github.com/la5nta/pat/issues/86)
* Add Cc field to the web GUI composer (https://github.com/la5nta/pat/issues/83)
* Tokenize address input in the web GUI composer (https://github.com/la5nta/pat/issues/84)
* Check for empty To/Cc on compose (https://github.com/la5nta/pat/issues/89)
-- Martin Hebnes Pedersen Tue, 17 Sep 2017 11:14:59 +0200
pat (0.3.0) stable; urgency=high (Fixes compatibility with an upcoming Winlink CMS release)
* Fix critical compatibility issues with WL2K-4.0 aka "AWS-CMS" (https://github.com/la5nta/pat/issues/81)
* Fix close of AX.25 listener on Linux (https://github.com/la5nta/pat/issues/68)
* Add "Delete" and "Move to archive" actions in web GUI (https://github.com/la5nta/pat/issues/63)
-- Martin Hebnes Pedersen Tue, 18 Jul 2017 21:13:08 +0200
pat (0.2.4) stable; urgency=medium
* Add progress bar for message transfer in web GUI (https://github.com/la5nta/pat/pull/78)
* Properly parse offset in B2 compressed message header for BPQ compatibility (https://github.com/la5nta/pat/issues/74)
* Fix libax25 segfault on invalid axport (https://github.com/la5nta/pat/issues/73)
* Silence FREQUENCY parse errors for ardop (https://github.com/la5nta/pat/issues/75)
-- Martin Hebnes Pedersen Tue, 28 Feb 2017 19:07:00 +0100
pat (0.2.3) stable; urgency=medium
* Support ARDOP >= v0.9 (https://github.com/la5nta/pat/issues/69)
* Improve list parsing in various UI fields
* Handle non-ascii attachment names
-- Martin Hebnes Pedersen Fri, 27 Jan 2016 18:17:30 +0100
pat (0.2.2) stable; urgency=medium
* Ensure default config is written before opening the configuration editor (https://github.com/la5nta/pat/issues/70)
* Add some missing config defaults
-- Martin Hebnes Pedersen Thu, 01 Dec 2016 18:14:09 +0100
pat (0.2.1) stable; urgency=medium
* Support ARDOP >= v0.6 (https://github.com/la5nta/pat/issues/60)
* Fix bug that caused 'configure' to fail if config format was invalid (https://github.com/la5nta/pat/issues/62)
* Add position format examples for --latlon (https://github.com/la5nta/pat/issues/65)
* Statically link libax25 (linux) to avoid crash on incompatible shared library (https://github.com/la5nta/pat/issues/59)
-- Martin Hebnes Pedersen Wed, 12 Oct 2016 20:24:18 +0200
pat (0.2.0) stable; urgency=medium
* Support Radio only - Winlink Hybrid Network (https://github.com/la5nta/pat/issues/44)
* Switch to Go port of lzhuf (https://github.com/la5nta/pat/issues/50)
* Linux ax25 scripts: Add method for custom TNC initialization (https://github.com/la5nta/pat/issues/53)
* Fix ardop PTT rigcontrol (https://github.com/la5nta/pat/issues/58)
* Minor bug fixes and improvements in the web GUI
-- Martin Hebnes Pedersen Fri, 05 Aug 2016 15:16:51 +0200
pat (0.1.5) stable; urgency=medium
* Fix bug that caused command-line interface composer's prompt scan to see whitespace as end of line (https://github.com/la5nta/pat/issues/45)
* Fix Mac OS default install path (https://github.com/la5nta/pat/issues/47)
-- Martin Hebnes Pedersen Mon, 27 Jun 2016 22:43:36 +0200
pat (0.1.4) stable; urgency=medium
* Fix case where secure_login_password was ignored if mycall was not all upper case (https://github.com/la5nta/pat/issues/42)
* Support image resize in cli composer (https://github.com/la5nta/pat/issues/38)
* Remove imagemagick dependency for image resize (https://github.com/la5nta/pat/issues/13)
* Minor improvement of cli mailbox navigation (https://github.com/la5nta/pat/issues/39)
-- Martin Hebnes Pedersen Thu, 09 Jun 2016 21:02:42 +0200
pat (0.1.3) stable; urgency=medium
* Add filename extension for mailbox messages (https://github.com/la5nta/pat/issues/34)
* Fix broken ax25:// digipeater syntax (https://github.com/la5nta/pat/issues/33)
* Enable gzip experiment by default (https://github.com/la5nta/pat/issues/29)
-- Martin Hebnes Pedersen Sat, 07 May 2016 22:18:12 +0200
pat (0.1.2) stable; urgency=medium
* Fix callsign casing bug (https://github.com/la5nta/pat/issues/19)
* Fix web composer Re: prefix issues in replies (https://github.com/la5nta/pat/issues/30)
* Support running http server while in interactive mode (https://github.com/la5nta/pat/issues/26)
* Send smallest messages first (suggested in the Winlink FAQ) (https://github.com/la5nta/pat/issues/25)
* Fix handling of proposal code H (https://github.com/la5nta/pat/issues/25)
* Fix handling of blocks with all messages deferred/rejected (https://github.com/la5nta/pat/issues/25)
* Fix unstable serialization of messages that could result in corrupt partial message transfer (https://github.com/la5nta/pat/issues/25)
* Support both utf8 and iso-8859-1 encoded subject header (https://github.com/la5nta/pat/issues/23)
* Re-implement ctrl+c for aborting connect/session (https://github.com/la5nta/pat/issues/22)
* Fix GUI post button issues on some browsers (https://github.com/la5nta/pat/issues/21)
* Fix WINMOR unexpected EOF issue on session termination (https://github.com/la5nta/pat/issues/20)
* Fix improper handling of callsign casing (https://github.com/la5nta/pat/issues/19)
-- Martin Hebnes Pedersen Sat, 02 Apr 2016 10:41:16 +0200
pat (0.1.1) stable; urgency=medium
* Fix various file locking errors on Windows (https://github.com/la5nta/pat/issues/9).
* Automatic version reporting to Winlink CMS Web Services.
-- Martin Hebnes Pedersen Fri, 11 Mar 2016 21:06:16 +0100
pat (0.1.0) stable; urgency=medium
* Initial release under new name.
* Fix leak that caused increasing CPU load.
* Add band filtering for rmslist command.
* Fix winmor robust issues.
-- Martin Hebnes Pedersen Sun, 06 Mar 2016 14:09:11 +0100
wl2k-go (0.0.4) stable; urgency=medium
* Fixed parse error of Date field from RMS Relay'ed messages (https://github.com/la5nta/wl2k-go/issues/29).
* Fixed parse of ax25 URLs with digipeaters (https://github.com/la5nta/wl2k-go/issues/28).
* Fixed panic on misconfigured (empty) axport (https://github.com/la5nta/wl2k-go/issues/27).
* Prompt user for login password if mycall is overridden by --mycall even though a password is defined in config.
* Run winmor in robust mode during handshake and proposal chatter.
* GPSd support (for position reporting using a local serial/usb GPS).
-- Martin Hebnes Pedersen Sun, 14 Feb 2016 18:19:02 +0100
wl2k-go (0.0.3) stable; urgency=medium
* Fixed web ui assets bug (https://github.com/la5nta/wl2k-go/issues/26).
* Fixed systemd user install script.
-- Martin Hebnes Pedersen Thu, 14 Jan 2016 19:26:49 +0100
wl2k-go (0.0.2) stable; urgency=medium
* Fixed ARDOPc issues.
-- Martin Hebnes Pedersen Sun, 10 Jan 2016 15:56:00 +0100
wl2k-go (0.0.1) stable; urgency=medium
* Initial release.
-- Martin Hebnes Pedersen Sun, 04 Nov 2016 16:24:24 +0100
pat-0.15.1/debian/compat 0000664 0000000 0000000 00000000002 14534256521 0015011 0 ustar 00root root 0000000 0000000 7
pat-0.15.1/debian/control 0000664 0000000 0000000 00000000747 14534256521 0015226 0 ustar 00root root 0000000 0000000 Source: pat
Section: ham
Priority: extra
Maintainer: Martin Hebnes Pedersen
Homepage: http://getpat.io
Build-Depends: debhelper (>= 7.0.50~), golang (>= 2:1.16), libax25, libax25-dev
Standards-Version: 3.9.1
Package: pat
Architecture: amd64 i386 armhf arm64
Conflicts: wl2k-go, dist
Replaces: wl2k-go
Recommends: libhamlib-utils (>= 1.2), ax25-tools, gpsd (>= 2.90)
Suggests: tmd710-tncsetup
Description: A portable Winlink client for amateur radio email.
pat-0.15.1/debian/pat.manpages 0000664 0000000 0000000 00000000010 14534256521 0016103 0 ustar 00root root 0000000 0000000 man/*.1
pat-0.15.1/debian/pat@.service 0000664 0000000 0000000 00000000352 14534256521 0016061 0 ustar 00root root 0000000 0000000 [Unit]
Description=pat - Winlink client for %I
Documentation=https://github.com/la5nta/pat/wiki
After=ax25.service network.target
[Service]
User=%i
ExecStart=/usr/bin/pat http
Restart=on-failure
[Install]
WantedBy=multi-user.target
pat-0.15.1/debian/rules 0000775 0000000 0000000 00000001023 14534256521 0014667 0 ustar 00root root 0000000 0000000 #!/usr/bin/make -f
# -*- makefile -*-
PKGDIR=debian/pat
%:
dh $@
clean:
dh_clean
rm -rf $(PKGDIR)
build:
./make.bash
binary-arch: clean build
dh_prep
dh_installdirs
mkdir -p $(PKGDIR)/usr/bin
mkdir -p $(PKGDIR)/usr/share/pat
mkdir -p $(PKGDIR)/lib/systemd/system
mv ./pat $(PKGDIR)/usr/bin/
cp -r share/* $(PKGDIR)/usr/share/pat/
cp debian/pat@.service $(PKGDIR)/lib/systemd/system/
dh_installman
dh_strip
dh_compress
dh_fixperms
dh_installdeb
dh_gencontrol
dh_md5sums
dh_builddeb
binary: binary-arch
pat-0.15.1/docker-compose.yml 0000664 0000000 0000000 00000000175 14534256521 0016031 0 ustar 00root root 0000000 0000000 services:
pat:
image: la5nta/pat
build: .
volumes:
- ./docker-data:/app/pat
ports:
- 8080:8080
pat-0.15.1/env.go 0000664 0000000 0000000 00000002345 14534256521 0013514 0 ustar 00root root 0000000 0000000 package main
import (
"context"
"fmt"
"io"
"os"
"runtime"
"github.com/la5nta/pat/internal/buildinfo"
)
func envHandle(_ context.Context, _ []string) {
writeEnvAll(os.Stdout)
}
func writeEnvAll(w io.Writer) {
writeEnv(w, "PAT_MYCALL", fOptions.MyCall)
writeEnv(w, "PAT_LOCATOR", config.Locator)
writeEnv(w, "PAT_VERSION", buildinfo.Version)
writeEnv(w, "PAT_ARCH", runtime.GOARCH)
writeEnv(w, "PAT_OS", runtime.GOOS)
writeEnv(w, "PAT_MAILBOX_PATH", fOptions.MailboxPath)
writeEnv(w, "PAT_CONFIG_PATH", fOptions.ConfigPath)
writeEnv(w, "PAT_LOG_PATH", fOptions.LogPath)
writeEnv(w, "PAT_EVENTLOG_PATH", fOptions.EventLogPath)
writeEnv(w, "PAT_FORMS_PATH", fOptions.FormsPath)
writeEnv(w, "PAT_DEBUG", os.Getenv("PAT_DEBUG"))
writeEnv(w, "PAT_WEB_DEV_ADDR", os.Getenv("PAT_WEB_DEV_ADDR"))
writeEnv(w, "ARDOP_DEBUG", os.Getenv("ARDOP_DEBUG"))
writeEnv(w, "PACTOR_DEBUG", os.Getenv("PACTOR_DEBUG"))
writeEnv(w, "AGWPE_DEBUG", os.Getenv("AGWPE_DEBUG"))
writeEnv(w, "VARA_DEBUG", os.Getenv("VARA_DEBUG"))
writeEnv(w, "GZIP_EXPERIMENT", os.Getenv("GZIP_EXPERIMENT"))
writeEnv(w, "ARDOP_FSKONLY_EXPERIMENT", os.Getenv("ARDOP_FSKONLY_EXPERIMENT"))
}
func writeEnv(w io.Writer, k, v string) {
fmt.Fprintf(w, "%s=\"%s\"\n", k, v)
}
pat-0.15.1/event_log.go 0000664 0000000 0000000 00000002337 14534256521 0014707 0 ustar 00root root 0000000 0000000 // 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 main
import (
"encoding/json"
"net"
"os"
"time"
)
type EventLogger struct {
file *os.File
enc *json.Encoder
}
func NewEventLogger(path string) (*EventLogger, error) {
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0o666)
return &EventLogger{file, json.NewEncoder(file)}, err
}
func (l *EventLogger) Close() error { return l.file.Close() }
func (l *EventLogger) Log(what string, event map[string]interface{}) {
event["log_time"] = time.Now()
event["what"] = what
if err := l.enc.Encode(event); err != nil {
panic(err)
}
}
func (l *EventLogger) LogConn(op string, freq Frequency, conn net.Conn, err error) {
e := map[string]interface{}{"success": err == nil}
if err != nil {
e["error"] = err.Error()
} else {
if remote := conn.RemoteAddr(); remote != nil {
e["remote_addr"] = remote.String()
e["network"] = conn.RemoteAddr().Network()
}
if local := conn.LocalAddr(); local != nil {
e["local_addr"] = local.String()
}
}
if freq > 0 {
e["freq"] = freq
}
e["operation"] = op
l.Log("connect", e)
}
pat-0.15.1/exchange.go 0000664 0000000 0000000 00000015423 14534256521 0014507 0 ustar 00root root 0000000 0000000 // 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 main
import (
"fmt"
"log"
"net"
"os"
"strings"
"time"
"github.com/la5nta/pat/internal/buildinfo"
"github.com/la5nta/wl2k-go/fbb"
)
type ex struct {
conn net.Conn
target string
master bool
errors chan error
}
func exchangeLoop() (ce chan ex) {
ce = make(chan ex)
go func() {
for ex := range ce {
ex.errors <- sessionExchange(ex.conn, ex.target, ex.master)
close(ex.errors)
}
}()
return ce
}
func exchange(conn net.Conn, targetCall string, master bool) error {
e := ex{
conn: conn,
target: targetCall,
master: master,
errors: make(chan error),
}
exchangeChan <- e
return <-e.errors
}
type NotifyMBox struct{ fbb.MBoxHandler }
func (m NotifyMBox) ProcessInbound(msgs ...*fbb.Message) error {
if err := m.MBoxHandler.ProcessInbound(msgs...); err != nil {
return err
}
for _, msg := range msgs {
websocketHub.WriteJSON(struct{ Notification Notification }{
Notification{
Title: fmt.Sprintf("New message from %s", msg.From().Addr),
Body: msg.Subject(),
},
})
}
return nil
}
func sessionExchange(conn net.Conn, targetCall string, master bool) error {
exchangeConn = conn
websocketHub.UpdateStatus()
defer func() { exchangeConn = nil; websocketHub.UpdateStatus() }()
// New wl2k Session
targetCall = strings.Split(targetCall, ` `)[0]
session := fbb.NewSession(
fOptions.MyCall,
targetCall,
config.Locator,
NotifyMBox{mbox},
)
session.SetUserAgent(fbb.UserAgent{
Name: buildinfo.AppName,
Version: buildinfo.Version,
})
if len(config.MOTD) > 0 {
session.SetMOTD(config.MOTD...)
}
// Handle secure login
session.SetSecureLoginHandleFunc(func(addr fbb.Address) (string, error) {
if addr.Addr == fOptions.MyCall && config.SecureLoginPassword != "" {
return config.SecureLoginPassword, nil
}
for _, aux := range config.AuxAddrs {
if !addr.EqualString(aux.Address) {
continue
}
switch {
case aux.Password != nil:
return *aux.Password, nil
case config.SecureLoginPassword != "":
return config.SecureLoginPassword, nil
}
}
resp := <-promptHub.Prompt("password", "Enter secure login password for "+addr.String())
return resp.Value, resp.Err
})
for _, addr := range config.AuxAddrs {
session.AddAuxiliaryAddress(fbb.AddressFromString(addr.Address))
}
session.IsMaster(master)
session.SetLogger(log.New(logWriter, "", 0))
session.SetStatusUpdater(new(StatusUpdate))
if fOptions.Robust {
session.SetRobustMode(fbb.RobustForced)
}
log.Printf("Connected to %s (%s)", conn.RemoteAddr(), conn.RemoteAddr().Network())
start := time.Now()
stats, err := session.Exchange(conn)
if fbb.IsLoginFailure(err) {
fmt.Println("NOTE: A new password scheme for Winlink is being implemented as of 2018-01-31.")
fmt.Println(" Users with passwords created/changed prior to January 31, 2018 should be")
fmt.Println(" aware that their password MUST be entered in ALL-UPPERCASE letters. Only")
fmt.Println(" passwords created/changed/issued after January 31, 2018 should/may contain")
fmt.Println(" lowercase letters. - https://github.com/la5nta/pat/issues/113")
}
event := map[string]interface{}{
"mycall": session.Mycall(),
"targetcall": session.Targetcall(),
"remote_fw": session.RemoteForwarders(),
"remote_sid": session.RemoteSID(),
"master": master,
"local_locator": config.Locator,
"auxiliary_addresses": config.AuxAddrs,
"network": conn.RemoteAddr().Network(),
"remote_addr": conn.RemoteAddr().String(),
"local_addr": conn.LocalAddr().String(),
"sent": stats.Sent,
"received": stats.Received,
"start": start.Unix(),
"end": time.Now().Unix(),
"success": err == nil,
}
if err != nil {
event["error"] = err.Error()
}
eventLog.Log("exchange", event)
return err
}
func abortActiveConnection(dirty bool) (ok bool) {
switch {
case dirty:
// This mean we've already tried to abort, but the connection is still active.
// Fallback to the below cases to try to identify the busy modem and abort hard.
case dialing != nil:
// If we're currently dialing a transport, attempt to abort by cancelling the associated context.
log.Printf("Got abort signal while dialing %s, cancelling...", dialing.Scheme)
go dialCancelFunc()
return true
case exchangeConn != nil:
// If we have an active connection, close it gracefully.
log.Println("Got abort signal, disconnecting...")
go exchangeConn.Close()
return true
}
// Any connection and/or dial operation has been cancelled at this point.
// User is attempting to abort something, so try to identify any non-idling transports and abort.
// It might be a "dirty disconnect" of an already cancelled connection or dial operation which is in the
// process of gracefully terminating. It might also be an attempt to close an inbound P2P connection.
switch {
case adTNC != nil && !adTNC.Idle():
if dirty {
log.Println("Dirty disconnecting ardop...")
adTNC.Abort()
return true
}
log.Println("Disconnecting ardop...")
go func() {
if err := adTNC.Disconnect(); err != nil {
log.Println(err)
}
}()
return true
case varaFMModem != nil && !varaFMModem.Idle():
if dirty {
log.Println("Dirty disconnecting varafm...")
varaFMModem.Abort()
return true
}
log.Println("Disconnecting varafm...")
go func() {
if err := varaFMModem.Close(); err != nil {
log.Println(err)
}
}()
return true
case varaHFModem != nil && !varaHFModem.Idle():
if dirty {
log.Println("Dirty disconnecting varahf...")
varaHFModem.Abort()
return true
}
log.Println("Disconnecting varahf...")
go func() {
if err := varaHFModem.Close(); err != nil {
log.Println(err)
}
}()
return true
case pModem != nil:
log.Println("Disconnecting pactor...")
err := pModem.Close()
if err != nil {
log.Println(err)
}
return err == nil
default:
return false
}
}
type StatusUpdate int
func (s *StatusUpdate) UpdateStatus(stat fbb.Status) {
var prop fbb.Proposal
switch {
case stat.Receiving != nil:
prop = *stat.Receiving
case stat.Sending != nil:
prop = *stat.Sending
}
websocketHub.WriteProgress(Progress{
MID: prop.MID(),
BytesTotal: stat.BytesTotal,
BytesTransferred: stat.BytesTransferred,
Subject: prop.Title(),
Receiving: stat.Receiving != nil,
Sending: stat.Sending != nil,
Done: stat.Done,
})
percent := float64(stat.BytesTransferred) / float64(stat.BytesTotal) * 100
fmt.Printf("\r%s: %3.0f%%", prop.Title(), percent)
if stat.Done {
fmt.Println("")
}
os.Stdout.Sync()
}
pat-0.15.1/flags.go 0000664 0000000 0000000 00000003422 14534256521 0014015 0 ustar 00root root 0000000 0000000 // 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 main
import (
"context"
"fmt"
"os"
"strings"
"github.com/spf13/pflag"
)
var ErrNoCmd = fmt.Errorf("no cmd")
type Command struct {
Str string
Aliases []string
Desc string
HandleFunc func(ctx context.Context, args []string)
Usage string
Options map[string]string
Example string
LongLived bool
MayConnect bool
}
func (cmd Command) PrintUsage() {
fmt.Fprintf(os.Stderr, "%s - %s\n", cmd.Str, cmd.Desc)
fmt.Fprintf(os.Stderr, "\nUsage:\n %s %s\n", cmd.Str, strings.TrimSpace(cmd.Usage))
if len(cmd.Options) > 0 {
fmt.Fprint(os.Stderr, "\nOptions:\n")
for f, desc := range cmd.Options {
fmt.Fprintf(os.Stderr, " %-17s %s\n", f, desc)
}
}
if cmd.Example != "" {
fmt.Fprintf(os.Stderr, "\nExample:\n %s\n", strings.TrimSpace(cmd.Example))
}
fmt.Fprint(os.Stderr, "\n")
}
func parseFlags(args []string) (cmd Command, arguments []string) {
var options []string
var err error
cmd, options, arguments, err = findCommand(args)
if err != nil {
pflag.Usage()
os.Exit(1)
}
optionsSet().Parse(options)
if len(arguments) == 0 {
arguments = append(arguments, "")
}
switch arguments[0] {
case "--help", "-help", "help", "-h":
cmd.PrintUsage()
os.Exit(1)
}
return
}
func findCommand(args []string) (cmd Command, pre, post []string, err error) {
cmdMap := make(map[string]Command, len(commands))
for _, c := range commands {
cmdMap[c.Str] = c
for _, alias := range c.Aliases {
cmdMap[alias] = c
}
}
for i, arg := range args {
if cmd, ok := cmdMap[arg]; ok {
return cmd, args[1:i], args[i+1:], nil
}
}
err = ErrNoCmd
return
}
pat-0.15.1/freq.go 0000664 0000000 0000000 00000006726 14534256521 0013670 0 ustar 00root root 0000000 0000000 // 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 main
import (
"encoding/json"
"fmt"
"log"
"strconv"
"strings"
"github.com/la5nta/wl2k-go/rigcontrol/hamlib"
)
var bands = map[string]Band{
"160m": {1.8e6, 2.0e6},
"80m": {3.5e6, 4.0e6},
"60m": {5.2e6, 5.5e6},
"40m": {7.0e6, 7.3e6},
"30m": {10.1e6, 10.2e6},
"20m": {14.0e6, 14.4e6},
"17m": {18.0e6, 18.2e6},
"15m": {21.0e6, 21.5e6},
"12m": {24.8e6, 25.0e6},
"10m": {28.0e6, 30.0e6},
"6m": {50.0e6, 54.0e6},
"4m": {70.0e6, 70.5e6},
"2m": {144.0e6, 148.0e6},
"1.25m": {219.0e6, 225.0e6}, // 220, 222 (MHz)
"70cm": {420.0e6, 450.0e6},
}
type Band struct{ lower, upper Frequency }
func (b Band) Contains(f Frequency) bool {
if b.lower == 0 && b.upper == 0 {
return true
}
return f >= b.lower && f <= b.upper
}
type Frequency int // Hz
func (f Frequency) String() string {
m := f / 1e6
k := (float64(f) - float64(m)*1e6) / 1e3
return fmt.Sprintf("%d.%06.2f MHz", m, k)
}
func (f Frequency) MarshalJSON() ([]byte, error) {
type obj struct {
Hz json.Number `json:"hz"`
KHz json.Number `json:"khz"`
Desc string `json:"desc"`
}
return json.Marshal(obj{
Hz: json.Number(fmt.Sprint(int(f))),
KHz: json.Number(fmt.Sprint(f.KHz())),
Desc: f.String(),
})
}
func (f Frequency) KHz() float64 { return float64(f) / 1e3 }
func (f Frequency) Dial(mode string) Frequency {
mode = strings.ToLower(mode)
// Try to detect FM modes, e.g. `ARDOP 2000 FM` and `VARA FM WIDE`
if strings.Contains(mode, "fm") {
return f
}
offsets := map[string]Frequency{
MethodPactor: 1500,
MethodArdop: 1500,
// varahf doesn't appear in RMS list from WDT
"vara": 1500,
}
var shift Frequency
for m, offset := range offsets {
if strings.Contains(mode, m) {
shift = -offset
break
}
}
return f + shift
}
func VFOForTransport(transport string) (vfo hamlib.VFO, rigName string, ok bool, err error) {
var rig string
switch {
case transport == MethodArdop:
rig = config.Ardop.Rig
case transport == MethodAX25, strings.HasPrefix(transport, MethodAX25+"+"):
rig = config.AX25.Rig
case transport == MethodPactor:
rig = config.Pactor.Rig
case transport == MethodVaraHF:
rig = config.VaraHF.Rig
case transport == MethodVaraFM:
rig = 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 = rigs[rig]
return vfo, rig, ok, nil
}
func freq(param string) {
parts := strings.SplitN(param, ":", 2)
if parts[0] == "" {
fmt.Println("Need freq method.")
}
rig, _, ok, _ := VFOForTransport(parts[0])
if !ok {
log.Printf("Hamlib rig not loaded.")
return
}
if len(parts) < 2 {
freq, err := rig.GetFreq()
if err != nil {
log.Printf("Unable to get frequency: %s", err)
}
fmt.Printf("%.3f\n", float64(freq)/1e3)
return
}
if _, _, err := setFreq(rig, parts[1]); err != nil {
log.Printf("Unable to set frequency: %s", err)
}
}
func setFreq(rig hamlib.VFO, freq string) (newFreq, oldFreq int, err error) {
oldFreq, err = rig.GetFreq()
if err != nil {
return 0, 0, fmt.Errorf("unable to get rig frequency: %w", err)
}
f, err := strconv.ParseFloat(freq, 64)
if err != nil {
return 0, 0, err
}
newFreq = int(f * 1e3)
err = rig.SetFreq(newFreq)
return
}
pat-0.15.1/go.mod 0000664 0000000 0000000 00000002776 14534256521 0013513 0 ustar 00root root 0000000 0000000 module github.com/la5nta/pat
go 1.19
require (
github.com/adrg/xdg v0.3.3
github.com/bndr/gotabulate v1.1.3-0.20170315142410-bc555436bfd5
github.com/dimchansky/utfbom v1.1.1
github.com/fsnotify/fsnotify v1.4.9
github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
github.com/harenber/ptc-go/v2 v2.2.3
github.com/howeyc/gopass v0.0.0-20190910152052-7cb4b85ec19c
github.com/kelseyhightower/envconfig v1.4.0
github.com/la5nta/wl2k-go v0.11.8
github.com/microcosm-cc/bluemonday v1.0.16
github.com/n8jja/Pat-Vara v1.1.4
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/pd0mz/go-maidenhead v1.0.0
github.com/peterh/liner v1.2.1
github.com/spf13/pflag v1.0.5
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/albenik/go-serial/v2 v2.6.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/howeyc/crc16 v0.0.0-20171223171357-2b2a61e366a6 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c // indirect
github.com/rivo/uniseg v0.2.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.0.0-20210813211128-0a44fdfbc16e // indirect
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect
)
pat-0.15.1/go.sum 0000664 0000000 0000000 00000022403 14534256521 0013525 0 ustar 00root root 0000000 0000000 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/adrg/xdg v0.3.3 h1:s/tV7MdqQnzB1nKY8aqHvAMD+uCiuEDzVB5HLRY849U=
github.com/adrg/xdg v0.3.3/go.mod h1:61xAR2VZcggl2St4O9ohF5qCKe08+JDmE4VNzPFQvOQ=
github.com/albenik/go-serial/v2 v2.4.0/go.mod h1:JUrQKdczCMB0FlXt2rlJJ8zbfFzmjTIAkLPyyVfr5ho=
github.com/albenik/go-serial/v2 v2.5.0/go.mod h1:ySdCqoERscw1xluK1n62R8Faoyu+jXKwVHPa1lSSAew=
github.com/albenik/go-serial/v2 v2.6.0 h1:UX30WZPL0qouDrKu4xwVFgvQA3YDTNhk3+aVC6X0jYg=
github.com/albenik/go-serial/v2 v2.6.0/go.mod h1:sqQA6eeZHKUB6rAgrBsP/8d3Go5Md5cjCof1WcyaK0o=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bndr/gotabulate v1.1.3-0.20170315142410-bc555436bfd5 h1:D48YSLPNJ8WpdwDqYF8bMMKUB2bgdWEiFx1MGwPIdbs=
github.com/bndr/gotabulate v1.1.3-0.20170315142410-bc555436bfd5/go.mod h1:0+8yUgaPTtLRTjf49E8oju7ojpU11YmXyvq1LbPAb3U=
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=
github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75 h1:f0n1xnMSmBLzVfsMMvriDyA75NB/oBgILX2GcHXIQzY=
github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75/go.mod h1:g2644b03hfBX9Ov0ZBDgXXens4rxSxmqFBbhvKv2yVA=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/harenber/ptc-go/v2 v2.2.3 h1:saGN1zhWozAF2kNseDI9YCHwuCl1Seb3++gkCwVfcj8=
github.com/harenber/ptc-go/v2 v2.2.3/go.mod h1:SDIy4XqnUq6YYPcLLjDTfbLPf1/Z82ho1VPkPGVXSTo=
github.com/howeyc/crc16 v0.0.0-20171223171357-2b2a61e366a6 h1:IIVxLyDUYErC950b8kecjoqDet8P5S4lcVRUOM6rdkU=
github.com/howeyc/crc16 v0.0.0-20171223171357-2b2a61e366a6/go.mod h1:JslaLRrzGsOKJgFEPBP65Whn+rdwDQSk0I0MCRFe2Zw=
github.com/howeyc/gopass v0.0.0-20190910152052-7cb4b85ec19c h1:aY2hhxLhjEAbfXOx2nRJxCXezC6CO2V/yN+OCr1srtk=
github.com/howeyc/gopass v0.0.0-20190910152052-7cb4b85ec19c/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/la5nta/wl2k-go v0.7.3/go.mod h1:rTQaxPiAFD3pWGWN8Lh+BskN3Fpii84GoVwpTHNiCjE=
github.com/la5nta/wl2k-go v0.11.5/go.mod h1:0c+/9KyDj7Ra7C/O4rVUYx1CzvdtS65di/93wlI22fo=
github.com/la5nta/wl2k-go v0.11.8 h1:fTrOYm7oJu/b+3RmQMGX9TfpADnrFFkLzkDpfRTaEIs=
github.com/la5nta/wl2k-go v0.11.8/go.mod h1:rUK5mVAldeSuru47APLp9wJMJ5BiaZZ3YxZafSNs6CI=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc=
github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
github.com/n8jja/Pat-Vara v1.1.4 h1:yXqQjQQmpcXc9dA5XjRVvC1eYaFoErxvFeIHzLlPA90=
github.com/n8jja/Pat-Vara v1.1.4/go.mod h1:9ovT5w1MeVtQ336AqhoPmgiQ4eGDgNiygBxFvAiSJbc=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/paulrosania/go-charset v0.0.0-20151028000031-621bb39fcc83/go.mod h1:YnNlZP7l4MhyGQ4CBRwv6ohZTPrUJJZtEv4ZgADkbs4=
github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c h1:P6XGcuPTigoHf4TSu+3D/7QOQ1MbL6alNwrGhcW7sKw=
github.com/paulrosania/go-charset v0.0.0-20190326053356-55c9d7a5834c/go.mod h1:YnNlZP7l4MhyGQ4CBRwv6ohZTPrUJJZtEv4ZgADkbs4=
github.com/pd0mz/go-maidenhead v1.0.0 h1:zl2AXA36LnmP5TDEfshM0fWi1mc08fNc6qhj7YD5xjw=
github.com/pd0mz/go-maidenhead v1.0.0/go.mod h1:4Q+QSDCqWqlabstLGUVm47rAcL06nEEty2d3KzsTNMk=
github.com/peterh/liner v1.2.1 h1:O4BlKaq/LWu6VRWmol4ByWfzx6MfXc5Op5HETyIy5yg=
github.com/peterh/liner v1.2.1/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0=
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/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/tarm/goserial v0.0.0-20151007205400-b3440c3c6355/go.mod h1:jcMo2Odv5FpDA6rp8bnczbUolcICW6t54K3s9gOlgII=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20210813211128-0a44fdfbc16e h1:VvfwVmMH40bpMeizC9/K7ipM5Qjucuu16RWfneFPyhQ=
golang.org/x/crypto v0.0.0-20210813211128-0a44fdfbc16e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210223212115-eede4237b368/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
pat-0.15.1/http.go 0000664 0000000 0000000 00000055341 14534256521 0013707 0 ustar 00root root 0000000 0000000 // 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 main
import (
"bufio"
"bytes"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"mime/multipart"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/la5nta/wl2k-go/transport/ardop"
"github.com/la5nta/pat/internal/buildinfo"
"github.com/la5nta/pat/internal/gpsd"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/la5nta/wl2k-go/catalog"
"github.com/la5nta/wl2k-go/fbb"
"github.com/la5nta/wl2k-go/mailbox"
"github.com/microcosm-cc/bluemonday"
"github.com/n8jja/Pat-Vara/vara"
)
//go:embed web/dist/**
var embeddedFS embed.FS
var staticContent fs.FS
// Status represents a status report as sent to the Web GUI
type Status struct {
ActiveListeners []string `json:"active_listeners"`
Connected bool `json:"connected"`
Dialing bool `json:"dialing"`
RemoteAddr string `json:"remote_addr"`
HTTPClients []string `json:"http_clients"`
}
// Progress represents a progress report as sent to the Web GUI
type Progress struct {
BytesTransferred int `json:"bytes_transferred"`
BytesTotal int `json:"bytes_total"`
MID string `json:"mid"`
Subject string `json:"subject"`
Receiving bool `json:"receiving"`
Sending bool `json:"sending"`
Done bool `json:"done"`
}
// Notification represents a desktop notification as sent to the Web GUI
type Notification struct {
Title string `json:"title"`
Body string `json:"body"`
}
type HTTPError struct {
error
StatusCode int
}
var websocketHub *WSHub
func init() {
var err error
staticContent, err = fs.Sub(embeddedFS, "web")
if err != nil {
panic(err)
}
}
func devServerAddr() string { return strings.TrimSuffix(os.Getenv("PAT_WEB_DEV_ADDR"), "/") }
func ListenAndServe(ctx context.Context, addr string) error {
log.Printf("Starting HTTP service (http://%s)...", addr)
if host, _, _ := net.SplitHostPort(addr); host == "" && config.GPSd.EnableHTTP {
// TODO: maybe make a popup showing the warning ont the web UI?
_, _ = fmt.Fprintf(logWriter, "\nWARNING: You have enable GPSd HTTP endpoint (enable_http). You might expose"+
"\n your current position to anyone who has access to the Pat web interface!\n\n")
}
r := mux.NewRouter()
r.HandleFunc("/api/bandwidths", bandwidthsHandler).Methods("GET")
r.HandleFunc("/api/connect_aliases", connectAliasesHandler).Methods("GET")
r.HandleFunc("/api/connect", ConnectHandler)
r.HandleFunc("/api/formcatalog", formsMgr.GetFormsCatalogHandler).Methods("GET")
r.HandleFunc("/api/form", formsMgr.PostFormDataHandler).Methods("POST")
r.HandleFunc("/api/form", formsMgr.GetFormDataHandler).Methods("GET")
r.HandleFunc("/api/forms", formsMgr.GetFormTemplateHandler).Methods("GET")
r.HandleFunc("/api/formsUpdate", formsMgr.UpdateFormTemplatesHandler).Methods("POST")
r.HandleFunc("/api/disconnect", DisconnectHandler)
r.HandleFunc("/api/mailbox/{box}", mailboxHandler).Methods("GET")
r.HandleFunc("/api/mailbox/{box}/{mid}", messageHandler).Methods("GET")
r.HandleFunc("/api/mailbox/{box}/{mid}", messageDeleteHandler).Methods("DELETE")
r.HandleFunc("/api/mailbox/{box}/{mid}/{attachment}", attachmentHandler).Methods("GET")
r.HandleFunc("/api/mailbox/{box}/{mid}/read", readHandler).Methods("POST")
r.HandleFunc("/api/mailbox/{box}", postMessageHandler).Methods("POST")
r.HandleFunc("/api/posreport", postPositionHandler).Methods("POST")
r.HandleFunc("/api/status", statusHandler).Methods("GET")
r.HandleFunc("/api/current_gps_position", positionHandler).Methods("GET")
r.HandleFunc("/api/qsy", qsyHandler).Methods("POST")
r.HandleFunc("/api/rmslist", rmslistHandler).Methods("GET")
r.PathPrefix("/dist/").Handler(distHandler())
r.HandleFunc("/ws", wsHandler)
r.HandleFunc("/ui", uiHandler()).Methods("GET")
r.HandleFunc("/", rootHandler).Methods("GET")
websocketHub = NewWSHub()
srv := http.Server{
Addr: addr,
Handler: r,
}
errs := make(chan error, 1)
go func() {
errs <- srv.ListenAndServe()
}()
select {
case <-ctx.Done():
log.Println("Shutting down HTTP server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
return nil
case err := <-errs:
return err
}
}
func distHandler() http.Handler {
switch target := devServerAddr(); {
case target != "":
targetURL, err := url.Parse(target)
if err != nil {
log.Fatalf("invalid proxy target URL: %v", err)
}
return httputil.NewSingleHostReverseProxy(targetURL)
default:
return http.FileServer(http.FS(staticContent))
}
}
func rootHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui", http.StatusFound)
}
func connectAliasesHandler(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(config.ConnectAliases)
}
func readHandler(w http.ResponseWriter, r *http.Request) {
var data struct{ Read bool }
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Printf("%s %s: %s", r.Method, r.URL.Path, err)
return
}
box, mid := mux.Vars(r)["box"], mux.Vars(r)["mid"]
msg, err := mailbox.OpenMessage(path.Join(mbox.MBoxPath, box, mid+mailbox.Ext))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := mailbox.SetUnread(msg, !data.Read); err != nil {
log.Printf("%s %s: %s", r.Method, r.URL.Path, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func postPositionHandler(w http.ResponseWriter, r *http.Request) {
var pos catalog.PosReport
if err := json.NewDecoder(r.Body).Decode(&pos); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_ = r.Body.Close()
if pos.Date.IsZero() {
pos.Date = time.Now()
}
// Post to outbox
msg := pos.Message(fOptions.MyCall)
if err := mbox.AddOut(msg); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
} else {
_, _ = fmt.Fprintln(w, "Position update posted")
}
}
func isInPath(base string, path string) error {
_, err := filepath.Rel(base, path)
return err
}
func postMessageHandler(w http.ResponseWriter, r *http.Request) {
box := mux.Vars(r)["box"]
if box == "out" {
postOutboundMessageHandler(w, r)
return
}
srcPath := r.Header.Get("X-Pat-SourcePath")
if srcPath == "" {
http.Error(w, "Not implemented", http.StatusNotImplemented)
return
}
srcPath, _ = url.PathUnescape(strings.TrimPrefix(srcPath, "/api/mailbox/"))
srcPath = filepath.Join(mbox.MBoxPath, srcPath+mailbox.Ext)
// Check that we don't escape our mailbox path
srcPath = filepath.Clean(srcPath)
if err := isInPath(mbox.MBoxPath, srcPath); err != nil {
log.Println("Malicious source path in move:", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
targetPath := filepath.Join(mbox.MBoxPath, box, filepath.Base(srcPath))
if err := os.Rename(srcPath, targetPath); err != nil {
log.Println("Could not move message:", err)
http.Error(w, err.Error(), http.StatusBadRequest)
} else {
_ = json.NewEncoder(w).Encode("OK")
}
}
func postOutboundMessageHandler(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(10 * (1024 ^ 2)) // 10Mb
if err != nil {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
msg := fbb.NewMessage(fbb.Private, fOptions.MyCall)
// files
if r.MultipartForm != nil {
files := r.MultipartForm.File["files"]
for _, f := range files {
err := attachFile(f, msg)
switch err := err.(type) {
case nil:
// No problem
case HTTPError:
http.Error(w, err.Error(), err.StatusCode)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
cookie, err := r.Cookie("forminstance")
if err == nil {
formData := formsMgr.GetPostedFormData(cookie.Value)
if xml := formData.MsgXML; xml != "" {
name := formsMgr.GetXMLAttachmentNameForForm(formData.TargetForm, formData.IsReply)
msg.AddFile(fbb.NewFile(name, []byte(formData.MsgXML)))
}
}
// Other fields
if v := r.Form["to"]; len(v) == 1 {
addrs := strings.FieldsFunc(v[0], SplitFunc)
msg.AddTo(addrs...)
}
if v := r.Form["cc"]; len(v) == 1 {
addrs := strings.FieldsFunc(v[0], SplitFunc)
msg.AddCc(addrs...)
}
if v := r.Form["subject"]; len(v) == 1 {
msg.SetSubject(v[0])
}
if v := r.Form["body"]; len(v) == 1 {
_ = msg.SetBody(v[0])
}
if v := r.Form["p2ponly"]; len(v) == 1 && v[0] != "" {
msg.Header.Set("X-P2POnly", "true")
}
if v := r.Form["date"]; len(v) == 1 {
t, err := time.Parse(time.RFC3339, v[0])
if err != nil {
log.Printf("Unable to parse message date: %s", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
msg.SetDate(t)
} else {
log.Printf("Missing date value")
http.Error(w, "Missing date value", http.StatusBadRequest)
return
}
if err := msg.Validate(); err != nil {
http.Error(w, "Validation error: "+err.Error(), http.StatusBadRequest)
return
}
// Post to outbox
if err := mbox.AddOut(msg); err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
var buf bytes.Buffer
_ = msg.Write(&buf)
_, _ = fmt.Fprintf(w, "Message posted (%.2f kB)", float64(buf.Len()/1024))
}
func attachFile(f *multipart.FileHeader, msg *fbb.Message) error {
// For some unknown reason, we receive this empty unnamed file when no
// attachment is provided. Prior to Go 1.10, this was filtered by
// multipart.Reader.
if f.Size == 0 && f.Filename == "" {
return nil
}
if f.Filename == "" {
err := errors.New("missing attachment name")
return HTTPError{err, http.StatusBadRequest}
}
file, err := f.Open()
if err != nil {
return HTTPError{err, http.StatusInternalServerError}
}
p, err := io.ReadAll(file)
_ = file.Close()
if err != nil {
return HTTPError{err, http.StatusInternalServerError}
}
if isConvertableImageMediaType(f.Filename, f.Header.Get("Content-Type")) {
log.Printf("Auto converting '%s' [%s]...", f.Filename, f.Header.Get("Content-Type"))
if converted, err := convertImage(p); err != nil {
log.Printf("Error converting image: %s", err)
} else {
log.Printf("Done converting '%s'.", f.Filename)
ext := path.Ext(f.Filename)
f.Filename = f.Filename[:len(f.Filename)-len(ext)] + ".jpg"
p = converted
}
}
msg.AddFile(fbb.NewFile(f.Filename, p))
return nil
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
_ = conn.WriteJSON(struct{ MyCall string }{fOptions.MyCall})
websocketHub.Handle(conn)
}
func uiHandler() http.HandlerFunc {
const indexPath = "dist/index.html"
templateFunc := func() ([]byte, error) { return fs.ReadFile(staticContent, indexPath) }
if target := devServerAddr(); target != "" {
templateFunc = func() ([]byte, error) {
resp, err := http.Get(target + "/" + indexPath)
if err != nil {
return nil, fmt.Errorf("dev server not reachable: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
}
return func(w http.ResponseWriter, _ *http.Request) {
data, err := templateFunc()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
t, err := template.New("index.html").Parse(string(data))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
tmplData := struct{ AppName, Version, Mycall string }{buildinfo.AppName, buildinfo.VersionString(), fOptions.MyCall}
if err := t.Execute(w, tmplData); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
func getStatus() Status {
status := Status{
ActiveListeners: []string{},
Dialing: dialing != nil,
Connected: exchangeConn != nil,
HTTPClients: websocketHub.ClientAddrs(),
}
for _, tl := range listenHub.Active() {
status.ActiveListeners = append(status.ActiveListeners, tl.Name())
}
sort.Strings(status.ActiveListeners)
if exchangeConn != nil {
addr := exchangeConn.RemoteAddr()
status.RemoteAddr = fmt.Sprintf("%s:%s", addr.Network(), addr)
}
return status
}
func statusHandler(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(getStatus())
}
func bandwidthsHandler(w http.ResponseWriter, req *http.Request) {
type BandwidthResponse struct {
Mode string `json:"mode"`
Bandwidths []string `json:"bandwidths"`
Default string `json:"default,omitempty"`
}
mode := strings.ToLower(req.FormValue("mode"))
resp := BandwidthResponse{Mode: mode, Bandwidths: []string{}}
switch {
case mode == MethodArdop:
for _, bw := range ardop.Bandwidths() {
resp.Bandwidths = append(resp.Bandwidths, bw.String())
}
if bw := config.Ardop.ARQBandwidth; !bw.IsZero() {
resp.Default = bw.String()
}
case mode == MethodVaraHF:
resp.Bandwidths = vara.Bandwidths()
if bw := config.VaraHF.Bandwidth; bw != 0 {
resp.Default = fmt.Sprintf("%d", bw)
}
}
_ = json.NewEncoder(w).Encode(resp)
}
func rmslistHandler(w http.ResponseWriter, req *http.Request) {
forceDownload, _ := strconv.ParseBool(req.FormValue("force-download"))
band := req.FormValue("band")
mode := strings.ToLower(req.FormValue("mode"))
prefix := strings.ToUpper(req.FormValue("prefix"))
list, err := ReadRMSList(req.Context(), forceDownload, func(r RMS) bool {
switch {
case r.URL == nil:
return false
case mode != "" && !r.IsMode(mode):
return false
case band != "" && !r.IsBand(band):
return false
case prefix != "" && !strings.HasPrefix(r.Callsign, prefix):
return false
default:
return true
}
})
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sort.Sort(byDist(list))
err = json.NewEncoder(w).Encode(list)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func qsyHandler(w http.ResponseWriter, req *http.Request) {
type QSYPayload struct {
Transport string `json:"transport"`
Freq json.Number `json:"freq"`
}
var payload QSYPayload
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
rig, rigName, ok, err := VFOForTransport(payload.Transport)
switch {
case rigName == "":
// Either unsupported mode or no rig configured for this transport
w.WriteHeader(http.StatusServiceUnavailable)
return
case !ok:
// A rig is configured, but not loaded properly
w.WriteHeader(http.StatusInternalServerError)
log.Printf("QSY failed: Hamlib rig '%s' not loaded.", rigName)
case err != nil:
w.WriteHeader(http.StatusInternalServerError)
log.Printf("QSY failed: %v", err)
default:
if _, _, err := setFreq(rig, string(payload.Freq)); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Printf("QSY failed: %v", err)
return
}
_ = json.NewEncoder(w).Encode(payload)
}
}
func positionHandler(w http.ResponseWriter, req *http.Request) {
// Throw error if GPSd http endpoint is not enabled
if !config.GPSd.EnableHTTP || config.GPSd.Addr == "" {
http.Error(w, "GPSd not enabled or address not set in config file", http.StatusInternalServerError)
return
}
host, _, _ := net.SplitHostPort(req.RemoteAddr)
log.Printf("Location data from GPSd served to %s", host)
conn, err := gpsd.Dial(config.GPSd.Addr)
if err != nil {
// do not pass error message to response as GPSd address might be leaked
http.Error(w, "GPSd Dial failed", http.StatusInternalServerError)
return
}
defer conn.Close()
conn.Watch(true)
pos, err := conn.NextPosTimeout(5 * time.Second)
if err != nil {
http.Error(w, "GPSd get next position failed: "+err.Error(), http.StatusInternalServerError)
return
}
if config.GPSd.UseServerTime {
pos.Time = time.Now()
}
_ = json.NewEncoder(w).Encode(pos)
}
func DisconnectHandler(w http.ResponseWriter, req *http.Request) {
dirty, _ := strconv.ParseBool(req.FormValue("dirty"))
if ok := abortActiveConnection(dirty); !ok {
w.WriteHeader(http.StatusBadRequest)
}
_ = json.NewEncoder(w).Encode(struct{}{})
}
func ConnectHandler(w http.ResponseWriter, req *http.Request) {
connectStr := req.FormValue("url")
nMsgs := mbox.InboxCount()
if success := Connect(connectStr); !success {
http.Error(w, "Session failure", http.StatusInternalServerError)
}
_ = json.NewEncoder(w).Encode(struct {
NumReceived int
}{
mbox.InboxCount() - nMsgs,
})
}
func mailboxHandler(w http.ResponseWriter, r *http.Request) {
box := mux.Vars(r)["box"]
var messages []*fbb.Message
var err error
switch box {
case "in":
messages, err = mbox.Inbox()
case "out":
messages, err = mbox.Outbox()
case "sent":
messages, err = mbox.Sent()
case "archive":
messages, err = mbox.Archive()
default:
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
log.Println(err)
}
sort.Sort(sort.Reverse(fbb.ByDate(messages)))
jsonSlice := make([]JSONMessage, len(messages))
for i, msg := range messages {
jsonSlice[i] = JSONMessage{Message: msg}
}
_ = json.NewEncoder(w).Encode(jsonSlice)
}
type JSONMessage struct {
*fbb.Message
inclBody bool
}
func (m JSONMessage) MarshalJSON() ([]byte, error) {
msg := struct {
MID string
Date time.Time
From fbb.Address
To []fbb.Address
Cc []fbb.Address
Subject string
Body string
BodyHTML string
Files []*fbb.File
P2POnly bool
Unread bool
}{
MID: m.MID(),
Date: m.Date(),
From: m.From(),
To: m.To(),
Cc: m.Cc(),
Subject: m.Subject(),
Files: m.Files(),
P2POnly: m.Header.Get("X-P2POnly") == "true",
Unread: mailbox.IsUnread(m.Message),
}
if m.inclBody {
msg.Body, _ = m.Body()
unsafe := toHTML([]byte(msg.Body))
msg.BodyHTML = string(bluemonday.UGCPolicy().SanitizeBytes(unsafe))
}
return json.Marshal(msg)
}
func messageDeleteHandler(w http.ResponseWriter, r *http.Request) {
box, mid := mux.Vars(r)["box"], mux.Vars(r)["mid"]
file := filepath.Clean(filepath.Join(mbox.MBoxPath, box, mid+mailbox.Ext))
if err := isInPath(mbox.MBoxPath, file); err != nil {
log.Println("Malicious source path in move:", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err := os.Remove(file)
if os.IsNotExist(err) {
http.NotFound(w, r)
return
} else if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
_ = json.NewEncoder(w).Encode("OK")
}
func messageHandler(w http.ResponseWriter, r *http.Request) {
box, mid := mux.Vars(r)["box"], mux.Vars(r)["mid"]
msg, err := mailbox.OpenMessage(path.Join(mbox.MBoxPath, box, mid+mailbox.Ext))
if os.IsNotExist(err) {
http.NotFound(w, r)
return
} else if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(JSONMessage{msg, true})
}
func attachmentHandler(w http.ResponseWriter, r *http.Request) {
// Attachments are potentially unsanitized HTML and/or javascript.
// To avoid XSS, we enable the CSP sandbox directive so that these
// attachments can't call other parts of the API (deny same origin).
w.Header().Set("Content-Security-Policy", "sandbox allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-presentation allow-scripts")
// Allow different sandboxed attachments to refer to each other.
// This can be useful to provide rich HTML content as attachments,
// without having to bundle it all up in one big file.
w.Header().Set("Access-Control-Allow-Origin", "null")
box, mid, attachment := mux.Vars(r)["box"], mux.Vars(r)["mid"], mux.Vars(r)["attachment"]
composereply, _ := strconv.ParseBool(r.URL.Query().Get("composereply"))
renderToHtml, _ := strconv.ParseBool(r.URL.Query().Get("rendertohtml"))
if composereply || renderToHtml {
// no-store is needed for displaying and replying to Winlink form-based messages
w.Header().Set("Cache-Control", "no-store")
}
msg, err := mailbox.OpenMessage(path.Join(mbox.MBoxPath, box, mid+mailbox.Ext))
if os.IsNotExist(err) {
http.NotFound(w, r)
return
} else if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Find and write attachment
var found bool
for _, f := range msg.Files() {
if f.Name() != attachment {
continue
}
found = true
if !renderToHtml {
http.ServeContent(w, r, f.Name(), msg.Date(), bytes.NewReader(f.Data()))
return
}
formRendered, err := formsMgr.RenderForm(f.Data(), composereply)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.ServeContent(w, r, f.Name()+".html", msg.Date(), bytes.NewReader([]byte(formRendered)))
}
if !found {
http.NotFound(w, r)
}
}
// toHTML takes the given body and turns it into proper html with
// paragraphs, blockquote, and
line breaks.
func toHTML(body []byte) []byte {
buf := bytes.NewBuffer(body)
var out bytes.Buffer
_, _ = fmt.Fprint(&out, "")
scanner := bufio.NewScanner(buf)
var blockquote int
for scanner.Scan() {
line := scanner.Text()
if len(line) == 0 {
_, _ = fmt.Fprint(&out, "
")
continue
}
depth := blockquoteDepth(line)
for depth != blockquote {
if depth > blockquote {
_, _ = fmt.Fprintf(&out, "
")
blockquote++
} else {
_, _ = fmt.Fprintf(&out, "
")
blockquote--
}
}
line = line[depth:]
line = htmlEncode(line)
line = linkify(line)
_, _ = fmt.Fprint(&out, line+"\n")
}
for ; blockquote > 0; blockquote-- {
_, _ = fmt.Fprintf(&out, "
")
}
_, _ = fmt.Fprint(&out, "
")
return out.Bytes()
}
// blcokquoteDepth counts the number of '>' at the beginning of the string.
func blockquoteDepth(str string) (n int) {
for _, c := range str {
if c != '>' {
break
}
n++
}
return
}
// htmlEncode encodes html characters
func htmlEncode(str string) string {
str = strings.ReplaceAll(str, ">", ">")
str = strings.ReplaceAll(str, "<", "<")
return str
}
// linkify detects url's in the given string and adds %s%s`, str[:start], link, str[start:end], linkify(str[end:]))
}
pat-0.15.1/interactive.go 0000664 0000000 0000000 00000006664 14534256521 0015251 0 ustar 00root root 0000000 0000000 // 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 main
import (
"bytes"
"context"
"fmt"
"log"
"os"
"runtime"
"strings"
"time"
"github.com/la5nta/wl2k-go/transport/ax25"
"github.com/peterh/liner"
)
func Interactive(ctx context.Context) {
line := liner.NewLiner()
defer line.Close()
done := make(chan struct{})
go func() {
defer close(done)
for {
str, _ := line.Prompt(getPrompt())
if str == "" {
continue
}
line.AppendHistory(str)
if str[0] == '#' {
continue
}
if quit := execCmd(str); quit {
break
}
}
}()
select {
case <-ctx.Done():
case <-done:
}
}
func execCmd(line string) (quit bool) {
cmd, param := parseCommand(line)
switch cmd {
case "connect":
if param == "" {
printInteractiveUsage()
return
}
Connect(param)
case "listen":
Listen(param)
case "unlisten":
Unlisten(param)
case "heard":
PrintHeard()
case "freq":
freq(param)
case "qtc":
PrintQTC()
case "debug":
os.Setenv("ardop_debug", "1")
fmt.Println("Number of goroutines:", runtime.NumGoroutine())
case "q", "quit":
return true
case "":
return
default:
printInteractiveUsage()
}
return
}
func printInteractiveUsage() {
fmt.Println("Uri examples: 'LA3F@5350', 'LA1B-10 v LA5NTA-1', 'LA5NTA:secret@192.168.1.1:54321'")
methods := []string{
MethodArdop,
MethodAX25, MethodAX25AGWPE, MethodAX25Linux, MethodAX25SerialTNC,
MethodPactor,
MethodTelnet,
MethodVaraHF,
MethodVaraFM,
}
fmt.Println("Methods:", strings.Join(methods, ", "))
cmds := []string{
"connect METHOD:[URI] or alias Connect to a remote station.",
"listen METHOD Listen for incoming connections.",
"unlisten METHOD Unregister listener for incoming connections.",
"freq METHOD:FREQ Change rig frequency.",
"heard Display all stations heard over the air.",
"qtc Print pending outbound messages.",
}
fmt.Println("Commands: ")
for _, cmd := range cmds {
fmt.Printf(" %s\n", cmd)
}
}
func getPrompt() string {
var buf bytes.Buffer
status := getStatus()
if len(status.ActiveListeners) > 0 {
fmt.Fprintf(&buf, "L%v", status.ActiveListeners)
}
fmt.Fprint(&buf, "> ")
return buf.String()
}
func PrintHeard() {
pf := func(call string, t time.Time) {
fmt.Printf(" %-10s (%s)\n", call, t.Format(time.RFC1123))
}
fmt.Println("ardop:")
if adTNC == nil {
fmt.Println(" (not initialized)")
} else if heard := adTNC.Heard(); len(heard) == 0 {
fmt.Println(" (none)")
} else {
for call, t := range heard {
pf(call, t)
}
}
fmt.Println("ax25+linux:")
if heard, err := ax25.Heard(config.AX25Linux.Port); err != nil {
fmt.Printf(" (%s)\n", err)
} else if len(heard) == 0 {
fmt.Println(" (none)")
} else {
for call, t := range heard {
pf(call, t)
}
}
}
func PrintQTC() {
msgs, err := mbox.Outbox()
if err != nil {
log.Println(err)
return
}
fmt.Printf("QTC: %d.\n", len(msgs))
for _, msg := range msgs {
fmt.Printf(`%-12.12s (%s): %s`, msg.MID(), msg.Subject(), fmt.Sprint(msg.To()))
if msg.Header.Get("X-P2POnly") == "true" {
fmt.Printf(" (P2P only)")
}
fmt.Println("")
}
}
func parseCommand(str string) (mode, param string) {
parts := strings.SplitN(str, " ", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], parts[1]
}
pat-0.15.1/internal/ 0000775 0000000 0000000 00000000000 14534256521 0014205 5 ustar 00root root 0000000 0000000 pat-0.15.1/internal/buildinfo/ 0000775 0000000 0000000 00000000000 14534256521 0016160 5 ustar 00root root 0000000 0000000 pat-0.15.1/internal/buildinfo/VERSION.go 0000664 0000000 0000000 00000001052 14534256521 0017632 0 ustar 00root root 0000000 0000000 // Copyright 2017 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 buildinfo
const (
// AppName is the friendly name of the app.
//
// Forks should consider using a different name.
AppName = "Pat"
// Version is the app's SemVer.
//
// Forks should NOT bump this unless they use a unique AppName. The Winlink
// system uses this to derive the "these users should upgrade" wall of shame
// from CMS connects.
Version = "0.15.1"
)
pat-0.15.1/internal/buildinfo/gitrev.go 0000664 0000000 0000000 00000000607 14534256521 0020012 0 ustar 00root root 0000000 0000000 //go:build go1.18
// +build go1.18
package buildinfo
import "runtime/debug"
// GitRev is the git commit hash that the binary was built at.
var GitRev = func() string {
if info, ok := debug.ReadBuildInfo(); ok {
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" && len(setting.Value) > 7 {
return setting.Value[:7]
}
}
}
return "unknown origin"
}()
pat-0.15.1/internal/buildinfo/gitrev_legacy.go 0000664 0000000 0000000 00000000252 14534256521 0021332 0 ustar 00root root 0000000 0000000 //go:build !go1.18
// +build !go1.18
package buildinfo
// GitRev is the git commit hash that the binary was built at.
var GitRev = "unknown origin" // Set by make.bash
pat-0.15.1/internal/buildinfo/strings.go 0000664 0000000 0000000 00000001375 14534256521 0020206 0 ustar 00root root 0000000 0000000 package buildinfo
import (
"fmt"
"runtime"
)
// VersionString returns a very descriptive version including the app SemVer, git rev plus the
// Golang OS, architecture and version.
func VersionString() string {
return fmt.Sprintf("%s %s/%s - %s",
VersionStringShort(), runtime.GOOS, runtime.GOARCH, runtime.Version())
}
// VersionStringShort returns the app SemVer and git rev.
func VersionStringShort() string {
return fmt.Sprintf("v%s (%s)", Version, GitRev)
}
// UserAgent returns a suitable HTTP user agent string containing app name, SemVer, git rev, plus
// the Golang OS, architecture and version.
func UserAgent() string {
return fmt.Sprintf("%v/%v (%v) %v (%v; %v)",
AppName, Version, GitRev, runtime.Version(), runtime.GOOS, runtime.GOARCH)
}
pat-0.15.1/internal/cmsapi/ 0000775 0000000 0000000 00000000000 14534256521 0015461 5 ustar 00root root 0000000 0000000 pat-0.15.1/internal/cmsapi/api.go 0000664 0000000 0000000 00000011053 14534256521 0016561 0 ustar 00root root 0000000 0000000 // 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 cmsapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
RootURL = "https://api.winlink.org"
PathVersionAdd = "/version/add"
PathGatewayStatus = "/gateway/status.json"
PathAccountExists = "/account/exists"
// AccessKey issued December 2017 by the WDT for use with Pat
AccessKey = "1880278F11684B358F36845615BD039A"
)
type VersionAdd struct {
Callsign string `json:"callsign"`
Program string `json:"program"`
Version string `json:"version"`
Comments string `json:"comments,omitempty"`
}
func (v VersionAdd) Post() error {
b, _ := json.Marshal(v)
buf := bytes.NewBuffer(b)
versionURL := RootURL + PathVersionAdd + "?key=" + AccessKey
req, _ := http.NewRequest("POST", versionURL, buf)
req.Header.Set("content-type", "application/json")
req.Header.Set("accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var response map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return err
}
if errMsg, ok := response["ErrorMessage"]; ok {
return fmt.Errorf("Winlink CMS Web Services: %s", errMsg)
}
return nil
}
func AccountExists(callsign string) (bool, error) {
accountURL := RootURL + PathAccountExists + "?key=" + AccessKey + "&callsign=" + url.QueryEscape(callsign)
req, _ := http.NewRequest("GET", accountURL, nil)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
var obj struct{ CallsignExists bool }
return obj.CallsignExists, json.NewDecoder(resp.Body).Decode(&obj)
}
type GatewayStatus struct {
ServerName string `json:"ServerName"`
ErrorCode int `json:"ErrorCode"`
Gateways []Gateway `json:"Gateways"`
}
type Gateway struct {
Callsign string
BaseCallsign string
RequestedMode string
Comments string
LastStatus RFC1123Time
Latitude float64
Longitude float64
Channels []GatewayChannel `json:"GatewayChannels"`
}
type GatewayChannel struct {
OperatingHours string
SupportedModes string
Frequency float64
ServiceCode string
Baud string
RadioRange string
Mode int
Gridsquare string
Antenna string
}
type RFC1123Time struct{ time.Time }
// GetGatewayStatus fetches the gateway status list returned by GatewayStatusUrl
//
// mode can be any of [packet, pactor, robustpacket, allhf or anyall]. Empty is AnyAll.
// historyHours is the number of hours of history to include (maximum: 48). If < 1, then API default is used.
// serviceCodes defaults to "PUBLIC".
func GetGatewayStatus(ctx context.Context, mode string, historyHours int, serviceCodes ...string) (io.ReadCloser, error) {
switch {
case mode == "":
mode = "AnyAll"
case historyHours > 48:
historyHours = 48
case len(serviceCodes) == 0:
serviceCodes = []string{"PUBLIC"}
}
params := url.Values{"Mode": {mode}}
params.Set("key", AccessKey)
if historyHours >= 0 {
params.Add("HistoryHours", fmt.Sprintf("%d", historyHours))
}
for _, str := range serviceCodes {
params.Add("ServiceCodes", str)
}
req, err := http.NewRequestWithContext(ctx, "POST", RootURL+PathGatewayStatus, strings.NewReader(params.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
switch {
case err != nil:
return nil, err
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("unexpected http status '%v'", resp.Status)
}
return resp.Body, err
}
func GetGatewayStatusCached(ctx context.Context, cacheFile string, forceDownload bool, serviceCodes ...string) (io.ReadCloser, error) {
if !forceDownload {
file, err := os.Open(cacheFile)
if err == nil {
return file, nil
}
}
log.Println("Downloading latest gateway status information...")
fresh, err := GetGatewayStatus(ctx, "", 48, serviceCodes...)
if err != nil {
return nil, err
}
file, err := os.Create(cacheFile)
if err != nil {
return nil, err
}
_, err = io.Copy(file, fresh)
file.Seek(0, 0)
if err == nil {
log.Println("download succeeded.")
}
return file, err
}
func (t *RFC1123Time) UnmarshalJSON(b []byte) (err error) {
var str string
if err = json.Unmarshal(b, &str); err != nil {
return err
}
t.Time, err = time.Parse(time.RFC1123, str)
return err
}
pat-0.15.1/internal/debug/ 0000775 0000000 0000000 00000000000 14534256521 0015273 5 ustar 00root root 0000000 0000000 pat-0.15.1/internal/debug/debug.go 0000664 0000000 0000000 00000000454 14534256521 0016713 0 ustar 00root root 0000000 0000000 package debug
import (
"log"
"os"
"strconv"
)
const (
EnvVar = "PAT_DEBUG"
Prefix = "[DEBUG] "
)
var enabled bool
func init() {
enabled, _ = strconv.ParseBool(os.Getenv(EnvVar))
}
func Printf(format string, v ...interface{}) {
if !enabled {
return
}
log.Printf(Prefix+format, v...)
}
pat-0.15.1/internal/directories/ 0000775 0000000 0000000 00000000000 14534256521 0016521 5 ustar 00root root 0000000 0000000 pat-0.15.1/internal/directories/directories.go 0000664 0000000 0000000 00000006725 14534256521 0021376 0 ustar 00root root 0000000 0000000 package directories
import (
"errors"
"log"
"os"
"path/filepath"
"strings"
"sync"
"github.com/la5nta/pat/internal/buildinfo"
"github.com/la5nta/pat/internal/debug"
"github.com/adrg/xdg"
)
var (
lock = &sync.Mutex{}
dataPath string
configPath string
statePath string
)
func DataDir() string {
return getDir(&dataPath, xdg.DataHome, "DataDir")
}
func ConfigDir() string {
return getDir(&configPath, xdg.ConfigHome, "ConfigDir")
}
func StateDir() string {
return getDir(&statePath, xdg.StateHome, "StateDir")
}
func getDir(dir *string, basePath string, methodName string) string {
lock.Lock()
defer lock.Unlock()
if *dir == "" {
initDir(dir, basePath, methodName)
}
return *dir
}
func initDir(dir *string, basePath string, methodName string) {
*dir = filepath.Join(basePath, strings.ToLower(buildinfo.AppName))
if _, err := os.Stat(*dir); os.IsNotExist(err) {
err := os.MkdirAll(*dir, os.ModeDir|0o755)
if err != nil {
log.Fatalf("unable to create or open %s %s: %v", methodName, *dir, err)
}
}
}
func MigrateLegacyDataDir() {
if f, err := os.Stat(ConfigDir()); err != nil && f.IsDir() {
debug.Printf("new config directory %s already exists, we have already migrated", ConfigDir())
return
}
homeDir, err := os.UserHomeDir()
if err != nil {
log.Fatal(err)
}
legacyDataDir := filepath.Join(homeDir, ".wl2k")
switch f, err := os.Stat(legacyDataDir); {
case os.IsNotExist(err):
debug.Printf("tried to migrate from %s but it doesn't exist; nothing to do", legacyDataDir)
return
case err != nil:
log.Fatal(err)
case !f.IsDir():
log.Printf("tried to migrate from %s but it's not a directory, that's weird; ignoring", legacyDataDir)
return
}
log.Printf("Migrating your Pat files from %s to new locations", legacyDataDir)
if err = migrateFile("config.json", legacyDataDir, ConfigDir()); err != nil {
log.Fatal(err)
}
if err = migrateFile("mailbox", legacyDataDir, DataDir()); err != nil {
log.Fatal(err)
}
if err = migrateFile("Standard_Forms", legacyDataDir, DataDir()); err != nil {
log.Fatal(err)
}
matches, err := filepath.Glob(filepath.Join(legacyDataDir, "rmslist*.json"))
if err != nil {
log.Fatal(err)
}
for _, match := range matches {
_, f := filepath.Split(match)
if err = migrateFile(f, legacyDataDir, DataDir()); err != nil {
log.Fatal(err)
}
}
debug.Printf("migration from %s finished, renaming it", legacyDataDir)
err = os.Rename(legacyDataDir, legacyDataDir+"-old")
if err != nil {
log.Fatal(err)
}
}
func migrateFile(fileName string, fromDir string, toDir string) error {
// make sure the old file is there
fromFile := filepath.Join(fromDir, fileName)
if _, err := os.Stat(fromFile); errors.Is(err, os.ErrNotExist) {
// no legacy file, nothing to do
debug.Printf("File %s doesn't exist, not migrating it", fromFile)
return nil
} else if err != nil {
return err
}
// touch the new file to make sure it's not there, and we can write to it
toFile := filepath.Join(toDir, fileName)
switch f, err := os.OpenFile(toFile, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o666); {
case errors.Is(err, os.ErrExist):
// new file already exists, don't clobber it
debug.Printf("new file %s already exists; ignoring %s", toFile, fromFile)
return nil
case err != nil:
return err
default:
if err := f.Close(); err != nil {
return err
}
if err := os.Remove(toFile); err != nil {
return err
}
}
debug.Printf("Migrating %s from %s to %s", fileName, fromDir, toDir)
return os.Rename(fromFile, toFile)
}
pat-0.15.1/internal/forms/ 0000775 0000000 0000000 00000000000 14534256521 0015333 5 ustar 00root root 0000000 0000000 pat-0.15.1/internal/forms/date.go 0000664 0000000 0000000 00000001235 14534256521 0016600 0 ustar 00root root 0000000 0000000 package forms
import (
"strings"
"time"
)
func formatDateTime(t time.Time) string { return t.Format("2006-01-02 15:04:05") }
func formatDateTimeUTC(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05Z07:00") }
func formatDate(t time.Time) string { return t.Format("2006-01-02") }
func formatTime(t time.Time) string { return t.Format("15:04:05") }
func formatDateUTC(t time.Time) string { return t.UTC().Format("2006-01-02Z07:00") }
func formatTimeUTC(t time.Time) string { return t.UTC().Format("15:04:05Z07:00") }
func formatUDTG(t time.Time) string { return strings.ToUpper(t.UTC().Format("021504Z07:00 Jan 2006")) }
pat-0.15.1/internal/forms/date_test.go 0000664 0000000 0000000 00000001167 14534256521 0017643 0 ustar 00root root 0000000 0000000 package forms
import (
"testing"
"time"
)
func TestDateFormat(t *testing.T) {
now := time.Date(2023, 12, 31, 23, 59, 59, 0, time.FixedZone("UTC-4", -4*60*60))
tests := []struct {
fn func(t time.Time) string
expect string
}{
{formatDateTime, "2023-12-31 23:59:59"},
{formatDateTimeUTC, "2024-01-01 03:59:59Z"},
{formatDate, "2023-12-31"},
{formatTime, "23:59:59"},
{formatDateUTC, "2024-01-01Z"},
{formatTimeUTC, "03:59:59Z"},
{formatUDTG, "010359Z JAN 2024"},
}
for i, tt := range tests {
if got := tt.fn(now); got != tt.expect {
t.Errorf("%d: got %q expected %q", i, got, tt.expect)
}
}
}
pat-0.15.1/internal/forms/forms.go 0000664 0000000 0000000 00000102425 14534256521 0017014 0 ustar 00root root 0000000 0000000 // Copyright 2020 Rainer Grosskopf (KI7RMJ). All rights reserved.
// Use of this source code is governed by the MIT-license that can be
// found in the LICENSE file.
// Processes Winlink-compatible message template (aka Winlink forms)
package forms
import (
"archive/zip"
"bufio"
"bytes"
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"math"
"net/http"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/dimchansky/utfbom"
"github.com/la5nta/pat/cfg"
"github.com/la5nta/pat/internal/debug"
"github.com/la5nta/pat/internal/gpsd"
"github.com/pd0mz/go-maidenhead"
)
const (
fieldValueFalseInXML = "False"
htmlFileExt = ".html"
txtFileExt = ".txt"
formsVersionInfoURL = "https://api.getpat.io/v1/forms/standard-templates/latest"
)
// Manager manages the forms subsystem
// When the web frontend POSTs the form template data, this map holds the POST'ed data.
// Each form composer instance renders into another browser tab, and has a unique instance cookie.
// This instance cookie is the key into the map, so that we can keep the values
// from different form authoring sessions separate from each other.
type Manager struct {
config Config
postedFormData struct {
sync.RWMutex
internalFormDataMap map[string]FormData
}
}
// Config passes config options to the forms package
type Config struct {
FormsPath string
MyCall string
Locator string
AppVersion string
LineReader func() string
UserAgent string
GPSd cfg.GPSdConfig
}
// Form holds information about a Winlink form template
type Form struct {
Name string `json:"name"`
TxtFileURI string `json:"txt_file_uri"`
InitialURI string `json:"initial_uri"`
ViewerURI string `json:"viewer_uri"`
ReplyTxtFileURI string `json:"reply_txt_file_uri"`
ReplyInitialURI string `json:"reply_initial_uri"`
ReplyViewerURI string `json:"reply_viewer_uri"`
}
// FormFolder is a folder with forms. A tree structure with Form leaves and sub-Folder branches
type FormFolder struct {
Name string `json:"name"`
Path string `json:"path"`
Version string `json:"version"`
FormCount int `json:"form_count"`
Forms []Form `json:"forms"`
Folders []FormFolder `json:"folders"`
}
// FormData holds the instance data that define a filled-in form
type FormData struct {
TargetForm Form `json:"target_form"`
Fields map[string]string `json:"fields"`
MsgTo string `json:"msg_to"`
MsgCc string `json:"msg_cc"`
MsgSubject string `json:"msg_subject"`
MsgBody string `json:"msg_body"`
MsgXML string `json:"msg_xml"`
IsReply bool `json:"is_reply"`
Submitted time.Time `json:"submitted"`
}
// MessageForm represents a concrete form-based message
type MessageForm struct {
To string
Cc string
Subject string
Body string
AttachmentXML string
AttachmentName string
}
// UpdateResponse is the API response format for the upgrade forms endpoint
type UpdateResponse struct {
NewestVersion string `json:"newestVersion"`
Action string `json:"action"`
}
var client = httpClient{http.Client{Timeout: 10 * time.Second}}
// NewManager instantiates the forms manager
func NewManager(conf Config) *Manager {
_ = os.MkdirAll(conf.FormsPath, 0o755)
retval := &Manager{
config: conf,
}
retval.postedFormData.internalFormDataMap = make(map[string]FormData)
return retval
}
// GetFormsCatalogHandler reads all forms from config.FormsPath and writes them in the http response as a JSON object graph
// This lets the frontend present a tree-like GUI for the user to select a form for composing a message
func (m *Manager) GetFormsCatalogHandler(w http.ResponseWriter, r *http.Request) {
formFolder, err := m.buildFormFolder()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Printf("%s %s: %s", r.Method, r.URL.Path, err)
return
}
_ = json.NewEncoder(w).Encode(formFolder)
}
// PostFormDataHandler - When the user is done filling a form, the frontend posts the input fields to this handler,
// which stores them in a map, so that other browser tabs can read the values back with GetFormDataHandler
func (m *Manager) PostFormDataHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(10e6); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
formPath := r.URL.Query().Get("formPath")
if formPath == "" {
http.Error(w, "formPath query param missing", http.StatusBadRequest)
log.Printf("formPath query param missing %s %s", r.Method, r.URL.Path)
return
}
composeReply, _ := strconv.ParseBool(r.URL.Query().Get("composereply"))
formFolder, err := m.buildFormFolder()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Printf("%s %s: %s", r.Method, r.URL.Path, err)
return
}
form, err := findFormFromURI(formPath, formFolder)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Printf("can't find form to match posted form data %s %s", formPath, r.URL)
return
}
formInstanceKey, err := r.Cookie("forminstance")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Printf("missing cookie %s %s", formPath, r.URL)
return
}
formData := FormData{
IsReply: composeReply,
TargetForm: form,
Fields: make(map[string]string),
}
for key, values := range r.PostForm {
formData.Fields[strings.TrimSpace(strings.ToLower(key))] = values[0]
}
formMsg, err := formMessageBuilder{
Template: form,
FormValues: formData.Fields,
Interactive: false,
IsReply: composeReply,
FormsMgr: m,
}.build()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Printf("%s %s: %s", r.Method, r.URL.Path, err)
}
formData.MsgTo = formMsg.To
formData.MsgCc = formMsg.Cc
formData.MsgSubject = formMsg.Subject
formData.MsgBody = formMsg.Body
formData.MsgXML = formMsg.AttachmentXML
formData.Submitted = time.Now()
m.postedFormData.Lock()
m.postedFormData.internalFormDataMap[formInstanceKey.Value] = formData
m.postedFormData.Unlock()
m.cleanupOldFormData()
_, _ = io.WriteString(w, "")
}
// GetFormDataHandler is the counterpart to PostFormDataHandler. Returns the form field values to the frontend
func (m *Manager) GetFormDataHandler(w http.ResponseWriter, r *http.Request) {
formInstanceKey, err := r.Cookie("forminstance")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Printf("missing cookie %s %s", formInstanceKey, r.URL)
return
}
_ = json.NewEncoder(w).Encode(m.GetPostedFormData(formInstanceKey.Value))
}
// GetPostedFormData is similar to GetFormDataHandler, but used when posting the form-based message to the outbox
func (m *Manager) GetPostedFormData(key string) FormData {
m.postedFormData.RLock()
defer m.postedFormData.RUnlock()
return m.postedFormData.internalFormDataMap[key]
}
// GetFormTemplateHandler handles the request for viewing a form filled-in with instance values
func (m *Manager) GetFormTemplateHandler(w http.ResponseWriter, r *http.Request) {
formPath := r.URL.Query().Get("formPath")
if formPath == "" {
http.Error(w, "formPath query param missing", http.StatusBadRequest)
log.Printf("formPath query param missing %s %s", r.Method, r.URL.Path)
return
}
absPathTemplate, err := m.findAbsPathForTemplatePath(formPath)
if err != nil {
http.Error(w, "find the full path for requested template "+formPath, http.StatusBadRequest)
log.Printf("find the full path for requested template %s %s: %s", r.Method, r.URL.Path, "can't open template "+formPath)
return
}
responseText, err := m.fillFormTemplate(absPathTemplate, "/api/form?"+r.URL.Query().Encode(), nil, make(map[string]string))
if err != nil {
http.Error(w, "can't open template "+formPath, http.StatusBadRequest)
log.Printf("problem filling form template file %s %s: can't open template %s. Err: %s", r.Method, r.URL.Path, formPath, err)
return
}
_, err = io.WriteString(w, responseText)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
log.Printf("can't write form into response %s %s: %s", r.Method, r.URL.Path, err)
return
}
}
// UpdateFormTemplatesHandler handles API calls to update form templates.
func (m *Manager) UpdateFormTemplatesHandler(w http.ResponseWriter, r *http.Request) {
response, err := m.UpdateFormTemplates(r.Context())
if err != nil {
http.Error(w, fmt.Sprint(err), http.StatusInternalServerError)
return
}
jsn, _ := json.Marshal(response)
_, _ = w.Write(jsn)
}
// UpdateFormTemplates handles searching for and installing the latest version of the form templates.
func (m *Manager) UpdateFormTemplates(ctx context.Context) (UpdateResponse, error) {
if err := os.MkdirAll(m.config.FormsPath, 0o755); err != nil {
return UpdateResponse{}, fmt.Errorf("can't write to forms dir [%w]", err)
}
log.Printf("Updating form templates; current version is %v", m.getFormsVersion())
latest, err := m.getLatestFormsInfo(ctx)
if err != nil {
return UpdateResponse{}, err
}
if !m.isNewerVersion(latest.Version) {
log.Printf("Latest forms version is %v; nothing to do", latest.Version)
return UpdateResponse{
NewestVersion: latest.Version,
Action: "none",
}, nil
}
if err = m.downloadAndUnzipForms(ctx, latest.ArchiveURL); err != nil {
return UpdateResponse{}, err
}
log.Printf("Finished forms update to %v", latest.Version)
// TODO: re-init forms manager
return UpdateResponse{
NewestVersion: latest.Version,
Action: "update",
}, nil
}
type formsInfo struct {
Version string `json:"version"`
ArchiveURL string `json:"archive_url"`
}
func (m *Manager) getLatestFormsInfo(ctx context.Context) (*formsInfo, error) {
resp, err := client.Get(ctx, m.config.UserAgent, formsVersionInfoURL)
if err != nil || resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("can't fetch winlink forms version page: %w", err)
}
defer resp.Body.Close()
var v formsInfo
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
return nil, err
}
return &v, nil
}
func (m *Manager) downloadAndUnzipForms(ctx context.Context, downloadLink string) error {
log.Printf("Updating forms via %v", downloadLink)
resp, err := client.Get(ctx, m.config.UserAgent, downloadLink)
if err != nil {
return fmt.Errorf("can't download update ZIP: %w", err)
}
defer resp.Body.Close()
f, err := ioutil.TempFile(os.TempDir(), "pat")
if err != nil {
return fmt.Errorf("can't create temp file for download: %w", err)
}
defer f.Close()
defer os.Remove(f.Name())
if _, err := io.Copy(f, resp.Body); err != nil {
return fmt.Errorf("can't write update ZIP: %w", err)
}
if err := unzip(f.Name(), m.config.FormsPath); err != nil {
return fmt.Errorf("can't unzip forms update: %w", err)
}
return nil
}
func unzip(srcArchivePath, dstRoot string) error {
// Closure to address file descriptors issue with all the deferred .Close() methods
extractAndWriteFile := func(zf *zip.File) error {
if zf.FileInfo().IsDir() {
return nil
}
destPath := filepath.Join(dstRoot, zf.Name)
// Check for ZipSlip (Directory traversal)
if !strings.HasPrefix(destPath, filepath.Clean(dstRoot)+string(os.PathSeparator)) {
return fmt.Errorf("illegal file path: %s", destPath)
}
// Ensure target directory exists
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return fmt.Errorf("can't create target directory: %w", err)
}
// Write file
src, err := zf.Open()
if err != nil {
return err
}
defer src.Close()
dst, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zf.Mode())
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
}
r, err := zip.OpenReader(srcArchivePath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
err := extractAndWriteFile(f)
if err != nil {
return err
}
}
return nil
}
// GetXMLAttachmentNameForForm returns the user-visible filename for the message attachment that holds the form instance values
func (m *Manager) GetXMLAttachmentNameForForm(f Form, isReply bool) string {
attachmentName := filepath.Base(f.ViewerURI)
if isReply {
attachmentName = filepath.Base(f.ReplyViewerURI)
}
attachmentName = strings.TrimSuffix(attachmentName, filepath.Ext(attachmentName))
attachmentName = "RMS_Express_Form_" + attachmentName + ".xml"
if len(attachmentName) > 255 {
attachmentName = strings.TrimPrefix(attachmentName, "RMS_Express_Form_")
}
return attachmentName
}
// RenderForm finds the associated form and returns the filled-in form in HTML given the contents of a form attachment
func (m *Manager) RenderForm(contentUnsanitized []byte, composeReply bool) (string, error) {
type Node struct {
XMLName xml.Name
Content []byte `xml:",innerxml"`
Nodes []Node `xml:",any"`
}
sr := utfbom.SkipOnly(bytes.NewReader(contentUnsanitized))
contentData, err := io.ReadAll(sr)
if err != nil {
return "", fmt.Errorf("error reading sanitized form xml: %w", err)
}
if !utf8.Valid(contentData) {
log.Println("Warning: unsupported string encoding in form XML, expected utf-8")
}
var n1 Node
formParams := make(map[string]string)
formVars := make(map[string]string)
if err := xml.Unmarshal(contentData, &n1); err != nil {
return "", err
}
if n1.XMLName.Local != "RMS_Express_Form" {
return "", errors.New("missing RMS_Express_Form tag in form XML")
}
for _, n2 := range n1.Nodes {
switch n2.XMLName.Local {
case "form_parameters":
for _, n3 := range n2.Nodes {
formParams[n3.XMLName.Local] = string(n3.Content)
}
case "variables":
for _, n3 := range n2.Nodes {
formVars[n3.XMLName.Local] = string(n3.Content)
}
}
}
switch {
case formParams["display_form"] == "":
return "", errors.New("missing display_form tag in form XML")
case composeReply && formParams["reply_template"] == "":
return "", errors.New("missing reply_template tag in form XML for a reply message")
}
formFolder, err := m.buildFormFolder()
if err != nil {
return "", err
}
formToLoad := formParams["display_form"]
if composeReply {
// we're authoring a reply
formToLoad = formParams["reply_template"]
}
form, err := findFormFromURI(formToLoad, formFolder)
if err != nil {
return "", err
}
var formRelPath string
switch {
case composeReply:
// authoring a form reply
formRelPath = form.ReplyInitialURI
case strings.HasSuffix(form.ReplyViewerURI, formParams["display_form"]):
// viewing a form reply
formRelPath = form.ReplyViewerURI
default:
// viewing a form
formRelPath = form.ViewerURI
}
absPathTemplate, err := m.findAbsPathForTemplatePath(formRelPath)
if err != nil {
return "", err
}
return m.fillFormTemplate(absPathTemplate, "/api/form?composereply=true&formPath="+formRelPath, regexp.MustCompile(`{[vV][aA][rR]\s+(\w+)\s*}`), formVars)
}
// ComposeForm combines all data needed for the whole form-based message: subject, body, and attachment
func (m *Manager) ComposeForm(tmplPath string, subject string) (MessageForm, error) {
formFolder, err := m.buildFormFolder()
if err != nil {
return MessageForm{}, fmt.Errorf("failed to build form folder tree: %v", err)
}
tmplPath = filepath.Clean(tmplPath)
form, err := findFormFromURI(tmplPath, formFolder)
if err != nil {
return MessageForm{}, fmt.Errorf("failed to find '%s': %v", tmplPath, err)
}
formValues := map[string]string{
"subjectline": subject,
"templateversion": m.getFormsVersion(),
"msgsender": m.config.MyCall,
}
fmt.Printf("Form '%s', version: %s", form.TxtFileURI, formValues["templateversion"])
formMsg, err := formMessageBuilder{
Template: form,
FormValues: formValues,
Interactive: true,
IsReply: false,
FormsMgr: m,
}.build()
if err != nil {
return MessageForm{}, err
}
return formMsg, nil
}
func (f Form) matchesName(nameToMatch string) bool {
return f.InitialURI == nameToMatch ||
strings.EqualFold(f.InitialURI, nameToMatch+htmlFileExt) ||
f.ViewerURI == nameToMatch ||
strings.EqualFold(f.ViewerURI, nameToMatch+htmlFileExt) ||
f.ReplyInitialURI == nameToMatch ||
f.ReplyInitialURI == nameToMatch+".0" ||
f.ReplyViewerURI == nameToMatch ||
f.ReplyViewerURI == nameToMatch+".0" ||
f.TxtFileURI == nameToMatch ||
strings.EqualFold(f.TxtFileURI, nameToMatch+txtFileExt)
}
func (f Form) containsName(partialName string) bool {
return strings.Contains(f.InitialURI, partialName) ||
strings.Contains(f.ViewerURI, partialName) ||
strings.Contains(f.ReplyInitialURI, partialName) ||
strings.Contains(f.ReplyViewerURI, partialName) ||
strings.Contains(f.ReplyTxtFileURI, partialName) ||
strings.Contains(f.TxtFileURI, partialName)
}
func (m *Manager) buildFormFolder() (FormFolder, error) {
formFolder, err := m.innerRecursiveBuildFormFolder(m.config.FormsPath)
formFolder.Version = m.getFormsVersion()
return formFolder, err
}
func (m *Manager) innerRecursiveBuildFormFolder(rootPath string) (FormFolder, error) {
rootFile, err := os.Open(rootPath)
if err != nil {
return FormFolder{}, err
}
defer rootFile.Close()
rootFileInfo, _ := os.Stat(rootPath)
if !rootFileInfo.IsDir() {
return FormFolder{}, errors.New(rootPath + " is not a directory")
}
folder := FormFolder{
Name: rootFileInfo.Name(),
Path: rootFile.Name(),
Forms: []Form{},
Folders: []FormFolder{},
}
infos, err := rootFile.Readdir(0)
if err != nil {
return folder, err
}
_ = rootFile.Close()
formCnt := 0
for _, info := range infos {
if info.IsDir() {
subfolder, err := m.innerRecursiveBuildFormFolder(path.Join(rootPath, info.Name()))
if err != nil {
return folder, err
}
folder.Folders = append(folder.Folders, subfolder)
folder.FormCount += subfolder.FormCount
continue
}
if !strings.EqualFold(filepath.Ext(info.Name()), txtFileExt) {
continue
}
frm, err := m.buildFormFromTxt(path.Join(rootPath, info.Name()))
if err != nil {
continue
}
if frm.InitialURI != "" || frm.ViewerURI != "" {
formCnt++
folder.Forms = append(folder.Forms, frm)
folder.FormCount++
}
}
sort.Slice(folder.Folders, func(i, j int) bool {
return folder.Folders[i].Name < folder.Folders[j].Name
})
sort.Slice(folder.Forms, func(i, j int) bool {
return folder.Forms[i].Name < folder.Forms[j].Name
})
return folder, nil
}
func (m *Manager) buildFormFromTxt(txtPath string) (Form, error) {
f, err := os.Open(txtPath)
if err != nil {
return Form{}, err
}
defer f.Close()
formsPathWithSlash := m.config.FormsPath + "/"
form := Form{
Name: strings.TrimSuffix(path.Base(txtPath), path.Ext(txtPath)),
TxtFileURI: strings.TrimPrefix(txtPath, formsPathWithSlash),
}
scanner := bufio.NewScanner(f)
baseURI := path.Dir(form.TxtFileURI)
for scanner.Scan() {
l := scanner.Text()
switch {
case strings.HasPrefix(l, "Form:"):
// Form: ,
files := strings.Split(strings.TrimPrefix(l, "Form:"), ",")
// Extend to absolute paths and add missing html extension
for i := range files {
files[i] = path.Join(baseURI, strings.TrimSpace(files[i]))
if ext := path.Ext(files[i]); ext == "" {
files[i] += ".html"
}
}
form.InitialURI = files[0]
if len(files) > 1 {
form.ViewerURI = files[1]
}
case strings.HasPrefix(l, "ReplyTemplate:"):
form.ReplyTxtFileURI = path.Join(baseURI, strings.TrimSpace(strings.TrimPrefix(l, "ReplyTemplate:")))
tmpForm, _ := m.buildFormFromTxt(path.Join(m.config.FormsPath, form.ReplyTxtFileURI))
form.ReplyInitialURI = tmpForm.InitialURI
form.ReplyViewerURI = tmpForm.ViewerURI
}
}
return form, err
}
func findFormFromURI(formName string, folder FormFolder) (Form, error) {
form := Form{Name: "unknown"}
for _, subFolder := range folder.Folders {
form, err := findFormFromURI(formName, subFolder)
if err == nil {
return form, nil
}
}
for _, form := range folder.Forms {
if form.matchesName(formName) {
return form, nil
}
}
// couldn't find it by full path, so try to find match by guessing folder name
formName = path.Join(folder.Name, formName)
for _, form := range folder.Forms {
if form.containsName(formName) {
return form, nil
}
}
return form, errors.New("form not found")
}
func (m *Manager) findAbsPathForTemplatePath(tmplPath string) (string, error) {
absPathTemplate := filepath.Join(m.config.FormsPath, path.Clean(tmplPath))
// now deal with cases where the html file name specified in the .txt file, has different caseness than the actual .html file on disk.
absPathTemplateFolder := filepath.Dir(absPathTemplate)
templateDir, err := os.Open(absPathTemplateFolder)
if err != nil {
return "", errors.New("can't read template folder")
}
defer templateDir.Close()
fileNames, err := templateDir.Readdirnames(0)
if err != nil {
return "", errors.New("can't read template folder")
}
for _, name := range fileNames {
if strings.EqualFold(filepath.Base(tmplPath), name) {
return filepath.Join(absPathTemplateFolder, name), nil
}
}
return "", fmt.Errorf("unable to resolve absolute template path")
}
// gpsPos returns the current GPS Position
func (m *Manager) gpsPos() (gpsd.Position, error) {
addr := m.config.GPSd.Addr
if addr == "" {
return gpsd.Position{}, errors.New("GPSd: not configured.")
}
if !m.config.GPSd.AllowForms {
return gpsd.Position{}, errors.New("GPSd: allow_forms is disabled. GPS position will not be available in form templates.")
}
conn, err := gpsd.Dial(addr)
if err != nil {
log.Printf("GPSd daemon: %s", err)
return gpsd.Position{}, err
}
defer conn.Close()
conn.Watch(true)
log.Println("Waiting for position from GPSd...")
// TODO: make the GPSd timeout configurable
return conn.NextPosTimeout(3 * time.Second)
}
type gpsStyle int
const (
// documentation: https://www.winlink.org/sites/default/files/RMSE_FORMS/insertion_tags.zip
signedDecimal gpsStyle = iota // 41.1234 -73.4567
decimal // 46.3795N 121.5835W
degreeMinute // 46-22.77N 121-35.01W
)
func gpsFmt(style gpsStyle, pos gpsd.Position) string {
var (
northing string
easting string
latDegrees int
latMinutes float64
lonDegrees int
lonMinutes float64
)
noPos := gpsd.Position{}
if pos == noPos {
return "(Not available)"
}
switch style {
case degreeMinute:
{
latDegrees = int(math.Trunc(math.Abs(pos.Lat)))
latMinutes = (math.Abs(pos.Lat) - float64(latDegrees)) * 60
lonDegrees = int(math.Trunc(math.Abs(pos.Lon)))
lonMinutes = (math.Abs(pos.Lon) - float64(lonDegrees)) * 60
}
fallthrough
case decimal:
{
if pos.Lat >= 0 {
northing = "N"
} else {
northing = "S"
}
if pos.Lon >= 0 {
easting = "E"
} else {
easting = "W"
}
}
}
switch style {
case signedDecimal:
return fmt.Sprintf("%.4f %.4f", pos.Lat, pos.Lon)
case decimal:
return fmt.Sprintf("%.4f%s %.4f%s", math.Abs(pos.Lat), northing, math.Abs(pos.Lon), easting)
case degreeMinute:
return fmt.Sprintf("%02d-%05.2f%s %03d-%05.2f%s", latDegrees, latMinutes, northing, lonDegrees, lonMinutes, easting)
default:
return "(Not available)"
}
}
func posToGridSquare(pos gpsd.Position) string {
point := maidenhead.NewPoint(pos.Lat, pos.Lon)
gridsquare, err := point.GridSquare()
if err != nil {
return ""
}
return gridsquare
}
func (m *Manager) fillFormTemplate(absPathTemplate string, formDestURL string, placeholderRegEx *regexp.Regexp, formVars map[string]string) (string, error) {
fUnsanitized, err := os.Open(absPathTemplate)
if err != nil {
return "", err
}
defer fUnsanitized.Close()
// skipping over UTF-8 byte-ordering mark EFBBEF, some 3rd party templates use it
// (e.g. Sonoma county's ICS213_v2.1_SonomaACS_TwoWay_Initial_Viewer.html)
f := utfbom.SkipOnly(fUnsanitized)
sanitizedFileContent, err := io.ReadAll(f)
if err != nil {
return "", fmt.Errorf("error reading file %s", absPathTemplate)
}
if !utf8.Valid(sanitizedFileContent) {
log.Printf("Warning: unsupported string encoding in template %s, expected utf-8", absPathTemplate)
}
now := time.Now()
validPos := "NO"
nowPos, err := m.gpsPos()
if err != nil {
debug.Printf("GPSd error: %v", err)
} else {
validPos = "YES"
debug.Printf("GPSd position: %s", gpsFmt(signedDecimal, nowPos))
}
var buf bytes.Buffer
scanner := bufio.NewScanner(bytes.NewReader(sanitizedFileContent))
for scanner.Scan() {
l := scanner.Text()
l = strings.ReplaceAll(l, "http://{FormServer}:{FormPort}", formDestURL)
// some Canada BC forms don't use the {FormServer} placeholder, it's OK, can deal with it here
l = strings.ReplaceAll(l, "http://localhost:8001", formDestURL)
l = strings.ReplaceAll(l, "{MsgSender}", m.config.MyCall)
l = strings.ReplaceAll(l, "{Callsign}", m.config.MyCall)
l = strings.ReplaceAll(l, "{ProgramVersion}", "Pat "+m.config.AppVersion)
l = strings.ReplaceAll(l, "{DateTime}", formatDateTime(now))
l = strings.ReplaceAll(l, "{UDateTime}", formatDateTimeUTC(now))
l = strings.ReplaceAll(l, "{Date}", formatDate(now))
l = strings.ReplaceAll(l, "{UDate}", formatDateUTC(now))
l = strings.ReplaceAll(l, "{UDTG}", formatUDTG(now))
l = strings.ReplaceAll(l, "{Time}", formatTime(now))
l = strings.ReplaceAll(l, "{UTime}", formatTimeUTC(now))
l = strings.ReplaceAll(l, "{GPS}", gpsFmt(degreeMinute, nowPos))
l = strings.ReplaceAll(l, "{GPS_DECIMAL}", gpsFmt(decimal, nowPos))
l = strings.ReplaceAll(l, "{GPS_SIGNED_DECIMAL}", gpsFmt(signedDecimal, nowPos))
// Lots of undocumented tags found in the Winlink check in form.
// Note also various ways of capitalizing. Perhaps best to do case insenstive string replacements....
l = strings.ReplaceAll(l, "{Latitude}", fmt.Sprintf("%.4f", nowPos.Lat))
l = strings.ReplaceAll(l, "{latitude}", fmt.Sprintf("%.4f", nowPos.Lat))
l = strings.ReplaceAll(l, "{Longitude}", fmt.Sprintf("%.4f", nowPos.Lon))
l = strings.ReplaceAll(l, "{longitude}", fmt.Sprintf("%.4f", nowPos.Lon))
l = strings.ReplaceAll(l, "{GridSquare}", posToGridSquare(nowPos))
l = strings.ReplaceAll(l, "{GPSValid}", fmt.Sprintf("%s ", validPos))
if placeholderRegEx != nil {
l = fillPlaceholders(l, placeholderRegEx, formVars)
}
buf.WriteString(l + "\n")
}
return buf.String(), nil
}
func (m *Manager) getFormsVersion() string {
// walking up the path to find a version file.
// Winlink's Standard_Forms.zip includes it in its root.
dir := m.config.FormsPath
if filepath.Ext(dir) == txtFileExt {
dir = filepath.Dir(dir)
}
var verFile *os.File
// loop to walk up the subfolders until we find the top, or Winlink's Standard_Forms_Version.dat file
for {
f, err := os.Open(filepath.Join(dir, "Standard_Forms_Version.dat"))
if err != nil {
dir = filepath.Dir(dir) // have not found the version file or couldn't open it, going up by one
if dir == "." || dir == ".." || strings.HasSuffix(dir, string(os.PathSeparator)) {
return "unknown" // reached top-level and couldn't find version .dat file
}
continue
}
// found and opened the version file
verFile = f
break
}
if verFile != nil {
defer verFile.Close()
return readFileFirstLine(verFile)
}
return "unknown"
}
func readFileFirstLine(f *os.File) string {
scanner := bufio.NewScanner(f)
if scanner.Scan() {
return scanner.Text()
}
return ""
}
type formMessageBuilder struct {
Interactive bool
IsReply bool
Template Form
FormValues map[string]string
FormsMgr *Manager
}
// build returns message subject, body, and XML attachment content for the given template and variable map
func (b formMessageBuilder) build() (MessageForm, error) {
tmplPath := filepath.Join(b.FormsMgr.config.FormsPath, b.Template.TxtFileURI)
if filepath.Ext(tmplPath) == "" {
tmplPath += txtFileExt
}
if b.IsReply && b.Template.ReplyTxtFileURI != "" {
tmplPath = filepath.Join(b.FormsMgr.config.FormsPath, b.Template.ReplyTxtFileURI)
}
b.initFormValues()
formVarsAsXML := ""
for varKey, varVal := range b.FormValues {
formVarsAsXML += fmt.Sprintf(" <%s>%s%s>\n", xmlEscape(varKey), xmlEscape(varVal), xmlEscape(varKey))
}
viewer := ""
if b.Template.ViewerURI != "" {
viewer = filepath.Base(b.Template.ViewerURI)
}
if b.IsReply && b.Template.ReplyViewerURI != "" {
viewer = filepath.Base(b.Template.ReplyViewerURI)
}
replier := ""
if !b.IsReply && b.Template.ReplyTxtFileURI != "" {
replier = filepath.Base(b.Template.ReplyTxtFileURI)
}
msgForm, err := b.scanTmplBuildMessage(tmplPath)
if err != nil {
return MessageForm{}, err
}
// Add XML if a viewer is defined for this form
if b.Template.ViewerURI != "" {
msgForm.AttachmentXML = fmt.Sprintf(`%s
%s
%s
%s
%s
%s
%s
%s
%s
`,
xml.Header,
"1.0",
b.FormsMgr.config.AppVersion,
time.Now().UTC().Format("20060102150405"),
b.FormsMgr.config.MyCall,
b.FormsMgr.config.Locator,
viewer,
replier,
formVarsAsXML)
msgForm.AttachmentName = b.FormsMgr.GetXMLAttachmentNameForForm(b.Template, false)
}
msgForm.To = strings.TrimSpace(msgForm.To)
msgForm.Cc = strings.TrimSpace(msgForm.Cc)
msgForm.Subject = strings.TrimSpace(msgForm.Subject)
msgForm.Body = strings.TrimSpace(msgForm.Body)
return msgForm, nil
}
func (b formMessageBuilder) initFormValues() {
if b.IsReply {
b.FormValues["msgisreply"] = "True"
} else {
b.FormValues["msgisreply"] = "False"
}
b.FormValues["msgsender"] = b.FormsMgr.config.MyCall
// some defaults that we can't set yet. Winlink doesn't seem to care about these
b.FormValues["msgto"] = ""
b.FormValues["msgcc"] = ""
b.FormValues["msgsubject"] = ""
b.FormValues["msgbody"] = ""
b.FormValues["msgp2p"] = ""
b.FormValues["msgisforward"] = fieldValueFalseInXML
b.FormValues["msgisacknowledgement"] = fieldValueFalseInXML
b.FormValues["msgseqnum"] = "0"
}
func (b formMessageBuilder) scanTmplBuildMessage(tmplPath string) (MessageForm, error) {
infile, err := os.Open(tmplPath)
if err != nil {
return MessageForm{}, err
}
defer infile.Close()
placeholderRegEx := regexp.MustCompile(`<[vV][aA][rR]\s+(\w+)\s*>`)
scanner := bufio.NewScanner(infile)
var msgForm MessageForm
var inBody bool
for scanner.Scan() {
lineTmpl := scanner.Text()
lineTmpl = fillPlaceholders(lineTmpl, placeholderRegEx, b.FormValues)
lineTmpl = strings.ReplaceAll(lineTmpl, "", b.FormsMgr.config.MyCall)
lineTmpl = strings.ReplaceAll(lineTmpl, "", "Pat "+b.FormsMgr.config.AppVersion)
if strings.HasPrefix(lineTmpl, "Form:") {
continue
}
if strings.HasPrefix(lineTmpl, "ReplyTemplate:") {
continue
}
if strings.HasPrefix(lineTmpl, "Msg:") {
lineTmpl = strings.TrimSpace(strings.TrimPrefix(lineTmpl, "Msg:"))
inBody = true
}
if b.Interactive {
matches := placeholderRegEx.FindAllStringSubmatch(lineTmpl, -1)
fmt.Println(lineTmpl)
for i := range matches {
varName := matches[i][1]
varNameLower := strings.ToLower(varName)
if b.FormValues[varNameLower] != "" {
continue
}
fmt.Print(varName + ": ")
b.FormValues[varNameLower] = "blank"
val := b.FormsMgr.config.LineReader()
if val != "" {
b.FormValues[varNameLower] = val
}
}
}
lineTmpl = fillPlaceholders(lineTmpl, placeholderRegEx, b.FormValues)
switch {
case strings.HasPrefix(lineTmpl, "Subject:"):
msgForm.Subject = strings.TrimPrefix(lineTmpl, "Subject:")
case strings.HasPrefix(lineTmpl, "To:"):
msgForm.To = strings.TrimPrefix(lineTmpl, "To:")
case strings.HasPrefix(lineTmpl, "Cc:"):
msgForm.Cc = strings.TrimPrefix(lineTmpl, "Cc:")
case inBody:
msgForm.Body += lineTmpl + "\n"
default:
log.Printf("skipping unknown template line: '%s'", lineTmpl)
}
}
return msgForm, nil
}
func xmlEscape(s string) string {
var buf bytes.Buffer
if err := xml.EscapeText(&buf, []byte(s)); err != nil {
log.Printf("Error trying to escape XML string %s", err)
}
return buf.String()
}
func fillPlaceholders(s string, re *regexp.Regexp, values map[string]string) string {
if _, ok := values["txtstr"]; !ok {
values["txtstr"] = ""
}
result := s
matches := re.FindAllStringSubmatch(s, -1)
for _, match := range matches {
value, ok := values[strings.ToLower(match[1])]
if ok {
result = strings.ReplaceAll(result, match[0], value)
}
}
return result
}
func (m *Manager) cleanupOldFormData() {
m.postedFormData.Lock()
defer m.postedFormData.Unlock()
for key, form := range m.postedFormData.internalFormDataMap {
elapsed := time.Since(form.Submitted).Hours()
if elapsed > 24 {
log.Println("deleting old FormData after", elapsed, "hrs")
delete(m.postedFormData.internalFormDataMap, key)
}
}
}
func (m *Manager) isNewerVersion(newestVersion string) bool {
currentVersion := m.getFormsVersion()
cv := strings.Split(currentVersion, ".")
nv := strings.Split(newestVersion, ".")
for i := 0; i < 4; i++ {
var cp int64
if len(cv) > i {
cp, _ = strconv.ParseInt(cv[i], 10, 16)
}
var np int64
if len(nv) > i {
np, _ = strconv.ParseInt(nv[i], 10, 16)
}
if cp < np {
return true
}
}
return false
}
type httpClient struct{ http.Client }
func (c httpClient) Get(ctx context.Context, userAgent, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Cache-Control", "no-cache")
return c.Do(req)
}
pat-0.15.1/internal/gpsd/ 0000775 0000000 0000000 00000000000 14534256521 0015142 5 ustar 00root root 0000000 0000000 pat-0.15.1/internal/gpsd/gpsd.go 0000664 0000000 0000000 00000011666 14534256521 0016440 0 ustar 00root root 0000000 0000000 package gpsd
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
)
type NMEAMode int
const (
ModeUnknown NMEAMode = iota
ModeNoFix
Mode2D
Mode3D
)
var ErrUnsupportedProtocolVersion = errors.New("unsupported protocol version")
// Positioner implementations provide geographic positioning data.
//
// This is particularly useful for testing if an object returned by Next can be used to determine the device position.
type Positioner interface {
Position() Position
HasFix() bool
}
// Position holds geographic positioning data.
type Position struct {
Lat, Lon float64 // Latitude/longitude in degrees. +/- signifies north/south.
Alt float64 // Altitude in meters.
Track float64 // Course over ground, degrees from true north.
Speed float64 // Speed over ground, meters per second.
Time time.Time // Time as reported by the device.
}
// Conn represents a socket connection to an GPSd daemon.
type Conn struct {
Version Version
mu sync.Mutex
tcpConn net.Conn
rd *bufio.Reader
watchEnabled bool
closed bool
}
// Dial establishes a socket connection to the GPSd daemon.
func Dial(addr string) (*Conn, error) {
tcpConn, err := net.DialTimeout("tcp", addr, 30*time.Second)
if err != nil {
return nil, err
}
c := &Conn{
tcpConn: tcpConn,
rd: bufio.NewReader(tcpConn),
}
err = json.NewDecoder(c.rd).Decode(&c.Version)
if err != nil || c.Version.Release == "" {
tcpConn.Close()
return nil, errors.New("unexpected server response")
}
if c.Version.ProtoMajor < 3 {
tcpConn.Close()
return nil, ErrUnsupportedProtocolVersion
}
return c, nil
}
// Watch enables or disables the watcher mode.
//
// In watcher mode, GPS reports are dumped as TPV and SKY objects. These objects are available through the Next method.
func (c *Conn) Watch(enable bool) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return false
}
if enable == c.watchEnabled {
return enable
}
c.tcpConn.SetDeadline(time.Now().Add(30 * time.Second))
defer c.tcpConn.SetDeadline(time.Time{})
param, _ := json.Marshal(
map[string]interface{}{
"class": "WATCH",
"enable": enable,
"json": true,
})
c.send("?WATCH=%s", param)
for {
obj, err := c.next()
if err != nil {
return false
}
if watch, ok := obj.(watch); ok {
c.watchEnabled = watch.Enable
break
}
}
return c.watchEnabled
}
// Close closes the GPSd daemon connection.
func (c *Conn) Close() error {
c.Watch(false)
c.closed = true
return c.tcpConn.Close()
}
// Next returns the next object sent from the daemon, or an error.
//
// The empty interface returned can be any of the following types:
// - Sky: A Sky object reports a sky view of the GPS satellite positions.
// - TPV: A TPV object is a time-position-velocity report.
func (c *Conn) Next() (interface{}, error) {
c.mu.Lock()
defer c.mu.Unlock()
for {
obj, err := c.next()
if err != nil {
return nil, err
}
switch obj.(type) {
case TPV, Sky:
return obj, nil
default:
// Ignore other objects for now.
}
}
}
func (c *Conn) next() (interface{}, error) {
line, err := c.rd.ReadBytes('\n')
if err != nil {
return nil, err
}
return parseJSONObject(line)
}
var (
ErrTimeout = errors.New("timeout")
ErrWatchModeEnabled = errors.New("operation not available while in watch mode")
)
// NextPos returns the next reported position.
func (c *Conn) NextPos() (Position, error) {
return c.NextPosTimeout(0)
}
// NextPosTimeout returns the next reported position, or an empty position on timeout.
func (c *Conn) NextPosTimeout(timeout time.Duration) (Position, error) {
var deadline time.Time
if timeout > 0 {
deadline = time.Now().Add(timeout)
c.tcpConn.SetDeadline(deadline)
defer c.tcpConn.SetDeadline(time.Time{})
}
for {
obj, err := c.Next()
var netErr net.Error
if ok := errors.As(err, &netErr); ok && netErr.Timeout() {
return Position{}, ErrTimeout
} else if err != nil {
return Position{}, err
}
if pos, ok := obj.(Positioner); ok && pos.HasFix() {
return pos.Position(), nil
}
if !deadline.IsZero() && time.Now().After(deadline) {
return Position{}, ErrTimeout
}
}
}
// Devices returns a list of all devices GPSd is aware of.
//
// ErrWatchModeEnabled will be returned if the connection is in watch mode.
// A nil-slice will be returned if the connection has been closed.
func (c *Conn) Devices() ([]Device, error) {
if c.closed {
return nil, nil
} else if c.watchEnabled {
return nil, ErrWatchModeEnabled
}
c.mu.Lock()
defer c.mu.Unlock()
c.send("?DEVICES;")
for {
obj, err := c.next()
if err != nil {
return nil, errUnexpected(err)
}
if devs, ok := obj.([]Device); ok {
return devs, nil
}
}
}
func (c *Conn) send(s string, params ...interface{}) error {
_, err := fmt.Fprintf(c.tcpConn, s, params...)
return errUnexpected(err)
}
func errUnexpected(err error) error {
if errors.Is(err, io.EOF) {
err = io.ErrUnexpectedEOF
}
return err
}
pat-0.15.1/internal/gpsd/objects.go 0000664 0000000 0000000 00000007662 14534256521 0017135 0 ustar 00root root 0000000 0000000 package gpsd
import (
"encoding/json"
"errors"
"time"
)
// A Sky object reports a sky view of the GPS satellite positions.
type Sky struct {
Device string `json:"device,omitempty"`
Time time.Time `json:"time,omitempty"`
XDOP, YDOP, VDOP, TDOP, HDOP, PDOP, GDOP json.Number
Satellites []Satellite `json:"satellites"`
}
// A TPV object is a time-position-velocity report.
type TPV struct {
Device string // Name of originating device.
Mode NMEAMode // NMEA mode: %d, 0=no mode value yet seen, 1=no fix, 2=2D, 3=3D.
Time time.Time // Time/date stamp. May have a fractional part of up to .001sec precision. May be absent if mode is not 2D or 3D.
EPT json.Number // Estimated timestamp error (%f, seconds, 95% confidence). Present if time is present.
Lat, Lon, Alt json.Number
EPX, EPY, EPV json.Number // Lat, Lon, Alt error estimate in meters, 95% confidence. Present if mode is 2 or 3 and DOPs can be calculated from the satellite view.
Track, Speed, Climb json.Number
EPD, EPS, EPC json.Number
}
func (t TPV) Position() Position {
lat, _ := t.Lat.Float64()
lon, _ := t.Lon.Float64()
alt, _ := t.Alt.Float64()
track, _ := t.Track.Float64()
speed, _ := t.Speed.Float64()
return Position{Lat: lat, Lon: lon, Alt: alt, Track: track, Speed: speed, Time: t.Time}
}
func (t TPV) HasFix() bool { return t.Mode > ModeNoFix }
// Satellite represents a GPS satellite.
type Satellite struct {
// PRN ID of the satellite. 1-63 are GNSS satellites, 64-96 are GLONASS satellites, 100-164 are SBAS satellites.
PRN int `json:"PRN"`
// Azimuth, degrees from true north.
Azimuth json.Number `json:"az"`
// Elevation in degrees.
Elevation json.Number `json:"el"`
// Signal strength in dB.
SignalStrength json.Number `json:"ss"`
// Used in current solution?
//
// (SBAS/WAAS/EGNOS satellites may be flagged used if the solution has corrections from them, but not all drivers make this information available).
Used bool `json:"used"`
}
// Version holds GPSd version data.
type Version struct {
Release string `json:"release"`
Rev string `json:"rev"`
ProtoMajor int `json:"proto_major"`
ProtoMinor int `json:"proto_minor"`
}
// Device represents a connected sensor/GPS.
type Device struct {
Path string `json:"path,omitempty"`
Flags *int `json:"flags,omitempty"`
Driver string `json:"driver,omitempty"`
Subtype string `json:"subtype,omitempty"`
Bps *int `json:"bps,omitempty"`
Parity string `json:"parity"`
StopBits int `json:"stopbits"`
// Activated time.Time `json:"activated,omitempty"` (Must parse as fractional epoch time)
}
type watch struct {
Class string `json:"class"`
Enable bool `json:"enable,omitempty"`
JSON *bool `json:"json,omitempty"`
NMEA *bool `json:"nmea,omitempty"`
Raw *int `json:"raw,omitempty"`
Scaled *bool `json:"scaled,omitempty"`
Split24 *bool `json:"split24,omitempty"`
PPS *bool `json:"pps,omitempty"`
Device string `json:"device,omitempty"`
Devices []Device `json:"devices,omitempty"` // Only in response
}
func parseJSONObject(raw []byte) (interface{}, error) {
var class struct{ Class string }
err := json.Unmarshal(raw, &class)
if err != nil {
return nil, err
}
switch class.Class {
case "WATCH":
var w watch
err = json.Unmarshal(raw, &w)
return w, err
case "DEVICES":
var devs struct{ Devices []Device }
err = json.Unmarshal(raw, &devs)
return devs.Devices, err
case "DEVICE":
var dev Device
err = json.Unmarshal(raw, &dev)
return dev, err
case "VERSION":
var ver Version
err = json.Unmarshal(raw, &ver)
return ver, err
case "ERROR":
var err struct{ Message string }
json.Unmarshal(raw, &err)
return nil, errors.New(err.Message)
case "SKY":
var sky Sky
err = json.Unmarshal(raw, &sky)
return sky, err
case "TPV":
var tpv TPV
err = json.Unmarshal(raw, &tpv)
return tpv, err
default:
var m map[string]interface{}
err = json.Unmarshal(raw, &m)
return m, err
}
}
pat-0.15.1/internal/osutil/ 0000775 0000000 0000000 00000000000 14534256521 0015524 5 ustar 00root root 0000000 0000000 pat-0.15.1/internal/osutil/rlimit_freebsd.go 0000664 0000000 0000000 00000001150 14534256521 0021042 0 ustar 00root root 0000000 0000000 //go:build freebsd
// +build freebsd
package osutil
import (
"fmt"
"syscall"
)
// RaiseOpenFileLimit tries to maximize the limit of open file descriptors, limited by max or the OS's hard limit
func RaiseOpenFileLimit(max uint64) error {
var limit syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &limit); err != nil {
return fmt.Errorf("Could not get current limit: %v", err)
}
if limit.Cur >= limit.Max || limit.Cur >= int64(max) {
return nil
}
limit.Cur = limit.Max
if limit.Cur > int64(max) {
limit.Cur = int64(max)
}
return syscall.Setrlimit(syscall.RLIMIT_NOFILE, &limit)
}
pat-0.15.1/internal/osutil/rlimit_unix.go 0000664 0000000 0000000 00000001152 14534256521 0020415 0 ustar 00root root 0000000 0000000 //go:build !windows && !freebsd
// +build !windows,!freebsd
package osutil
import (
"fmt"
"syscall"
)
// RaiseOpenFileLimit tries to maximize the limit of open file descriptors, limited by max or the OS's hard limit
func RaiseOpenFileLimit(max uint64) error {
var limit syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &limit); err != nil {
return fmt.Errorf("could not get current limit: %w", err)
}
if limit.Cur >= limit.Max || limit.Cur >= max {
return nil
}
limit.Cur = limit.Max
if limit.Cur > max {
limit.Cur = max
}
return syscall.Setrlimit(syscall.RLIMIT_NOFILE, &limit)
}
pat-0.15.1/internal/osutil/rlimit_windows.go 0000664 0000000 0000000 00000000242 14534256521 0021123 0 ustar 00root root 0000000 0000000 //go:build windows
// +build windows
package osutil
import "fmt"
func RaiseOpenFileLimit(max uint64) error {
return fmt.Errorf("Not available for Windows")
}
pat-0.15.1/listen.go 0000664 0000000 0000000 00000012615 14534256521 0014223 0 ustar 00root root 0000000 0000000 // 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 main
import (
"context"
"log"
"net"
"strings"
"time"
"github.com/la5nta/wl2k-go/transport/ax25"
"github.com/la5nta/wl2k-go/transport/telnet"
)
func Unlisten(param string) {
methods := strings.FieldsFunc(param, SplitFunc)
for _, method := range methods {
ok, err := listenHub.Disable(method)
if err != nil {
log.Printf("Unable to close %s listener: %s", method, err)
} else if !ok {
log.Printf("No active %s listener, ignoring.\n", method)
}
}
}
func Listen(listenStr string) {
methods := strings.FieldsFunc(listenStr, SplitFunc)
for _, method := range methods {
// Rewrite the generic ax25:// scheme to use a specified AX.25 engine.
if method == MethodAX25 {
method = defaultAX25Method()
}
switch strings.ToLower(method) {
case MethodArdop:
listenHub.Enable(ARDOPListener{})
case MethodTelnet:
listenHub.Enable(TelnetListener{})
case MethodAX25AGWPE:
listenHub.Enable(&AX25AGWPEListener{})
case MethodAX25Linux:
listenHub.Enable(&AX25LinuxListener{})
case MethodVaraFM:
listenHub.Enable(VaraFMListener{})
case MethodVaraHF:
listenHub.Enable(VaraHFListener{})
case MethodAX25SerialTNC, MethodSerialTNCDeprecated:
log.Printf("%s listen not implemented, ignoring.", method)
default:
log.Printf("'%s' is not a valid listen method", method)
return
}
}
log.Printf("Listening for incoming traffic on %s...", listenStr)
}
type AX25LinuxListener struct{ stopBeacon func() }
func (l *AX25LinuxListener) Init() (net.Listener, error) {
return ax25.ListenAX25(config.AX25Linux.Port, fOptions.MyCall)
}
func (l *AX25LinuxListener) BeaconStart() error {
interval := time.Duration(config.AX25.Beacon.Every) * time.Second
if interval == 0 {
return nil
}
b, err := ax25.NewAX25Beacon(config.AX25Linux.Port, fOptions.MyCall, config.AX25.Beacon.Destination, config.AX25.Beacon.Message)
if err != nil {
return err
}
l.stopBeacon = doEvery(interval, func() {
if err := b.Now(); err != nil {
log.Printf("%s beacon failed: %s", l.Name(), err)
l.stopBeacon()
}
})
return nil
}
func (l *AX25LinuxListener) BeaconStop() {
if l.stopBeacon != nil {
l.stopBeacon()
}
}
func (l *AX25LinuxListener) CurrentFreq() (Frequency, bool) { return 0, false }
func (l *AX25LinuxListener) Name() string { return MethodAX25Linux }
type ARDOPListener struct{}
func (l ARDOPListener) Name() string { return MethodArdop }
func (l ARDOPListener) Init() (net.Listener, error) {
if err := initArdopTNC(); err != nil {
return nil, err
}
ln, err := adTNC.Listen()
if err != nil {
return nil, err
}
return ln, err
}
func (l ARDOPListener) CurrentFreq() (Frequency, bool) {
if rig, ok := rigs[config.Ardop.Rig]; ok {
f, _ := rig.GetFreq()
return Frequency(f), ok
}
return 0, false
}
func (l ARDOPListener) BeaconStart() error {
return adTNC.BeaconEvery(time.Duration(config.Ardop.BeaconInterval) * time.Second)
}
func (l ARDOPListener) BeaconStop() { adTNC.BeaconEvery(0) }
type VaraFMListener struct{}
func (l VaraFMListener) Name() string { return MethodVaraFM }
func (l VaraFMListener) Init() (net.Listener, error) {
if err := initVaraFMModem(); err != nil {
return nil, err
}
ln, err := varaFMModem.Listen()
if err != nil {
return nil, err
}
return ln, err
}
func (l VaraFMListener) CurrentFreq() (Frequency, bool) {
if rig, ok := rigs[config.VaraFM.Rig]; ok {
f, _ := rig.GetFreq()
return Frequency(f), ok
}
return 0, false
}
type VaraHFListener struct{}
func (l VaraHFListener) Name() string { return MethodVaraHF }
func (l VaraHFListener) Init() (net.Listener, error) {
if err := initVaraHFModem(); err != nil {
return nil, err
}
ln, err := varaHFModem.Listen()
if err != nil {
return nil, err
}
return ln, err
}
func (l VaraHFListener) CurrentFreq() (Frequency, bool) {
if rig, ok := rigs[config.VaraHF.Rig]; ok {
f, _ := rig.GetFreq()
return Frequency(f), ok
}
return 0, false
}
type AX25AGWPEListener struct{ stopBeacon func() }
func (l *AX25AGWPEListener) Name() string { return MethodAX25AGWPE }
func (l *AX25AGWPEListener) Init() (net.Listener, error) {
if err := initAGWPE(); err != nil {
return nil, err
}
return agwpeTNC.Listen()
}
func (l *AX25AGWPEListener) CurrentFreq() (Frequency, bool) { return 0, false }
func (l *AX25AGWPEListener) BeaconStart() error {
b := config.AX25.Beacon
interval := time.Duration(b.Every) * time.Second
l.stopBeacon = doEvery(interval, func() {
if err := agwpeTNC.SendUI([]byte(b.Message), b.Destination); err != nil {
log.Printf("%s beacon failed: %s", l.Name(), err)
l.stopBeacon()
}
})
return nil
}
func (l AX25AGWPEListener) BeaconStop() {
if l.stopBeacon != nil {
l.stopBeacon()
}
}
type TelnetListener struct{}
func (l TelnetListener) Name() string { return MethodTelnet }
func (l TelnetListener) Init() (net.Listener, error) { return telnet.Listen(config.Telnet.ListenAddr) }
func (l TelnetListener) CurrentFreq() (Frequency, bool) { return 0, false }
func doEvery(interval time.Duration, fn func()) (cancel func()) {
if interval == 0 {
return
}
ctx, cancel := context.WithCancel(context.Background())
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
fn()
}
}
}()
return cancel
}
pat-0.15.1/listener_hub.go 0000664 0000000 0000000 00000007135 14534256521 0015411 0 ustar 00root root 0000000 0000000 // Copyright 2017 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 main
import (
"log"
"net"
"sync"
"time"
)
type TransportListener interface {
Init() (net.Listener, error)
Name() string
CurrentFreq() (Frequency, bool)
}
type Beaconer interface {
BeaconStop()
BeaconStart() error
}
type Listener struct {
t TransportListener
hub *ListenerHub
mu sync.Mutex
isClosed bool
err error
ln net.Listener
}
func NewListener(t TransportListener) *Listener { return &Listener{t: t} }
func (l *Listener) Err() error {
l.mu.Lock()
defer l.mu.Unlock()
return l.err
}
func (l *Listener) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.isClosed {
return l.err
}
l.isClosed = true
// If l.err is not nil, then the last attempt to open the listener failed and we don't have anything to close
if l.err != nil {
return l.err
}
return l.ln.Close()
}
func (l *Listener) listenLoop() {
var silenceErr bool
for {
l.mu.Lock()
if l.isClosed {
l.mu.Unlock()
break
}
// Try to init the TNC
l.ln, l.err = l.t.Init()
if l.err != nil {
l.mu.Unlock()
if !silenceErr {
log.Printf("Listener %s failed: %s", l.t.Name(), l.err)
log.Printf("Will try to re-establish listener in the background...")
silenceErr = true
websocketHub.UpdateStatus()
}
time.Sleep(time.Second)
continue
}
l.mu.Unlock()
if silenceErr {
log.Printf("Listener %s re-established", l.t.Name())
silenceErr = false
websocketHub.UpdateStatus()
}
if b, ok := l.t.(Beaconer); ok {
b.BeaconStart()
}
// Run the accept loop until an error occurs
if err := l.acceptLoop(); err != nil {
log.Printf("Accept %s failed: %s", l.t.Name(), err)
}
if b, ok := l.t.(Beaconer); ok {
b.BeaconStop()
}
}
}
type RemoteCaller interface {
RemoteCall() string
}
func (l *Listener) acceptLoop() error {
for {
conn, err := l.ln.Accept()
if err != nil {
return err
}
remoteCall := conn.RemoteAddr().String()
if c, ok := conn.(RemoteCaller); ok {
remoteCall = c.RemoteCall()
}
freq, _ := l.t.CurrentFreq()
eventLog.LogConn("accept", freq, conn, nil)
log.Printf("Got connect (%s:%s)", l.t.Name(), remoteCall)
err = exchange(conn, remoteCall, true)
if err != nil {
log.Printf("Exchange failed: %s", err)
} else {
log.Println("Disconnected.")
}
}
}
type ListenerHub struct {
mu sync.Mutex
listeners map[string]*Listener
}
func NewListenerHub() *ListenerHub {
return &ListenerHub{
listeners: map[string]*Listener{},
}
}
func (h *ListenerHub) Active() []TransportListener {
h.mu.Lock()
defer h.mu.Unlock()
slice := make([]TransportListener, 0, len(h.listeners))
for _, l := range h.listeners {
if l.Err() != nil {
continue
}
slice = append(slice, l.t)
}
return slice
}
func (h *ListenerHub) Enable(t TransportListener) {
h.mu.Lock()
defer func() {
h.mu.Unlock()
websocketHub.UpdateStatus()
}()
l := NewListener(t)
if _, ok := h.listeners[t.Name()]; ok {
return
}
h.listeners[t.Name()] = l
go l.listenLoop()
}
func (h *ListenerHub) Disable(name string) (bool, error) {
if name == MethodAX25 {
name = defaultAX25Method()
}
h.mu.Lock()
defer func() {
h.mu.Unlock()
websocketHub.UpdateStatus()
}()
l, ok := h.listeners[name]
if !ok {
return false, nil
}
delete(h.listeners, name)
return true, l.Close()
}
func (h *ListenerHub) Close() {
h.mu.Lock()
defer func() {
h.mu.Unlock()
websocketHub.UpdateStatus()
}()
for k, l := range h.listeners {
l.Close()
delete(h.listeners, k)
}
}
pat-0.15.1/main.go 0000664 0000000 0000000 00000044067 14534256521 0013657 0 ustar 00root root 0000000 0000000 // 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.
// A portable Winlink client for amateur radio email.
package main
import (
"context"
"fmt"
"io"
"log"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"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/gpsd"
"github.com/la5nta/wl2k-go/catalog"
"github.com/la5nta/wl2k-go/fbb"
"github.com/la5nta/wl2k-go/mailbox"
"github.com/la5nta/wl2k-go/rigcontrol/hamlib"
"github.com/spf13/pflag"
)
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"
)
var commands = []Command{
{
Str: "connect",
Desc: "Connect to a remote station.",
HandleFunc: connectHandle,
Usage: UsageConnect,
Example: ExampleConnect,
MayConnect: true,
},
{
Str: "interactive",
Desc: "Run interactive mode.",
Usage: "[options]",
Options: map[string]string{
"--http, -h": "Start http server for web UI in the background.",
},
HandleFunc: InteractiveHandle,
MayConnect: true,
LongLived: true,
},
{
Str: "http",
Desc: "Run http server for web UI.",
Usage: "[options]",
Options: map[string]string{
"--addr, -a": "Listen address. Default is :8080.",
},
HandleFunc: httpHandle,
MayConnect: true,
LongLived: true,
},
{
Str: "compose",
Desc: "Compose a new message.",
Usage: "[options]\n" +
"\tIf no options are passed, composes interactively.\n" +
"\tIf options are passed, reads message from stdin similar to mail(1).",
Options: map[string]string{
"--from, -r": "Address to send from. Default is your call from config or --mycall, but can be specified to use tactical addresses.",
"--subject, -s": "Subject",
"--attachment , -a": "Attachment path (may be repeated)",
"--cc, -c": "CC Address(es) (may be repeated)",
"--p2p-only": "Send over peer to peer links only (avoid CMS)",
"": "Recipient address (may be repeated)",
},
HandleFunc: composeMessage,
},
{
Str: "read",
Desc: "Read messages.",
HandleFunc: func(ctx context.Context, args []string) {
readMail(ctx)
},
},
{
Str: "composeform",
Aliases: []string{"formPath"},
Desc: "Post form-based report.",
Usage: "[options]",
Options: map[string]string{
"--template": "path to the form template file. Uses the --forms directory as root. Defaults to 'ICS USA Forms/ICS213.txt'",
},
HandleFunc: composeFormReport,
},
{
Str: "position",
Aliases: []string{"pos"},
Desc: "Post a position report (GPSd or manual entry).",
Usage: "[options]",
Options: map[string]string{
"--latlon": "latitude,longitude in decimal degrees for manual entry. Will use GPSd if this is empty.",
"--comment, -c": "Comment to be included in the position report.",
},
Example: ExamplePosition,
HandleFunc: posReportHandle,
},
{
Str: "extract",
Desc: "Extract attachments from a message file.",
Usage: "file",
HandleFunc: extractMessageHandle,
},
{
Str: "rmslist",
Desc: "Print/search in list of RMS nodes.",
Usage: "[options] [search term]",
Options: map[string]string{
"--mode, -m": "Mode filter.",
"--band, -b": "Band filter (e.g. '80m').",
"--force-download, -d": "Force download of latest list from winlink.org.",
"--sort-distance, -s": "Sort by distance",
},
HandleFunc: rmsListHandle,
},
{
Str: "updateforms",
Desc: "Download the latest form templates from winlink.org.",
HandleFunc: func(ctx context.Context, args []string) {
if _, err := formsMgr.UpdateFormTemplates(ctx); err != nil {
log.Printf("%v", err)
}
},
},
{
Str: "configure",
Desc: "Open configuration file for editing.",
HandleFunc: configureHandle,
},
{
Str: "version",
Desc: "Print the application version.",
HandleFunc: func(_ context.Context, args []string) {
fmt.Printf("%s %s\n", buildinfo.AppName, buildinfo.VersionString())
},
},
{
Str: "env",
Desc: "List environment variables.",
HandleFunc: envHandle,
},
{
Str: "help",
Desc: "Print detailed help for a given command.",
// Avoid initialization loop by invoking helpHandler in main
},
}
var (
config cfg.Config
rigs map[string]hamlib.VFO
logWriter io.Writer
eventLog *EventLogger
exchangeChan chan ex // The channel that the exchange loop is listening on
exchangeConn net.Conn // Pointer to the active session connection (exchange)
mbox *mailbox.DirHandler // The mailbox
listenHub *ListenerHub
promptHub *PromptHub
formsMgr *forms.Manager
)
var fOptions struct {
IgnoreBusy bool // Move to connect?
SendOnly bool // Move to connect?
RadioOnly bool
Robust bool
MyCall string
Listen string
MailboxPath string
ConfigPath string
LogPath string
EventLogPath string
FormsPath string
}
func optionsSet() *pflag.FlagSet {
set := pflag.NewFlagSet("options", pflag.ExitOnError)
set.StringVar(&fOptions.MyCall, "mycall", "", "Your callsign (winlink user).")
set.StringVarP(&fOptions.Listen, "listen", "l", "", "Comma-separated list of methods to listen on (e.g. ardop,telnet,ax25).")
set.BoolVarP(&fOptions.SendOnly, "send-only", "s", false, "Download inbound messages later, send only.")
set.BoolVarP(&fOptions.RadioOnly, "radio-only", "", false, "Radio Only mode (Winlink Hybrid RMS only).")
set.BoolVar(&fOptions.IgnoreBusy, "ignore-busy", false, "Don't wait for clear channel before connecting to a node.")
defaultMBox := filepath.Join(directories.DataDir(), "mailbox")
defaultFormsPath := filepath.Join(directories.DataDir(), "Standard_Forms")
defaultConfigPath := filepath.Join(directories.ConfigDir(), "config.json")
defaultLogPath := filepath.Join(directories.StateDir(), strings.ToLower(buildinfo.AppName+".log"))
defaultEventLogPath := filepath.Join(directories.StateDir(), "eventlog.json")
set.StringVar(&fOptions.MailboxPath, "mbox", defaultMBox, "Path to mailbox directory.")
set.StringVar(&fOptions.FormsPath, "forms", defaultFormsPath, "Path to forms directory.")
set.StringVar(&fOptions.ConfigPath, "config", defaultConfigPath, "Path to config file.")
set.StringVar(&fOptions.LogPath, "log", defaultLogPath, "Path to log file. The file is truncated on each startup.")
set.StringVar(&fOptions.EventLogPath, "event-log", defaultEventLogPath, "Path to event log file.")
return set
}
func init() {
listenHub = NewListenerHub()
promptHub = NewPromptHub()
pflag.Usage = func() {
fmt.Fprintf(os.Stderr, "%s is a client for the Winlink 2000 Network.\n\n", buildinfo.AppName)
fmt.Fprintf(os.Stderr, "Usage:\n %s [options] command [arguments]\n", os.Args[0])
fmt.Fprintln(os.Stderr, "\nCommands:")
for _, cmd := range commands {
fmt.Fprintf(os.Stderr, " %-15s %s\n", cmd.Str, cmd.Desc)
}
fmt.Fprintln(os.Stderr, "\nOptions:")
optionsSet().PrintDefaults()
fmt.Fprint(os.Stderr, "\n")
}
}
func main() {
cmd, args := parseFlags(os.Args)
debug.Printf("Version: %s", buildinfo.VersionString())
debug.Printf("Command: %s %v", cmd.Str, args)
fOptions.MailboxPath = filepath.Clean(fOptions.MailboxPath)
fOptions.FormsPath = filepath.Clean(fOptions.FormsPath)
fOptions.ConfigPath = filepath.Clean(fOptions.ConfigPath)
fOptions.LogPath = filepath.Clean(fOptions.LogPath)
fOptions.EventLogPath = filepath.Clean(fOptions.EventLogPath)
debug.Printf("Mailbox dir is\t'%s'", fOptions.MailboxPath)
debug.Printf("Forms dir is\t'%s'", fOptions.FormsPath)
debug.Printf("Config file is\t'%s'", fOptions.ConfigPath)
debug.Printf("Log file is \t'%s'", fOptions.LogPath)
debug.Printf("Event log file is\t'%s'", fOptions.EventLogPath)
directories.MigrateLegacyDataDir()
// Graceful shutdown by cancelling background context on interrupt.
//
// If we have an active connection, cancel that instead.
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
dirtyDisconnectNext := false // So we can do a dirty disconnect on the second interrupt
for {
<-sig
if ok := abortActiveConnection(dirtyDisconnectNext); ok {
dirtyDisconnectNext = !dirtyDisconnectNext
} else {
break
}
}
cancel()
}()
// Skip initialization for some commands
switch cmd.Str {
case "help":
helpHandle(args)
return
case "configure", "version":
cmd.HandleFunc(ctx, args)
return
}
// Enable the GZIP extension experiment by default
if _, ok := os.LookupEnv("GZIP_EXPERIMENT"); !ok {
os.Setenv("GZIP_EXPERIMENT", "1")
}
// Parse configuration file
var err error
config, err = LoadConfig(fOptions.ConfigPath, cfg.DefaultConfig)
if err != nil {
log.Fatalf("Unable to load/write config: %s", err)
}
// Initialize logger
f, err := os.Create(fOptions.LogPath)
if err != nil {
log.Fatal(err)
}
logWriter = io.MultiWriter(f, os.Stdout)
log.SetOutput(logWriter)
eventLog, err = NewEventLogger(fOptions.EventLogPath)
if err != nil {
log.Fatal("Unable to open event log file:", err)
}
// Read command line options from config if unset
if fOptions.MyCall == "" && config.MyCall == "" {
fmt.Fprint(os.Stderr, "Missing mycall\n")
os.Exit(1)
} else if fOptions.MyCall == "" {
fOptions.MyCall = config.MyCall
}
// Ensure mycall is all upper case.
fOptions.MyCall = strings.ToUpper(fOptions.MyCall)
// Don't use config password if we don't use config mycall
if !strings.EqualFold(fOptions.MyCall, config.MyCall) {
config.SecureLoginPassword = ""
}
// Replace placeholders in connect aliases
for k, v := range config.ConnectAliases {
config.ConnectAliases[k] = strings.ReplaceAll(v, cfg.PlaceholderMycall, fOptions.MyCall)
}
if fOptions.Listen == "" && len(config.Listen) > 0 {
fOptions.Listen = strings.Join(config.Listen, ",")
}
// init forms subsystem
formsMgr = forms.NewManager(forms.Config{
FormsPath: fOptions.FormsPath,
MyCall: fOptions.MyCall,
Locator: config.Locator,
AppVersion: buildinfo.VersionStringShort(),
UserAgent: buildinfo.UserAgent(),
LineReader: readLine,
GPSd: config.GPSd,
})
// Make sure we clean up on exit, closing any open resources etc.
defer cleanup()
// Load the mailbox handler
loadMBox()
if cmd.MayConnect {
rigs = loadHamlibRigs()
exchangeChan = exchangeLoop()
go func() {
if config.VersionReportingDisabled {
return
}
for { // Check every 6 hours, but it won't post more frequent than 24h.
postVersionUpdate() // Ignore errors
time.Sleep(6 * time.Hour)
}
}()
}
if cmd.LongLived {
if fOptions.Listen != "" {
Listen(fOptions.Listen)
}
scheduleLoop()
}
// Start command execution
cmd.HandleFunc(ctx, args)
}
func configureHandle(ctx context.Context, args []string) {
// Ensure config file has been written
_, err := ReadConfig(fOptions.ConfigPath)
if os.IsNotExist(err) {
err = WriteConfig(cfg.DefaultConfig, fOptions.ConfigPath)
if err != nil {
log.Fatalf("Unable to write default config: %s", err)
}
}
cmd := exec.CommandContext(ctx, EditorName(), fOptions.ConfigPath)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
log.Fatalf("Unable to start editor: %s", err)
}
}
func InteractiveHandle(ctx context.Context, args []string) {
var http string
set := pflag.NewFlagSet("interactive", pflag.ExitOnError)
set.StringVar(&http, "http", "", "HTTP listen address")
set.Lookup("http").NoOptDefVal = config.HTTPAddr
set.Parse(args)
if http == "" {
Interactive(ctx)
return
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
if err := ListenAndServe(ctx, http); err != nil {
log.Println(err)
}
}()
time.Sleep(time.Second)
Interactive(ctx)
}
func httpHandle(ctx context.Context, args []string) {
addr := config.HTTPAddr
if addr == "" {
addr = ":8080" // For backwards compatibility (remove in future)
}
set := pflag.NewFlagSet("http", pflag.ExitOnError)
set.StringVarP(&addr, "addr", "a", addr, "Listen address.")
set.Parse(args)
if addr == "" {
set.Usage()
os.Exit(1)
}
promptHub.OmitTerminal(true)
if err := ListenAndServe(ctx, addr); err != nil {
log.Println(err)
}
}
func connectHandle(_ context.Context, args []string) {
if args[0] == "" {
fmt.Println("Missing argument, try 'connect help'.")
}
if success := Connect(args[0]); !success {
os.Exit(1)
}
}
func helpHandle(args []string) {
arg := args[0]
var cmd *Command
for _, c := range commands {
if c.Str == arg {
cmd = &c
break
}
}
if arg == "" || cmd == nil {
pflag.Usage()
return
}
cmd.PrintUsage()
}
func cleanup() {
debug.Printf("Starting cleanup")
defer debug.Printf("Cleanup done")
abortActiveConnection(false)
listenHub.Close()
if adTNC != nil {
if err := adTNC.Close(); err != nil {
log.Printf("Failure to close ardop TNC: %s", err)
}
}
if pModem != nil {
if err := pModem.Close(); err != nil {
log.Printf("Failure to close pactor modem: %s", err)
}
}
if varaFMModem != nil {
if err := varaFMModem.Close(); err != nil {
log.Printf("Failure to close varafm modem: %s", err)
}
}
if varaHFModem != nil {
if err := varaHFModem.Close(); err != nil {
log.Printf("Failure to close varahf modem: %s", err)
}
}
if agwpeTNC != nil {
if err := agwpeTNC.Close(); err != nil {
log.Printf("Failure to close AGWPE TNC: %s", err)
}
}
eventLog.Close()
}
func loadMBox() {
mbox = mailbox.NewDirHandler(
filepath.Join(fOptions.MailboxPath, fOptions.MyCall),
fOptions.SendOnly,
)
// Ensure the mailbox handler is ready
if err := mbox.Prepare(); err != nil {
log.Fatal(err)
}
}
func loadHamlibRigs() map[string]hamlib.VFO {
rigs := make(map[string]hamlib.VFO, len(config.HamlibRigs))
for name, conf := range config.HamlibRigs {
if conf.Address == "" {
log.Printf("Missing address-field for rig '%s', skipping.", name)
continue
}
if conf.Network == "" {
conf.Network = "tcp"
}
rig, 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 = rig.VFOA()
case "B", "VFOB":
vfo, err = rig.VFOB()
case "":
vfo = rig.CurrentVFO()
default:
log.Printf("Cannot load rig '%s': Unrecognized VFO identifier '%s'", name, conf.VFO)
continue
}
if err != nil {
log.Printf("Cannot load rig '%s': Unable to select VFO: %s", name, err)
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))
}
rigs[name] = vfo
}
return rigs
}
func extractMessageHandle(_ context.Context, args []string) {
if len(args) == 0 || args[0] == "" {
panic("TODO: usage")
}
file, _ := os.Open(args[0])
defer file.Close()
msg := new(fbb.Message)
if err := msg.ReadFrom(file); err != nil {
log.Fatal(err)
} else {
fmt.Println(msg)
for _, f := range msg.Files() {
if err := os.WriteFile(f.Name(), f.Data(), 0o664); err != nil {
log.Fatal(err)
}
}
}
}
func EditorName() string {
if e := os.Getenv("EDITOR"); e != "" {
return e
} else if e := os.Getenv("VISUAL"); e != "" {
return e
}
switch runtime.GOOS {
case "windows":
return "notepad"
case "linux":
if path, err := exec.LookPath("editor"); err == nil {
return path
}
}
return "vi"
}
func posReportHandle(ctx context.Context, args []string) {
var latlon, comment string
set := pflag.NewFlagSet("position", pflag.ExitOnError)
set.StringVar(&latlon, "latlon", "", "")
set.StringVarP(&comment, "comment", "c", "", "")
set.Parse(args)
report := catalog.PosReport{Comment: comment}
if latlon != "" {
parts := strings.Split(latlon, ",")
if len(parts) != 2 {
log.Fatal(`Invalid position format. Expected "latitude,longitude".`)
}
lat, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
log.Fatal(err)
}
report.Lat = &lat
lon, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
log.Fatal(err)
}
report.Lon = &lon
} else if config.GPSd.Addr != "" {
conn, err := gpsd.Dial(config.GPSd.Addr)
if err != nil {
log.Fatalf("GPSd daemon: %s", err)
}
defer conn.Close()
conn.Watch(true)
posChan := make(chan gpsd.Position)
go func() {
defer close(posChan)
pos, err := conn.NextPos()
if err != nil {
log.Printf("GPSd: %s", err)
return
}
posChan <- pos
}()
log.Println("Waiting for position from GPSd...") // TODO: Spinning bar?
var pos gpsd.Position
select {
case p := <-posChan:
pos = p
case <-ctx.Done():
log.Println("Cancelled")
return
}
report.Lat = &pos.Lat
report.Lon = &pos.Lon
if config.GPSd.UseServerTime {
report.Date = time.Now()
} else {
report.Date = pos.Time
}
// Course and speed is part of the spec, but does not seem to be
// supported by winlink.org anymore. Ignore it for now.
if false && pos.Track != 0 {
course := CourseFromFloat64(pos.Track, false)
report.Course = &course
}
} else {
fmt.Println("No position available. See --help")
os.Exit(1)
}
if report.Date.IsZero() {
report.Date = time.Now()
}
postMessage(report.Message(fOptions.MyCall))
}
func CourseFromFloat64(f float64, magnetic bool) catalog.Course {
c := catalog.Course{Magnetic: magnetic}
str := fmt.Sprintf("%03.0f", f)
for i := 0; i < 3; i++ {
c.Digits[i] = str[i]
}
return c
}
func postMessage(msg *fbb.Message) {
if err := msg.Validate(); err != nil {
fmt.Printf("WARNING - Message does not validate: %s\n", err)
}
if err := mbox.AddOut(msg); err != nil {
log.Fatal(err)
}
fmt.Println("Message posted")
}
pat-0.15.1/make.bash 0000775 0000000 0000000 00000004766 14534256521 0014165 0 ustar 00root root 0000000 0000000 #!/usr/bin/env bash
set -e
export GO111MODULE=on
if [ -d $GOOS ]; then OS=$(go env GOOS); else OS=$GOOS; fi
if [ -d $CGO_ENABLED ]; then CGO_ENABLED=$(go env CGO_ENABLED); else OS=$CGO_ENABLED; fi
GITREV=$(git rev-parse --short HEAD)
VERSION=$(grep "Version =" internal/buildinfo/VERSION.go|cut -d '"' -f2)
# Go 1.19 or later is required
GO_POINT_VERSION=$(go version| perl -ne 'm/go1\.(\d+)/; print $1;')
[ "$GO_POINT_VERSION" -lt "19" ] && echo "Go 1.19 or later required" && exit 1;
AX25VERSION="0.0.12-rc4"
AX25DIST="libax25-${AX25VERSION}"
AX25DIST_URL="http://http.debian.net/debian/pool/main/liba/libax25/libax25_${AX25VERSION}.orig.tar.gz"
function install_libax25 {
mkdir -p .build && cd .build
[[ -f "${AX25DIST}" ]] || curl -LSsf "${AX25DIST_URL}" | tar zx
cd "${AX25DIST}/" && ./configure --prefix=/ && make && cd ../../
}
function build_web {
cd web
if [ -d $NVM_DIR ]; then
source $NVM_DIR/nvm.sh
nvm install
nvm use
fi
npm install
npm run production
}
[[ "$1" == "libax25" ]] && install_libax25 && exit 0;
[[ "$1" == "web" ]] && build_web && exit 0;
# Link against libax25 (statically) on Linux
if [[ "$OS" == "linux"* ]] && [[ "$CGO_ENABLED" == "1" ]]; then
TAGS="libax25 $TAGS"
LIB=".build/${AX25DIST}/.libs/libax25.a"
if [[ -z "$CGO_LDFLAGS" ]] && [[ -f "$LIB" ]]; then
export CGO_CFLAGS="-I$(pwd)/.build/${AX25DIST}"
export CGO_LDFLAGS="$(pwd)/${LIB}"
fi
if [[ -z "$CGO_LDFLAGS" ]]; then
echo "WARNING: No static libax25 library available."
echo " Linking against shared library instead. To fix"
echo " this issue, set CGO_LDFLAGS to the full path of"
echo " libax25.a, or run 'make.bash libax25' to download"
echo " and compile ${AX25DIST} in .build/"
else
TAGS="static $TAGS"
fi
else
if [[ "$OS" == "linux"* ]]; then
echo "WARNING: CGO unavailable. libax25 (ax25+linux) will not be supported with this build."
fi
fi
echo -e "Downloading Go dependencies..."
go mod download
echo "Running tests..."
if [[ "$SKIP_TESTS" == "1" ]]; then
echo "Skipping."
else
go test -tags "$TAGS" ./... github.com/la5nta/wl2k-go/...
fi
echo
echo "Building Pat v$VERSION..."
go build -tags "$TAGS" -ldflags "-X \"github.com/la5nta/pat/internal/buildinfo.GitRev=$GITREV\"" $(go list .)
# Build macOS pkg
if [[ "$OS" == "darwin"* ]] && command -v packagesbuild >/dev/null 2>&1; then
ARCH=$(go env GOARCH)
echo "Generating macOS installer package..."
packagesbuild osx/pat.pkgproj
mv 'Pat :: A Modern Winlink Client.pkg' "pat_${VERSION}_darwin_${ARCH}_unsigned.pkg"
fi
echo -e "Enjoy!"
pat-0.15.1/man/ 0000775 0000000 0000000 00000000000 14534256521 0013144 5 ustar 00root root 0000000 0000000 pat-0.15.1/man/pat-configure.1 0000664 0000000 0000000 00000001167 14534256521 0015776 0 ustar 00root root 0000000 0000000 .TH PAT 1 "2017-09-04" "" "Pat Configure"
.SH NAME
pat configure \- opens Pat's configuration file using the system default editor
.SH Configuration
.SS Main Configuration
The current configuration file is located in the \fIPAT_CONFIG_PATH\fP returned from \fIpat env\fP
.sp 1
To get "on the air" you'll first have to set up your callsign, maidenhead locator, and secure login credentials. Look for the attributes \fImycall\fP, \fIlocator\fP and \fIsecure_login_password\fP and set them appropriately.
.sp 1
.in 20
{
"mycall": "LA5NTA",
"locator": "JP20qe",
"secure_login_password": "MYPASSWORD",
}
.in
.SH "See Also"
pat(1)
pat-0.15.1/man/pat.1 0000664 0000000 0000000 00000003332 14534256521 0014013 0 ustar 00root root 0000000 0000000 .TH PAT 1 "2017-09-04" "" "Pat Overview"
.SH NAME
pat \- a cross platform Winlink client with basic messaging capabilities
.SH SYNOPSIS
\fBpat\fP [options] \fIcommand\fP [arguments]
.SS Commands
.TP
\fIconnect\fP
Connect to a remote station.
.TP
\fIinteractive\fP
Run interactive mode.
.TP
\fIhttp\fP
Run http server for web gui.
.TP
\fIcompose\fP
Compose a new message.
.TP
\fIcomposeform\fP
Compose a new message based on a Winlink-style form, e.g. the ICS213 form.
.TP
\fIread\fP
Read Messages.
.TP
\fIposition\fP
Post a position report (GPSd or manual entry).
.TP
\fIextract\fP
Extract attachments from a message file.
.TP
\fIrmslist\fP
Print/search in list of RMS nodes.
.TP
\fIconfigure\fP
Open configuration file for editing.
.TP
\fIversion\fP
Print the application version.
.TP
\fIhelp\fP
Print detailed help for a given command.
.SS Options
.TP
\fR--config string\fP
Path to config file (located in the \fIPAT_CONFIG_PATH\fP returned from \fIpat env\fP).
.TP
\fR--event-log string\fP
Path to event log file (located in the \fIPAT_CONFIG_PATH\fP returned from \fIpat env\fP).
.TP
\fR--ignore-busy\fP
Don't wait for clear channel before connevting to a node.
.TP
\fR-l, --listen string\fP
Comma-separated list of methods to listen on (e.g. ardop,telnet,ax25).
.TP
\fR--log string\fP
Path to log file. The file is truncated on each startup (located in the \fIPAT_LOG_PATH\fP returned from \fIpat env\fP).
.TP
\fR--mbox string\fP
Path to mailbox directory (located in the \fIPAT_MAILBOX_PATH\fP returned from \fIpat env\fP).
.TP
\fR--mycall string\fP
Your callsing (winlink user).
.TP
\fR--radio-only\fP
Radio Only mode (Winlink Hybrid RMS only).
.TP
\fR-s, --send-only\fP
Download inbound messages later, send only.
.SH "See Also"
pat-configure(1)
pat-0.15.1/osx/ 0000775 0000000 0000000 00000000000 14534256521 0013202 5 ustar 00root root 0000000 0000000 pat-0.15.1/osx/Pat-Info.rtfd/ 0000775 0000000 0000000 00000000000 14534256521 0015555 5 ustar 00root root 0000000 0000000 pat-0.15.1/osx/Pat-Info.rtfd/TXT.rtf 0000664 0000000 0000000 00000001067 14534256521 0016755 0 ustar 00root root 0000000 0000000 {\rtf1\ansi\ansicpg1252\cocoartf1404\cocoasubrtf470
{\fonttbl\f0\fswiss\fcharset0 Helvetica;\f1\fnil\fcharset0 Menlo-Regular;}
{\colortbl;\red255\green255\blue255;}
\margl1440\margr1440\vieww12540\viewh16140\viewkind1
\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\qc\partightenfactor0
\f0\fs24 \cf0 This will install
\f1 pat
\f0 into
\f1 /usr/local/bin
\f0 . \
To run
\f1 pat
\f0 , use Terminal.app in the
\f1 /Applications/Utilities
\f0 folder.\
\
\fs26 For more help and information, visit getpat.io} pat-0.15.1/osx/Pat-License.txt 0000664 0000000 0000000 00000002117 14534256521 0016050 0 ustar 00root root 0000000 0000000 The MIT License (MIT)
Copyright (c) 2014-2017 Martin Hebnes Pedersen (LA5NTA)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
pat-0.15.1/osx/Pat-Welcome.rtfd/ 0000775 0000000 0000000 00000000000 14534256521 0016255 5 ustar 00root root 0000000 0000000 pat-0.15.1/osx/Pat-Welcome.rtfd/Pasted Graphic.tiff 0000664 0000000 0000000 00000044132 14534256521 0021711 0 ustar 00root root 0000000 0000000 MM * ;8 P8$
BaPd6DbQ8V-FcQv=HdR9$M'JeRd]/LfS9m7NgS}?PhT:%GRiTe6OTjU:VWVkUv_XlV;%gZmVeo\nW;w^oW`pX<&
bqXf7drY'ry\g7tz]>Wv{]wx|^?'z}^g|~_?~_,
Lj P*3
CNDB Dq4J Ds
B4:`BBT; @"!H(dCN~g'{' B} r&DQ\\4f JB: ;6 @l:Gq"R8?I/S s ' R*CByg(Irx|UE104 r8B , :
B}PRȀ924OjAp
'B?* T>v'U0u gMLRP"s7 PbLg3X0
bLP_%gCo0v g!4,q .S\ pETF;hpWrE AT.
CNŀ3wŜDݑI#gE&i5M`
9COD0!PiUUnK`osMjO"vAgvu]'o`5K T9O)Dh $&)}VY T``^* 1
#2ŊT 0e>oAP'Y$MLqWPěqQ*2i `{H8㢂2 }$GkHϚiF(3PB>4ۅb*!Fqj
{T )|foH'ˣk>p dzKc`E` h(079Nsuq2.xgVUx$!zN"1 PjZDlGε]%,n$U
[WN#=y1c-A'XV tu (y$ eE,5}=('an/^s\cYֵs]Eؔ{(. ; L)Tс"j e4M?8#?TvWxXcFh&J ƕ`8#mk4j>ns8Xi[W _c0tSюKhWv
jgŠ'ևyXz4ZWa:((ۆ@|y8'
ݍmXPS>c_F"Drڟ-drX k J95u|8YbN3^q-kr 3-usn"M>&Ec{^=:yȪ`3o3"*`~!}civؒ0{Z]A#~(ܡnCMˍ@X!H3l5,zg+EUr
Ql4KaM=f?I3
}@˜3&$ۂ_A`,5~aڱ/h DR!J @К=2F\V~a꾆d^Ƒ@)Q AlP5G'g!ëE}BZ0HoY259S@6E>GJw5$5зZmA" h$08UCwPnK]g @))4X&,p_`%/U3e*e٪0GbD0b"]I{Pw7}kdp~2 <Q'2_P-]}7-+ jjSde&
6x/DSόcό, # gIՀb
|
0S{VX?"q)* ^m4Y!h H7UBU7!l:u{O2Gd>Pc ,Iʦ C*Z&` #w!Wl-8 a=Ҕd#/E1bxeFPouj` pirGg,!o(g$C.rhʦ]@~< "@`*G^ty?C'oW Uh<~<@]p
Cl
,pgSk\P?d7@ ZVJH E4Z"sF.Ay@{"CP C pTe`%ri$Vv&4$
MP)@8~dD p=N(3h
A>pqP[6:0:pY0"zSEk*!2@SN24cnBxO />/OF((D8wLP, yC /дalCo<\t#>GbKq*@菶k)]+{@P\o@(G#S2@ٕIիM#@ EEѐ08D:| <`y.z Za EBQ
1 J,I¹MM[`1Q .l0eNy`>!0ݹ bX h`L6<iMI!+RUȬ8 *
^*|'{7T?M!¿-`]v5x،+m" hgjX3辘
Zwz 302+x`/î/ 0R(S c8dhxpy3B/a}[4j2Ո(
¨@ Hyw. X}8P hj c HNO kl;Xz2z)`+@4**+D 9 ~|P=<