Cleanup, proper project setup, codestyle & typechecking
This commit is contained in:
parent
8e2a22a6f1
commit
61fcf7a5be
22 changed files with 1017 additions and 407 deletions
9
.gitignore
vendored
9
.gitignore
vendored
|
@ -1,2 +1,11 @@
|
|||
**/.idea/
|
||||
*.iml
|
||||
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
**/*.egg-info/
|
||||
*.coverage
|
||||
**/.mypy_cache/
|
||||
|
||||
ca.pem
|
||||
multischleuder.yml
|
95
.gitlab-ci.yml
Normal file
95
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
image: python:3.9-bullseye
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
|
||||
|
||||
before_script:
|
||||
- pip3 install coverage pycodestyle mypy
|
||||
- export MULTISCHLEUDER_VERSION=$(python -c 'import multischleuder; print(multischleuder.__version__)')
|
||||
|
||||
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- pip3 install -e .
|
||||
- python3 -m coverage run --rcfile=setup.cfg -m unittest discover multischleuder
|
||||
- python3 -m coverage combine
|
||||
- python3 -m coverage report --rcfile=setup.cfg
|
||||
|
||||
codestyle:
|
||||
stage: test
|
||||
script:
|
||||
- pip3 install -e .
|
||||
- pycodestyle multischleuder
|
||||
|
||||
mypy:
|
||||
stage: test
|
||||
script:
|
||||
- pip3 install -e .
|
||||
- mypy --install-types
|
||||
- mypy multischleuder
|
||||
|
||||
|
||||
|
||||
build_wheel:
|
||||
stage: build
|
||||
script:
|
||||
- python3 setup.py egg_info bdist_wheel
|
||||
- cd dist
|
||||
- sha256sum *.whl > SHA256SUMS
|
||||
artifacts:
|
||||
paths:
|
||||
- "dist/*.whl"
|
||||
- dist/SHA256SUMS
|
||||
only:
|
||||
- tags
|
||||
|
||||
build_debian:
|
||||
stage: build
|
||||
script:
|
||||
- apt update && apt install --yes lintian rsync sudo
|
||||
- echo -n > package/debian/multischleuder/usr/share/doc/multischleuder/changelog
|
||||
- |
|
||||
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
|
||||
echo "multischleuder (${version}-1); urgency=medium\n" >> package/debian/multischleuder/usr/share/doc/multischleuder/changelog
|
||||
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES ${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES ${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/ */g' >> package/debian/multischleuder/usr/share/doc/multischleuder/changelog
|
||||
echo "\n -- ${PACKAGE_AUTHOR} $(date -R)\n" >> package/debian/multischleuder/usr/share/doc/multischleuder/changelog
|
||||
done
|
||||
- gzip -9n package/debian/multischleuder/usr/share/doc/multischleuder/changelog
|
||||
- python3 setup.py egg_info install --root=package/debian/multischleuder/ --prefix=/usr --optimize=1
|
||||
- cd package/debian
|
||||
- sed -re "s/__MULTISCHLEUDER_VERSION__/${MULTISCHLEUDER_VERSION}/g" -i multischleuder/DEBIAN/control
|
||||
- mkdir -p multischleuder/usr/lib/python3/dist-packages/
|
||||
- rsync -a multischleuder/usr/lib/python3.9/site-packages/ multischleuder/usr/lib/python3/dist-packages/
|
||||
- rm -rf multischleuder/usr/lib/python3.9/site-packages
|
||||
- find multischleuder/usr/lib/python3/dist-packages -name __pycache__ -exec rm -r {} \; 2>/dev/null || true
|
||||
- find multischleuder/usr/lib/python3/dist-packages -name '*.pyc' -exec rm {} \;
|
||||
- find multischleuder/usr/lib/python3/dist-packages -name '*.pyo' -exec rm {} \;
|
||||
- sed -re 's$#!/usr/local/bin/python3$#!/usr/bin/python3$' -i multischleuder/usr/bin/multischleuder
|
||||
- find multischleuder -type f -exec chmod 0644 {} \;
|
||||
- find multischleuder -type d -exec chmod 755 {} \;
|
||||
- chmod +x multischleuder/usr/bin/multischleuder multischleuder/DEBIAN/postinst multischleuder/DEBIAN/prerm multischleuder/DEBIAN/postrm
|
||||
- dpkg-deb --build multischleuder
|
||||
- mv multischleuder.deb "multischleuder_${MULTISCHLEUDER_VERSION}-1_all.deb"
|
||||
- sudo -u nobody lintian "multischleuder_${MULTISCHLEUDER_VERSION}-1_all.deb"
|
||||
- sha256sum *.deb > SHA256SUMS
|
||||
artifacts:
|
||||
paths:
|
||||
- "package/debian/*.deb"
|
||||
- package/debian/SHA256SUMS
|
||||
only:
|
||||
- tags
|
||||
|
||||
|
||||
release:
|
||||
stage: deploy
|
||||
script:
|
||||
- python3 package/release.py
|
||||
only:
|
||||
- tags
|
|
@ -1,404 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import email
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import urllib.request
|
||||
import smtplib
|
||||
import ssl
|
||||
import sys
|
||||
import hashlib
|
||||
|
||||
import yaml
|
||||
import pgpy
|
||||
|
||||
from dataclasses import dataclass, field, Field
|
||||
from datetime import datetime
|
||||
from dateutil.parser import isoparse
|
||||
|
||||
|
||||
LOG: logging.Logger = logging.getLogger('multischleuder')
|
||||
|
||||
|
||||
class SmtpServerConfig:
|
||||
|
||||
def __init__(self, hostname: str, port: int, tls: bool, username: str, password: str):
|
||||
self.hostname = hostname
|
||||
self.port = port
|
||||
self.tls = tls
|
||||
self.username = username
|
||||
self.password = password
|
||||
self._smtp = None
|
||||
|
||||
@staticmethod
|
||||
def parse(config: Dict[str, Any]) -> 'SmtpServerConfig':
|
||||
tls = config.get('tls', 'PLAIN')
|
||||
port = {
|
||||
'SSL': 465,
|
||||
'STARTTLS': 587
|
||||
}.get(tls, 25)
|
||||
return SmtpServerConfig(
|
||||
hostname=config['hostname'],
|
||||
port=config.get('port', port),
|
||||
tls=tls,
|
||||
username=config.get('username'),
|
||||
password=config.get('password')
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
cls = smtplib.SMTP if self.tls != 'SSL' else smtplib.SMTP_SSL
|
||||
self._smtp = cls(self.hostname, self.port)
|
||||
smtp = self._smtp.__enter__()
|
||||
if self.tls == 'STARTTLS':
|
||||
smtp.starttls()
|
||||
if self.username is not None and self.password is not None:
|
||||
smtp.login(self.username, self.password)
|
||||
return smtp
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
ret = self._smtp.__exit__(exc_type, exc_val, exc_tb)
|
||||
self._smtp = None
|
||||
return ret
|
||||
|
||||
def send_message(self, msg):
|
||||
if self._smtp is None:
|
||||
raise RuntimeError('SMTP connection is not established')
|
||||
self._smtp.send_message(msg)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchleuderSubscriber:
|
||||
email: str
|
||||
key: 'SchleuderKey'
|
||||
sub_id: int
|
||||
schleuder: 'SchleuderList'
|
||||
created_at: datetime
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.email}'
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.email)
|
||||
|
||||
def __eq__(self, o) -> bool:
|
||||
if not isinstance(o, SchleuderSubscriber):
|
||||
return False
|
||||
return self.email == o.email
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchleuderKey:
|
||||
fingerprint: str
|
||||
email: str
|
||||
blob: Optional[str] = field(repr=False, default=None)
|
||||
source_list: int
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.fingerprint, self.email))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.fingerprint} ({self.email})'
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchleuderList:
|
||||
id: int
|
||||
name: str
|
||||
fingerprint: str
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
class ConflictMessage:
|
||||
|
||||
def __init__(self,
|
||||
multilist: 'MultiList',
|
||||
chosen: 'SchleuderSubscriber',
|
||||
affected: List['SchleuderSubscriber']):
|
||||
self.multlist = multilist
|
||||
self.chosen = chosen
|
||||
self.affected = affected
|
||||
# Generate a SHA1 digest that only changes when the subscription list changes
|
||||
self.affected.sort(key=lambda x: x.created_at)
|
||||
digest = hashlib.new('sha1')
|
||||
digest.update(self.chosen.email)
|
||||
digest.update(self.chosen.scheluder.name)
|
||||
for affected in self.affected:
|
||||
digest.update(affected.scheluder.name)
|
||||
digest.update(affected.key.blob)
|
||||
self.now = datetime.utcnow()
|
||||
self.digest = digest.hexdigest()
|
||||
|
||||
def to_mime(self):
|
||||
_chosen = f'{self.chosen.key.fingerprint} {self.chosen.email}'
|
||||
for affected in self.affected:
|
||||
_affected += f'{affected.key.fingerprint} {affected.schleuder.name}\n'
|
||||
msg = self.multilist.conflict_message.format(
|
||||
subscriber=self.chosen.email,
|
||||
schleuder=self.multilist.list,
|
||||
chosen=_chosen,
|
||||
affected=_affected
|
||||
)
|
||||
pgp = pgpy.PGPMessage.new(msg)
|
||||
# Encrypt the message to all keys
|
||||
cipher = pgpy.constants.SymmetricKeyAlgorithm.AES256
|
||||
sessionkey = cipher.gen_key()
|
||||
try:
|
||||
for affected in self.affected:
|
||||
key, _ = pgpy.PGPKey.from_blob(self.affected.key.blob)
|
||||
key._require_usage_flags = False
|
||||
pgp = key.encrypt(pgp, cipher=cipher, sessionkey=sessionkey)
|
||||
finally:
|
||||
del sessionkey
|
||||
# Build the MIME message
|
||||
mp1 = email.mime.application.MIMEApplication('Version: 1', _subtype='pgp-encrypted')
|
||||
mp1['Content-Description'] = 'PGP/MIME version identification'
|
||||
mp1['Content-Disposition'] = 'attachment'
|
||||
mp2 = email.mime.application.MIMEApplication(str(pgp), _subtype='octet-stream', name='encrypted.asc')
|
||||
mp2['Content-Description'] = 'OpenPGP encrypted message'
|
||||
mp2['Content-Disposition'] = 'inline; filename="message.asc"'
|
||||
mp0 = email.mime.multipart.MIMEMultipart(_subtype='encrypted', protocol='application/pgp-encrypted')
|
||||
mp0.attach(mp1)
|
||||
mp0.attach(mp2)
|
||||
mp0['Subject'] = f'MultiSchleuder {self.multilist.name} - Key Conflict'
|
||||
mp0['From'] = self.multilist.conflict_from
|
||||
mp0['Reply-To'] = self.multilist.conflict_from
|
||||
mp0['To'] = self.chosen.address
|
||||
mp0['Date'] = email.util.formatdate(self.no)
|
||||
mp0['Auto-Submitted'] = 'auto-generated'
|
||||
mp0['Precedence'] = 'list'
|
||||
mp0['List-Id'] = f'<{self.multilist.list.replace("@", ".")}>'
|
||||
mp0['List-Help'] = '<https://gitlab.com/s3lph/multischleuder>'
|
||||
mp0['X-MultiSchleuder-Digest'] = self.digest
|
||||
return mp0
|
||||
|
||||
|
||||
class MultiList:
|
||||
|
||||
def __init__(self,
|
||||
ns: argparse.Namespace,
|
||||
list: str,
|
||||
source: List[str],
|
||||
unmanaged: List[str],
|
||||
banned: List[str],
|
||||
from_address: str,
|
||||
api_url: str,
|
||||
api_token: str,
|
||||
api_cafile: str,
|
||||
conflict_state_file: str,
|
||||
conflict_message: str,
|
||||
conflict_interval: int,
|
||||
conflict_smtp: SmtpServerConfig):
|
||||
self.ns: argparse.Namespace = ns
|
||||
self.list: str = list
|
||||
self.source: List[str] = source
|
||||
self.unmanaged: List[str] = unmanaged
|
||||
self.banned: List[str] = banned
|
||||
self.api_url: str = api_url
|
||||
self.basic_auth = {'Authorization': b'Basic ' + base64.b64encode(f'schleuder:{api_token}'.encode())}
|
||||
self.api_cafile: str = api_cafile
|
||||
self.conflict_messages = []
|
||||
self.conflict_from = from_address
|
||||
self.conflict_state_file: str = conflict_state_file
|
||||
self.conflict_message: str = conflict_message
|
||||
self.conflict_interval: int = conflict_interval
|
||||
self.conflict_smtp: SmtpServerConfig = conflict_smtp
|
||||
|
||||
def request(self, path: str, list_id: Optional[int] = None, data: Optional[str] = None, method: str = 'GET', fmt=[]):
|
||||
if fmt:
|
||||
path = path.format(*fmt)
|
||||
url = os.path.join(self.api_url, path)
|
||||
if list_id is not None:
|
||||
url += f'?list_id={list_id}'
|
||||
payload: Optional[bytes] = data.encode() if data is not None else None
|
||||
context = ssl.create_default_context(cafile=self.api_cafile)
|
||||
context.check_hostname = False
|
||||
req = urllib.request.Request(url, data=payload, method=method, headers=self.basic_auth)
|
||||
resp = urllib.request.urlopen(req, context=context)
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
def resolve_lists(self) -> Dict[str, 'SchleuderList']:
|
||||
response = self.request('lists.json')
|
||||
lists = {}
|
||||
for r in response:
|
||||
l = SchleuderList(r['id'], r['email'], r['fingerprint'])
|
||||
if l.name == self.list or l.name in self.source:
|
||||
lists[l.name] = l
|
||||
return lists
|
||||
|
||||
def get_list_subscribers(self, list: 'SchleuderList') -> Dict[str, 'SchleuderSubscriber']:
|
||||
response = self.request('subscriptions.json', list.id)
|
||||
subs = {}
|
||||
for r in response:
|
||||
key = SchleuderKey(r['fingerprint'], r['email'], list.id)
|
||||
sub = SchleuderSubscriber(r['email'], key, isoparse(r['created_at']), list, r['id'])
|
||||
subs[sub.email] = sub
|
||||
return subs
|
||||
|
||||
def get_key(self, key: 'SchleuderKey') -> 'SchleuderKey':
|
||||
if key.blob is not None:
|
||||
return key
|
||||
r = self.request('keys/{}.json', list_id=key.source_list, fmt=[key.fingerprint])
|
||||
key.blob = r['ascii']
|
||||
return key
|
||||
|
||||
def post_key(self, key: 'SchleuderKey', list: 'SchleuderList'):
|
||||
if self.ns.dry_run:
|
||||
return
|
||||
if not key.blob:
|
||||
raise RuntimeError('Key blob needs to be retrieved first')
|
||||
data = json.dumps({
|
||||
'keymaterial': key.blob
|
||||
})
|
||||
self.request('keys.json', list_id=list.id, data=data, method='POST')
|
||||
|
||||
def delete_key(self, key: SchleuderKey, list: 'SchleuderList'):
|
||||
if self.ns.dry_run:
|
||||
return
|
||||
self.request('keys/{}.json', list_id=list.id, fmt=[key.fingerprint], method='DELETE')
|
||||
|
||||
def subscribe(self, sub: 'SchleuderSubscriber', list: 'SchleuderList'):
|
||||
if self.ns.dry_run:
|
||||
return
|
||||
data = json.dumps({
|
||||
'email': sub.email,
|
||||
'fingerprint': sub.key.fingerprint
|
||||
})
|
||||
self.request('subscriptions.json', list_id=list.id, data=data, method='POST')
|
||||
|
||||
def unsubscribe(self, sub: 'SchleuderSubscriber'):
|
||||
if self.ns.dry_run:
|
||||
return
|
||||
self.request('subscriptions/{}.json', fmt=[sub.sub_id], method='DELETE')
|
||||
|
||||
def resolve_subscriber_conflicts(self, subscriptions: List['SchleuderSubscription']) -> 'SchleuderSubscription':
|
||||
if len(subscriptions) == 1:
|
||||
return subscriptions[0]
|
||||
earliest = min(subscriptions, key=lambda x: x.created_at)
|
||||
LOG.debug(f'Key Conflict for {earliest.email} in lists, chose {earliest.schleuder.name}:')
|
||||
for s in subscriptions:
|
||||
LOG.debug(f' - {s.schleuder.name}: {s.key.fingerprint}')
|
||||
for s in subscriptions:
|
||||
self.get_key(s.key, s.list.id)
|
||||
self.conflict_messages.append(ConflictMessage(earliest, subscriptions))
|
||||
return earliest
|
||||
|
||||
def send_conflict_messages(self):
|
||||
now = datetime.utcnow().timestamp()
|
||||
with open(self.conflict_state_file, 'a+') as f:
|
||||
f.seek(0)
|
||||
state = json.load(f)
|
||||
# Remove all state entries older than conflict_interval
|
||||
state = {k: v for k, v in state.items() if now-v < self.conflict_interval}
|
||||
# Remove all messages not already sent recently
|
||||
msgs = [m for m in self.conflict_messages if m.digest not in state]
|
||||
# Add all remaining messages to state dict
|
||||
for m in msgs:
|
||||
state[m.digest] = now
|
||||
if not self.ns.dry_run:
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(state)
|
||||
# Finally send the mails
|
||||
for m in msgs:
|
||||
LOG.info(f'Sending key conflict message to {m["To"]}')
|
||||
LOG.debug(f'MIME Message:\n{str(m)}')
|
||||
if not self.ns.dry_run:
|
||||
with self.smtp_config as smtp:
|
||||
smtp.send_message(m)
|
||||
# Clear conflict messages
|
||||
self.conflict_messages = []
|
||||
|
||||
def process(self):
|
||||
LOG.info(f'Processing: {self.list} {"DRY RUN" if self.ns.dry_run else ""}')
|
||||
# todo: conflict handling - what if a user is subscribed through 2 lists (and possibly with different keys?)
|
||||
lists: Dict[str, SchleuderList] = self.resolve_lists()
|
||||
target_list = lists[self.list]
|
||||
# Get current subs, except for unmanaged adresses
|
||||
current_subs = {s
|
||||
for s in self.get_list_subscribers(target_list).values()
|
||||
if s.email not in self.unmanaged}
|
||||
current_keys = {s.key for s in current_subs}
|
||||
intended_subs = set()
|
||||
intended_keys = set()
|
||||
all_subs = dict()
|
||||
# This loop may return multiple subscriptions for some users if they are subscribed on multiple sub-lists
|
||||
for source in self.source:
|
||||
for sub in self.get_list_subscribers(lists[source]).values():
|
||||
# Don't include banned or unmanaged adresses
|
||||
if sub.email in self.banned or s.email in self.unmanaged:
|
||||
continue
|
||||
all_subs.setdefault(sub.email, []).append(sub)
|
||||
# Remove
|
||||
for subs in all_subs.values():
|
||||
sub = self.resolve_subscriber_conflicts(subs)
|
||||
intended_subs.add(sub)
|
||||
intended_keys.add(sub.key)
|
||||
# Determine the change set
|
||||
to_subscribe = intended_subs.difference(current_subs)
|
||||
to_unsubscribe = current_subs.difference(intended_subs)
|
||||
to_remove = current_keys.difference(intended_keys)
|
||||
to_add = intended_keys.difference(current_keys)
|
||||
# Retrieve actual key blobs
|
||||
for key in to_add:
|
||||
self.get_key(key)
|
||||
# Perform the actual list modifications in an order where nothing breaks
|
||||
for key in to_add:
|
||||
self.post_key(key, target_list_id)
|
||||
LOG.info(f'Added key: {key}')
|
||||
for email in to_subscribe:
|
||||
self.subscribe(email, target_list_id)
|
||||
LOG.info(f'Subscribed user: {email}')
|
||||
for sub_id in to_unsubscribe:
|
||||
self.unsubscribe(sub_id)
|
||||
LOG.info(f'Unsubscribed user: {sub_id}')
|
||||
for key in to_remove:
|
||||
self.delete_key(key, target_list_id)
|
||||
LOG.info(f'Removed key: {key}')
|
||||
self.send_conflict_messages()
|
||||
if len(to_add) + len(to_subscribe) + len(to_unsubscribe) + len(to_remove) == 0:
|
||||
LOG.info(f'No changes for {self.list}')
|
||||
else:
|
||||
LOG.info(f'Finished processing: {self.list}')
|
||||
|
||||
|
||||
def parse_config(ns: argparse.Namespace) -> List['MultiList']:
|
||||
with open(ns.config, 'r') as f:
|
||||
y = yaml.safe_load(f)
|
||||
lists = []
|
||||
for l in y['lists']:
|
||||
lc = MultiList(ns,
|
||||
l['name'], l['source'], l.get('unmanaged', []), l.get('banned', []),
|
||||
l.get('from'),
|
||||
y['api']['url'], y['api']['token'], y['api']['cafile'],
|
||||
y['conflict']['state'], y['conflict']['message'], y['conflict']['interval'],
|
||||
SmtpServerConfig.parse(y['conflict']['smtp']))
|
||||
lists.append(lc)
|
||||
return lists
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--config', '-c', type=str, default='/etc/multischleuder/config.yml')
|
||||
ap.add_argument('--dry-run', '-n', action='store_true', default=False)
|
||||
ap.add_argument('--verbose', '-v', action='store_true', default=False)
|
||||
ns = ap.parse_args(sys.argv[1:])
|
||||
if ns.verbose:
|
||||
LOG.setLevel('DEBUG')
|
||||
LOG.debug('Enabled verbose logging')
|
||||
lists = parse_config(ns)
|
||||
for l in lists:
|
||||
l.process()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
2
multischleuder/__init__.py
Normal file
2
multischleuder/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
|
||||
__version__ = '0.1'
|
107
multischleuder/api.py
Normal file
107
multischleuder/api.py
Normal file
|
@ -0,0 +1,107 @@
|
|||
|
||||
from typing import List, Optional
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import urllib.request
|
||||
|
||||
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
||||
|
||||
|
||||
class SchleuderApi:
|
||||
|
||||
def __init__(self,
|
||||
url: str,
|
||||
token: str,
|
||||
cafile: str):
|
||||
self._url = url
|
||||
self._cafile = cafile
|
||||
self._dry_run = False
|
||||
# The API token is not used as a Bearer token, but as the
|
||||
# password for the static "schleuder" user.
|
||||
auth = base64.b64encode(f'schleuder:{token}'.encode()).decode()
|
||||
self._headers = {
|
||||
'Authorization': f'Basic {auth}'
|
||||
}
|
||||
|
||||
def __request(self,
|
||||
path: str,
|
||||
list_id: Optional[int] = None,
|
||||
data: Optional[str] = None,
|
||||
method: str = 'GET',
|
||||
fmt=[]):
|
||||
# Compose and render fully-qualified URL
|
||||
if fmt:
|
||||
path = path.format(*fmt)
|
||||
url = os.path.join(self._url, path)
|
||||
if list_id is not None:
|
||||
url += f'?list_id={list_id}'
|
||||
|
||||
payload: Optional[bytes] = data.encode() if data is not None else None
|
||||
# Create a custom SSL context that does not validate the hostname, but
|
||||
# validates against the self-signed schleuder-api-daemon certificate
|
||||
context = ssl.create_default_context(cafile=self._cafile)
|
||||
context.check_hostname = False
|
||||
# Perform the actual request
|
||||
req = urllib.request.Request(url, data=payload, method=method, headers=self._headers)
|
||||
resp = urllib.request.urlopen(req, context=context)
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
def dry_run(self):
|
||||
self._dry_run = True
|
||||
|
||||
# List Management
|
||||
|
||||
def get_lists(self) -> List['SchleuderList']:
|
||||
lists = self.__request('lists.json')
|
||||
return [SchleuderList(**s) for s in lists]
|
||||
|
||||
# Subscriber Management
|
||||
|
||||
def get_subscribers(self, schleuder: 'SchleuderList') -> List['SchleuderSubscriber']:
|
||||
response = self.__request('subscriptions.json', schleuder.id)
|
||||
subs: List[SchleuderSubscriber] = []
|
||||
for r in response:
|
||||
key = self.__request('keys/{}.json', schleuder.id, fmt=[r['fingerprint']])
|
||||
sub = SchleuderSubscriber.from_api(key, **r)
|
||||
subs.append(sub)
|
||||
return subs
|
||||
|
||||
def subscribe(self, sub: 'SchleuderSubscriber', schleuder: 'SchleuderList'):
|
||||
if self._dry_run:
|
||||
return
|
||||
data = json.dumps({
|
||||
'email': sub.email,
|
||||
'fingerprint': sub.key.fingerprint
|
||||
})
|
||||
self.__request('subscriptions.json', list_id=schleuder.id, data=data, method='POST')
|
||||
|
||||
def unsubscribe(self, sub: 'SchleuderSubscriber'):
|
||||
if self._dry_run:
|
||||
return
|
||||
self.__request('subscriptions/{}.json', fmt=[sub.id], method='DELETE')
|
||||
|
||||
def update_fingerprint(self, sub: 'SchleuderSubscriber'):
|
||||
if self._dry_run:
|
||||
return
|
||||
data = json.dumps({'fingerprint': sub.key.fingerprint})
|
||||
self.__request('subscriptions/{}.json', fmt=[sub.id], data=data, method='PATCH')
|
||||
|
||||
# Key Management
|
||||
|
||||
def post_key(self, key: 'SchleuderKey', schleuder: 'SchleuderList'):
|
||||
if self._dry_run:
|
||||
return
|
||||
if not key.blob:
|
||||
raise ValueError('Cannot upload a key stub')
|
||||
data = json.dumps({
|
||||
'keymaterial': key.blob
|
||||
})
|
||||
self.__request('keys.json', list_id=schleuder.id, data=data, method='POST')
|
||||
|
||||
def delete_key(self, key: 'SchleuderKey', schleuder: 'SchleuderList'):
|
||||
if self._dry_run:
|
||||
return
|
||||
self.__request('keys/{}.json', list_id=schleuder.id, fmt=[key.fingerprint], method='DELETE')
|
179
multischleuder/conflict.py
Normal file
179
multischleuder/conflict.py
Normal file
|
@ -0,0 +1,179 @@
|
|||
|
||||
from typing import Dict, List
|
||||
|
||||
import email.mime.application
|
||||
import email.mime.multipart
|
||||
import email.utils
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import struct
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
|
||||
import pgpy # type: ignore
|
||||
|
||||
from multischleuder.types import SchleuderKey, SchleuderSubscriber
|
||||
from multischleuder.smtp import SmtpClient
|
||||
|
||||
|
||||
class ConflictMessage:
|
||||
|
||||
def __init__(self,
|
||||
schleuder: str,
|
||||
chosen: SchleuderSubscriber,
|
||||
affected: List[SchleuderSubscriber],
|
||||
mail_from: str,
|
||||
template: str):
|
||||
self._schleuder: str = schleuder
|
||||
self._chosen: SchleuderSubscriber = chosen
|
||||
self._affected: List[SchleuderSubscriber] = affected
|
||||
self._template: str = template
|
||||
self._from: str = mail_from
|
||||
# Generate a SHA1 digest that only changes when the subscription list changes
|
||||
self._digest = self._make_digest()
|
||||
|
||||
def _make_digest(self) -> str:
|
||||
# Sort so the hash stays the same if the set of subscriptions is the same.
|
||||
# There is no guarantee that the subs are in any specific order.
|
||||
subs: List[SchleuderSubscriber] = list(self._affected)
|
||||
subs.sort(key=lambda x: x.schleuder)
|
||||
h = hashlib.new('sha1')
|
||||
# Include the chosen email an source sub-list
|
||||
h.update(struct.pack('!sd',
|
||||
self._chosen.email,
|
||||
self._chosen.schleuder))
|
||||
# Include all subscriptions, including the FULL key
|
||||
for s in subs:
|
||||
h.update(struct.pack('!ds',
|
||||
s.schleuder,
|
||||
s.key.blob))
|
||||
return h.hexdigest()
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return self._digest
|
||||
|
||||
@property
|
||||
def mime(self) -> email.mime.multipart.MIMEMultipart:
|
||||
# Render the message body
|
||||
_chosen = f'{self._chosen.key.fingerprint} {self._chosen.email}'
|
||||
_affected = ''
|
||||
for affected in self._affected:
|
||||
_affected += f'{affected.key.fingerprint} {affected.schleuder}\n'
|
||||
msg: str = self._template.format(
|
||||
subscriber=self._chosen.email,
|
||||
schleuder=self._schleuder,
|
||||
chosen=_chosen,
|
||||
affected=_affected
|
||||
)
|
||||
pgp = pgpy.PGPMessage.new(msg)
|
||||
# Encrypt the message to all keys
|
||||
cipher = pgpy.constants.SymmetricKeyAlgorithm.AES256
|
||||
sessionkey = cipher.gen_key()
|
||||
try:
|
||||
for affected in self._affected:
|
||||
key, _ = pgpy.PGPKey.from_blob(affected.key.blob)
|
||||
key._require_usage_flags = False
|
||||
pgp = key.encrypt(pgp, cipher=cipher, sessionkey=sessionkey)
|
||||
finally:
|
||||
del sessionkey
|
||||
# Build the MIME message
|
||||
# First the small "version" part ...
|
||||
mp1 = email.mime.application.MIMEApplication('Version: 1', _subtype='pgp-encrypted')
|
||||
mp1['Content-Description'] = 'PGP/MIME version identification'
|
||||
mp1['Content-Disposition'] = 'attachment'
|
||||
# ... then the actual encrypted payload ...
|
||||
mp2 = email.mime.application.MIMEApplication(str(pgp), _subtype='octet-stream', name='encrypted.asc')
|
||||
mp2['Content-Description'] = 'OpenPGP encrypted message'
|
||||
mp2['Content-Disposition'] = 'inline; filename="message.asc"'
|
||||
# ... and finally the root multipart container
|
||||
mp0 = email.mime.multipart.MIMEMultipart(_subtype='encrypted', protocol='application/pgp-encrypted')
|
||||
mp0.attach(mp1)
|
||||
mp0.attach(mp2)
|
||||
# Set all the email headers
|
||||
mp0['Subject'] = f'MultiSchleuder {self._schleuder} - Key Conflict'
|
||||
mp0['From'] = self._from
|
||||
mp0['Reply-To'] = self._from
|
||||
mp0['To'] = self._chosen.email
|
||||
mp0['Date'] = email.utils.formatdate()
|
||||
mp0['Auto-Submitted'] = 'auto-generated'
|
||||
mp0['Precedence'] = 'list'
|
||||
mp0['List-Id'] = f'<{self._schleuder.replace("@", ".")}>'
|
||||
mp0['List-Help'] = '<https://gitlab.com/s3lph/multischleuder>'
|
||||
mp0['X-MultiSchleuder-Digest'] = self._digest
|
||||
return mp0
|
||||
|
||||
|
||||
class KeyConflictResolution:
|
||||
|
||||
def __init__(self, smtp: 'SmtpClient', interval: int, statefile: str, template: str):
|
||||
self._smtp = smtp
|
||||
self._interval: int
|
||||
self._state_file: str = statefile
|
||||
self._template: str = statefile
|
||||
self._messages: List[ConflictMessage] = []
|
||||
self._logger: logging.Logger = logging.getLogger()
|
||||
|
||||
def resolve(self,
|
||||
target: str,
|
||||
mail_from: str,
|
||||
subscriptions: List['SchleuderSubscriber']) -> List['SchleuderSubscriber']:
|
||||
subs: Dict[str, List[SchleuderSubscriber]] = OrderedDict()
|
||||
for s in subscriptions:
|
||||
subs.setdefault(s.email, []).append(s)
|
||||
# Perform conflict resolution for each set of subscribers with the same email
|
||||
return [self._resolve(target, mail_from, s) for s in subs.values()]
|
||||
|
||||
def _resolve(self,
|
||||
target: str,
|
||||
mail_from: str,
|
||||
subscriptions: List['SchleuderSubscriber']) -> 'SchleuderSubscriber':
|
||||
if len({s.email for s in subscriptions}) != 1:
|
||||
raise ValueError('Number of unique subscriptions must be 1')
|
||||
if len(subscriptions) == 1:
|
||||
return subscriptions[0]
|
||||
# Conflict Resolution: Choose the OLDEST subscriptions, but notify using ALL keys
|
||||
earliest: SchleuderSubscriber = min(subscriptions, key=lambda x: x.created_at)
|
||||
self._logger.debug(f'Key Conflict for {earliest.email} in lists, chose {earliest.schleuder}:')
|
||||
for s in subscriptions:
|
||||
self._logger.debug(f' - {s.schleuder}: {s.key.fingerprint}')
|
||||
# At this point, the messages are only queued locally, they will be sent afterwards
|
||||
msg = ConflictMessage(
|
||||
target,
|
||||
earliest,
|
||||
subscriptions,
|
||||
self._template,
|
||||
mail_from
|
||||
)
|
||||
self._messages.append(msg)
|
||||
# Return the result of conflict resolution
|
||||
return earliest
|
||||
|
||||
def send_messages(self, dry_run: bool = False):
|
||||
now = int(datetime.utcnow().timestamp())
|
||||
with open(self._state_file, 'a+') as f:
|
||||
f.seek(0)
|
||||
state: Dict[str, int] = json.load(f)
|
||||
# Remove all state entries older than conflict_interval
|
||||
state = {k: v for k, v in state.items() if now-v < self._interval}
|
||||
# Remove all messages not already sent recently
|
||||
msgs = [m for m in self._messages if m.digest not in state]
|
||||
# Add all remaining messages to state dict
|
||||
for m in msgs:
|
||||
state[m.digest] = now
|
||||
# Write the new state to file
|
||||
if not dry_run:
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
json.dump(state, f)
|
||||
# Finally send the mails
|
||||
with self._smtp as smtp:
|
||||
for m in msgs:
|
||||
msg = m.mime
|
||||
self._logger.debug(f'MIME Message:\n{str(m)}')
|
||||
if not dry_run:
|
||||
self._logger.info(f'Sending key conflict message to {msg["To"]}')
|
||||
smtp.send_message(msg)
|
||||
# Clear conflict messages
|
||||
self._messages = []
|
69
multischleuder/main.py
Normal file
69
multischleuder/main.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import argparse
|
||||
|
||||
import yaml
|
||||
|
||||
from multischleuder.api import SchleuderApi
|
||||
from multischleuder.conflict import KeyConflictResolution
|
||||
from multischleuder.processor import MultiList
|
||||
from multischleuder.smtp import SmtpClient
|
||||
|
||||
|
||||
def parse_list_config(api: SchleuderApi,
|
||||
kcr: KeyConflictResolution,
|
||||
config: Dict[str, Any]) -> 'MultiList':
|
||||
target = config['target']
|
||||
default_from = target.replace('@', '-owner@')
|
||||
mail_from = config.get('from', default_from)
|
||||
banned = config.get('banned', [])
|
||||
unmanaged = config.get('unmanaged', [])
|
||||
return MultiList(
|
||||
sources=config['sources'],
|
||||
target=target,
|
||||
banned=banned,
|
||||
unmanaged=unmanaged,
|
||||
mail_from=mail_from,
|
||||
api=api,
|
||||
kcr=kcr
|
||||
)
|
||||
|
||||
|
||||
def parse_config(ns: argparse.Namespace) -> List['MultiList']:
|
||||
with open(ns.config, 'r') as f:
|
||||
c = yaml.safe_load(f)
|
||||
|
||||
api = SchleuderApi(**c['api'])
|
||||
if ns.dry_run:
|
||||
api.dry_run()
|
||||
|
||||
smtp_config = c.get('smtp', {})
|
||||
smtp = SmtpClient.parse(**smtp_config)
|
||||
|
||||
kcr_config = c.get('conflict', {})
|
||||
kcr = KeyConflictResolution(smtp, **kcr_config)
|
||||
|
||||
lists = []
|
||||
for clist in c['lists']:
|
||||
ml = parse_list_config(api, kcr, clist)
|
||||
lists.append(ml)
|
||||
return lists
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--config', '-c', type=str, default='/etc/multischleuder/config.yml')
|
||||
ap.add_argument('--dry-run', '-n', action='store_true', default=False)
|
||||
ap.add_argument('--verbose', '-v', action='store_true', default=False)
|
||||
ns = ap.parse_args(sys.argv[1:])
|
||||
if ns.verbose:
|
||||
logger = logging.getLogger().setLevel('DEBUG')
|
||||
logger.debug('Verbose logging enabled')
|
||||
lists = parse_config(ns)
|
||||
for lst in lists:
|
||||
lst.process(ns.dry_run)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
85
multischleuder/processor.py
Normal file
85
multischleuder/processor.py
Normal file
|
@ -0,0 +1,85 @@
|
|||
|
||||
from typing import Dict, List, Set, Tuple
|
||||
|
||||
import logging
|
||||
|
||||
from multischleuder.api import SchleuderApi
|
||||
from multischleuder.conflict import KeyConflictResolution
|
||||
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
||||
|
||||
|
||||
class MultiList:
|
||||
|
||||
def __init__(self,
|
||||
sources: List[str],
|
||||
target: str,
|
||||
unmanaged: List[str],
|
||||
banned: List[str],
|
||||
mail_from: str,
|
||||
api: SchleuderApi,
|
||||
kcr: KeyConflictResolution):
|
||||
self._sources: List[str] = sources
|
||||
self._target: str = target
|
||||
self._unmanaged: List[str] = unmanaged
|
||||
self._banned: List[str] = banned
|
||||
self._mail_from: str = mail_from
|
||||
self._api: SchleuderApi = api
|
||||
self._kcr: KeyConflictResolution = kcr
|
||||
self._logger: logging.Logger = logging.getLogger()
|
||||
|
||||
def process(self, dry_run: bool = False):
|
||||
self._logger.info(f'Processing: {self._target} {"DRY RUN" if dry_run else ""}')
|
||||
target_list, sources = self._lists_by_name()
|
||||
# Get current subs, except for unmanaged adresses
|
||||
current_subs: Set[SchleuderSubscriber] = {
|
||||
s
|
||||
for s in self._api.get_subscribers(target_list)
|
||||
if s.email not in self._unmanaged
|
||||
}
|
||||
current_keys: Set[SchleuderKey] = {s.key for s in current_subs}
|
||||
all_subs: List[SchleuderSubscriber] = []
|
||||
# This loop may return multiple subscriptions for some users if they are subscribed on multiple sub-lists ...
|
||||
for source in sources:
|
||||
subs: List[SchleuderSubscriber] = self._api.get_subscribers(source)
|
||||
for s in subs:
|
||||
if s.email in self._banned or s.email in self._unmanaged:
|
||||
continue
|
||||
all_subs.append(s)
|
||||
# ... which is taken care of by the key conflict resolution routine
|
||||
self._kcr.resolve(self._target, self._mail_from, all_subs)
|
||||
intended_subs: Set[SchleuderSubscriber] = set(all_subs)
|
||||
intended_keys: Set[SchleuderKey] = {s.key for s in intended_subs}
|
||||
# Determine the change set
|
||||
to_subscribe = intended_subs.difference(current_subs)
|
||||
to_unsubscribe = current_subs.difference(intended_subs)
|
||||
to_remove = current_keys.difference(intended_keys)
|
||||
to_add = intended_keys.difference(current_keys)
|
||||
to_update = {s for s in intended_subs if s in current_subs and s.key in to_add}
|
||||
# Perform the actual list modifications in an order which avoids race conditions
|
||||
for key in to_add:
|
||||
self._api.post_key(key, target_list)
|
||||
self._logger.info(f'Added key: {key}')
|
||||
for sub in to_subscribe:
|
||||
self._api.subscribe(sub, target_list)
|
||||
self._logger.info(f'Subscribed user: {sub}')
|
||||
for sub in to_update:
|
||||
self._api.update_fingerprint(sub)
|
||||
self._logger.info(f'Updated key: {sub}')
|
||||
for sub in to_unsubscribe:
|
||||
self._api.unsubscribe(sub)
|
||||
self._logger.info(f'Unsubscribed user: {sub}')
|
||||
for key in to_remove:
|
||||
self._api.delete_key(key, target_list)
|
||||
self._logger.info(f'Removed key: {key}')
|
||||
# Finally, send any queued key conflict messages.
|
||||
self._kcr.send_messages()
|
||||
if len(to_add) + len(to_subscribe) + len(to_unsubscribe) + len(to_remove) == 0:
|
||||
self._logger.info(f'No changes for {self._target}')
|
||||
else:
|
||||
self._logger.info(f'Finished processing: {self._target}')
|
||||
|
||||
def _lists_by_name(self) -> Tuple[SchleuderList, List[SchleuderList]]:
|
||||
lists = self._api.get_lists()
|
||||
target = [x for x in lists if x.name == self._target][0]
|
||||
sources = [x for x in lists if x.name in self._sources]
|
||||
return target, sources
|
81
multischleuder/smtp.py
Normal file
81
multischleuder/smtp.py
Normal file
|
@ -0,0 +1,81 @@
|
|||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import email
|
||||
import enum
|
||||
import logging
|
||||
import smtplib
|
||||
|
||||
|
||||
class TlsMode(enum.Enum):
|
||||
PLAIN = TlsMode('smtp', 25)
|
||||
SMTPS = TlsMode('smtps', 465)
|
||||
STARTTLS = TlsMode('smtp+starttls', 587)
|
||||
|
||||
def __init__(self, proto: str, port: int):
|
||||
self._proto = proto
|
||||
self._port = port
|
||||
|
||||
@property
|
||||
def proto(self):
|
||||
return self._proto
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
return self._port
|
||||
|
||||
|
||||
class SmtpClient:
|
||||
|
||||
def __init__(self, hostname: str, port: int, tls: 'TlsMode',
|
||||
username: Optional[str], password: Optional[str]):
|
||||
self._hostname: str = hostname
|
||||
self._port: int = port
|
||||
self._tls: TlsMode = tls
|
||||
self._username: Optional[str] = username
|
||||
self._password: Optional[str] = password
|
||||
self._smtp: Optional[smtplib.SMTP] = None
|
||||
self._logger = logging.getLogger()
|
||||
|
||||
@staticmethod
|
||||
def parse(config: Dict[str, Any]) -> 'SmtpClient':
|
||||
tls = TlsMode[config.get('tls', 'PLAIN')]
|
||||
return SmtpClient(
|
||||
hostname=config.get('hostname', 'localhost'),
|
||||
port=config.get('port', tls.port),
|
||||
tls=tls,
|
||||
username=config.get('username'),
|
||||
password=config.get('password')
|
||||
)
|
||||
|
||||
def send_message(self, msg: email.message.Message):
|
||||
if self._smtp is None:
|
||||
raise RuntimeError('SMTP connection is not established')
|
||||
self._smtp.send_message(msg)
|
||||
self._logger.debug(f'Sent email message.')
|
||||
|
||||
def __enter__(self):
|
||||
# TLS from the start requires a different class
|
||||
cls = smtplib.SMTP if self._tls != TlsMode.SMTPS else smtplib.SMTP_SSL
|
||||
self._smtp = cls(self._hostname, self._port)
|
||||
# Establish the connection
|
||||
smtp = self._smtp.__enter__()
|
||||
if self._tls == TlsMode.STARTTLS:
|
||||
smtp.starttls()
|
||||
# Only sign in if both username and password are provided
|
||||
if self._username is not None and self._password is not None:
|
||||
smtp.login(self._username, self._password)
|
||||
self._logger.debug(f'SMTP connection to {str(self)} established')
|
||||
return smtp
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self._smtp is None:
|
||||
raise RuntimeError('SMTP connection is not established')
|
||||
self._smtp.quit()
|
||||
ret = self._smtp.__exit__(exc_type, exc_val, exc_tb)
|
||||
self._logger.debug(f'SMTP connection to {str(self)} closed')
|
||||
self._smtp = None
|
||||
return ret
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self._tls.proto}://{self._username}@{self._hostname}:{self._port}'
|
68
multischleuder/types.py
Normal file
68
multischleuder/types.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
|
||||
from dataclasses import dataclass, field, Field
|
||||
from datetime import datetime
|
||||
from dateutil.parser import isoparse
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchleuderList:
|
||||
id: int
|
||||
name: str
|
||||
fingerprint: str
|
||||
|
||||
@staticmethod
|
||||
def from_api(id: int,
|
||||
email: str,
|
||||
fingerprint: str,
|
||||
*args, **kwargs) -> 'SchleuderList':
|
||||
return SchleuderList(id, email, fingerprint)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchleuderSubscriber:
|
||||
id: int
|
||||
email: str
|
||||
key: 'SchleuderKey'
|
||||
schleuder: int
|
||||
created_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def from_api(key: 'SchleuderKey',
|
||||
id: int,
|
||||
list_id: int,
|
||||
email: str,
|
||||
fingerprint: str,
|
||||
admin: bool,
|
||||
delivery_enabled: bool,
|
||||
created_at: str,
|
||||
updated_at: str) -> 'SchleuderSubscriber':
|
||||
created = isoparse(created_at)
|
||||
return SchleuderSubscriber(id, email, key, list_id, created)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.email}'
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.email)
|
||||
|
||||
def __eq__(self, o) -> bool:
|
||||
if not isinstance(o, SchleuderSubscriber):
|
||||
return False
|
||||
return self.email == o.email
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchleuderKey:
|
||||
fingerprint: str
|
||||
email: str
|
||||
blob: str = field(repr=False)
|
||||
schleuder: int
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.fingerprint, self.email))
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.fingerprint} ({self.email})'
|
1
package/debian/multischleuder/DEBIAN/conffiles
Normal file
1
package/debian/multischleuder/DEBIAN/conffiles
Normal file
|
@ -0,0 +1 @@
|
|||
/etc/multischleuder/multischleuder.yml
|
11
package/debian/multischleuder/DEBIAN/control
Normal file
11
package/debian/multischleuder/DEBIAN/control
Normal file
|
@ -0,0 +1,11 @@
|
|||
Package: multischleuder
|
||||
Version: __MULTISCHLEUDER_VERSION__
|
||||
Maintainer: s3lph <1375407-s3lph@users.noreply.gitlab.com>
|
||||
Section: web
|
||||
Priority: optional
|
||||
Architecture: all
|
||||
Depends: python3 (>= 3.6), python3-dateutil, python3-pgpy, python3-yaml
|
||||
Recommends: schleuder (>= 3.6)
|
||||
Description: Schleuder subscriber merging tool
|
||||
Automatically and periodically merge subscribers and keys of multiple
|
||||
Schleuder lists into one.
|
24
package/debian/multischleuder/DEBIAN/postinst
Executable file
24
package/debian/multischleuder/DEBIAN/postinst
Executable file
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "$1" == "configure" ]]; then
|
||||
|
||||
if ! getent group multischleuder >/dev/null; then
|
||||
groupadd --system multischleuder
|
||||
fi
|
||||
|
||||
if ! getent passwd multischleuder >/dev/null; then
|
||||
useradd --system --create-home --gid multischleuder --home-dir /var/lib/multischleuder --shell /usr/sbin/nologin multischleuder
|
||||
fi
|
||||
|
||||
chown multischleuder:multischleuder /var/lib/multischleuder
|
||||
chmod 0750 /var/lib/multischleuder
|
||||
|
||||
chown root:multischleuder /etc/multischleuder
|
||||
chmod 0750 /etc/multischleuder
|
||||
|
||||
deb-systemd-helper enable multischleuder.timer
|
||||
deb-systemd-invoke restart multischleuder.timer
|
||||
|
||||
fi
|
9
package/debian/multischleuder/DEBIAN/postrm
Executable file
9
package/debian/multischleuder/DEBIAN/postrm
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "$1" == "remove" ]]; then
|
||||
|
||||
systemctl daemon-reload || true
|
||||
|
||||
fi
|
9
package/debian/multischleuder/DEBIAN/prerm
Executable file
9
package/debian/multischleuder/DEBIAN/prerm
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ "$1" == "remove" ]]; then
|
||||
|
||||
deb-systemd-invoke stop multischleuder.timer
|
||||
|
||||
fi
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=Multischleuder Sync Job
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/multischleuder --config=/etc/multischleuder/config.yaml
|
||||
User=multischleuder
|
||||
Group=multischleuder
|
||||
WorkingDirectory=/var/lib/multischleuder
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=Multischleuder Sync Job
|
||||
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
|
||||
[Install]e
|
||||
WantedBy=timers.target
|
|
@ -0,0 +1,16 @@
|
|||
Copyright 2022 s3lph
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
183
package/release.py
Executable file
183
package/release.py
Executable file
|
@ -0,0 +1,183 @@
|
|||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import urllib.request
|
||||
import http.client
|
||||
from urllib.error import HTTPError
|
||||
|
||||
|
||||
def parse_changelog(tag: str) -> Optional[str]:
|
||||
release_changelog: str = ''
|
||||
with open('CHANGELOG.md', 'r') as f:
|
||||
in_target: bool = False
|
||||
done: bool = False
|
||||
for line in f.readlines():
|
||||
if in_target:
|
||||
if f'<!-- END RELEASE {tag} -->' in line:
|
||||
done = True
|
||||
break
|
||||
release_changelog += line
|
||||
elif f'<!-- BEGIN RELEASE {tag} -->' in line:
|
||||
in_target = True
|
||||
continue
|
||||
if not done:
|
||||
return None
|
||||
return release_changelog
|
||||
|
||||
|
||||
def fetch_job_ids(project_id: str, pipeline_id: str, api_token: str) -> Dict[str, str]:
|
||||
url: str = f'https://gitlab.com/api/v4/projects/{project_id}/pipelines/{pipeline_id}/jobs'
|
||||
headers: Dict[str, str] = {
|
||||
'Private-Token': api_token,
|
||||
'User-Agent': 'curl/7.70.0'
|
||||
}
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
resp: http.client.HTTPResponse = urllib.request.urlopen(req)
|
||||
except HTTPError as e:
|
||||
print(e.read().decode())
|
||||
sys.exit(1)
|
||||
resp_data: bytes = resp.read()
|
||||
joblist: List[Dict[str, Any]] = json.loads(resp_data.decode())
|
||||
|
||||
jobidmap: Dict[str, str] = {}
|
||||
for job in joblist:
|
||||
name: str = job['name']
|
||||
job_id: str = job['id']
|
||||
jobidmap[name] = job_id
|
||||
return jobidmap
|
||||
|
||||
|
||||
def fetch_single_shafile(url: str, api_token: str) -> str:
|
||||
headers: Dict[str, str] = {
|
||||
'User-Agent': 'curl/7.70.0',
|
||||
'Private-Token': api_token
|
||||
}
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
resp: http.client.HTTPResponse = urllib.request.urlopen(req)
|
||||
except HTTPError as e:
|
||||
print(e.read().decode())
|
||||
sys.exit(1)
|
||||
resp_data: bytes = resp.readline()
|
||||
shafile: str = resp_data.decode()
|
||||
filename: str = shafile.strip().split(' ')[-1].strip()
|
||||
return filename
|
||||
|
||||
|
||||
def fetch_wheel_url(base_url: str, project_id: str, job_ids: Dict[str, str], api_token: str) -> Optional[Tuple[str, str]]:
|
||||
mybase: str = f'{base_url}/jobs/{job_ids["build_wheel"]}/artifacts/raw'
|
||||
wheel_sha_url: str = f'https://gitlab.com/api/v4/projects/{project_id}/jobs/{job_ids["build_wheel"]}'\
|
||||
'/artifacts/dist/SHA256SUMS'
|
||||
wheel_filename: str = fetch_single_shafile(wheel_sha_url, api_token)
|
||||
wheel_url: str = f'{mybase}/dist/{wheel_filename}'
|
||||
return wheel_url, wheel_sha_url
|
||||
|
||||
|
||||
def fetch_debian_url(base_url: str, project_id: str, job_ids: Dict[str, str], api_token: str) -> Optional[Tuple[str, str]]:
|
||||
mybase: str = f'{base_url}/jobs/{job_ids["build_debian"]}/artifacts/raw'
|
||||
debian_sha_url: str = f'https://gitlab.com/api/v4/projects/{project_id}/jobs/{job_ids["build_debian"]}'\
|
||||
'/artifacts/package/debian/SHA256SUMS'
|
||||
debian_filename: str = fetch_single_shafile(debian_sha_url, api_token)
|
||||
debian_url: str = f'{mybase}/package/debian/{debian_filename}'
|
||||
return debian_url, debian_sha_url
|
||||
|
||||
|
||||
def main():
|
||||
api_token: Optional[str] = os.getenv('GITLAB_API_TOKEN')
|
||||
release_tag: Optional[str] = os.getenv('CI_COMMIT_TAG')
|
||||
project_name: Optional[str] = os.getenv('CI_PROJECT_PATH')
|
||||
project_id: Optional[str] = os.getenv('CI_PROJECT_ID')
|
||||
pipeline_id: Optional[str] = os.getenv('CI_PIPELINE_ID')
|
||||
if api_token is None:
|
||||
print('GITLAB_API_TOKEN is not set.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if release_tag is None:
|
||||
print('CI_COMMIT_TAG is not set.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if project_name is None:
|
||||
print('CI_PROJECT_PATH is not set.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if project_id is None:
|
||||
print('CI_PROJECT_ID is not set.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if pipeline_id is None:
|
||||
print('CI_PIPELINE_ID is not set.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
changelog: Optional[str] = parse_changelog(release_tag)
|
||||
if changelog is None:
|
||||
print('Changelog could not be parsed.', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
job_ids: Dict[str, str] = fetch_job_ids(project_id, pipeline_id, api_token)
|
||||
|
||||
base_url: str = f'https://gitlab.com/{project_name}/-'
|
||||
|
||||
wheel_url, wheel_sha_url = fetch_wheel_url(base_url, project_id, job_ids, api_token)
|
||||
debian_url, debian_sha_url = fetch_debian_url(base_url, project_id, job_ids, api_token)
|
||||
|
||||
augmented_changelog = f'''{changelog.strip()}
|
||||
|
||||
### Download
|
||||
|
||||
- [Python Wheel]({wheel_url}) ([sha256]({wheel_sha_url}))
|
||||
- [Debian Package]({debian_url}) ([sha256]({debian_sha_url}))'''
|
||||
# Docker currently not working
|
||||
# - Docker image: registry.gitlab.com/{project_name}:{release_tag}
|
||||
|
||||
post_body: str = json.dumps({
|
||||
'tag_name': release_tag,
|
||||
'description': augmented_changelog,
|
||||
'assets': {
|
||||
'links': [
|
||||
{
|
||||
'name': 'Python Wheel',
|
||||
'url': wheel_url,
|
||||
'link_type': 'package'
|
||||
},
|
||||
{
|
||||
'name': 'Debian Package',
|
||||
'url': debian_url,
|
||||
'link_type': 'package'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
gitlab_release_api_url: str = \
|
||||
f'https://gitlab.com/api/v4/projects/{project_id}/releases'
|
||||
headers: Dict[str, str] = {
|
||||
'Private-Token': api_token,
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'User-Agent': 'curl/7.70.0'
|
||||
}
|
||||
|
||||
request = urllib.request.Request(
|
||||
gitlab_release_api_url,
|
||||
post_body.encode('utf-8'),
|
||||
headers=headers,
|
||||
method='POST'
|
||||
)
|
||||
try:
|
||||
response: http.client.HTTPResponse = urllib.request.urlopen(request)
|
||||
except HTTPError as e:
|
||||
print(e.read().decode())
|
||||
sys.exit(1)
|
||||
response_bytes: bytes = response.read()
|
||||
response_str: str = response_bytes.decode()
|
||||
response_data: Dict[str, Any] = json.loads(response_str)
|
||||
|
||||
if response_data['tag_name'] != release_tag:
|
||||
print('Something went wrong...', file=sys.stderr)
|
||||
print(response_str, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(response_data['description'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -1,3 +0,0 @@
|
|||
dateutil
|
||||
PyYAML
|
||||
PGPy
|
22
setup.cfg
Normal file
22
setup.cfg
Normal file
|
@ -0,0 +1,22 @@
|
|||
|
||||
#
|
||||
# PyCodestyle
|
||||
#
|
||||
|
||||
[pycodestyle]
|
||||
max-line-length = 120
|
||||
statistics = True
|
||||
|
||||
#
|
||||
# Coverage
|
||||
#
|
||||
|
||||
[run]
|
||||
branch = True
|
||||
parallel = True
|
||||
source = multischleuder/
|
||||
|
||||
[report]
|
||||
show_missing = True
|
||||
include = multischleuder/*
|
||||
omit = */test/*.py
|
29
setup.py
Executable file
29
setup.py
Executable file
|
@ -0,0 +1,29 @@
|
|||
from setuptools import setup, find_packages
|
||||
|
||||
from multischleuder import __version__
|
||||
|
||||
|
||||
setup(
|
||||
name='multischleuder',
|
||||
version=__version__,
|
||||
author='s3lph',
|
||||
author_email='1375407-s3lph@users.noreply.gitlab.com',
|
||||
description='Merge subscribers and keys of multiple Schleuder lists into one',
|
||||
license='MIT',
|
||||
keywords='schleuder,pgp',
|
||||
url='https://gitlab.com/s3lph/multischleuder',
|
||||
packages=find_packages(exclude=['*.test']),
|
||||
install_requires=[
|
||||
'python-dateutil',
|
||||
'PyYAML',
|
||||
'PGPy',
|
||||
],
|
||||
test_requirements=[
|
||||
'mypy'
|
||||
],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'multischleuder = multischleuder.main:main'
|
||||
]
|
||||
},
|
||||
)
|
Loading…
Reference in a new issue