added list images

This commit is contained in:
duffyduck 2026-02-25 02:52:36 +01:00
parent 0da0bfe8a3
commit 244138b6bd
8 changed files with 164 additions and 11 deletions

16
.env.example Normal file
View File

@ -0,0 +1,16 @@
# ===========================================
# Docker Registry - Konfiguration
# ===========================================
# Domain unter der die Registry erreichbar ist
# Muss auf die IP des Servers zeigen (DNS A-Record)
# Caddy holt automatisch ein Let's Encrypt Zertifikat
DOMAIN=registry.example.com
# Admin-Zugangsdaten fuer die Benutzerverwaltungs-Weboberflaeche
ADMIN_USER=admin
ADMIN_PASSWORD=changeme
# Geheimer Schluessel fuer Flask-Sessions (bitte aendern!)
# Mit ./generate-secret.sh automatisch generieren
SECRET_KEY=bitte-aendern-zu-einem-zufaelligen-schluessel

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
!.env.example
data/

View File

@ -1,8 +1,10 @@
import os
import secrets
import sqlite3
from functools import wraps
import bcrypt as bc
import requests as http_client
from flask import (
Flask,
flash,
@ -20,6 +22,12 @@ ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "changeme")
DB_PATH = os.environ.get("DB_PATH", "/data/users.db")
HTPASSWD_PATH = os.environ.get("HTPASSWD_PATH", "/auth/htpasswd")
REGISTRY_URL = os.environ.get("REGISTRY_URL", "http://registry:5000")
# Internal service account for registry API queries (not stored in SQLite)
_SERVICE_USER = "_service"
_SERVICE_PASSWORD = secrets.token_hex(16)
_SERVICE_PASSWORD_HASH = None
def get_db():
@ -53,13 +61,29 @@ def hash_password(password):
def sync_htpasswd():
"""Regenerate htpasswd file from all users in SQLite."""
"""Regenerate htpasswd file from all users in SQLite + internal service user."""
conn = get_db()
users = conn.execute("SELECT username, password_hash FROM users").fetchall()
conn.close()
with open(HTPASSWD_PATH, "w") as f:
for user in users:
f.write(f"{user['username']}:{user['password_hash']}\n")
f.write(f"{_SERVICE_USER}:{_SERVICE_PASSWORD_HASH}\n")
def query_registry(path):
"""Query the registry API using the internal service account."""
try:
resp = http_client.get(
f"{REGISTRY_URL}{path}",
auth=(_SERVICE_USER, _SERVICE_PASSWORD),
timeout=5,
)
if resp.ok:
return resp.json()
except http_client.RequestException:
pass
return None
def login_required(f):
@ -101,6 +125,25 @@ def users():
return render_template("users.html", users=user_list)
@app.route("/images")
@login_required
def images():
repos = []
data = query_registry("/v2/_catalog")
if data:
for name in sorted(data.get("repositories", [])):
tags_data = query_registry(f"/v2/{name}/tags/list")
tags = sorted(tags_data.get("tags", [])) if tags_data and tags_data.get("tags") else []
repos.append({"name": name, "tags": tags})
return render_template("images.html", repos=repos)
@app.route("/help")
@login_required
def help_page():
return render_template("help.html", domain=request.host)
@app.route("/add", methods=["POST"])
@login_required
def add_user():
@ -175,4 +218,5 @@ def delete_user(user_id):
# Initialize database and htpasswd on startup
init_db()
_SERVICE_PASSWORD_HASH = hash_password(_SERVICE_PASSWORD)
sync_htpasswd()

View File

@ -1,3 +1,4 @@
flask>=3.0
bcrypt>=4.0
gunicorn>=21.0
requests>=2.31

View File

@ -37,13 +37,28 @@
.inline-form { display: flex; gap: 0.5rem; align-items: center; }
.inline-form input { width: 150px; }
.empty { text-align: center; padding: 2rem; color: #9ca3af; }
nav { display: flex; gap: 1.5rem; align-items: center; }
nav a { color: #94a3b8; text-decoration: none; font-size: 0.9rem; padding: 0.25rem 0; border-bottom: 2px solid transparent; }
nav a:hover { color: #fff; }
nav a.active { color: #fff; border-bottom-color: #2563eb; }
.tag { display: inline-block; background: #e0e7ff; color: #3730a3; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.8rem; margin: 0.15rem; font-family: monospace; }
code { background: #f3f4f6; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.85rem; }
pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; line-height: 1.6; margin: 0.75rem 0; }
pre code { background: none; padding: 0; color: inherit; }
.help-step { margin-bottom: 0.5rem; }
.help-step strong { color: #374151; }
</style>
</head>
<body>
<header>
<h1>Docker Registry</h1>
{% if session.get('logged_in') %}
<a href="{{ url_for('logout') }}">Abmelden</a>
<nav>
<a href="{{ url_for('users') }}" {% if request.endpoint == 'users' %}class="active"{% endif %}>Benutzer</a>
<a href="{{ url_for('images') }}" {% if request.endpoint == 'images' %}class="active"{% endif %}>Images</a>
<a href="{{ url_for('help_page') }}" {% if request.endpoint == 'help_page' %}class="active"{% endif %}>Hilfe</a>
<a href="{{ url_for('logout') }}">Abmelden</a>
</nav>
{% endif %}
</header>
<div class="container">

View File

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Hilfe - Docker Registry{% endblock %}
{% block content %}
<div class="card">
<h2>Anmelden</h2>
<p class="help-step">Mit einem in der <a href="{{ url_for('users') }}">Benutzerverwaltung</a> angelegten Account:</p>
<pre><code>docker login {{ domain }}</code></pre>
<p class="help-step" style="color: #6b7280; font-size: 0.85rem;">Benutzername und Passwort werden interaktiv abgefragt.</p>
</div>
<div class="card">
<h2>Image hochladen (push)</h2>
<p class="help-step"><strong>1.</strong> Lokales Image fuer die Registry taggen:</p>
<pre><code>docker tag mein-image:latest {{ domain }}/mein-image:latest</code></pre>
<p class="help-step"><strong>2.</strong> Image hochladen:</p>
<pre><code>docker push {{ domain }}/mein-image:latest</code></pre>
<p class="help-step" style="margin-top: 1rem;"><strong>Oder direkt beim Build:</strong></p>
<pre><code>docker build -t {{ domain }}/mein-app:1.0 .
docker push {{ domain }}/mein-app:1.0</code></pre>
</div>
<div class="card">
<h2>Image herunterladen (pull)</h2>
<pre><code>docker pull {{ domain }}/mein-image:latest</code></pre>
</div>
<div class="card">
<h2>In docker-compose.yml verwenden</h2>
<pre><code>services:
meine-app:
image: {{ domain }}/mein-image:latest</code></pre>
<p class="help-step" style="color: #6b7280; font-size: 0.85rem;">Vorher auf dem Zielserver <code>docker login {{ domain }}</code> ausfuehren.</p>
</div>
<div class="card">
<h2>Nuetzliche Befehle</h2>
<p class="help-step"><strong>Alle Images auflisten:</strong></p>
<pre><code>curl -u benutzer:passwort https://{{ domain }}/v2/_catalog</code></pre>
<p class="help-step"><strong>Tags eines Images anzeigen:</strong></p>
<pre><code>curl -u benutzer:passwort https://{{ domain }}/v2/mein-image/tags/list</code></pre>
<p class="help-step"><strong>Abmelden:</strong></p>
<pre><code>docker logout {{ domain }}</code></pre>
</div>
{% endblock %}

View File

@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Images - Docker Registry{% endblock %}
{% block content %}
<div class="card">
<h2>Gespeicherte Images</h2>
{% if repos %}
<table>
<thead>
<tr>
<th>Image</th>
<th>Tags</th>
</tr>
</thead>
<tbody>
{% for repo in repos %}
<tr>
<td><strong>{{ request.host }}/{{ repo.name }}</strong></td>
<td>
{% if repo.tags %}
{% for tag in repo.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
{% else %}
<span style="color: #9ca3af;">keine Tags</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Noch keine Images in der Registry vorhanden.</p>
{% endif %}
</div>
{% endblock %}

View File

@ -58,13 +58,4 @@
<p class="empty">Noch keine Benutzer angelegt.</p>
{% endif %}
</div>
<div class="card">
<h2>Nutzung</h2>
<p style="font-size: 0.9rem; color: #6b7280; line-height: 1.6;">
Nach dem Anlegen eines Benutzers kann sich dieser mit <code>docker login {{ request.host }}</code> anmelden
und Images pushen/pullen.
</p>
</div>
{% endblock %}