1
0
mirror of https://github.com/craigerl/aprsd.git synced 2025-08-10 01:02:27 -04:00

Merge pull request #47 from craigerl/stabilize_1_6_0

Branch to stabilize for the 1.6.0 release.
This commit is contained in:
Walter A. Boring IV 2021-02-12 14:09:48 -05:00 committed by GitHub
commit 3a6316fa8a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 767 additions and 158 deletions

View File

@ -1,42 +0,0 @@
FROM alpine:latest as aprsd
ENV VERSION=1.0.0
ENV APRS_USER=aprs
ENV HOME=/home/aprs
ENV VIRTUAL_ENV=$HOME/.venv3
ENV INSTALL=$HOME/install
RUN apk add --update git wget py3-pip py3-virtualenv bash fortune
# Setup Timezone
ENV TZ=US/Eastern
#RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
#RUN apt-get install -y tzdata
#RUN dpkg-reconfigure --frontend noninteractive tzdata
RUN addgroup --gid 1000 $APRS_USER
RUN adduser -h $HOME -D -u 1001 -G $APRS_USER $APRS_USER
ENV LC_ALL=C.UTF-8
ENV LANG=C.UTF-8
USER $APRS_USER
RUN pip3 install wheel
RUN python3 -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN echo "export PATH=\$PATH:\$HOME/.local/bin" >> $HOME/.bashrc
VOLUME ["/config", "/plugins"]
WORKDIR $HOME
RUN pip install aprsd
USER root
RUN aprsd sample-config > /config/aprsd.yml
RUN chown -R $APRS_USER:$APRS_USER /config
# override this to run another configuration
ENV CONF default
USER $APRS_USER
ADD build/bin/run.sh $HOME/
ENTRYPOINT ["/home/aprs/run.sh"]

View File

@ -7,7 +7,7 @@ import re
import smtplib
import time
from aprsd import messaging, stats, threads
from aprsd import messaging, stats, threads, trace
import imapclient
from validate_email import validate_email
@ -17,6 +17,7 @@ LOG = logging.getLogger("APRSD")
CONFIG = None
@trace.trace
def _imap_connect():
imap_port = CONFIG["aprsd"]["email"]["imap"].get("port", 143)
use_ssl = CONFIG["aprsd"]["email"]["imap"].get("use_ssl", False)
@ -31,9 +32,10 @@ def _imap_connect():
port=imap_port,
use_uid=True,
ssl=use_ssl,
timeout=30,
)
except Exception:
LOG.error("Failed to connect IMAP server")
except Exception as e:
LOG.error("Failed to connect IMAP server", e)
return
try:
@ -47,9 +49,15 @@ def _imap_connect():
return
server.select_folder("INBOX")
server.fetch = trace.trace(server.fetch)
server.search = trace.trace(server.search)
server.remove_flags = trace.trace(server.remove_flags)
server.add_flags = trace.trace(server.add_flags)
return server
@trace.trace
def _smtp_connect():
host = CONFIG["aprsd"]["email"]["smtp"]["host"]
smtp_port = CONFIG["aprsd"]["email"]["smtp"]["port"]
@ -64,15 +72,28 @@ def _smtp_connect():
try:
if use_ssl:
server = smtplib.SMTP_SSL(host=host, port=smtp_port)
server = smtplib.SMTP_SSL(
host=host,
port=smtp_port,
timeout=30,
)
else:
server = smtplib.SMTP(host=host, port=smtp_port)
server = smtplib.SMTP(
host=host,
port=smtp_port,
timeout=30,
)
except Exception:
LOG.error("Couldn't connect to SMTP Server")
return
LOG.debug("Connected to smtp host {}".format(msg))
debug = CONFIG["aprsd"]["email"]["smtp"].get("debug", False)
if debug:
server.set_debuglevel(5)
server.sendmail = trace.trace(server.sendmail)
try:
server.login(
CONFIG["aprsd"]["email"]["smtp"]["login"],
@ -87,7 +108,7 @@ def _smtp_connect():
def validate_shortcuts(config):
shortcuts = config.get("shortcuts", None)
shortcuts = config["aprsd"]["email"].get("shortcuts", None)
if not shortcuts:
return
@ -120,7 +141,7 @@ def validate_shortcuts(config):
for key in delete_keys:
del config["aprsd"]["email"]["shortcuts"][key]
LOG.info("Available shortcuts: {}".format(config["shortcuts"]))
LOG.info("Available shortcuts: {}".format(config["aprsd"]["email"]["shortcuts"]))
def get_email_from_shortcut(addr):
@ -152,6 +173,7 @@ def validate_email_config(config, disable_validation=False):
return False
@trace.trace
def parse_email(msgid, data, server):
envelope = data[b"ENVELOPE"]
# email address match
@ -162,7 +184,12 @@ def parse_email(msgid, data, server):
else:
from_addr = "noaddr"
LOG.debug("Got a message from '{}'".format(from_addr))
m = server.fetch([msgid], ["RFC822"])
try:
m = server.fetch([msgid], ["RFC822"])
except Exception as e:
LOG.exception("Couldn't fetch email from server in parse_email", e)
return
msg = email.message_from_string(m[msgid][b"RFC822"].decode(errors="ignore"))
if msg.is_multipart():
text = ""
@ -238,6 +265,7 @@ def parse_email(msgid, data, server):
# end parse_email
@trace.trace
def send_email(to_addr, content):
global check_email_delay
@ -282,6 +310,7 @@ def send_email(to_addr, content):
# end send_email
@trace.trace
def resend_email(count, fromcall):
global check_email_delay
date = datetime.datetime.now()
@ -290,7 +319,7 @@ def resend_email(count, fromcall):
year = date.year
today = "{}-{}-{}".format(day, month, year)
shortcuts = CONFIG["shortcuts"]
shortcuts = CONFIG["aprsd"]["email"]["shortcuts"]
# swap key/value
shortcuts_inverted = {v: k for k, v in shortcuts.items()}
@ -300,7 +329,12 @@ def resend_email(count, fromcall):
LOG.exception("Failed to Connect to IMAP. Cannot resend email ", e)
return
messages = server.search(["SINCE", today])
try:
messages = server.search(["SINCE", today])
except Exception as e:
LOG.exception("Couldn't search for emails in resend_email ", e)
return
# LOG.debug("%d messages received today" % len(messages))
msgexists = False
@ -308,11 +342,21 @@ def resend_email(count, fromcall):
messages.sort(reverse=True)
del messages[int(count) :] # only the latest "count" messages
for message in messages:
for msgid, data in list(server.fetch(message, ["ENVELOPE"]).items()):
try:
parts = server.fetch(message, ["ENVELOPE"]).items()
except Exception as e:
LOG.exception("Couldn't fetch email parts in resend_email", e)
continue
for msgid, data in list(parts):
# one at a time, otherwise order is random
(body, from_addr) = parse_email(msgid, data, server)
# unset seen flag, will stay bold in email client
server.remove_flags(msgid, [imapclient.SEEN])
try:
server.remove_flags(msgid, [imapclient.SEEN])
except Exception as e:
LOG.exception("Failed to remove SEEN flag in resend_email", e)
if from_addr in shortcuts_inverted:
# reverse lookup of a shortcut
from_addr = shortcuts_inverted[from_addr]
@ -320,7 +364,7 @@ def resend_email(count, fromcall):
reply = "-" + from_addr + " * " + body.decode(errors="ignore")
# messaging.send_message(fromcall, reply)
msg = messaging.TextMessage(
CONFIG["aprsd"]["email"]["aprs"]["login"],
CONFIG["aprs"]["login"],
fromcall,
reply,
)
@ -358,6 +402,7 @@ class APRSDEmailThread(threads.APRSDThread):
self.msg_queues = msg_queues
self.config = config
@trace.trace
def run(self):
global check_email_delay
@ -395,17 +440,30 @@ class APRSDEmailThread(threads.APRSDThread):
try:
server = _imap_connect()
except Exception as e:
LOG.exception("Failed to get IMAP server Can't check email.", e)
LOG.exception("IMAP failed to connect.", e)
if not server:
continue
messages = server.search(["SINCE", today])
try:
messages = server.search(["SINCE", today])
except Exception as e:
LOG.exception("IMAP failed to search for messages since today.", e)
continue
LOG.debug("{} messages received today".format(len(messages)))
for msgid, data in server.fetch(messages, ["ENVELOPE"]).items():
try:
_msgs = server.fetch(messages, ["ENVELOPE"])
except Exception as e:
LOG.exception("IMAP failed to fetch/flag messages: ", e)
continue
for msgid, data in _msgs.items():
envelope = data[b"ENVELOPE"]
# LOG.debug('ID:%d "%s" (%s)' % (msgid, envelope.subject.decode(), envelope.date))
LOG.debug(
'ID:%d "%s" (%s)'
% (msgid, envelope.subject.decode(), envelope.date),
)
f = re.search(
r"'([[A-a][0-9]_-]+@[[A-a][0-9]_-\.]+)",
str(envelope.from_[0]),
@ -418,16 +476,31 @@ class APRSDEmailThread(threads.APRSDThread):
# LOG.debug("Message flags/tags: " + str(server.get_flags(msgid)[msgid]))
# if "APRS" not in server.get_flags(msgid)[msgid]:
# in python3, imap tags are unicode. in py2 they're strings. so .decode them to handle both
taglist = [
x.decode(errors="ignore")
for x in server.get_flags(msgid)[msgid]
]
try:
taglist = [
x.decode(errors="ignore")
for x in server.get_flags(msgid)[msgid]
]
except Exception as e:
LOG.exception("Failed to get flags.", e)
break
if "APRS" not in taglist:
# if msg not flagged as sent via aprs
server.fetch([msgid], ["RFC822"])
try:
server.fetch([msgid], ["RFC822"])
except Exception as e:
LOG.exception("Failed single server fetch for RFC822", e)
break
(body, from_addr) = parse_email(msgid, data, server)
# unset seen flag, will stay bold in email client
server.remove_flags(msgid, [imapclient.SEEN])
try:
server.remove_flags(msgid, [imapclient.SEEN])
except Exception as e:
LOG.exception("Failed to remove flags SEEN", e)
# Not much we can do here, so lets try and
# send the aprs message anyway
if from_addr in shortcuts_inverted:
# reverse lookup of a shortcut
@ -441,14 +514,28 @@ class APRSDEmailThread(threads.APRSDThread):
)
self.msg_queues["tx"].put(msg)
# flag message as sent via aprs
server.add_flags(msgid, ["APRS"])
# unset seen flag, will stay bold in email client
server.remove_flags(msgid, [imapclient.SEEN])
try:
server.add_flags(msgid, ["APRS"])
# unset seen flag, will stay bold in email client
except Exception as e:
LOG.exception("Couldn't add APRS flag to email", e)
try:
server.remove_flags(msgid, [imapclient.SEEN])
except Exception as e:
LOG.exception("Couldn't remove seen flag from email", e)
# check email more often since we just received an email
check_email_delay = 60
# reset clock
LOG.debug("Done looping over Server.fetch, logging out.")
past = datetime.datetime.now()
server.logout()
try:
server.logout()
except Exception as e:
LOG.exception("IMAP failed to logout: ", e)
continue
else:
# We haven't hit the email delay yet.
# LOG.debug("Delta({}) < {}".format(now - past, check_email_delay))
@ -457,6 +544,3 @@ class APRSDEmailThread(threads.APRSDThread):
# Remove ourselves from the global threads list
threads.APRSDThreadList().remove(self)
LOG.info("Exiting")
# end check_email()

View File

@ -1,21 +1,65 @@
import json
import logging
import aprsd
from aprsd import messaging, stats
import flask
import flask_classful
from flask_httpauth import HTTPBasicAuth
from werkzeug.security import check_password_hash, generate_password_hash
LOG = logging.getLogger("APRSD")
auth = HTTPBasicAuth()
users = None
# 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
class APRSDFlask(flask_classful.FlaskView):
config = None
def set_config(self, config):
global users
self.config = config
self.users = {}
for user in self.config["aprsd"]["web"]["users"]:
self.users[user] = generate_password_hash(
self.config["aprsd"]["web"]["users"][user],
)
users = self.users
def index(self):
return "Hello"
# return flask.render_template("index.html", message=msg)
@auth.login_required
def messages(self):
track = messaging.MsgTrack()
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
def save(self):
"""Save the existing queue to disk."""
track = messaging.MsgTrack()
track.save()
return json.dumps({"messages": "saved"})
def stats(self):
stats_obj = stats.APRSDStats()
track = messaging.MsgTrack()
@ -30,9 +74,16 @@ class APRSDFlask(flask_classful.FlaskView):
def init_flask(config):
flask_app = flask.Flask("aprsd")
flask_app = flask.Flask(
"aprsd",
static_url_path="",
static_folder="web/static",
template_folder="web/templates",
)
server = APRSDFlask()
server.set_config(config)
# flask_app.route('/', methods=['GET'])(server.index)
flask_app.route("/stats", methods=["GET"])(server.stats)
flask_app.route("/messages", methods=["GET"])(server.messages)
flask_app.route("/save", methods=["GET"])(server.save)
return flask_app

View File

@ -20,6 +20,7 @@
#
# python included libs
import datetime
import logging
from logging import NullHandler
from logging.handlers import RotatingFileHandler
@ -27,12 +28,11 @@ import os
import queue
import signal
import sys
import threading
import time
# local imports here
import aprsd
from aprsd import client, email, flask, messaging, plugin, stats, threads, utils
from aprsd import client, email, flask, messaging, plugin, stats, threads, trace, utils
import aprslib
from aprslib.exceptions import LoginError
import click
@ -52,7 +52,9 @@ LOG_LEVELS = {
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
server_event = threading.Event()
flask_enabled = False
# server_event = threading.Event()
# localization, please edit:
# HOST = "noam.aprs2.net" # north america tier2 servers round robin
@ -150,20 +152,23 @@ def install(append, case_insensitive, shell, path):
def signal_handler(sig, frame):
global server_vent
global flask_enabled
LOG.info(
"Ctrl+C, Sending all threads exit! Can take up to 10 seconds to exit all threads",
)
threads.APRSDThreadList().stop_all()
server_event.set()
LOG.info("EXITING STATS")
LOG.info(stats.APRSDStats())
# time.sleep(1)
signal.signal(signal.SIGTERM, sys.exit(0))
# end signal_handler
if "subprocess" not in str(frame):
LOG.info(
"Ctrl+C, Sending all threads exit! Can take up to 10 seconds {}".format(
datetime.datetime.now(),
),
)
time.sleep(5)
tracker = messaging.MsgTrack()
tracker.save()
LOG.info(stats.APRSDStats())
# signal.signal(signal.SIGTERM, sys.exit(0))
# sys.exit(0)
if flask_enabled:
signal.signal(signal.SIGTERM, sys.exit(0))
# Setup the logging faciility
@ -184,10 +189,21 @@ def setup_logging(config, loglevel, quiet):
fh.setFormatter(log_formatter)
LOG.addHandler(fh)
imap_logger = None
if config["aprsd"]["email"].get("enabled", False) and config["aprsd"]["email"][
"imap"
].get("debug", False):
imap_logger = logging.getLogger("imapclient.imaplib")
imap_logger.setLevel(log_level)
imap_logger.addHandler(fh)
if not quiet:
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(log_formatter)
LOG.addHandler(sh)
if imap_logger:
imap_logger.addHandler(sh)
@main.command()
@ -394,9 +410,7 @@ def server(
flush,
):
"""Start the aprsd server process."""
global event
event = threading.Event()
global flask_enabled
signal.signal(signal.SIGINT, signal_handler)
if not quiet:
@ -410,6 +424,8 @@ def server(
email.CONFIG = config
setup_logging(config, loglevel, quiet)
if config["aprsd"].get("trace", False):
trace.setup_tracing(["method", "api"])
LOG.info("APRSD Started version: {}".format(aprsd.__version__))
stats.APRSDStats(config)
@ -468,6 +484,7 @@ def server(
web_enabled = False
if web_enabled:
flask_enabled = True
app = flask.init_flask(config)
app.run(
host=config["aprsd"]["web"]["host"],
@ -475,10 +492,8 @@ def server(
)
# If there are items in the msgTracker, then save them
tracker = messaging.MsgTrack()
tracker.save()
LOG.info(stats.APRSDStats())
LOG.info("APRSD Exiting.")
return 0
if __name__ == "__main__":

View File

@ -53,6 +53,38 @@ class MsgTrack:
cls._instance.lock = threading.Lock()
return cls._instance
def __getitem__(self, name):
with self.lock:
return self.track[name]
def __iter__(self):
with self.lock:
return iter(self.track)
def keys(self):
with self.lock:
return self.track.keys()
def items(self):
with self.lock:
return self.track.items()
def values(self):
with self.lock:
return self.track.values()
def __len__(self):
with self.lock:
return len(self.track)
def __str__(self):
with self.lock:
result = "{"
for key in self.track.keys():
result += "{}: {}, ".format(key, str(self.track[key]))
result += "}"
return result
def add(self, msg):
with self.lock:
key = int(msg.id)
@ -71,24 +103,18 @@ class MsgTrack:
if key in self.track.keys():
del self.track[key]
def __len__(self):
with self.lock:
return len(self.track)
def __str__(self):
with self.lock:
result = "{"
for key in self.track.keys():
result += "{}: {}, ".format(key, str(self.track[key]))
result += "}"
return result
def save(self):
"""Save this shit to disk?"""
"""Save any queued to disk?"""
LOG.debug("Save tracker to disk? {}".format(len(self)))
if len(self) > 0:
LOG.info("Saving {} tracking messages to disk".format(len(self)))
pickle.dump(self.dump(), open(utils.DEFAULT_SAVE_FILE, "wb+"))
else:
LOG.debug(
"Nothing to save, flushing old save file '{}'".format(
utils.DEFAULT_SAVE_FILE,
),
)
self.flush()
def dump(self):
@ -229,8 +255,17 @@ class RawMessage(Message):
super().__init__(None, None, msg_id=None)
self.message = message
def __repr__(self):
return self.message
def dict(self):
now = datetime.datetime.now()
return {
"type": "raw",
"message": self.message.rstrip("\n"),
"raw": self.message.rstrip("\n"),
"retry_count": self.retry_count,
"last_send_attempt": self.last_send_attempt,
"last_send_time": str(self.last_send_time),
"last_send_age": str(now - self.last_send_time),
}
def __str__(self):
return self.message
@ -246,12 +281,12 @@ class RawMessage(Message):
cl = client.get_client()
log_message(
"Sending Message Direct",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
self.message,
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(repr(self))
cl.sendall(str(self))
stats.APRSDStats().msgs_sent_inc()
@ -267,7 +302,22 @@ class TextMessage(Message):
# an ack? Some messages we don't want to do this ever.
self.allow_delay = allow_delay
def __repr__(self):
def dict(self):
now = datetime.datetime.now()
return {
"id": self.id,
"type": "text-message",
"fromcall": self.fromcall,
"tocall": self.tocall,
"message": self.message.rstrip("\n"),
"raw": str(self).rstrip("\n"),
"retry_count": self.retry_count,
"last_send_attempt": self.last_send_attempt,
"last_send_time": str(self.last_send_time),
"last_send_age": str(now - self.last_send_time),
}
def __str__(self):
"""Build raw string to send over the air."""
return "{}>APZ100::{}:{}{{{}\n".format(
self.fromcall,
@ -276,19 +326,6 @@ class TextMessage(Message):
str(self.id),
)
def __str__(self):
delta = "Never"
if self.last_send_time:
now = datetime.datetime.now()
delta = now - self.last_send_time
return "{}>{} Msg({})({}): '{}'".format(
self.fromcall,
self.tocall,
self.id,
delta,
self.message,
)
def _filter_for_send(self):
"""Filter and format message string for FCC."""
# max? ftm400 displays 64, raw msg shows 74
@ -311,12 +348,12 @@ class TextMessage(Message):
cl = client.get_client()
log_message(
"Sending Message Direct",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
self.message,
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(repr(self))
cl.sendall(str(self))
stats.APRSDStats().msgs_tx_inc()
@ -370,13 +407,13 @@ class SendMessageThread(threads.APRSDThread):
# tracking the time.
log_message(
"Sending Message",
repr(msg).rstrip("\n"),
str(msg).rstrip("\n"),
msg.message,
tocall=self.msg.tocall,
retry_number=msg.last_send_attempt,
msg_num=msg.id,
)
cl.sendall(repr(msg))
cl.sendall(str(msg))
stats.APRSDStats().msgs_tx_inc()
msg.last_send_time = datetime.datetime.now()
msg.last_send_attempt += 1
@ -392,29 +429,40 @@ class AckMessage(Message):
def __init__(self, fromcall, tocall, msg_id):
super().__init__(fromcall, tocall, msg_id=msg_id)
def __repr__(self):
def dict(self):
now = datetime.datetime.now()
return {
"id": self.id,
"type": "ack",
"fromcall": self.fromcall,
"tocall": self.tocall,
"raw": str(self).rstrip("\n"),
"retry_count": self.retry_count,
"last_send_attempt": self.last_send_attempt,
"last_send_time": str(self.last_send_time),
"last_send_age": str(now - self.last_send_time),
}
def __str__(self):
return "{}>APZ100::{}:ack{}\n".format(
self.fromcall,
self.tocall.ljust(9),
self.id,
)
def __str__(self):
return "From({}) TO({}) Ack ({})".format(self.fromcall, self.tocall, self.id)
def send_thread(self):
"""Separate thread to send acks with retries."""
cl = client.get_client()
for i in range(self.retry_count, 0, -1):
log_message(
"Sending ack",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
None,
ack=self.id,
tocall=self.tocall,
retry_number=i,
)
cl.sendall(repr(self))
cl.sendall(str(self))
stats.APRSDStats().ack_tx_inc()
# aprs duplicate detection is 30 secs?
# (21 only sends first, 28 skips middle)
@ -433,13 +481,13 @@ class AckMessage(Message):
cl = client.get_client()
log_message(
"Sending ack",
repr(self).rstrip("\n"),
str(self).rstrip("\n"),
None,
ack=self.id,
tocall=self.tocall,
fromcall=self.fromcall,
)
cl.sendall(repr(self))
cl.sendall(str(self))
class SendAckThread(threads.APRSDThread):
@ -476,13 +524,13 @@ class SendAckThread(threads.APRSDThread):
cl = client.get_client()
log_message(
"Sending ack",
repr(self.ack).rstrip("\n"),
str(self.ack).rstrip("\n"),
None,
ack=self.ack.id,
tocall=self.ack.tocall,
retry_number=self.ack.last_send_attempt,
)
cl.sendall(repr(self.ack))
cl.sendall(str(self.ack))
stats.APRSDStats().ack_tx_inc()
self.ack.last_send_attempt += 1
self.ack.last_send_time = datetime.datetime.now()

View File

@ -2,7 +2,7 @@ import logging
import re
import time
from aprsd import email, messaging, plugin
from aprsd import email, messaging, plugin, trace
LOG = logging.getLogger("APRSD")
@ -18,6 +18,7 @@ class EmailPlugin(plugin.APRSDPluginBase):
# five mins {int:int}
email_sent_dict = {}
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("Email COMMAND")
reply = None
@ -79,6 +80,7 @@ class EmailPlugin(plugin.APRSDPluginBase):
self.email_sent_dict.clear()
self.email_sent_dict[ack] = now
else:
reply = messaging.NULL_MESSAGE
LOG.info(
"Email for message number "
+ ack

View File

@ -2,7 +2,7 @@ import logging
import shutil
import subprocess
from aprsd import plugin
from aprsd import plugin, trace
LOG = logging.getLogger("APRSD")
@ -14,6 +14,7 @@ class FortunePlugin(plugin.APRSDPluginBase):
command_regex = "^[fF]"
command_name = "fortune"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("FortunePlugin")
reply = None

View File

@ -2,7 +2,7 @@ import logging
import re
import time
from aprsd import plugin, plugin_utils, utils
from aprsd import plugin, plugin_utils, trace, utils
LOG = logging.getLogger("APRSD")
@ -14,6 +14,7 @@ class LocationPlugin(plugin.APRSDPluginBase):
command_regex = "^[lL]"
command_name = "location"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("Location Plugin")
# get last location of a callsign, get descriptive name from weather service
@ -65,6 +66,10 @@ class LocationPlugin(plugin.APRSDPluginBase):
LOG.error("Couldn't fetch forecast.weather.gov '{}'".format(ex))
wx_data = {"location": {"areaDescription": "Unknown Location"}}
if "location" not in wx_data:
LOG.error("Couldn't fetch forecast.weather.gov '{}'".format(wx_data))
wx_data = {"location": {"areaDescription": "Unknown Location"}}
reply = "{}: {} {}' {},{} {}h ago".format(
searchcall,
wx_data["location"]["areaDescription"],

View File

@ -1,7 +1,7 @@
import logging
import time
from aprsd import plugin
from aprsd import plugin, trace
LOG = logging.getLogger("APRSD")
@ -13,6 +13,7 @@ class PingPlugin(plugin.APRSDPluginBase):
command_regex = "^[pP]"
command_name = "ping"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("PINGPlugin")
stm = time.localtime()

View File

@ -2,7 +2,7 @@ import datetime
import logging
import re
from aprsd import messaging, plugin
from aprsd import messaging, plugin, trace
LOG = logging.getLogger("APRSD")
@ -14,6 +14,7 @@ class QueryPlugin(plugin.APRSDPluginBase):
command_regex = r"^\!.*"
command_name = "query"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("Query COMMAND")

View File

@ -1,7 +1,7 @@
import logging
import time
from aprsd import fuzzyclock, plugin, plugin_utils, utils
from aprsd import fuzzyclock, plugin, plugin_utils, trace, utils
from opencage.geocoder import OpenCageGeocode
import pytz
@ -38,6 +38,7 @@ class TimePlugin(plugin.APRSDPluginBase):
return reply
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("TIME COMMAND")
# So we can mock this in unit tests
@ -52,6 +53,7 @@ class TimeOpenCageDataPlugin(TimePlugin):
command_regex = "^[tT]"
command_name = "Time"
@trace.trace
def command(self, fromcall, message, ack):
api_key = self.config["services"]["aprs.fi"]["apiKey"]
try:
@ -92,6 +94,7 @@ class TimeOWMPlugin(TimePlugin):
command_regex = "^[tT]"
command_name = "Time"
@trace.trace
def command(self, fromcall, message, ack):
api_key = self.config["services"]["aprs.fi"]["apiKey"]
try:

View File

@ -1,7 +1,7 @@
import logging
import aprsd
from aprsd import plugin
from aprsd import plugin, trace
LOG = logging.getLogger("APRSD")
@ -17,6 +17,7 @@ class VersionPlugin(plugin.APRSDPluginBase):
# five mins {int:int}
email_sent_dict = {}
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("Version COMMAND")
return "APRSD version '{}'".format(aprsd.__version__)

View File

@ -2,7 +2,7 @@ import json
import logging
import re
from aprsd import plugin, plugin_utils, utils
from aprsd import plugin, plugin_utils, trace, utils
import requests
LOG = logging.getLogger("APRSD")
@ -25,6 +25,7 @@ class USWeatherPlugin(plugin.APRSDPluginBase):
command_regex = "^[wW]"
command_name = "weather"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("Weather Plugin")
try:
@ -84,6 +85,7 @@ class USMetarPlugin(plugin.APRSDPluginBase):
command_regex = "^[metar]"
command_name = "Metar"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("WX Plugin '{}'".format(message))
a = re.search(r"^.*\s+(.*)", message)
@ -175,6 +177,7 @@ class OWMWeatherPlugin(plugin.APRSDPluginBase):
command_regex = "^[wW]"
command_name = "Weather"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("OWMWeather Plugin '{}'".format(message))
a = re.search(r"^.*\s+(.*)", message)
@ -295,6 +298,7 @@ class AVWXWeatherPlugin(plugin.APRSDPluginBase):
command_regex = "^[metar]"
command_name = "Weather"
@trace.trace
def command(self, fromcall, message, ack):
LOG.info("OWMWeather Plugin '{}'".format(message))
a = re.search(r"^.*\s+(.*)", message)

View File

@ -4,8 +4,9 @@ import logging
import queue
import threading
import time
import tracemalloc
from aprsd import client, messaging, plugin, stats
from aprsd import client, messaging, plugin, stats, trace
import aprslib
LOG = logging.getLogger("APRSD")
@ -69,6 +70,7 @@ class KeepAliveThread(APRSDThread):
def __init__(self):
super().__init__("KeepAlive")
tracemalloc.start()
def loop(self):
if self.cntr % 6 == 0:
@ -81,14 +83,17 @@ class KeepAliveThread(APRSDThread):
else:
email_thread_time = "N/A"
current, peak = tracemalloc.get_traced_memory()
LOG.debug(
"Uptime ({}) Tracker({}) "
"Msgs: TX:{} RX:{} EmailThread: {}".format(
"Msgs: TX:{} RX:{} EmailThread: {} RAM: Current:{} Peak:{}".format(
stats_obj.uptime,
len(tracker),
stats_obj.msgs_tx,
stats_obj.msgs_rx,
email_thread_time,
current,
peak,
),
)
self.cntr += 1
@ -219,11 +224,11 @@ class APRSDRXThread(APRSDThread):
self.msg_queues["tx"].put(ack)
LOG.debug("Packet processing complete")
@trace.trace
def process_packet(self, packet):
"""Process a packet recieved from aprs-is server."""
try:
LOG.info("Got message: {}".format(packet))
stats.APRSDStats().msgs_rx_inc()
msg = packet.get("message_text", None)

181
aprsd/trace.py Normal file
View File

@ -0,0 +1,181 @@
import abc
import functools
import inspect
import logging
import time
import types
VALID_TRACE_FLAGS = {"method", "api"}
TRACE_API = False
TRACE_METHOD = False
TRACE_ENABLED = False
LOG = logging.getLogger("APRSD")
def trace(*dec_args, **dec_kwargs):
"""Trace calls to the decorated function.
This decorator should always be defined as the outermost decorator so it
is defined last. This is important so it does not interfere
with other decorators.
Using this decorator on a function will cause its execution to be logged at
`DEBUG` level with arguments, return values, and exceptions.
:returns: a function decorator
"""
def _decorator(f):
func_name = f.__name__
@functools.wraps(f)
def trace_logging_wrapper(*args, **kwargs):
filter_function = dec_kwargs.get("filter_function")
logger = LOG
# NOTE(ameade): Don't bother going any further if DEBUG log level
# is not enabled for the logger.
if not logger.isEnabledFor(logging.DEBUG) or not TRACE_ENABLED:
return f(*args, **kwargs)
all_args = inspect.getcallargs(f, *args, **kwargs)
pass_filter = filter_function is None or filter_function(all_args)
if pass_filter:
logger.debug(
"==> %(func)s: call %(all_args)r",
{
"func": func_name,
"all_args": str(all_args),
},
)
start_time = time.time() * 1000
try:
result = f(*args, **kwargs)
except Exception as exc:
total_time = int(round(time.time() * 1000)) - start_time
logger.debug(
"<== %(func)s: exception (%(time)dms) %(exc)r",
{
"func": func_name,
"time": total_time,
"exc": exc,
},
)
raise
total_time = int(round(time.time() * 1000)) - start_time
if isinstance(result, dict):
mask_result = result
elif isinstance(result, str):
mask_result = result
else:
mask_result = result
if pass_filter:
logger.debug(
"<== %(func)s: return (%(time)dms) %(result)r",
{
"func": func_name,
"time": total_time,
"result": mask_result,
},
)
return result
return trace_logging_wrapper
if len(dec_args) == 0:
# filter_function is passed and args does not contain f
return _decorator
else:
# filter_function is not passed
return _decorator(dec_args[0])
def trace_api(*dec_args, **dec_kwargs):
"""Decorates a function if TRACE_API is true."""
def _decorator(f):
@functools.wraps(f)
def trace_api_logging_wrapper(*args, **kwargs):
if TRACE_API:
return trace(f, *dec_args, **dec_kwargs)(*args, **kwargs)
return f(*args, **kwargs)
return trace_api_logging_wrapper
if len(dec_args) == 0:
# filter_function is passed and args does not contain f
return _decorator
else:
# filter_function is not passed
return _decorator(dec_args[0])
def trace_method(f):
"""Decorates a function if TRACE_METHOD is true."""
@functools.wraps(f)
def trace_method_logging_wrapper(*args, **kwargs):
if TRACE_METHOD:
return trace(f)(*args, **kwargs)
return f(*args, **kwargs)
return trace_method_logging_wrapper
class TraceWrapperMetaclass(type):
"""Metaclass that wraps all methods of a class with trace_method.
This metaclass will cause every function inside of the class to be
decorated with the trace_method decorator.
To use the metaclass you define a class like so:
class MyClass(object, metaclass=utils.TraceWrapperMetaclass):
"""
def __new__(cls, classname, bases, class_dict):
new_class_dict = {}
for attribute_name, attribute in class_dict.items():
if isinstance(attribute, types.FunctionType):
# replace it with a wrapped version
attribute = functools.update_wrapper(
trace_method(attribute),
attribute,
)
new_class_dict[attribute_name] = attribute
return type.__new__(cls, classname, bases, new_class_dict)
class TraceWrapperWithABCMetaclass(abc.ABCMeta, TraceWrapperMetaclass):
"""Metaclass that wraps all methods of a class with trace."""
pass
def setup_tracing(trace_flags):
"""Set global variables for each trace flag.
Sets variables TRACE_METHOD and TRACE_API, which represent
whether to log methods or api traces.
:param trace_flags: a list of strings
"""
global TRACE_METHOD
global TRACE_API
global TRACE_ENABLED
try:
trace_flags = [flag.strip() for flag in trace_flags]
except TypeError: # Handle when trace_flags is None or a test mock
trace_flags = []
for invalid_flag in set(trace_flags) - VALID_TRACE_FLAGS:
LOG.warning("Invalid trace flag: %s", invalid_flag)
TRACE_METHOD = "method" in trace_flags
TRACE_API = "api" in trace_flags
TRACE_ENABLED = True

View File

@ -22,6 +22,7 @@ DEFAULT_CONFIG_DICT = {
},
"aprsd": {
"logfile": "/tmp/aprsd.log",
"trace": False,
"plugin_dir": "~/.config/aprsd/plugins",
"enabled_plugins": plugin.CORE_PLUGINS,
"units": "imperial",
@ -29,6 +30,9 @@ DEFAULT_CONFIG_DICT = {
"enabled": True,
"host": "0.0.0.0",
"port": 8001,
"users": {
"admin": "aprsd",
},
},
"email": {
"enabled": True,
@ -43,6 +47,7 @@ DEFAULT_CONFIG_DICT = {
"host": "smtp.gmail.com",
"port": 465,
"use_ssl": False,
"debug": False,
},
"imap": {
"login": "IMAP_USERNAME",
@ -50,6 +55,7 @@ DEFAULT_CONFIG_DICT = {
"host": "imap.gmail.com",
"port": 993,
"use_ssl": True,
"debug": False,
},
},
},
@ -297,6 +303,15 @@ def parse_config(config_file):
["aprs", "password"],
default_fail=DEFAULT_CONFIG_DICT["aprs"]["password"],
)
# Ensure they change the admin password
if config["aprsd"]["web"]["enabled"] is True:
check_option(
config,
["aprsd", "web", "users", "admin"],
default_fail=DEFAULT_CONFIG_DICT["aprsd"]["web"]["users"]["admin"],
)
if config["aprsd"]["email"]["enabled"] is True:
# Check IMAP server settings
check_option(config, ["aprsd", "email", "imap", "host"])

View File

@ -0,0 +1,57 @@
/* Root element */
.json-document {
padding: 1em 2em;
}
/* Syntax highlighting for JSON objects */
ul.json-dict, ol.json-array {
list-style-type: none;
margin: 0 0 0 1px;
border-left: 1px dotted #ccc;
padding-left: 2em;
}
.json-string {
color: #0B7500;
}
.json-literal {
color: #1A01CC;
font-weight: bold;
}
/* Toggle button */
a.json-toggle {
position: relative;
color: inherit;
text-decoration: none;
}
a.json-toggle:focus {
outline: none;
}
a.json-toggle:before {
font-size: 1.1em;
color: #c0c0c0;
content: "\25BC"; /* down arrow */
position: absolute;
display: inline-block;
width: 1em;
text-align: center;
line-height: 1em;
left: -1.2em;
}
a.json-toggle:hover:before {
color: #aaa;
}
a.json-toggle.collapsed:before {
/* Use rotated down arrow, prevents right arrow appearing smaller than down arrow in some browsers */
transform: rotate(-90deg);
}
/* Collapsable placeholder links */
a.json-placeholder {
color: #aaa;
padding: 0 1em;
text-decoration: none;
}
a.json-placeholder:hover {
text-decoration: underline;
}

View File

@ -0,0 +1,158 @@
/**
* jQuery json-viewer
* @author: Alexandre Bodelot <alexandre.bodelot@gmail.com>
* @link: https://github.com/abodelot/jquery.json-viewer
*/
(function($) {
/**
* Check if arg is either an array with at least 1 element, or a dict with at least 1 key
* @return boolean
*/
function isCollapsable(arg) {
return arg instanceof Object && Object.keys(arg).length > 0;
}
/**
* Check if a string represents a valid url
* @return boolean
*/
function isUrl(string) {
var urlRegexp = /^(https?:\/\/|ftps?:\/\/)?([a-z0-9%-]+\.){1,}([a-z0-9-]+)?(:(\d{1,5}))?(\/([a-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+)?)?$/i;
return urlRegexp.test(string);
}
/**
* Transform a json object into html representation
* @return string
*/
function json2html(json, options) {
var html = '';
if (typeof json === 'string') {
// Escape tags and quotes
json = json
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&apos;')
.replace(/"/g, '&quot;');
if (options.withLinks && isUrl(json)) {
html += '<a href="' + json + '" class="json-string" target="_blank">' + json + '</a>';
} else {
// Escape double quotes in the rendered non-URL string.
json = json.replace(/&quot;/g, '\\&quot;');
html += '<span class="json-string">"' + json + '"</span>';
}
} else if (typeof json === 'number') {
html += '<span class="json-literal">' + json + '</span>';
} else if (typeof json === 'boolean') {
html += '<span class="json-literal">' + json + '</span>';
} else if (json === null) {
html += '<span class="json-literal">null</span>';
} else if (json instanceof Array) {
if (json.length > 0) {
html += '[<ol class="json-array">';
for (var i = 0; i < json.length; ++i) {
html += '<li>';
// Add toggle button if item is collapsable
if (isCollapsable(json[i])) {
html += '<a href class="json-toggle"></a>';
}
html += json2html(json[i], options);
// Add comma if item is not last
if (i < json.length - 1) {
html += ',';
}
html += '</li>';
}
html += '</ol>]';
} else {
html += '[]';
}
} else if (typeof json === 'object') {
var keyCount = Object.keys(json).length;
if (keyCount > 0) {
html += '{<ul class="json-dict">';
for (var key in json) {
if (Object.prototype.hasOwnProperty.call(json, key)) {
html += '<li>';
var keyRepr = options.withQuotes ?
'<span class="json-string">"' + key + '"</span>' : key;
// Add toggle button if item is collapsable
if (isCollapsable(json[key])) {
html += '<a href class="json-toggle">' + keyRepr + '</a>';
} else {
html += keyRepr;
}
html += ': ' + json2html(json[key], options);
// Add comma if item is not last
if (--keyCount > 0) {
html += ',';
}
html += '</li>';
}
}
html += '</ul>}';
} else {
html += '{}';
}
}
return html;
}
/**
* jQuery plugin method
* @param json: a javascript object
* @param options: an optional options hash
*/
$.fn.jsonViewer = function(json, options) {
// Merge user options with default options
options = Object.assign({}, {
collapsed: false,
rootCollapsable: true,
withQuotes: false,
withLinks: true
}, options);
// jQuery chaining
return this.each(function() {
// Transform to HTML
var html = json2html(json, options);
if (options.rootCollapsable && isCollapsable(json)) {
html = '<a href class="json-toggle"></a>' + html;
}
// Insert HTML in target DOM element
$(this).html(html);
$(this).addClass('json-document');
// Bind click on toggle buttons
$(this).off('click');
$(this).on('click', 'a.json-toggle', function() {
var target = $(this).toggleClass('collapsed').siblings('ul.json-dict, ol.json-array');
target.toggle();
if (target.is(':visible')) {
target.siblings('.json-placeholder').remove();
} else {
var count = target.children('li').length;
var placeholder = count + (count > 1 ? ' items' : ' item');
target.after('<a href class="json-placeholder">' + placeholder + '</a>');
}
return false;
});
// Simulate click on toggle button when placeholder is clicked
$(this).on('click', 'a.json-placeholder', function() {
$(this).siblings('a.json-toggle').click();
return false;
});
if (options.collapsed == true) {
// Trigger click to collapse all nodes
$(this).find('a.json-toggle').click();
}
});
};
})(jQuery);

View File

@ -0,0 +1,15 @@
<html>
<head>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script src="json-viewer/jquery.json-viewer.js"></script>
<link href="json-viewer/jquery.json-viewer.css" type="text/css" rel="stylesheet" />
</head>
<pre id="json-viewer"></pre>
<script>
var data = {{ messages | safe }}
$('#json-viewer').jsonViewer(data)
</script>
</html>

View File

@ -1,4 +0,0 @@
#!/bin/bash
# Use this script to locally build the docker image
docker build --no-cache -t hemna6969/aprsd:latest ..

View File

@ -1,12 +1,12 @@
FROM alpine:latest as aprsd
# Dockerfile for building a container during aprsd development.
ARG BRANCH
ENV VERSION=1.5.1
ENV APRS_USER=aprs
ENV HOME=/home/aprs
ENV APRSD=http://github.com/craigerl/aprsd.git
ENV APRSD_BRANCH="master"
ENV APRSD_BRANCH=$BRANCH
ENV VIRTUAL_ENV=$HOME/.venv3
ENV INSTALL=$HOME/install
@ -46,5 +46,5 @@ RUN chown -R $APRS_USER:$APRS_USER /config
ENV CONF default
USER $APRS_USER
ADD build/bin/run.sh $HOME/
ADD bin/run.sh $HOME/
ENTRYPOINT ["/home/aprs/run.sh"]

4
docker/build.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
# Use this script to locally build the docker image
docker build --no-cache -t hemna6969/aprsd:latest -f ./Dockerfile .

View File

@ -13,4 +13,5 @@ pre-commit
pytz
opencage
flask
flask_classful
flask-classful
flask-httpauth

View File

@ -37,10 +37,13 @@ filelock==3.0.12
# virtualenv
flask-classful==0.14.2
# via -r requirements.in
flask-httpauth==4.2.0
# via -r requirements.in
flask==1.1.2
# via
# -r requirements.in
# flask-classful
# flask-httpauth
identify==1.5.13
# via pre-commit
idna==2.10