1
0
mirror of https://github.com/craigerl/aprsd.git synced 2026-08-16 00:23:34 -04:00

Compare commits

...

57 Commits

Author SHA1 Message Date
hemna dfd3688d8f Changelog updates for v3.1.0
This patch is an update to the Changelog for the
3.1.0 release.
2023-07-24 11:22:53 -04:00
hemna c7d629f88a Use CONF.admin.web_port for single launch web admin
This patch changes the non uwsgi launch of the admin page
to use the config for the web port
2023-07-24 09:39:16 -04:00
hemna 099b87e250 Fixed sio namespace registration 2023-07-23 20:22:48 -04:00
hemna 1ab9c3fee4 Update Dockerfile-dev to include uwsgi 2023-07-24 00:13:28 +00:00
hemna 8891cd3002 Merge pull request #124 from craigerl/wsgi-rework
replacement of flask-socketio with python-socketio
2023-07-23 19:45:51 -04:00
hemna 4664ead9e7 Fixed pep8 2023-07-23 19:34:02 -04:00
hemna e51a501544 change port to 8000 2023-07-23 19:19:55 -04:00
hemna 89576a3c43 replacement of flask-socketio with python-socketio
This patch starts the work to replace flask-socketio with
python-socketio so that uwsgi can be used instead of gunicorn.
uwsgi can support websockets.

Have to rework webchat command next
2023-07-23 18:54:23 -04:00
hemna 5383b698ea Change how fetch-stats gets it's defaults
The defaults come from the aprsd.conf CONF attributes now.
2023-07-22 17:05:11 -04:00
hemna cbef93b327 Ensure fetch-stats ip is a string 2023-07-22 16:41:54 -04:00
hemna 6ae55fc9a1 Add info logging for rpc server calls 2023-07-20 16:43:31 -04:00
hemna 588e140a7f updated wsgi config default /config/aprsd.conf
This patch changes wsgi.py to default to /config/aprsd.conf

It's assumed that this will be used as a docker container
2023-07-20 15:59:44 -04:00
hemna d251a2727a Merge pull request #123 from craigerl/flask-update
Remove flask pinning
2023-07-20 15:10:07 -04:00
hemna d3a93b735d Added timing after each thread loop
This is to help keep track of which non-blocking threads are still
alive.

The RPC Server thread blocks, so the time will always increase.
2023-07-20 14:44:46 -04:00
hemna fa452cc773 Update docker bin/admin.sh
This patch uses the wsgi.py instead of admin_Web.py
2023-07-20 14:34:32 -04:00
hemna 6a6e854caf Removed flask-classful from webchat
This patch removed the dependency on flask-classful.  This required
making all of the flask web routing non class based.

This patch also changes the aprsis class to allow retries for failed
connections when the aprsis servers are full and not responding to
login requests.
2023-07-20 14:34:31 -04:00
hemna e1183a7e30 Remove flask pinning
Also removed need for flask-classful. Created new
aprsd/wsgi.py for the web admin interface.
2023-07-20 14:34:31 -04:00
hemna 5723e3a77b removed linux/arm/v8 2023-07-20 14:33:59 -04:00
hemna dee73c1060 Update master build to include linux/arm/v8 2023-07-17 22:08:00 +00:00
hemna d8318f2ae2 Update Dockerfile-dev to fix plugin permissions
This patch changes the user creation to include creating
a home directory so the plugin install installs those plugins
as a --user option.
2023-07-17 21:57:39 +00:00
hemna 2825cac446 Merge pull request #122 from craigerl/crypto-upgrade
Update requirements for upgraded cryptography
2023-07-17 16:56:30 -04:00
hemna fa6e738a20 update manual build github 2023-07-17 13:05:31 -04:00
hemna 0c179005ee Update requirements for upgraded cryptography
This patch updates the requirements.in to remove the
pinning to cryptography 38.0.1.  Lets see if the docker
images build.
2023-07-17 11:15:50 -04:00
hemna ad004633de Added more libs for Dockerfile-dev 2023-07-17 14:09:37 +00:00
hemna ccd564a52e Replace Dockerfile-dev with python3 slim 2023-07-17 13:28:34 +00:00
hemna 35d41582ee Moved logging to log for wsgi.py
Added wsgi.py to be used with gunicorn to start aprsd's web admin
interface.

gunicorn -b :8080 "aprsd.wsgi:app"
2023-07-16 16:32:39 -04:00
hemna 565ffe3f72 Changed weather plugin regex pattern
The weather plugins used to match on w, but now require wx
2023-07-15 18:22:24 -04:00
hemna 0bd11d05c6 Limit the float values to 3 decimal places 2023-07-14 11:35:32 -04:00
hemna 62eff8645d Merge pull request #119 from craigerl/wx-fixes
Fixed rain numbers from aprslib
2023-07-14 11:18:11 -04:00
hemna aa547cbef5 Fixed rain numbers from aprslib 2023-07-14 10:42:36 -04:00
hemna 7f2aba702a Fixed rpc client initialization 2023-07-13 14:58:12 -04:00
hemna 63bf82aab5 Fix in for aprslib issue #80
aprslib incorrectly decodes weather packets and doesn't provide
wind_speed or wind_direction from the CSE/SPD 7 bytes in the APRS
packet.  This patch puts a temporary fix in place until the
aprslib pull request lands and is released.

https://github.com/rossengeorgiev/aprs-python/issues/80

https://github.com/rossengeorgiev/aprs-python/pull/81
2023-07-13 14:35:55 -04:00
hemna bba7b68112 Try and fix Dockerfile-dev 2023-07-10 16:34:14 +00:00
hemna 005675cb46 Fixed pep8 errors 2023-07-10 11:01:41 -04:00
hemna 191e1ff552 Populate stats object with threads info
This patch adds the thread names and state to the stats object
so the aprsd fetch-stats command can show it.
2023-07-10 10:44:24 -04:00
hemna 0a14b07fae added counts to the fetch-stats table 2023-07-09 21:29:29 -04:00
hemna b2e621da4b Added the fetch-stats command
You can now fetch and view the stats of a live running aprsd server
if it has enabled the rpc server in the config file's rpc_settings
block.
You just have to match the magic word as specified in the config file to
authorize against the rpc server.

aprsd fetch-stats --ip-address <ip of aprsd> --port <port> --magic-word
<magic word>
2023-07-09 21:06:57 -04:00
hemna fe0d71de4d Replace ratelimiter with rush
This patch replaces the ratelimiter library with rush for rate limiting
as the ratelimiter package doesn't work with python 3.11.

This patch also refactors the flask.pu to admin_web.py and
aprsd.py to main.py
2023-07-08 17:30:22 -04:00
hemna 9b944142bd Added some utilities to Dockerfile-dev
This patch adds telnet, sudo and vim to the development
Dockerfile-dev file for testing aprsd in a container
2023-06-22 15:51:45 -04:00
hemna b172c692a1 add arm64 for manual github build 2023-06-22 10:44:08 -04:00
hemna 311cebaf27 Added manual master build 2023-06-22 10:08:28 -04:00
hemna f4d60357ee Update master-build.yml
undo
2023-06-22 10:04:59 -04:00
hemna 09a0c4cb02 Add github manual trigger for master build 2023-06-22 10:03:39 -04:00
hemna 80b85e648f Fixed unit tests for Location plugin 2023-06-22 09:06:55 -04:00
hemna 9931c8a6c5 USe new tox and update githubworkflows
This patch updates tox to the latest and updates the github workflows
to use tox-gh, which is claimed to work with github parallel tox runs
2023-06-22 07:58:35 -04:00
hemna 319969cc08 Updated requirements 2023-06-21 19:12:38 -04:00
hemna da20ff038b force tox to 4.3.5 2023-06-21 19:09:26 -04:00
hemna 15bf3710d2 Update github workflows
removed building for arm64
2023-06-21 18:55:32 -04:00
hemna 5bc589f21f Fixed pep8 violation
This patch fixes a pep8 violation in the location plugin
2023-06-21 18:51:53 -04:00
hemna 8b73372b6e Added rpc server for listen
Added the ability to start the rpc server for fetching stats from the
listen command.  If the rpc server is enabled in config, the rpc
server will now start.
2023-06-21 18:48:08 -04:00
hemna 26c1e7afbb Update location plugin and reworked requirements
Added geopy as a dependency for the location plugin.
The us weather service API is now broken upstream.

Reworked the requirements.txt and dev-requirements.txt files
2023-06-15 16:08:28 -04:00
hemna c99d5b859e Merge pull request #116 from jhmartin/fix-example-plugin
Example plugin wrong function
2023-06-14 11:26:38 -04:00
hemna cad22e1744 Fixed .readthedocs.yaml format 2023-06-14 09:31:19 -04:00
hemna 43d6b62760 Add .readthedocs.yaml
Read the docs service is now requiring the config file
.readthedocs.yaml for it to be able to build the online documentation
for a project.
2023-06-14 09:11:39 -04:00
Jason Martin 96fa4330ba Example plugin wrong function
The example plugin, used verbatim, complains about an abstract class. The interface requires 'process'  not 'command'.
2023-05-19 22:53:15 +00:00
hemna 4e99e30f16 Ensure conf is imported for threads/tx
Import the conf for threads/tx.py to ensure that the
msg_rate_limit_period is defined prior to the conf entry
being referenced.
2023-05-05 11:07:23 -04:00
hemna 00f1c3a2ba Update Dockerfile to help build cryptography 2023-04-26 14:31:50 +00:00
54 changed files with 1262 additions and 395 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Manual Build docker container
on:
workflow_dispatch:
inputs:
logLevel:
description: 'Log level'
required: true
default: 'warning'
type: choice
options:
- info
- warning
- debug
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Get Branch Name
id: branch-name
uses: tj-actions/branch-names@v7
- name: Extract Branch
id: extract_branch
run: |
echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT
- name: What is the selected branch?
run: |
echo "Selected Branch '${{ steps.extract_branch.outputs.branch }}'"
- name: Setup QEMU
uses: docker/setup-qemu-action@v2
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker HUB
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build the Docker image
uses: docker/build-push-action@v3
with:
context: "{{defaultContext}}:docker"
platforms: linux/amd64,linux/arm64
file: ./Dockerfile-dev
build-args: |
BRANCH=${{ steps.extract_branch.outputs.branch }}
BUILDX_QEMU_ENV=true
push: true
tags: |
hemna6969/aprsd:${{ steps.extract_branch.outputs.branch }}
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10"]
python-version: ["3.8", "3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
@@ -27,7 +27,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
pip install tox tox-gh>=1.2
- name: Test with tox
run: tox
+1 -1
View File
@@ -17,6 +17,6 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
pip install tox tox-gh>=1.2
- name: Test with tox
run: tox
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
uses: docker/build-push-action@v3
with:
context: "{{defaultContext}}:docker"
platforms: linux/amd64,linux/arm64,linux/arm/v7
platforms: linux/amd64,linux/arm64
file: ./Dockerfile
build-args: |
VERSION=${{ inputs.aprsd_version }}
+23
View File
@@ -0,0 +1,23 @@
---
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
# We recommend specifying your dependencies to enable reproducible builds:
# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
python:
install:
- requirements: dev-requirements.txt
+56
View File
@@ -1,9 +1,65 @@
CHANGES
=======
v3.1.0
------
* Use CONF.admin.web\_port for single launch web admin
* Fixed sio namespace registration
* Update Dockerfile-dev to include uwsgi
* Fixed pep8
* change port to 8000
* replacement of flask-socketio with python-socketio
* Change how fetch-stats gets it's defaults
* Ensure fetch-stats ip is a string
* Add info logging for rpc server calls
* updated wsgi config default /config/aprsd.conf
* Added timing after each thread loop
* Update docker bin/admin.sh
* Removed flask-classful from webchat
* Remove flask pinning
* removed linux/arm/v8
* Update master build to include linux/arm/v8
* Update Dockerfile-dev to fix plugin permissions
* update manual build github
* Update requirements for upgraded cryptography
* Added more libs for Dockerfile-dev
* Replace Dockerfile-dev with python3 slim
* Moved logging to log for wsgi.py
* Changed weather plugin regex pattern
* Limit the float values to 3 decimal places
* Fixed rain numbers from aprslib
* Fixed rpc client initialization
* Fix in for aprslib issue #80
* Try and fix Dockerfile-dev
* Fixed pep8 errors
* Populate stats object with threads info
* added counts to the fetch-stats table
* Added the fetch-stats command
* Replace ratelimiter with rush
* Added some utilities to Dockerfile-dev
* add arm64 for manual github build
* Added manual master build
* Update master-build.yml
* Add github manual trigger for master build
* Fixed unit tests for Location plugin
* USe new tox and update githubworkflows
* Updated requirements
* force tox to 4.3.5
* Update github workflows
* Fixed pep8 violation
* Added rpc server for listen
* Update location plugin and reworked requirements
* Fixed .readthedocs.yaml format
* Add .readthedocs.yaml
* Example plugin wrong function
* Ensure conf is imported for threads/tx
* Update Dockerfile to help build cryptography
v3.0.3
------
* Update Changelog to 3.0.3
* cleanup some debug messages
* Fixed loading of plugins for server
* Don't load help plugin for listen command
+2 -2
View File
@@ -9,8 +9,8 @@ include Makefile.venv
Makefile.venv:
curl \
-o Makefile.fetched \
-L "https://github.com/sio/Makefile.venv/raw/v2022.07.20/Makefile.venv"
echo "147b164f0cbbbe4a2740dcca6c9adb6e9d8d15b895be3998697aa6a821a277d8 *Makefile.fetched" \
-L "https://raw.githubusercontent.com/sio/Makefile.venv/master/Makefile.venv"
echo " fb48375ed1fd19e41e0cdcf51a4a0c6d1010dfe03b672ffc4c26a91878544f82 *Makefile.fetched" \
| sha256sum --check - \
&& mv Makefile.fetched Makefile.venv
+14
View File
@@ -289,6 +289,20 @@ LOCATION
AND... ping, fortune, time.....
Web Admin Interface
===================
To start the web admin interface, You have to install gunicorn in your virtualenv that already has aprsd installed.
::
source <path to APRSD's virtualenv>/bin/activate
pip install gunicorn
gunicorn --bind 0.0.0.0:8080 "aprsd.wsgi:app"
The web admin interface will be running on port 8080 on the local machine. http://localhost:8080
Development
===========
+23 -10
View File
@@ -14,7 +14,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
import aprsd
from aprsd import cli_helper, client, conf, packets, plugin, threads
from aprsd.logging import rich as aprsd_logging
from aprsd.log import rich as aprsd_logging
from aprsd.rpc import client as aprsd_rpc_client
@@ -335,13 +335,26 @@ def init_flask(loglevel, quiet):
return socketio, flask_app
def create_app(config_file=None, log_level=None):
global socketio
global app
default_config_file = cli_helper.DEFAULT_CONFIG_FILE
if not config_file:
config_file = default_config_file
CONF(
[], project="aprsd", version=aprsd.__version__,
default_config_files=[config_file],
)
if not log_level:
log_level = CONF.logging.log_level
socketio, app = init_flask(log_level, False)
setup_logging(app, log_level, False)
return app
if __name__ == "aprsd.flask":
try:
default_config_file = cli_helper.DEFAULT_CONFIG_FILE
CONF(
[], project="aprsd", version=aprsd.__version__,
default_config_files=[default_config_file],
)
except cfg.ConfigFilesNotFoundError:
pass
sio, app = init_flask("DEBUG", False)
sio, app = create_app()
+1 -1
View File
@@ -8,7 +8,7 @@ from oslo_config import cfg
import aprsd
from aprsd import conf # noqa: F401
from aprsd.logging import log
from aprsd.log import log
from aprsd.utils import trace
+5 -2
View File
@@ -62,6 +62,8 @@ class Client:
"""Call this to force a rebuild/reconnect."""
if self._client:
del self._client
else:
LOG.warning("Client not initialized, nothing to reset.")
@abc.abstractmethod
def setup_connection(self):
@@ -144,7 +146,7 @@ class APRSISClient(Client):
try:
LOG.info("Creating aprslib client")
aprs_client = aprsis.Aprsdis(user, passwd=password, host=host, port=port)
# Force the logging to be the same
# Force the log to be the same
aprs_client.logger = LOG
aprs_client.connect()
connected = True
@@ -152,9 +154,10 @@ class APRSISClient(Client):
except LoginError as e:
LOG.error(f"Failed to login to APRS-IS Server '{e}'")
connected = False
raise e
time.sleep(backoff)
except Exception as e:
LOG.error(f"Unable to connect to APRS-IS server. '{e}' ")
connected = False
time.sleep(backoff)
backoff = backoff * 2
continue
+11 -6
View File
@@ -112,23 +112,23 @@ class Aprsdis(aprslib.IS):
self._sendall(login_str)
self.sock.settimeout(5)
test = self.sock.recv(len(login_str) + 100)
self.logger.debug("Server: '%s'", test)
if is_py3:
test = test.decode("latin-1")
test = test.rstrip()
self.logger.debug("Server: %s", test)
self.logger.debug("Server: '%s'", test)
a, b, callsign, status, e = test.split(" ", 4)
if not test:
raise LoginError(f"Server Response Empty: '{test}'")
_, _, callsign, status, e = test.split(" ", 4)
s = e.split(",")
if len(s):
server_string = s[0].replace("server ", "")
else:
server_string = e.replace("server ", "")
self.logger.info(f"Connected to {server_string}")
self.server_string = server_string
stats.APRSDStats().set_aprsis_server(server_string)
if callsign == "":
raise LoginError("Server responded with empty callsign???")
if callsign != self.callsign:
@@ -141,6 +141,10 @@ class Aprsdis(aprslib.IS):
else:
self.logger.info("Login successful")
self.logger.info(f"Connected to {server_string}")
self.server_string = server_string
stats.APRSDStats().set_aprsis_server(server_string)
except LoginError as e:
self.logger.error(str(e))
self.close()
@@ -148,6 +152,7 @@ class Aprsdis(aprslib.IS):
except Exception as e:
self.close()
self.logger.error(f"Failed to login '{e}'")
self.logger.exception(e)
raise LoginError("Failed to login")
def consumer(self, callback, blocking=True, immortal=False, raw=False):
+1 -1
View File
@@ -1,7 +1,7 @@
import click
import click_completion
from aprsd.aprsd import cli
from aprsd.main import cli
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
+1 -1
View File
@@ -10,7 +10,7 @@ from oslo_config import cfg
# local imports here
from aprsd import cli_helper, client, conf, packets, plugin
from aprsd.aprsd import cli
from aprsd.main import cli
from aprsd.utils import trace
+156
View File
@@ -0,0 +1,156 @@
# Fetch active stats from a remote running instance of aprsd server
# This uses the RPC server to fetch the stats from the remote server.
import logging
import click
from oslo_config import cfg
from rich.console import Console
from rich.table import Table
# local imports here
import aprsd
from aprsd import cli_helper
from aprsd.main import cli
from aprsd.rpc import client as rpc_client
# setup the global logger
# log.basicConfig(level=log.DEBUG) # level=10
LOG = logging.getLogger("APRSD")
CONF = cfg.CONF
@cli.command()
@cli_helper.add_options(cli_helper.common_options)
@click.option(
"--host", type=str,
default=None,
help="IP address of the remote aprsd server to fetch stats from.",
)
@click.option(
"--port", type=int,
default=None,
help="Port of the remote aprsd server rpc port to fetch stats from.",
)
@click.option(
"--magic-word", type=str,
default=None,
help="Magic word of the remote aprsd server rpc port to fetch stats from.",
)
@click.pass_context
@cli_helper.process_standard_options
def fetch_stats(ctx, host, port, magic_word):
"""Fetch stats from a remote running instance of aprsd server."""
LOG.info(f"APRSD Fetch-Stats started version: {aprsd.__version__}")
CONF.log_opt_values(LOG, logging.DEBUG)
if not host:
host = CONF.rpc_settings.ip
if not port:
port = CONF.rpc_settings.port
if not magic_word:
magic_word = CONF.rpc_settings.magic_word
msg = f"Fetching stats from {host}:{port} with magic word '{magic_word}'"
console = Console()
console.print(msg)
with console.status(msg):
client = rpc_client.RPCClient(host, port, magic_word)
stats = client.get_stats_dict()
console.print_json(data=stats)
aprsd_title = (
"APRSD "
f"[bold cyan]v{stats['aprsd']['version']}[/] "
f"Callsign [bold green]{stats['aprsd']['callsign']}[/] "
f"Uptime [bold yellow]{stats['aprsd']['uptime']}[/]"
)
console.rule(f"Stats from {host}:{port} with magic word '{magic_word}'")
console.print("\n\n")
console.rule(aprsd_title)
# Show the connection to APRS
# It can be a connection to an APRS-IS server or a local TNC via KISS or KISSTCP
if "aprs-is" in stats:
title = f"APRS-IS Connection {stats['aprs-is']['server']}"
table = Table(title=title)
table.add_column("Key")
table.add_column("Value")
for key, value in stats["aprs-is"].items():
table.add_row(key, value)
console.print(table)
threads_table = Table(title="Threads")
threads_table.add_column("Name")
threads_table.add_column("Alive?")
for name, alive in stats["aprsd"]["threads"].items():
threads_table.add_row(name, str(alive))
console.print(threads_table)
msgs_table = Table(title="Messages")
msgs_table.add_column("Key")
msgs_table.add_column("Value")
for key, value in stats["messages"].items():
msgs_table.add_row(key, str(value))
console.print(msgs_table)
packet_totals = Table(title="Packet Totals")
packet_totals.add_column("Key")
packet_totals.add_column("Value")
packet_totals.add_row("Total Received", str(stats["packets"]["total_received"]))
packet_totals.add_row("Total Sent", str(stats["packets"]["total_sent"]))
packet_totals.add_row("Total Tracked", str(stats["packets"]["total_tracked"]))
console.print(packet_totals)
# Show each of the packet types
packets_table = Table(title="Packets By Type")
packets_table.add_column("Packet Type")
packets_table.add_column("TX")
packets_table.add_column("RX")
for key, value in stats["packets"]["by_type"].items():
packets_table.add_row(key, str(value["tx"]), str(value["rx"]))
console.print(packets_table)
if "plugins" in stats:
count = len(stats["plugins"])
plugins_table = Table(title=f"Plugins ({count})")
plugins_table.add_column("Plugin")
plugins_table.add_column("Enabled")
plugins_table.add_column("Version")
plugins_table.add_column("TX")
plugins_table.add_column("RX")
for key, value in stats["plugins"].items():
plugins_table.add_row(
key,
str(stats["plugins"][key]["enabled"]),
stats["plugins"][key]["version"],
str(stats["plugins"][key]["tx"]),
str(stats["plugins"][key]["rx"]),
)
console.print(plugins_table)
if "seen_list" in stats["aprsd"]:
count = len(stats["aprsd"]["seen_list"])
seen_table = Table(title=f"Seen List ({count})")
seen_table.add_column("Callsign")
seen_table.add_column("Message Count")
seen_table.add_column("Last Heard")
for key, value in stats["aprsd"]["seen_list"].items():
seen_table.add_row(key, str(value["count"]), value["last"])
console.print(seen_table)
if "watch_list" in stats["aprsd"]:
count = len(stats["aprsd"]["watch_list"])
watch_table = Table(title=f"Watch List ({count})")
watch_table.add_column("Callsign")
watch_table.add_column("Last Heard")
for key, value in stats["aprsd"]["watch_list"].items():
watch_table.add_row(key, value["last"])
console.print(watch_table)
+2 -2
View File
@@ -16,12 +16,12 @@ import aprsd
from aprsd import cli_helper, utils
from aprsd import conf # noqa
# local imports here
from aprsd.aprsd import cli
from aprsd.main import cli
from aprsd.rpc import client as aprsd_rpc_client
# setup the global logger
# logging.basicConfig(level=logging.DEBUG) # level=10
# log.basicConfig(level=log.DEBUG) # level=10
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
console = Console()
+1 -1
View File
@@ -19,7 +19,7 @@ from thesmuggler import smuggle
from aprsd import cli_helper
from aprsd import plugin as aprsd_plugin
from aprsd.aprsd import cli
from aprsd.main import cli
from aprsd.plugins import (
email, fortune, location, notify, ping, query, time, version, weather,
)
+10 -2
View File
@@ -16,12 +16,13 @@ from rich.console import Console
# local imports here
import aprsd
from aprsd import cli_helper, client, packets, plugin, stats, threads
from aprsd.aprsd import cli
from aprsd.main import cli
from aprsd.rpc import server as rpc_server
from aprsd.threads import rx
# setup the global logger
# logging.basicConfig(level=logging.DEBUG) # level=10
# log.basicConfig(level=log.DEBUG) # level=10
LOG = logging.getLogger("APRSD")
CONF = cfg.CONF
console = Console()
@@ -181,6 +182,10 @@ def listen(
keepalive = threads.KeepAliveThread()
keepalive.start()
if CONF.rpc_settings.enabled:
rpc = rpc_server.APRSDRPCThread()
rpc.start()
pm = None
pm = plugin.PluginManager()
if load_plugins:
@@ -204,3 +209,6 @@ def listen(
keepalive.join()
LOG.debug("listen_thread Join")
listen_thread.join()
if CONF.rpc_settings.enabled:
rpc.join()
+1 -1
View File
@@ -10,7 +10,7 @@ from oslo_config import cfg
import aprsd
from aprsd import cli_helper, client, packets
from aprsd import conf # noqa : F401
from aprsd.aprsd import cli
from aprsd.main import cli
from aprsd.threads import tx
+4 -3
View File
@@ -6,9 +6,10 @@ import click
from oslo_config import cfg
import aprsd
from aprsd import aprsd as aprsd_main
from aprsd import cli_helper, client, packets, plugin, threads, utils
from aprsd.aprsd import cli
from aprsd import cli_helper, client
from aprsd import main as aprsd_main
from aprsd import packets, plugin, threads, utils
from aprsd.main import cli
from aprsd.rpc import server as rpc_server
from aprsd.threads import rx
+109 -111
View File
@@ -12,7 +12,6 @@ import click
import flask
from flask import request
from flask.logging import default_handler
import flask_classful
from flask_httpauth import HTTPBasicAuth
from flask_socketio import Namespace, SocketIO
from oslo_config import cfg
@@ -22,8 +21,8 @@ import wrapt
import aprsd
from aprsd import cli_helper, client, conf, packets, stats, threads, utils
from aprsd.aprsd import cli
from aprsd.logging import rich as aprsd_logging
from aprsd.log import rich as aprsd_logging
from aprsd.main import cli
from aprsd.threads import rx, tx
from aprsd.utils import objectstore, trace
@@ -31,9 +30,16 @@ from aprsd.utils import objectstore, trace
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
auth = HTTPBasicAuth()
users = None
users = {}
socketio = None
flask_app = flask.Flask(
"aprsd",
static_url_path="/static",
static_folder="web/chat/static",
template_folder="web/chat/templates",
)
def signal_handler(sig, frame):
@@ -174,106 +180,108 @@ class WebChatProcessPacketThread(rx.APRSDProcessPacketThread):
)
class WebChatFlask(flask_classful.FlaskView):
def set_config():
global users
def set_config(self):
global users
self.users = {}
user = CONF.admin.user
self.users[user] = generate_password_hash(CONF.admin.password)
users = self.users
def _get_transport(self, stats):
if CONF.aprs_network.enabled:
transport = "aprs-is"
aprs_connection = (
"APRS-IS Server: <a href='http://status.aprs2.net' >"
"{}</a>".format(stats["stats"]["aprs-is"]["server"])
)
else:
# We might be connected to a KISS socket?
if client.KISSClient.is_enabled():
transport = client.KISSClient.transport()
if transport == client.TRANSPORT_TCPKISS:
aprs_connection = (
"TCPKISS://{}:{}".format(
CONF.kiss_tcp.host,
CONF.kiss_tcp.port,
)
)
elif transport == client.TRANSPORT_SERIALKISS:
# for pep8 violation
aprs_connection = (
"SerialKISS://{}@{} baud".format(
CONF.kiss_serial.device,
CONF.kiss_serial.baudrate,
),
)
return transport, aprs_connection
@auth.login_required
def index(self):
ua_str = request.headers.get("User-Agent")
# this takes about 2 seconds :(
user_agent = ua_parse(ua_str)
LOG.debug(f"Is mobile? {user_agent.is_mobile}")
stats = self._stats()
if user_agent.is_mobile:
html_template = "mobile.html"
else:
html_template = "index.html"
# For development
# html_template = "mobile.html"
LOG.debug(f"Template {html_template}")
transport, aprs_connection = self._get_transport(stats)
LOG.debug(f"transport {transport} aprs_connection {aprs_connection}")
stats["transport"] = transport
stats["aprs_connection"] = aprs_connection
LOG.debug(f"initial stats = {stats}")
return flask.render_template(
html_template,
initial_stats=stats,
aprs_connection=aprs_connection,
callsign=CONF.callsign,
version=aprsd.__version__,
def _get_transport(stats):
if CONF.aprs_network.enabled:
transport = "aprs-is"
aprs_connection = (
"APRS-IS Server: <a href='http://status.aprs2.net' >"
"{}</a>".format(stats["stats"]["aprs-is"]["server"])
)
else:
# We might be connected to a KISS socket?
if client.KISSClient.is_enabled():
transport = client.KISSClient.transport()
if transport == client.TRANSPORT_TCPKISS:
aprs_connection = (
"TCPKISS://{}:{}".format(
CONF.kiss_tcp.host,
CONF.kiss_tcp.port,
)
)
elif transport == client.TRANSPORT_SERIALKISS:
# for pep8 violation
aprs_connection = (
"SerialKISS://{}@{} baud".format(
CONF.kiss_serial.device,
CONF.kiss_serial.baudrate,
),
)
@auth.login_required
def send_message_status(self):
LOG.debug(request)
msgs = SentMessages()
info = msgs.get_all()
return json.dumps(info)
return transport, aprs_connection
def _stats(self):
stats_obj = stats.APRSDStats()
now = datetime.datetime.now()
time_format = "%m-%d-%Y %H:%M:%S"
stats_dict = stats_obj.stats()
# Webchat doesnt need these
del stats_dict["aprsd"]["watch_list"]
del stats_dict["aprsd"]["seen_list"]
# del stats_dict["email"]
# del stats_dict["plugins"]
# del stats_dict["messages"]
@auth.login_required
@flask_app.route("/")
def index():
ua_str = request.headers.get("User-Agent")
# this takes about 2 seconds :(
user_agent = ua_parse(ua_str)
LOG.debug(f"Is mobile? {user_agent.is_mobile}")
stats = _stats()
result = {
"time": now.strftime(time_format),
"stats": stats_dict,
}
if user_agent.is_mobile:
html_template = "mobile.html"
else:
html_template = "index.html"
return result
# For development
# html_template = "mobile.html"
def stats(self):
return json.dumps(self._stats())
LOG.debug(f"Template {html_template}")
transport, aprs_connection = _get_transport(stats)
LOG.debug(f"transport {transport} aprs_connection {aprs_connection}")
stats["transport"] = transport
stats["aprs_connection"] = aprs_connection
LOG.debug(f"initial stats = {stats}")
return flask.render_template(
html_template,
initial_stats=stats,
aprs_connection=aprs_connection,
callsign=CONF.callsign,
version=aprsd.__version__,
)
@auth.login_required
@flask_app.route("//send-message-status")
def send_message_status():
LOG.debug(request)
msgs = SentMessages()
info = msgs.get_all()
return json.dumps(info)
def _stats():
stats_obj = stats.APRSDStats()
now = datetime.datetime.now()
time_format = "%m-%d-%Y %H:%M:%S"
stats_dict = stats_obj.stats()
# Webchat doesnt need these
del stats_dict["aprsd"]["watch_list"]
del stats_dict["aprsd"]["seen_list"]
# del stats_dict["email"]
# del stats_dict["plugins"]
# del stats_dict["messages"]
result = {
"time": now.strftime(time_format),
"stats": stats_dict,
}
return result
@flask_app.route("/stats")
def get_stats():
return json.dumps(_stats())
class SendMessageNamespace(Namespace):
@@ -377,21 +385,9 @@ def setup_logging(flask_app, loglevel, quiet):
@trace.trace
def init_flask(loglevel, quiet):
global socketio
global socketio, flask_app
flask_app = flask.Flask(
"aprsd",
static_url_path="/static",
static_folder="web/chat/static",
template_folder="web/chat/templates",
)
setup_logging(flask_app, loglevel, quiet)
server = WebChatFlask()
server.set_config()
flask_app.route("/", methods=["GET"])(server.index)
flask_app.route("/stats", methods=["GET"])(server.stats)
# flask_app.route("/send-message", methods=["GET"])(server.send_message)
flask_app.route("/send-message-status", methods=["GET"])(server.send_message_status)
socketio = SocketIO(
flask_app, logger=False, engineio_logger=False,
@@ -407,7 +403,7 @@ def init_flask(loglevel, quiet):
"/sendmsg",
),
)
return socketio, flask_app
return socketio
# main() ###
@@ -448,6 +444,8 @@ def webchat(ctx, flush, port):
LOG.info(f"APRSD Started version: {aprsd.__version__}")
CONF.log_opt_values(LOG, logging.DEBUG)
user = CONF.admin.user
users[user] = generate_password_hash(CONF.admin.password)
# Initialize the client factory and create
# The correct client object ready for use
@@ -466,7 +464,7 @@ def webchat(ctx, flush, port):
packets.WatchList()
packets.SeenList()
(socketio, app) = init_flask(loglevel, quiet)
socketio = init_flask(loglevel, quiet)
rx_thread = rx.APRSDPluginRXThread(
packet_queue=threads.packet_queue,
)
@@ -482,7 +480,7 @@ def webchat(ctx, flush, port):
keepalive.start()
LOG.info("Start socketio.run()")
socketio.run(
app,
flask_app,
ssl_context="adhoc",
host=CONF.admin.web_ip,
port=port,
+1 -1
View File
@@ -28,7 +28,7 @@ def set_lib_defaults():
def set_log_defaults():
# logging.set_defaults(default_log_levels=logging.get_default_log_levels())
# log.set_defaults(default_log_levels=log.get_default_log_levels())
pass
+1 -1
View File
@@ -1,5 +1,5 @@
"""
The options for logging setup
The options for log setup
"""
from oslo_config import cfg
+8 -2
View File
@@ -1,5 +1,5 @@
"""
The options for logging setup
The options for log setup
"""
import logging
@@ -33,7 +33,7 @@ logging_opts = [
cfg.BoolOpt(
"rich_logging",
default=True,
help="Enable Rich logging",
help="Enable Rich log",
),
cfg.StrOpt(
"logfile",
@@ -45,6 +45,12 @@ logging_opts = [
default=DEFAULT_LOG_FORMAT,
help="Log file format, unless rich_logging enabled.",
),
cfg.StrOpt(
"log_level",
default="INFO",
choices=LOG_LEVELS.keys(),
help="Log level for logging of events.",
),
]
+3 -3
View File
@@ -7,7 +7,7 @@ import sys
from oslo_config import cfg
from aprsd import conf
from aprsd.logging import rich as aprsd_logging
from aprsd.log import rich as aprsd_logging
CONF = cfg.CONF
@@ -15,8 +15,8 @@ LOG = logging.getLogger("APRSD")
logging_queue = queue.Queue()
# Setup the logging faciility
# to disable logging to stdout, but still log to file
# Setup the log faciility
# to disable log to stdout, but still log to file
# use the --quiet option on the cmdln
def setup_logging(loglevel, quiet):
log_level = conf.log.LOG_LEVELS[loglevel]
+1 -1
View File
@@ -130,7 +130,7 @@ class APRSDRichHandler(RichHandler):
"""Render log for display.
Args:
record (LogRecord): logging Record.
record (LogRecord): log Record.
traceback (Optional[Traceback]): Traceback instance or None for no Traceback.
message_renderable (ConsoleRenderable): Renderable (typically Text) containing log message contents.
+3 -3
View File
@@ -39,7 +39,7 @@ from aprsd import cli_helper, packets, stats, threads, utils
# setup the global logger
# logging.basicConfig(level=logging.DEBUG) # level=10
# log.basicConfig(level=log.DEBUG) # level=10
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@@ -70,8 +70,8 @@ def main():
# First import all the possible commands for the CLI
# The commands themselves live in the cmds directory
from .cmds import ( # noqa
completion, dev, healthcheck, list_plugins, listen, send_message,
server, webchat,
completion, dev, fetch_stats, healthcheck, list_plugins, listen,
send_message, server, webchat,
)
cli(auto_envvar_prefix="APRSD")
+46 -9
View File
@@ -126,9 +126,43 @@ class Packet(metaclass=abc.ABCMeta):
if packet_type == PACKET_TYPE_WX:
# the weather information is in a dict
# this brings those values out to the outer dict
for key in raw["weather"]:
raw[key] = raw["weather"][key]
# If we have the broken aprslib, then we need to
# Convert the course and speed to wind_speed and wind_direction
# aprslib issue #80
# https://github.com/rossengeorgiev/aprs-python/issues/80
# Wind speed and course is option in the SPEC.
# For some reason aprslib multiplies the speed by 1.852.
if "wind_speed" not in raw and "wind_direction" not in raw:
# Most likely this is the broken aprslib
# So we need to convert the wind_gust speed
raw["wind_gust"] = round(raw.get("wind_gust", 0) / 0.44704, 3)
if "wind_speed" not in raw:
wind_speed = raw.get("speed")
if wind_speed:
raw["wind_speed"] = round(wind_speed / 1.852, 3)
raw["weather"]["wind_speed"] = raw["wind_speed"]
if "speed" in raw:
del raw["speed"]
# Let's adjust the rain numbers as well, since it's wrong
raw["rain_1h"] = round((raw.get("rain_1h", 0) / .254) * .01, 3)
raw["weather"]["rain_1h"] = raw["rain_1h"]
raw["rain_24h"] = round((raw.get("rain_24h", 0) / .254) * .01, 3)
raw["weather"]["rain_24h"] = raw["rain_24h"]
raw["rain_since_midnight"] = round((raw.get("rain_since_midnight", 0) / .254) * .01, 3)
raw["weather"]["rain_since_midnight"] = raw["rain_since_midnight"]
if "wind_direction" not in raw:
wind_direction = raw.get("course")
if wind_direction:
raw["wind_direction"] = wind_direction
raw["weather"]["wind_direction"] = raw["wind_direction"]
if "course" in raw:
del raw["course"]
return dacite.from_dict(data_class=class_name, data=raw)
def log(self, header=None):
@@ -281,10 +315,6 @@ class GPSPacket(PathPacket):
comment: str = None
symbol: str = field(default="l")
symbol_table: str = field(default="/")
# in MPH
speed: float = 0.00
# 0 to 360
course: int = 0
def decdeg2dms(self, degrees_decimal):
is_positive = degrees_decimal >= 0
@@ -387,6 +417,10 @@ class MicEPacket(GPSPacket):
messagecapable: bool = False
mbits: str = None
mtype: str = None
# in MPH
speed: float = 0.00
# 0 to 360
course: int = 0
def _build_raw(self):
raise NotImplementedError
@@ -397,6 +431,10 @@ class ObjectPacket(GPSPacket):
alive: bool = True
raw_timestamp: str = None
symbol: str = field(default="r")
# in MPH
speed: float = 0.00
# 0 to 360
course: int = 0
def _build_raw(self):
"""
@@ -424,6 +462,8 @@ class ObjectPacket(GPSPacket):
@dataclass()
class WeatherPacket(GPSPacket):
symbol: str = "_"
wind_speed: float = 0.00
wind_direction: int = 0
wind_gust: float = 0.00
temperature: float = 0.00
# in inches. 1.04 means 1.04 inches
@@ -461,16 +501,13 @@ class WeatherPacket(GPSPacket):
"""
time_zulu = self._build_time_zulu()
course = "%03u" % self.course
contents = [
f"{self.from_call}>{self.to_call},WIDE1-1,WIDE2-1:",
f"@{time_zulu}z{self.latitude}{self.symbol_table}",
f"{self.longitude}{self.symbol}",
# Add CSE = Course
f"{course}",
f"{self.wind_direction:03d}",
# Speed = sustained 1 minute wind speed in mph
f"{self.symbol_table}", f"{self.speed:03.0f}",
f"{self.symbol_table}", f"{self.wind_speed:03.0f}",
# wind gust (peak wind speed in mph in the last 5 minutes)
f"g{self.wind_gust:03.0f}",
# Temperature in degrees F
+1 -1
View File
@@ -666,7 +666,7 @@ class APRSDEmailThread(threads.APRSDThread):
EmailInfo().delay = 60
# reset clock
LOG.debug("Done looping over Server.fetch, logging out.")
LOG.debug("Done looping over Server.fetch, log out.")
self.past = datetime.datetime.now()
try:
server.logout()
+26 -15
View File
@@ -2,6 +2,7 @@ import logging
import re
import time
from geopy.geocoders import Nominatim
from oslo_config import cfg
from aprsd import packets, plugin, plugin_utils
@@ -50,8 +51,28 @@ class LocationPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
LOG.error("Didn't get any entries from aprs.fi")
return "Failed to fetch aprs.fi location"
lat = aprs_data["entries"][0]["lat"]
lon = aprs_data["entries"][0]["lng"]
lat = float(aprs_data["entries"][0]["lat"])
lon = float(aprs_data["entries"][0]["lng"])
# Get some information about their location
try:
tic = time.perf_counter()
geolocator = Nominatim(user_agent="APRSD")
coordinates = f"{lat:0.6f}, {lon:0.6f}"
location = geolocator.reverse(coordinates)
address = location.raw.get("address")
toc = time.perf_counter()
if address:
LOG.info(f"Geopy address {address} took {toc - tic:0.4f}")
if address.get("country_code") == "us":
area_info = f"{address.get('county')}, {address.get('state')}"
else:
# what to do for address for non US?
area_info = f"{address.get('country'), 'Unknown'}"
except Exception as ex:
LOG.error(f"Failed to fetch Geopy address {ex}")
area_info = "Unknown Location"
try: # altitude not always provided
alt = float(aprs_data["entries"][0]["altitude"])
except Exception:
@@ -64,22 +85,12 @@ class LocationPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
delta_seconds = time.time() - int(aprs_lasttime_seconds)
delta_hours = delta_seconds / 60 / 60
try:
wx_data = plugin_utils.get_weather_gov_for_gps(lat, lon)
except Exception as ex:
LOG.error(f"Couldn't fetch forecast.weather.gov '{ex}'")
wx_data = {"location": {"areaDescription": "Unknown Location"}}
if "location" not in wx_data:
LOG.error(f"Couldn't fetch forecast.weather.gov '{wx_data}'")
wx_data = {"location": {"areaDescription": "Unknown Location"}}
reply = "{}: {} {}' {},{} {}h ago".format(
searchcall,
wx_data["location"]["areaDescription"],
area_info,
str(altfeet),
str(lat),
str(lon),
f"{lat:0.2f}",
f"{lon:0.2f}",
str("%.1f" % round(delta_hours, 1)),
).rstrip()
+3 -3
View File
@@ -26,7 +26,7 @@ class USWeatherPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin)
"weather" - returns weather near the calling callsign
"""
command_regex = r"^([w]|[w]\s|[w][x]|[w][x]\s|weather)"
command_regex = r"^([w][x]|[w][x]\s|weather)"
command_name = "USWeather"
short_description = "Provide USA only weather of GPS Beacon location"
@@ -92,7 +92,7 @@ class USMetarPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
"""
command_regex = r"^([m]|[m]|[m]\s|metar)"
command_regex = r"^([m]|[M]|[m]\s|metar)"
command_name = "USMetar"
short_description = "USA only METAR of GPS Beacon location"
@@ -182,7 +182,7 @@ class OWMWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
"""
command_regex = r"^([w]|[w]\s|[w][x]|[w][x]\s|weather)"
command_regex = r"^([w][x]|[w][x]\s|weather)"
command_name = "OpenWeatherMap"
short_description = "OpenWeatherMap weather of GPS Beacon location"
+30 -15
View File
@@ -16,31 +16,47 @@ class RPCClient:
_instance = None
_rpc_client = None
ip = None
port = None
magic_word = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
def __init__(self, ip=None, port=None, magic_word=None):
if ip:
self.ip = ip
else:
self.ip = CONF.rpc_settings.ip
if port:
self.port = int(port)
else:
self.port = CONF.rpc_settings.port
if magic_word:
self.magic_word = magic_word
else:
self.magic_word = CONF.rpc_settings.magic_word
self._check_settings()
self.get_rpc_client()
def _check_settings(self):
if not CONF.rpc_settings.enabled:
LOG.error("RPC is not enabled, no way to get stats!!")
LOG.warning("RPC is not enabled, no way to get stats!!")
if CONF.rpc_settings.magic_word == conf.common.APRSD_DEFAULT_MAGIC_WORD:
if self.magic_word == conf.common.APRSD_DEFAULT_MAGIC_WORD:
LOG.warning("You are using the default RPC magic word!!!")
LOG.warning("edit aprsd.conf and change rpc_settings.magic_word")
def _rpyc_connect(
self, host, port,
service=rpyc.VoidService,
config={}, ipv6=False,
keepalive=False, authorizer=None,
):
LOG.debug(f"RPC Client: {self.ip}:{self.port} {self.magic_word}")
print(f"Connecting to RPC host {host}:{port}")
def _rpyc_connect(
self, host, port, service=rpyc.VoidService,
config={}, ipv6=False,
keepalive=False, authorizer=None, ):
LOG.info(f"Connecting to RPC host '{host}:{port}'")
try:
s = rpc.AuthSocketStream.connect(
host, port, ipv6=ipv6, keepalive=keepalive,
@@ -48,16 +64,15 @@ class RPCClient:
)
return rpyc.utils.factory.connect_stream(s, service, config=config)
except ConnectionRefusedError:
LOG.error(f"Failed to connect to RPC host {host}")
LOG.error(f"Failed to connect to RPC host '{host}:{port}'")
return None
def get_rpc_client(self):
if not self._rpc_client:
magic = CONF.rpc_settings.magic_word
self._rpc_client = self._rpyc_connect(
CONF.rpc_settings.ip,
CONF.rpc_settings.port,
authorizer=lambda sock: sock.send(magic.encode()),
self.ip,
self.port,
authorizer=lambda sock: sock.send(self.magic_word.encode()),
)
return self._rpc_client
+10 -2
View File
@@ -60,32 +60,40 @@ class APRSDService(rpyc.Service):
@rpyc.exposed
def get_stats(self):
LOG.info("get_stats")
stat = stats.APRSDStats()
stats_dict = stat.stats()
LOG.debug(stats_dict)
return json.dumps(stats_dict, indent=4, sort_keys=True, default=str)
return_str = json.dumps(stats_dict, indent=4, sort_keys=True, default=str)
LOG.info(f"get_stats: return size {len(return_str)}")
return return_str
@rpyc.exposed
def get_stats_obj(self):
LOG.info("get_stats_obj")
return stats.APRSDStats()
@rpyc.exposed
def get_packet_list(self):
LOG.info("get_packet_list")
return packets.PacketList()
@rpyc.exposed
def get_packet_track(self):
LOG.info("get_packet_track")
return packets.PacketTrack()
@rpyc.exposed
def get_watch_list(self):
LOG.info("get_watch_list")
return packets.WatchList()
@rpyc.exposed
def get_seen_list(self):
LOG.info("get_seen_list")
return packets.SeenList()
@rpyc.exposed
def get_log_entries(self):
LOG.info("get_log_entries")
entries = log_monitor.LogEntries().get_all_and_purge()
return json.dumps(entries, default=str)
+17 -3
View File
@@ -29,6 +29,8 @@ class APRSDStats:
_mem_current = 0
_mem_peak = 0
_thread_info = {}
_pkt_cnt = {
"Packet": {
"tx": 0,
@@ -95,6 +97,15 @@ class APRSDStats:
def set_memory_peak(self, memory):
self._mem_peak = memory
@wrapt.synchronized(lock)
def set_thread_info(self, thread_info):
self._thread_info = thread_info
@wrapt.synchronized(lock)
@property
def thread_info(self):
return self._thread_info
@wrapt.synchronized(lock)
@property
def aprsis_server(self):
@@ -207,6 +218,7 @@ class APRSDStats:
"memory_current_str": utils.human_size(self.memory),
"memory_peak": int(self.memory_peak),
"memory_peak_str": utils.human_size(self.memory_peak),
"threads": self._thread_info,
"watch_list": wl.get_all(),
"seen_list": sl.get_all(),
},
@@ -216,9 +228,10 @@ class APRSDStats:
"last_update": last_aprsis_keepalive,
},
"packets": {
"tracked": int(pl.total_tx() + pl.total_rx()),
"sent": int(pl.total_tx()),
"received": int(pl.total_rx()),
"total_tracked": int(pl.total_tx() + pl.total_rx()),
"total_sent": int(pl.total_tx()),
"total_received": int(pl.total_rx()),
"by_type": self._pkt_cnt,
},
"messages": {
"sent": self._pkt_cnt["MessagePacket"]["tx"],
@@ -234,6 +247,7 @@ class APRSDStats:
"plugins": plugin_stats,
}
LOG.debug(f"STATS = {stats}")
LOG.info("APRSD Stats: DONE")
return stats
def __str__(self):
+7
View File
@@ -1,4 +1,5 @@
import abc
import datetime
import logging
import threading
@@ -50,6 +51,7 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
super().__init__(name=name)
self.thread_stop = False
APRSDThreadList().add(self)
self._last_loop = datetime.datetime.now()
def _should_quit(self):
""" see if we have a quit message from the global queue."""
@@ -70,10 +72,15 @@ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
out = f"Thread <{self.__class__.__name__}({self.name}) Alive? {self.is_alive()}>"
return out
def loop_age(self):
"""How old is the last loop call?"""
return datetime.datetime.now() - self._last_loop
def run(self):
LOG.debug("Starting")
while not self._should_quit():
can_loop = self.loop()
self._last_loop = datetime.datetime.now()
if not can_loop:
self.stop()
self._cleanup()
+9 -1
View File
@@ -65,12 +65,20 @@ class KeepAliveThread(APRSDThread):
)
LOG.info(keepalive)
thread_out = []
thread_info = {}
for thread in thread_list.threads_list:
alive = thread.is_alive()
thread_out.append(f"{thread.__class__.__name__}:{alive}")
age = thread.loop_age()
key = thread.__class__.__name__
thread_out.append(f"{key}:{alive}:{age}")
if key not in thread_info:
thread_info[key] = {}
thread_info[key]["alive"] = alive
thread_info[key]["age"] = age
if not alive:
LOG.error(f"Thread {thread}")
LOG.info(",".join(thread_out))
stats_obj.set_thread_info(thread_info)
# check the APRS connection
cl = client.factory.create()
+1 -1
View File
@@ -4,7 +4,7 @@ import threading
import wrapt
from aprsd import threads
from aprsd.logging import log
from aprsd.log import log
LOG = logging.getLogger("APRSD")
+25 -6
View File
@@ -3,9 +3,13 @@ import logging
import time
from oslo_config import cfg
from ratelimiter import RateLimiter
from rush import quota, throttle
from rush.contrib import decorator
from rush.limiters import periodic
from rush.stores import dictionary
from aprsd import client
from aprsd import conf # noqa
from aprsd import threads as aprsd_threads
from aprsd.packets import core, tracker
@@ -13,10 +17,25 @@ from aprsd.packets import core, tracker
CONF = cfg.CONF
LOG = logging.getLogger("APRSD")
msg_t = throttle.Throttle(
limiter=periodic.PeriodicLimiter(
store=dictionary.DictionaryStore(),
),
rate=quota.Quota.per_second(
count=CONF.msg_rate_limit_period,
),
)
ack_t = throttle.Throttle(
limiter=periodic.PeriodicLimiter(
store=dictionary.DictionaryStore(),
),
rate=quota.Quota.per_second(
count=CONF.ack_rate_limit_period,
),
)
def limited(until):
duration = int(round(until - time.time()))
LOG.debug(f"Rate limited, sleeping for {duration:d} seconds")
msg_throttle_decorator = decorator.ThrottleDecorator(throttle=msg_t)
ack_throttle_decorator = decorator.ThrottleDecorator(throttle=ack_t)
def send(packet: core.Packet, direct=False, aprs_client=None):
@@ -30,7 +49,7 @@ def send(packet: core.Packet, direct=False, aprs_client=None):
_send_packet(packet, direct=direct, aprs_client=aprs_client)
@RateLimiter(max_calls=1, period=CONF.msg_rate_limit_period, callback=limited)
@msg_throttle_decorator.sleep_and_retry
def _send_packet(packet: core.Packet, direct=False, aprs_client=None):
if not direct:
thread = SendPacketThread(packet=packet)
@@ -39,7 +58,7 @@ def _send_packet(packet: core.Packet, direct=False, aprs_client=None):
_send_direct(packet, aprs_client=aprs_client)
@RateLimiter(max_calls=1, period=CONF.ack_rate_limit_period, callback=limited)
@ack_throttle_decorator.sleep_and_retry
def _send_ack(packet: core.AckPacket, direct=False, aprs_client=None):
if not direct:
thread = SendAckThread(packet=packet)
+1 -1
View File
@@ -3,7 +3,7 @@
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
<script src="https://cdn.socket.io/4.1.2/socket.io.min.js" integrity="sha384-toS6mmwu70G0fw54EGlWWeA4z3dyJ+dlXBtSURSKN4vyRFOcxd3Bzjj/AoOwY+Rg" crossorigin="anonymous"></script>
<script src="https://cdn.socket.io/4.7.1/socket.io.min.js" integrity="sha512-+NaO7d6gQ1YPxvc/qHIqZEchjGm207SszoNeMgppoqD/67fEqmc1edS8zrbxPD+4RQI3gDgT/83ihpFW61TG/Q==" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.bundle.js"></script>
+363
View File
@@ -0,0 +1,363 @@
import datetime
import json
import logging
from logging.handlers import RotatingFileHandler
import time
import flask
from flask import Flask
from flask.logging import default_handler
from flask_httpauth import HTTPBasicAuth
from oslo_config import cfg
import socketio
from werkzeug.security import check_password_hash
import aprsd
from aprsd import cli_helper, client, conf, packets, plugin, threads
from aprsd.log import rich as aprsd_logging
from aprsd.rpc import client as aprsd_rpc_client
CONF = cfg.CONF
LOG = logging.getLogger("gunicorn.access")
auth = HTTPBasicAuth()
users = {}
app = Flask(
"aprsd",
static_url_path="/static",
static_folder="web/admin/static",
template_folder="web/admin/templates",
)
bg_thread = None
app.config["SECRET_KEY"] = "secret!"
# HTTPBasicAuth doesn't work on a class method.
# This has to be out here. Rely on the APRSDFlask
# class to initialize the users from the config
@auth.verify_password
def verify_password(username, password):
global users
if username in users and check_password_hash(users.get(username), password):
return username
def _stats():
track = aprsd_rpc_client.RPCClient().get_packet_track()
now = datetime.datetime.now()
time_format = "%m-%d-%Y %H:%M:%S"
stats_dict = aprsd_rpc_client.RPCClient().get_stats_dict()
if not stats_dict:
stats_dict = {
"aprsd": {},
"aprs-is": {"server": ""},
"messages": {
"sent": 0,
"received": 0,
},
"email": {
"sent": 0,
"received": 0,
},
"seen_list": {
"sent": 0,
"received": 0,
},
}
# Convert the watch_list entries to age
wl = aprsd_rpc_client.RPCClient().get_watch_list()
new_list = {}
if wl:
for call in wl.get_all():
# call_date = datetime.datetime.strptime(
# str(wl.last_seen(call)),
# "%Y-%m-%d %H:%M:%S.%f",
# )
# We have to convert the RingBuffer to a real list
# so that json.dumps works.
# pkts = []
# for pkt in wl.get(call)["packets"].get():
# pkts.append(pkt)
new_list[call] = {
"last": wl.age(call),
# "packets": pkts
}
stats_dict["aprsd"]["watch_list"] = new_list
packet_list = aprsd_rpc_client.RPCClient().get_packet_list()
rx = tx = 0
if packet_list:
rx = packet_list.total_rx()
tx = packet_list.total_tx()
stats_dict["packets"] = {
"sent": tx,
"received": rx,
}
if track:
size_tracker = len(track)
else:
size_tracker = 0
result = {
"time": now.strftime(time_format),
"size_tracker": size_tracker,
"stats": stats_dict,
}
return result
@app.route("/stats")
def stats():
LOG.debug("/stats called")
return json.dumps(_stats())
@app.route("/")
def index():
stats = _stats()
LOG.debug(stats)
wl = aprsd_rpc_client.RPCClient().get_watch_list()
if wl and wl.is_enabled():
watch_count = len(wl)
watch_age = wl.max_delta()
else:
watch_count = 0
watch_age = 0
sl = aprsd_rpc_client.RPCClient().get_seen_list()
if sl:
seen_count = len(sl)
else:
seen_count = 0
pm = plugin.PluginManager()
plugins = pm.get_plugins()
plugin_count = len(plugins)
if CONF.aprs_network.enabled:
transport = "aprs-is"
aprs_connection = (
"APRS-IS Server: <a href='http://status.aprs2.net' >"
"{}</a>".format(stats["stats"]["aprs-is"]["server"])
)
else:
# We might be connected to a KISS socket?
if client.KISSClient.kiss_enabled():
transport = client.KISSClient.transport()
if transport == client.TRANSPORT_TCPKISS:
aprs_connection = (
"TCPKISS://{}:{}".format(
CONF.kiss_tcp.host,
CONF.kiss_tcp.port,
)
)
elif transport == client.TRANSPORT_SERIALKISS:
aprs_connection = (
"SerialKISS://{}@{} baud".format(
CONF.kiss_serial.device,
CONF.kiss_serial.baudrate,
)
)
stats["transport"] = transport
stats["aprs_connection"] = aprs_connection
entries = conf.conf_to_dict()
return flask.render_template(
"index.html",
initial_stats=stats,
aprs_connection=aprs_connection,
callsign=CONF.callsign,
version=aprsd.__version__,
config_json=json.dumps(
entries, indent=4,
sort_keys=True, default=str,
),
watch_count=watch_count,
watch_age=watch_age,
seen_count=seen_count,
plugin_count=plugin_count,
)
@auth.login_required
def messages():
track = packets.PacketTrack()
msgs = []
for id in track:
LOG.info(track[id].dict())
msgs.append(track[id].dict())
return flask.render_template("messages.html", messages=json.dumps(msgs))
@auth.login_required
@app.route("/packets")
def get_packets():
LOG.debug("/packets called")
packet_list = aprsd_rpc_client.RPCClient().get_packet_list()
if packet_list:
packets = packet_list.get()
tmp_list = []
for pkt in packets:
tmp_list.append(pkt.json)
return json.dumps(tmp_list)
else:
return json.dumps([])
@auth.login_required
@app.route("/plugins")
def plugins():
LOG.debug("/plugins called")
pm = plugin.PluginManager()
pm.reload_plugins()
return "reloaded"
@auth.login_required
@app.route("/save")
def save():
"""Save the existing queue to disk."""
track = packets.PacketTrack()
track.save()
return json.dumps({"messages": "saved"})
class LogUpdateThread(threads.APRSDThread):
def __init__(self):
super().__init__("LogUpdate")
def loop(self):
if sio:
log_entries = aprsd_rpc_client.RPCClient().get_log_entries()
if log_entries:
LOG.info(f"Sending log entries! {len(log_entries)}")
for entry in log_entries:
sio.emit(
"log_entry", entry,
namespace="/logs",
)
time.sleep(5)
return True
class LoggingNamespace(socketio.Namespace):
log_thread = None
def on_connect(self, sid, environ):
global sio
LOG.debug(f"LOG on_connect {sid}")
sio.emit(
"connected", {"data": "/logs Connected"},
namespace="/logs",
)
self.log_thread = LogUpdateThread()
self.log_thread.start()
def on_disconnect(self, sid):
LOG.debug(f"LOG Disconnected {sid}")
if self.log_thread:
self.log_thread.stop()
def setup_logging(flask_app, loglevel):
global app, LOG
log_level = conf.log.LOG_LEVELS[loglevel]
app.logger.setLevel(log_level)
flask_app.logger.removeHandler(default_handler)
date_format = CONF.logging.date_format
if CONF.logging.rich_logging:
log_format = "%(message)s"
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
rh = aprsd_logging.APRSDRichHandler(
show_thread=True, thread_width=15,
rich_tracebacks=True, omit_repeated_times=False,
)
rh.setFormatter(log_formatter)
app.logger.addHandler(rh)
log_file = CONF.logging.logfile
if log_file:
log_format = CONF.logging.logformat
log_formatter = logging.Formatter(fmt=log_format, datefmt=date_format)
fh = RotatingFileHandler(
log_file, maxBytes=(10248576 * 5),
backupCount=4,
)
fh.setFormatter(log_formatter)
app.logger.addHandler(fh)
LOG = app.logger
def init_app(config_file=None, log_level=None):
default_config_file = cli_helper.DEFAULT_CONFIG_FILE
if not config_file:
config_file = default_config_file
CONF(
[], project="aprsd", version=aprsd.__version__,
default_config_files=[config_file],
)
if not log_level:
log_level = CONF.logging.log_level
return log_level
if __name__ == "__main__":
async_mode = "threading"
sio = socketio.Server(logger=True, async_mode=async_mode)
app.wsgi_app = socketio.WSGIApp(sio, app.wsgi_app)
log_level = init_app(log_level="DEBUG")
setup_logging(app, log_level)
sio.register_namespace(LoggingNamespace("/logs"))
CONF.log_opt_values(LOG, logging.DEBUG)
app.run(threaded=True, debug=True, port=CONF.admin.web_port)
if __name__ == "uwsgi_file_aprsd_wsgi":
# Start with
# uwsgi --http :8000 --gevent 1000 --http-websockets --master -w aprsd.wsgi --callable app
async_mode = "gevent_uwsgi"
sio = socketio.Server(logger=True, async_mode=async_mode)
app.wsgi_app = socketio.WSGIApp(sio, app.wsgi_app)
log_level = init_app(
log_level="DEBUG",
config_file="/config/aprsd.conf",
)
setup_logging(app, log_level)
sio.register_namespace(LoggingNamespace("/logs"))
CONF.log_opt_values(LOG, logging.DEBUG)
if __name__ == "aprsd.wsgi":
# set async_mode to 'threading', 'eventlet', 'gevent' or 'gevent_uwsgi' to
# force a mode else, the best mode is selected automatically from what's
# installed
async_mode = "gevent_uwsgi"
sio = socketio.Server(logger=True, async_mode=async_mode)
app.wsgi_app = socketio.WSGIApp(sio, app.wsgi_app)
log_level = init_app(config_file="/config/aprsd.conf", log_level="DEBUG")
setup_logging(app, log_level)
sio.register_namespace(LoggingNamespace("/logs"))
CONF.log_opt_values(LOG, logging.DEBUG)
+52 -53
View File
@@ -1,88 +1,87 @@
#
# This file is autogenerated by pip-compile with Python 3.9
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --annotation-style=line --resolver=backtracking dev-requirements.in
# pip-compile --annotation-style=line dev-requirements.in
#
add-trailing-comma==2.4.0 # via gray
add-trailing-comma==3.0.0 # via gray
alabaster==0.7.13 # via sphinx
attrs==22.2.0 # via jsonschema, pytest
attrs==23.1.0 # via jsonschema, referencing
autoflake==1.5.3 # via gray
babel==2.11.0 # via sphinx
black==22.12.0 # via gray
babel==2.12.1 # via sphinx
black==23.7.0 # via gray
build==0.10.0 # via pip-tools
cachetools==5.2.1 # via tox
certifi==2022.12.7 # via requests
cachetools==5.3.1 # via tox
certifi==2023.7.22 # via requests
cfgv==3.3.1 # via pre-commit
chardet==5.1.0 # via tox
charset-normalizer==3.0.1 # via requests
click==8.1.3 # via black, pip-tools
charset-normalizer==3.2.0 # via requests
click==8.1.6 # via black, pip-tools
colorama==0.4.6 # via tox
commonmark==0.9.1 # via rich
configargparse==1.5.3 # via gray
coverage[toml]==7.0.5 # via pytest-cov
distlib==0.3.6 # via virtualenv
docutils==0.19 # via sphinx
exceptiongroup==1.1.0 # via pytest
filelock==3.9.0 # via tox, virtualenv
configargparse==1.7 # via gray
coverage[toml]==7.2.7 # via pytest-cov
distlib==0.3.7 # via virtualenv
docutils==0.20.1 # via sphinx
exceptiongroup==1.1.2 # via pytest
filelock==3.12.2 # via tox, virtualenv
fixit==0.1.4 # via gray
flake8==6.0.0 # via -r dev-requirements.in, fixit, pep8-naming
gray==0.13.0 # via -r dev-requirements.in
identify==2.5.13 # via pre-commit
identify==2.5.26 # via pre-commit
idna==3.4 # via requests
imagesize==1.4.1 # via sphinx
importlib-metadata==6.0.0 # via sphinx
importlib-resources==5.10.2 # via fixit
importlib-resources==6.0.0 # via fixit
iniconfig==2.0.0 # via pytest
isort==5.11.4 # via -r dev-requirements.in, gray
isort==5.12.0 # via -r dev-requirements.in, gray
jinja2==3.1.2 # via sphinx
jsonschema==4.17.3 # via fixit
libcst==0.4.9 # via fixit
markupsafe==2.1.2 # via jinja2
jsonschema==4.18.4 # via fixit
jsonschema-specifications==2023.7.1 # via jsonschema
libcst==1.0.1 # via fixit
markupsafe==2.1.3 # via jinja2
mccabe==0.7.0 # via flake8
mypy==0.991 # via -r dev-requirements.in
mypy-extensions==0.4.3 # via black, mypy, typing-inspect
nodeenv==1.7.0 # via pre-commit
packaging==23.0 # via build, pyproject-api, pytest, sphinx, tox
pathspec==0.10.3 # via black
mypy==1.4.1 # via -r dev-requirements.in
mypy-extensions==1.0.0 # via black, mypy, typing-inspect
nodeenv==1.8.0 # via pre-commit
packaging==23.1 # via black, build, pyproject-api, pytest, sphinx, tox
pathspec==0.11.1 # via black
pep8-naming==0.13.3 # via -r dev-requirements.in
pip-tools==6.12.1 # via -r dev-requirements.in
platformdirs==2.6.2 # via black, tox, virtualenv
pluggy==1.0.0 # via pytest, tox
pre-commit==2.21.0 # via -r dev-requirements.in
pip-tools==7.1.0 # via -r dev-requirements.in
platformdirs==3.9.1 # via black, tox, virtualenv
pluggy==1.2.0 # via pytest, tox
pre-commit==3.3.3 # via -r dev-requirements.in
pycodestyle==2.10.0 # via flake8
pyflakes==3.0.1 # via autoflake, flake8
pygments==2.14.0 # via rich, sphinx
pyproject-api==1.5.0 # via tox
pygments==2.15.1 # via rich, sphinx
pyproject-api==1.5.3 # via tox
pyproject-hooks==1.0.0 # via build
pyrsistent==0.19.3 # via jsonschema
pytest==7.2.1 # via -r dev-requirements.in, pytest-cov
pytest-cov==4.0.0 # via -r dev-requirements.in
pytz==2022.7.1 # via babel
pyupgrade==3.3.1 # via gray
pyyaml==6.0 # via fixit, libcst, pre-commit
requests==2.28.2 # via sphinx
pytest==7.4.0 # via -r dev-requirements.in, pytest-cov
pytest-cov==4.1.0 # via -r dev-requirements.in
pyupgrade==3.9.0 # via gray
pyyaml==6.0.1 # via fixit, libcst, pre-commit
referencing==0.30.0 # via jsonschema, jsonschema-specifications
requests==2.31.0 # via sphinx
rich==12.6.0 # via gray
rpds-py==0.9.2 # via jsonschema, referencing
snowballstemmer==2.2.0 # via sphinx
sphinx==6.1.3 # via -r dev-requirements.in
sphinxcontrib-applehelp==1.0.3 # via sphinx
sphinx==7.0.1 # via -r dev-requirements.in
sphinxcontrib-applehelp==1.0.4 # via sphinx
sphinxcontrib-devhelp==1.0.2 # via sphinx
sphinxcontrib-htmlhelp==2.0.0 # via sphinx
sphinxcontrib-htmlhelp==2.0.1 # via sphinx
sphinxcontrib-jsmath==1.0.1 # via sphinx
sphinxcontrib-qthelp==1.0.3 # via sphinx
sphinxcontrib-serializinghtml==1.1.5 # via sphinx
tokenize-rt==5.0.0 # via add-trailing-comma, pyupgrade
tokenize-rt==5.1.0 # via add-trailing-comma, pyupgrade
toml==0.10.2 # via autoflake
tomli==2.0.1 # via black, build, coverage, mypy, pyproject-api, pyproject-hooks, pytest, tox
tox==4.3.5 # via -r dev-requirements.in
typing-extensions==4.4.0 # via black, libcst, mypy, typing-inspect
typing-inspect==0.8.0 # via libcst
tomli==2.0.1 # via black, build, coverage, mypy, pip-tools, pyproject-api, pyproject-hooks, pytest, tox
tox==4.6.4 # via -r dev-requirements.in
typing-extensions==4.7.1 # via libcst, mypy, typing-inspect
typing-inspect==0.9.0 # via libcst
unify==0.5 # via gray
untokenize==0.1.1 # via unify
urllib3==1.26.14 # via requests
virtualenv==20.17.1 # via pre-commit, tox
wheel==0.38.4 # via pip-tools
zipp==3.11.0 # via importlib-metadata, importlib-resources
urllib3==2.0.4 # via requests
virtualenv==20.24.1 # via pre-commit, tox
wheel==0.41.0 # via pip-tools
# The following packages are considered to be unsafe in a requirements file:
# pip
+5 -2
View File
@@ -6,7 +6,7 @@ FROM ubuntu:22.04 as aprsd
ARG UID
ARG GID
ARG TZ
ARG VERSION=3.0.0
ARG VERSION=3.0.3
ARG BUILDX_QEMU_ENV
ENV APRS_USER=aprs
ENV HOME=/home/aprs
@@ -22,9 +22,12 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt update
RUN apt install -y git build-essential
RUN apt install -y libffi-dev python3-dev libssl-dev libxml2-dev libxslt-dev
RUN apt install -y python3 python3-pip python3-dev python3-lxml
RUN apt install -y python3 python3-pip python3-dev python3-lxml python3-setuptools-rust
RUN apt install -y libffi-dev cargo pkg-config
RUN pip3 install -U pip
RUN pip3 install -U setuptools_rust
RUN addgroup --gid $GID $APRS_USER
RUN useradd -m -u $UID -g $APRS_USER $APRS_USER
+43 -57
View File
@@ -1,71 +1,57 @@
# syntax=docker/dockerfile:1
FROM ubuntu:22.04
FROM python:3.11-slim as build
# Dockerfile for building a container during aprsd development.
ARG BRANCH=master
ARG UID
ARG GID
ARG BUILDX_QEMU_ENV
ENV APRS_USER=aprs
ENV HOME=/home/aprs
ENV APRSD=http://github.com/craigerl/aprsd.git
ENV APRSD_BRANCH=${BRANCH:-master}
ENV VIRTUAL_ENV=$HOME/.venv3
ENV UID=${UID:-1000}
ENV GID=${GID:-1000}
ENV DEBIAN_FRONTEND=noninteractive
ENV INSTALL=$HOME/install
RUN apt update
RUN apt install -y --no-install-recommends git build-essential bash fortune
RUN apt install -y libffi-dev python3-dev libssl-dev libxml2-dev libxslt-dev
RUN apt install -y python3 python3-pip python3-dev python3-lxml
#RUN apt-get clean
RUN apt-get -o Dpkg::Options::="--force-confmiss" install --reinstall netbase
ENV PIP_DEFAULT_TIMEOUT=100 \
# Allow statements and log messages to immediately appear
PYTHONUNBUFFERED=1 \
# disable a pip version check to reduce run-time & log-spam
PIP_DISABLE_PIP_VERSION_CHECK=1 \
# cache is useless in docker image, so disable to reduce image size
PIP_NO_CACHE_DIR=1
RUN addgroup --gid 1001 $APRS_USER
RUN useradd -m -u $UID -g $APRS_USER $APRS_USER
ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
RUN set -ex \
# Create a non-root user
&& addgroup --system --gid 1001 appgroup \
&& useradd --uid 1001 --gid 1001 -s /usr/bin/bash -m -d /app appuser \
# Upgrade the package index and install security upgrades
&& apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y git build-essential curl vim libffi-dev \
python3-dev libssl-dev libxml2-dev libxslt-dev telnet sudo \
# Install dependencies
# Clean up
&& apt-get autoremove -y \
&& apt-get clean -y
WORKDIR $HOME
USER $APRS_USER
RUN pip install wheel
#RUN python3 -m venv $VIRTUAL_ENV
#ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN echo "export PATH=\$PATH:\$HOME/.local/bin" >> $HOME/.bashrc
RUN cat $HOME/.bashrc
USER root
WORKDIR $HOME
# Handle an extremely specific issue when building the cryptography package for
# 32-bit architectures within QEMU running on a 64-bit host (issue #30).
RUN if [ "${BUILDX_QEMU_ENV}" = "true" -a "$(getconf LONG_BIT)" = "32" ]; then \
pip3 install -U cryptography==3.3.2; \
else \
pip3 install cryptography ;\
fi
RUN mkdir $INSTALL
RUN git clone -b $BRANCH $APRSD $INSTALL/aprsd
RUN cd $INSTALL/aprsd && pip3 install -v .
RUN ls -al /usr/local/bin
RUN ls -al /usr/bin
### Final stage
FROM build as final
WORKDIR /app
RUN git clone -b $APRSD_BRANCH https://github.com/craigerl/aprsd
RUN cd aprsd && pip install --no-cache-dir .
RUN pip install gevent uwsgi
RUN which aprsd
RUN mkdir /config
RUN chown -R appuser:appgroup /app
RUN chown -R appuser:appgroup /config
USER appuser
RUN which aprsd
RUN mkdir -p /config
RUN aprsd sample-config > /config/aprsd.conf
RUN chown -R $APRS_USER:$APRS_USER /config
# override this to run another configuration
ENV CONF default
USER $APRS_USER
VOLUME ["/config", "/plugins"]
ADD bin/run.sh /app
ADD bin/listen.sh /app
ADD bin/admin.sh /app
ADD bin/run.sh $HOME/
ADD bin/listen.sh $HOME/
ENTRYPOINT ["/home/aprs/run.sh"]
EXPOSE 8000
HEALTHCHECK --interval=5m --timeout=12s --start-period=30s \
CMD aprsd healthcheck --config /config/aprsd.conf
# CMD ["gunicorn", "aprsd.wsgi:app", "--host", "0.0.0.0", "--port", "8000"]
ENTRYPOINT ["/app/run.sh"]
VOLUME ["/config"]
# Set the user to run the application
USER appuser
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -x
if [ ! -z "${APRSD_PLUGINS}" ]; then
OLDIFS=$IFS
IFS=','
echo "Installing pypi plugins '$APRSD_PLUGINS'";
for plugin in ${APRSD_PLUGINS}; do
IFS=$OLDIFS
# call your procedure/other scripts here below
echo "Installing '$plugin'"
pip3 install --user $plugin
done
fi
if [ -z "${LOG_LEVEL}" ] || [[ ! "${LOG_LEVEL}" =~ ^(CRITICAL|ERROR|WARNING|INFO)$ ]]; then
LOG_LEVEL="DEBUG"
fi
echo "Log level is set to ${LOG_LEVEL}";
# check to see if there is a config file
APRSD_CONFIG="/config/aprsd.conf"
if [ ! -e "$APRSD_CONFIG" ]; then
echo "'$APRSD_CONFIG' File does not exist. Creating."
aprsd sample-config > $APRSD_CONFIG
fi
export COLUMNS=200
#exec gunicorn -b :8000 --workers 4 "aprsd.admin_web:create_app(config_file='$APRSD_CONFIG', log_level='$LOG_LEVEL')"
# exec gunicorn -b :8000 --workers 4 "aprsd.wsgi:app"
exec uwsgi --http :8000 --gevent 1000 --http-websockets --master -w aprsd.wsgi --callable app
#exec aprsd listen -c $APRSD_CONFIG --loglevel ${LOG_LEVEL} ${APRSD_LOAD_PLUGINS} ${APRSD_LISTEN_FILTER}
+1 -1
View File
@@ -9,7 +9,7 @@ if [ ! -z "${APRSD_PLUGINS}" ]; then
IFS=$OLDIFS
# call your procedure/other scripts here below
echo "Installing '$plugin'"
pip3 install $plugin
pip3 install --user $plugin
done
fi
+1 -1
View File
@@ -9,7 +9,7 @@ if [ ! -z "${APRSD_PLUGINS}" ]; then
IFS=$OLDIFS
# call your procedure/other scripts here below
echo "Installing '$plugin'"
pip3 install $plugin
pip3 install --user $plugin
done
fi
+1 -1
View File
@@ -14,7 +14,7 @@ class HelloPlugin(plugin.APRSDRegexCommandPluginBase):
command_regex = "^[hH]"
command_name = "hello"
def command(self, packet):
def process(self, packet):
LOG.info("HelloPlugin")
reply = f"Hello '{packet.from_call}'"
return reply
+9 -6
View File
@@ -1,9 +1,9 @@
aprslib>=0.7.0
click
click-params
click-completion
flask==2.1.2
werkzeug==2.1.2
flask-classful
flask
werkzeug
flask-httpauth
imapclient
pluggy
@@ -15,6 +15,8 @@ six
thesmuggler
update_checker
flask-socketio
python-socketio
gevent
eventlet
tabulate
# Pinned due to gray needing 12.6.0
@@ -34,6 +36,7 @@ oslo.config
rpyc
# Pin this here so it doesn't require a compile on
# raspi
cryptography==38.0.1
shellingham==1.5.0.post1
ratelimiter
cryptography
shellingham
geopy
rush
+54 -38
View File
@@ -1,69 +1,85 @@
#
# This file is autogenerated by pip-compile with Python 3.9
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile --annotation-style=line --resolver=backtracking requirements.in
# pip-compile --annotation-style=line requirements.in
#
anyio==3.7.1 # via httpcore
aprslib==0.7.2 # via -r requirements.in
attrs==22.2.0 # via -r requirements.in, ax253, kiss3
attrs==23.1.0 # via -r requirements.in, ax253, kiss3, rush
ax253==0.1.5.post1 # via kiss3
beautifulsoup4==4.11.1 # via -r requirements.in
beautifulsoup4==4.12.2 # via -r requirements.in
bidict==0.22.1 # via python-socketio
bitarray==2.6.2 # via ax253, kiss3
certifi==2022.12.7 # via requests
bitarray==2.8.0 # via ax253, kiss3
blinker==1.6.2 # via flask
certifi==2023.7.22 # via httpcore, requests
cffi==1.15.1 # via cryptography
charset-normalizer==3.0.1 # via requests
click==8.1.3 # via -r requirements.in, click-completion, flask
charset-normalizer==3.2.0 # via requests
click==8.1.6 # via -r requirements.in, click-completion, click-params, flask
click-completion==0.5.2 # via -r requirements.in
click-params==0.4.1 # via -r requirements.in
commonmark==0.9.1 # via rich
cryptography==38.0.1 # via -r requirements.in, pyopenssl
cryptography==41.0.2 # via -r requirements.in, pyopenssl
dacite2==2.0.0 # via -r requirements.in
dataclasses==0.6 # via -r requirements.in
debtcollector==2.5.0 # via oslo-config
dnspython==2.3.0 # via eventlet
decorator==5.1.1 # via validators
dnspython==2.4.0 # via eventlet
eventlet==0.33.3 # via -r requirements.in
flask==2.1.2 # via -r requirements.in, flask-classful, flask-httpauth, flask-socketio
flask-classful==0.14.2 # via -r requirements.in
flask-httpauth==4.7.0 # via -r requirements.in
flask-socketio==5.3.2 # via -r requirements.in
greenlet==2.0.1 # via eventlet
idna==3.4 # via requests
exceptiongroup==1.1.2 # via anyio
flask==2.3.2 # via -r requirements.in, flask-httpauth, flask-socketio
flask-httpauth==4.8.0 # via -r requirements.in
flask-socketio==5.3.4 # via -r requirements.in
geographiclib==2.0 # via geopy
geopy==2.3.0 # via -r requirements.in
gevent==23.7.0 # via -r requirements.in
greenlet==2.0.2 # via eventlet, gevent
h11==0.14.0 # via httpcore
httpcore==0.17.3 # via dnspython
idna==3.4 # via anyio, requests
imapclient==2.3.1 # via -r requirements.in
importlib-metadata==6.0.0 # via ax253, flask, kiss3
importlib-metadata==6.8.0 # via ax253, kiss3
itsdangerous==2.1.2 # via flask
jinja2==3.1.2 # via click-completion, flask
kiss3==8.0.0 # via -r requirements.in
markupsafe==2.1.2 # via jinja2
markupsafe==2.1.3 # via jinja2, werkzeug
netaddr==0.8.0 # via oslo-config
oslo-config==9.1.0 # via -r requirements.in
oslo-i18n==5.1.0 # via oslo-config
oslo-config==9.1.1 # via -r requirements.in
oslo-i18n==6.0.0 # via oslo-config
pbr==5.11.1 # via -r requirements.in, oslo-i18n, stevedore
pluggy==1.0.0 # via -r requirements.in
plumbum==1.8.1 # via rpyc
pluggy==1.2.0 # via -r requirements.in
plumbum==1.8.2 # via rpyc
pycparser==2.21 # via cffi
pygments==2.14.0 # via rich
pyopenssl==23.0.0 # via -r requirements.in
pygments==2.15.1 # via rich
pyopenssl==23.2.0 # via -r requirements.in
pyserial==3.5 # via pyserial-asyncio
pyserial-asyncio==0.6 # via kiss3
python-engineio==4.3.4 # via python-socketio
python-socketio==5.7.2 # via flask-socketio
pytz==2022.7.1 # via -r requirements.in
pyyaml==6.0 # via -r requirements.in, oslo-config
ratelimiter==1.2.0.post0 # via -r requirements.in
requests==2.28.2 # via -r requirements.in, oslo-config, update-checker
python-engineio==4.5.1 # via python-socketio
python-socketio==5.8.0 # via -r requirements.in, flask-socketio
pytz==2023.3 # via -r requirements.in
pyyaml==6.0.1 # via -r requirements.in, oslo-config
requests==2.31.0 # via -r requirements.in, oslo-config, update-checker
rfc3986==2.0.0 # via oslo-config
rich==12.6.0 # via -r requirements.in
rpyc==5.3.0 # via -r requirements.in
rpyc==5.3.1 # via -r requirements.in
rush==2021.4.0 # via -r requirements.in
shellingham==1.5.0.post1 # via -r requirements.in, click-completion
six==1.16.0 # via -r requirements.in, click-completion, eventlet, imapclient
soupsieve==2.3.2.post1 # via beautifulsoup4
stevedore==4.1.1 # via oslo-config
sniffio==1.3.0 # via anyio, dnspython, httpcore
soupsieve==2.4.1 # via beautifulsoup4
stevedore==5.1.0 # via oslo-config
tabulate==0.9.0 # via -r requirements.in
thesmuggler==1.0.1 # via -r requirements.in
ua-parser==0.16.1 # via user-agents
ua-parser==0.18.0 # via user-agents
update-checker==0.18.0 # via -r requirements.in
urllib3==1.26.14 # via requests
urllib3==2.0.4 # via requests
user-agents==2.2.0 # via -r requirements.in
werkzeug==2.1.2 # via -r requirements.in, flask
wrapt==1.14.1 # via -r requirements.in, debtcollector
zipp==3.11.0 # via importlib-metadata
validators==0.20.0 # via click-params
werkzeug==2.3.6 # via -r requirements.in, flask
wrapt==1.15.0 # via -r requirements.in, debtcollector
zipp==3.16.2 # via importlib-metadata
zope-event==5.0 # via gevent
zope-interface==6.0 # via gevent
# The following packages are considered to be unsafe in a requirements file:
# setuptools
+1 -1
View File
@@ -33,7 +33,7 @@ packages =
[entry_points]
console_scripts =
aprsd = aprsd.aprsd:main
aprsd = aprsd.main:main
oslo.config.opts =
aprsd.conf = aprsd.conf.opts:list_opts
oslo.config.opts.defaults =
+3 -3
View File
@@ -6,8 +6,8 @@ from click.testing import CliRunner
from oslo_config import cfg
from aprsd import conf # noqa : F401
from aprsd.aprsd import cli
from aprsd.cmds import send_message # noqa
from aprsd.main import cli
from .. import fake
@@ -30,7 +30,7 @@ class TestSendMessageCommand(unittest.TestCase):
CONF.admin.user = "admin"
CONF.admin.password = "password"
@mock.patch("aprsd.logging.log.setup_logging")
@mock.patch("aprsd.log.log.setup_logging")
def test_no_tocallsign(self, mock_logging):
"""Make sure we get an error if there is no tocallsign."""
@@ -47,7 +47,7 @@ class TestSendMessageCommand(unittest.TestCase):
assert result.exit_code == 2
assert "Error: Missing argument 'TOCALLSIGN'" in result.output
@mock.patch("aprsd.logging.log.setup_logging")
@mock.patch("aprsd.log.log.setup_logging")
def test_no_command(self, mock_logging):
"""Make sure we get an error if there is no command."""
+3 -3
View File
@@ -32,16 +32,16 @@ class TestSendMessageCommand(unittest.TestCase):
CONF.admin.user = "admin"
CONF.admin.password = "password"
@mock.patch("aprsd.logging.log.setup_logging")
@mock.patch("aprsd.log.log.setup_logging")
def test_init_flask(self, mock_logging):
"""Make sure we get an error if there is no login and config."""
CliRunner()
self.config_and_init()
socketio, flask_app = webchat.init_flask("DEBUG", False)
socketio = webchat.init_flask("DEBUG", False)
self.assertIsInstance(socketio, flask_socketio.SocketIO)
self.assertIsInstance(flask_app, flask.Flask)
self.assertIsInstance(webchat.flask_app, flask.Flask)
@mock.patch("aprsd.packets.tracker.PacketTrack.remove")
@mock.patch("aprsd.cmds.webchat.socketio")
+24 -14
View File
@@ -49,51 +49,61 @@ class TestLocationPlugin(test_plugin.TestPlugin):
self.assertEqual(expected, actual)
@mock.patch("aprsd.plugin_utils.get_aprs_fi")
@mock.patch("aprsd.plugin_utils.get_weather_gov_for_gps")
@mock.patch("geopy.geocoders.Nominatim.reverse")
@mock.patch("time.time")
def test_location_unknown_gps(self, mock_time, mock_weather, mock_check_aprs):
def test_location_unknown_gps(self, mock_time, mock_geocode, mock_check_aprs):
# When the aprs.fi api key isn't set, then
# the LocationPlugin will be disabled.
mock_check_aprs.return_value = {
"entries": [
{
"lat": 10,
"lng": 11,
"lat": 1,
"lng": 1,
"lasttime": 10,
},
],
}
mock_weather.side_effect = Exception
mock_geocode.side_effect = Exception
mock_time.return_value = 10
CONF.callsign = fake.FAKE_TO_CALLSIGN
fortune = location_plugin.LocationPlugin()
expected = "KFAKE: Unknown Location 0' 10,11 0.0h ago"
expected = "KFAKE: Unknown Location 0' 1.00,1.00 0.0h ago"
packet = fake.fake_packet(message="location")
actual = fortune.filter(packet)
self.assertEqual(expected, actual)
@mock.patch("aprsd.plugin_utils.get_aprs_fi")
@mock.patch("aprsd.plugin_utils.get_weather_gov_for_gps")
@mock.patch("geopy.geocoders.Nominatim.reverse")
@mock.patch("time.time")
def test_location_works(self, mock_time, mock_weather, mock_check_aprs):
def test_location_works(self, mock_time, mock_geocode, mock_check_aprs):
# When the aprs.fi api key isn't set, then
# the LocationPlugin will be disabled.
mock_check_aprs.return_value = {
"entries": [
{
"lat": 10,
"lng": 11,
"lat": 1,
"lng": 1,
"lasttime": 10,
},
],
}
expected_town = "Appomattox, VA"
wx_data = {"location": {"areaDescription": expected_town}}
mock_weather.return_value = wx_data
expected = "Appomattox"
state = "VA"
class TempLocation:
raw = {
"address": {
"county": expected,
"country_code": "us",
"state": state,
"country": "United States",
},
}
mock_geocode.return_value = TempLocation()
mock_time.return_value = 10
CONF.callsign = fake.FAKE_TO_CALLSIGN
fortune = location_plugin.LocationPlugin()
expected = f"KFAKE: {expected_town} 0' 10,11 0.0h ago"
expected = f"KFAKE: {expected}, {state} 0' 1.00,1.00 0.0h ago"
packet = fake.fake_packet(message="location")
actual = fortune.filter(packet)
self.assertEqual(expected, actual)