Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ RUN venv/bin/pip install /etc/apt-server/*.whl
COPY tests/keys/* /etc/apt-server/keys/

# Start apt-server
ENTRYPOINT venv/bin/python3 venv/bin/apt-server.py --server-port 80 \
ENTRYPOINT venv/bin/python3 venv/bin/apt-server.py \
--private-key-path /etc/apt-server/keys/private-key.asc \
--public-key-path /etc/apt-server/keys/public-key.asc "$@"
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ architectures = amd64, arm64, armhf
distributions = bullseye, bookworm
repository_dir = /etc/apt-repo
deb_package_dir = /opt/debs
release_template = templates/Release.template
release_template = templates/Release.j2

[signature]
private_key_id = C1AEE2EDBAEC37595801DDFAE15BC62117A4E0F3
Expand All @@ -129,7 +129,7 @@ Output:

```bash
2025-03-15T16:56:23.740797Z [info ] Using configuration file [ConfigLoader] app_version=1.1.5 application=apt-server config_file=/etc/effective-range/apt-server/apt-server.conf hostname=Legion7iPro
2025-03-15T16:56:23.742358Z [info ] Started apt-server [AptServerApp] app_version=1.1.5 application=apt-server arguments={'log_level': 'info', 'log_file': '/var/log/effective-range/apt-server/apt-server.log', 'server_port': 9000, 'architectures': 'amd64, arm64, armhf', 'distributions': 'bullseye, bookworm', 'repository_dir': '/etc/apt-repo', 'deb_package_dir': '/tmp/packages', 'release_template': 'templates/Release.template', 'private_key_id': 'C1AEE2EDBAEC37595801DDFAE15BC62117A4E0F3', 'private_key_path': 'tests/keys/private-key.asc', 'private_key_pass': 'test1234', 'public_key_path': 'tests/keys/public-key.asc', 'config_file': '/etc/effective-range/apt-server/apt-server.conf'} hostname=Legion7iPro
2025-03-15T16:56:23.742358Z [info ] Started apt-server [AptServerApp] app_version=1.1.5 application=apt-server arguments={'log_level': 'info', 'log_file': '/var/log/effective-range/apt-server/apt-server.log', 'server_port': 9000, 'architectures': 'amd64, arm64, armhf', 'distributions': 'bullseye, bookworm', 'repository_dir': '/etc/apt-repo', 'deb_package_dir': '/tmp/packages', 'release_template': 'templates/Release.j2', 'private_key_id': 'C1AEE2EDBAEC37595801DDFAE15BC62117A4E0F3', 'private_key_path': 'tests/keys/private-key.asc', 'private_key_pass': 'test1234', 'public_key_path': 'tests/keys/public-key.asc', 'config_file': '/etc/effective-range/apt-server/apt-server.conf'} hostname=Legion7iPro
2025-03-15T16:56:23.746562Z [info ] Creating initial repository [AptServer] app_version=1.1.5 application=apt-server hostname=Legion7iPro
2025-03-15T16:56:23.747002Z [info ] Removing existing link [AptRepository] app_version=1.1.5 application=apt-server hostname=Legion7iPro target=/etc/apt-repo/pool/main
2025-03-15T16:56:23.747415Z [info ] Linked .deb package directory [AptRepository] app_version=1.1.5 application=apt-server hostname=Legion7iPro source=/tmp/packages target=/etc/apt-repo/pool/main
Expand Down
2 changes: 2 additions & 0 deletions apt_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
from .webServer import *
from .directoryService import *
from .aptServer import *
79 changes: 48 additions & 31 deletions apt_server/aptServer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,41 @@
# SPDX-FileCopyrightText: 2024 Attila Gombos <attila.gombos@effective-range.com>
# SPDX-License-Identifier: MIT

from http.server import HTTPServer
from dataclasses import dataclass
from pathlib import Path
from threading import Thread
from threading import Thread, Event, Lock
from typing import Any

from common_utility import IReusableTimer
from context_logger import get_logger
from watchdog.events import FileSystemEventHandler, FileSystemEvent
from watchdog.observers.api import BaseObserver

from apt_repository import AptSigner, GpgException
from apt_repository.aptRepository import AptRepository
from apt_server import IDirectoryService

log = get_logger('AptServer')


@dataclass
class AptServerConfig:
deb_package_dir: Path
repo_create_delay: float


class AptServer(FileSystemEventHandler):

def __init__(self, apt_repository: AptRepository, apt_signer: AptSigner, observer: BaseObserver,
web_server: HTTPServer, deb_package_dir: Path) -> None:
def __init__(self, timer: IReusableTimer, apt_repository: AptRepository, apt_signer: AptSigner,
file_observer: BaseObserver, directory_service: IDirectoryService, config: AptServerConfig) -> None:
self._timer = timer
self._apt_repository = apt_repository
self._apt_signer = apt_signer
self._observer = observer
self._web_server = web_server
self._deb_package_dir = deb_package_dir
self._file_observer = file_observer
self._directory_service = directory_service
self._config = config
self._shutdown_event = Event()
self._repository_lock = Lock()

def __enter__(self) -> 'AptServer':
return self
Expand All @@ -35,23 +46,25 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:

def run(self) -> None:
log.info('Creating initial repository')
self._apt_repository.create()
self._create_repository()

log.info('Signing initial repository')
self._sign_repository()

log.info('Watching directory for .deb file changes', directory=str(self._deb_package_dir))
self._observer.schedule(self, str(self._deb_package_dir), recursive=True)
deb_package_dir = str(self._config.deb_package_dir)
log.info('Watching directory for .deb file changes', directory=deb_package_dir)
self._file_observer.schedule(self, deb_package_dir, recursive=True)

log.info('Starting component', component='file-observer')
self._observer.start()
self._file_observer.start()

log.info('Starting component', component='directory-service')
self._directory_service.start()

log.info('Starting component', component='web-server')
self._web_server.serve_forever()
self._shutdown_event.wait()

def shutdown(self) -> None:
self._observer.stop()
Thread(target=self._web_server.shutdown).start()
self._file_observer.stop()
self._timer.cancel()
Thread(target=self._directory_service.shutdown).start()
self._shutdown_event.set()

def on_created(self, event: FileSystemEvent) -> None:
self._on_changed(event)
Expand All @@ -65,19 +78,23 @@ def on_deleted(self, event: FileSystemEvent) -> None:
def _on_changed(self, event: FileSystemEvent) -> None:
if str(event.src_path).endswith('.deb'):
log.info('File change event, recreating repository', event_type=event.event_type, file=event.src_path)
self._apt_repository.create()
self._sign_repository()
if self._timer.is_alive():
self._timer.restart()
else:
self._timer.start(self._config.repo_create_delay, self._create_repository)
else:
log.info('File change event, ignoring as not a package', event_type=event.event_type, file=event.src_path)

def _sign_repository(self) -> None:
try:
self._apt_signer.sign()
except GpgException as exception:
log.error(
'Error signing repository',
operation=exception.operation,
code=exception.code,
status=exception.status,
error=exception.error,
)
def _create_repository(self) -> None:
if self._shutdown_event.is_set():
return

with self._repository_lock:
try:
self._apt_repository.create()
self._apt_signer.sign()
except GpgException as exception:
log.error('Error signing repository', operation=exception.operation, code=exception.code,
status=exception.status, error=exception.error)
except Exception as exception:
log.error('Error creating repository', error=exception)
148 changes: 148 additions & 0 deletions apt_server/directoryService.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# SPDX-FileCopyrightText: 2024 Ferenc Nandor Janky <ferenj@effective-range.com>
# SPDX-FileCopyrightText: 2024 Attila Gombos <attila.gombos@effective-range.com>
# SPDX-License-Identifier: MIT

import os
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import quote

from context_logger import get_logger
from flask import send_from_directory, abort, request, Response, render_template

from apt_server import IWebServer

log = get_logger('DirectoryService')


@dataclass
class DirectoryConfig:
root_dir: Path
username: str
password: str
private_dirs: list[Path]
html_template: Path


class IDirectoryService(object):

def start(self) -> None:
raise NotImplementedError()

def shutdown(self) -> None:
raise NotImplementedError()


class DirectoryService(IDirectoryService):

def __init__(self, web_server: IWebServer, config: DirectoryConfig) -> None:
self._web_server = web_server
self._config = config

self._register_routes()

def __enter__(self) -> 'DirectoryService':
return self

def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self.shutdown()

def start(self) -> None:
self._web_server.start()

def shutdown(self) -> None:
self._web_server.shutdown()

def _register_routes(self) -> None:
app = self._web_server.get_app()

app.template_folder = str(self._config.html_template.parent)

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def serve_file_or_directory(path: str) -> Response:
full_path = self._config.root_dir / path

if not self._authorize(full_path):
return Response('Unauthorized', 401, {'WWW-Authenticate': 'Basic realm="Private Area"'})

if full_path.is_dir():
return self._list_directory(path, full_path)
elif os.path.isfile(full_path):
return send_from_directory(self._config.root_dir, path, as_attachment=False, mimetype='text/plain')
else:
abort(404)

def _authorize(self, full_path: Path) -> bool:
if any(full_path.is_relative_to(private_dir) for private_dir in self._config.private_dirs):
auth = request.authorization
if not auth or auth.username != self._config.username or auth.password != self._config.password:
return False

return True

def _list_directory(self, path: str, full_path: Path) -> Response:
sort_by = request.args.get('sort', 'name')
reverse = request.args.get('desc', '0') == '1'

entries = []

for item in sorted(os.listdir(full_path)):
entries.append(self._create_child_entry(full_path, path, item, sort_by))

entries.sort(key=lambda x: x['sort_key'], reverse=reverse)

if path:
entries.insert(0, self._create_parent_entry(path))

breadcrumbs = self._create_breadcrumbs(path)

return Response(render_template(self._config.html_template.name, items=entries, path=path,
breadcrumbs=breadcrumbs, sort_by=sort_by, reverse=reverse))

def _create_parent_entry(self, path: str) -> dict[str, Any]:
parent_path = '/' + quote(str(Path(path).parent)) + '/'
return {
'name': '../',
'href': parent_path,
'is_parent': True,
'date': '',
'size': '',
'sort_key': ''
}

def _create_child_entry(self, full_path: Path, path: str, item: str, sort_by: str) -> dict[str, Any]:
item_path = os.path.join(full_path, item)
is_dir = os.path.isdir(item_path)
stat = os.stat(item_path)
size = stat.st_size
date = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(stat.st_mtime))
href = '/' + quote(os.path.join(path, item).replace(os.sep, '/'))
if is_dir:
href += '/'
return {
'name': item + ('/' if is_dir else ''),
'href': href,
'is_parent': False,
'is_dir': is_dir,
'date': date,
'size': '-' if is_dir else f'{size:,} bytes',
'sort_key': {
'name': item.lower(),
'date': date,
'size': 0 if is_dir else size
}[sort_by]
}

def _create_breadcrumbs(self, path: str) -> list[dict[str, str]]:
breadcrumbs = []
path_accum = ''
for part in path.split('/') if path else []:
path_accum = os.path.join(path_accum, part)
breadcrumbs.append({
'name': part,
'href': '/' + quote(path_accum.replace(os.sep, '/')) + '/'
})
return breadcrumbs
83 changes: 83 additions & 0 deletions apt_server/webServer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2024 Ferenc Nandor Janky <ferenj@effective-range.com>
# SPDX-FileCopyrightText: 2024 Attila Gombos <attila.gombos@effective-range.com>
# SPDX-License-Identifier: MIT

from dataclasses import dataclass
from threading import Thread, Lock
from typing import Any, Union

from context_logger import get_logger
from flask import Flask
from waitress.server import create_server

log = get_logger('WebServer')


@dataclass
class ServerConfig:
listen: list[str]
url_scheme: str = 'http'
url_prefix: str = ''


class IWebServer(object):

def start(self) -> None:
raise NotImplementedError()

def shutdown(self) -> None:
raise NotImplementedError()

def is_running(self) -> bool:
raise NotImplementedError()

def get_app(self) -> Flask:
raise NotImplementedError()


class WebServer(IWebServer):

def __init__(self, config: ServerConfig) -> None:
self._app = Flask(__name__)
self._server = create_server(self._app,
listen=' '.join(config.listen),
url_scheme=config.url_scheme,
url_prefix=config.url_prefix)
self._thread: Union[Thread, None] = None
self._lock = Lock()

def __enter__(self) -> 'WebServer':
return self

def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
self.shutdown()

def start(self) -> None:
if self._thread:
self.shutdown()

with self._lock:
self._thread = Thread(target=self._start_server)
self._thread.start()

def shutdown(self) -> None:
with self._lock:
log.info('Stopping server')
self._server.close()
if self._thread:
self._thread.join(1)
self._thread = None

def is_running(self) -> bool:
with self._lock:
return self._thread is not None and self._thread.is_alive()

def get_app(self) -> Flask:
return self._app

def _start_server(self) -> None:
try:
log.info('Starting server')
self._server.run()
except Exception as error:
log.info('Shutdown', reason=error)
Loading
Loading