FossilRepo
Add Fossil sync protocol proxy (HTTP + SSH) with encrypted key storage HTTP sync: Django view proxies clone/push/pull via `fossil http` CGI mode, handling auth at the Django layer with --localauth for the subprocess. SSH sync: sshd in container (port 2222) with restricted fossil user, forced-command fossil-shell script, authorized_keys generated from DB. User SSH key management: model with encrypted storage, add/delete UI at /auth/ssh-keys/, fingerprint computation, authorized_keys regen on change. Encryption at rest: EncryptedTextField (Fernet/AES-128-CBC) applied to UserSSHKey.public_key and GitMirror.auth_credential, keyed from SECRET_KEY.
Commit
a1351527f88202320670bec83a314dae53ffb64078973cbaf990b4cebd2ce892
Parent
8a9a0c95c0e55f5…
18 files changed
+21
-5
+2
+118
-1
+42
+1
+39
+65
+33
+9
+75
+219
+1
+2
-1
+1
+28
+54
-1
+1
+59
~
Dockerfile
~
auth1/urls.py
~
auth1/views.py
+
core/fields.py
~
docker-compose.yaml
+
docker/entrypoint.sh
+
docker/fossil-shell
+
docker/sshd_config
~
fossil/admin.py
~
fossil/cli.py
+
fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py
~
fossil/models.py
~
fossil/sync_models.py
~
fossil/urls.py
+
fossil/user_keys.py
~
fossil/views.py
~
pyproject.toml
+
templates/auth1/ssh_keys.html
+21
-5
| --- Dockerfile | ||
| +++ Dockerfile | ||
| @@ -24,11 +24,11 @@ | ||
| 24 | 24 | # ── Stage 2: Runtime image ───────────────────────────────────────────────── |
| 25 | 25 | |
| 26 | 26 | FROM python:3.12-slim-bookworm |
| 27 | 27 | |
| 28 | 28 | RUN apt-get update && apt-get install -y --no-install-recommends \ |
| 29 | - postgresql-client ca-certificates zlib1g libssl3 \ | |
| 29 | + postgresql-client ca-certificates zlib1g libssl3 openssh-server \ | |
| 30 | 30 | && rm -rf /var/lib/apt/lists/* |
| 31 | 31 | |
| 32 | 32 | # Copy Fossil binary from builder |
| 33 | 33 | COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil |
| 34 | 34 | RUN fossil version |
| @@ -42,15 +42,31 @@ | ||
| 42 | 42 | |
| 43 | 43 | COPY . . |
| 44 | 44 | |
| 45 | 45 | RUN DJANGO_SECRET_KEY=build-placeholder DJANGO_DEBUG=true python manage.py collectstatic --noinput |
| 46 | 46 | |
| 47 | -# Create data directory for .fossil files | |
| 48 | -RUN mkdir -p /data/repos /data/trash | |
| 47 | +# Create data directories | |
| 48 | +RUN mkdir -p /data/repos /data/trash /data/ssh | |
| 49 | + | |
| 50 | +# SSH setup — restricted fossil user + sshd for clone/push | |
| 51 | +RUN useradd -r -m -d /home/fossil -s /bin/bash fossil \ | |
| 52 | + && mkdir -p /run/sshd /home/fossil/.ssh \ | |
| 53 | + && chown fossil:fossil /home/fossil/.ssh \ | |
| 54 | + && chmod 700 /home/fossil/.ssh | |
| 55 | + | |
| 56 | +COPY docker/sshd_config /etc/ssh/sshd_config | |
| 57 | +COPY docker/fossil-shell /usr/local/bin/fossil-shell | |
| 58 | +RUN chmod +x /usr/local/bin/fossil-shell | |
| 59 | + | |
| 60 | +# Generate host keys if they don't exist (entrypoint will handle persistent keys) | |
| 61 | +RUN ssh-keygen -A | |
| 49 | 62 | |
| 50 | 63 | ENV PYTHONUNBUFFERED=1 |
| 51 | 64 | ENV PYTHONDONTWRITEBYTECODE=1 |
| 52 | 65 | ENV DJANGO_SETTINGS_MODULE=config.settings |
| 53 | 66 | |
| 54 | -EXPOSE 8000 | |
| 67 | +EXPOSE 8000 2222 | |
| 68 | + | |
| 69 | +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh | |
| 70 | +RUN chmod +x /usr/local/bin/entrypoint.sh | |
| 55 | 71 | |
| 56 | -CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"] | |
| 72 | +CMD ["/usr/local/bin/entrypoint.sh"] | |
| 57 | 73 |
| --- Dockerfile | |
| +++ Dockerfile | |
| @@ -24,11 +24,11 @@ | |
| 24 | # ── Stage 2: Runtime image ───────────────────────────────────────────────── |
| 25 | |
| 26 | FROM python:3.12-slim-bookworm |
| 27 | |
| 28 | RUN apt-get update && apt-get install -y --no-install-recommends \ |
| 29 | postgresql-client ca-certificates zlib1g libssl3 \ |
| 30 | && rm -rf /var/lib/apt/lists/* |
| 31 | |
| 32 | # Copy Fossil binary from builder |
| 33 | COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil |
| 34 | RUN fossil version |
| @@ -42,15 +42,31 @@ | |
| 42 | |
| 43 | COPY . . |
| 44 | |
| 45 | RUN DJANGO_SECRET_KEY=build-placeholder DJANGO_DEBUG=true python manage.py collectstatic --noinput |
| 46 | |
| 47 | # Create data directory for .fossil files |
| 48 | RUN mkdir -p /data/repos /data/trash |
| 49 | |
| 50 | ENV PYTHONUNBUFFERED=1 |
| 51 | ENV PYTHONDONTWRITEBYTECODE=1 |
| 52 | ENV DJANGO_SETTINGS_MODULE=config.settings |
| 53 | |
| 54 | EXPOSE 8000 |
| 55 | |
| 56 | CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"] |
| 57 |
| --- Dockerfile | |
| +++ Dockerfile | |
| @@ -24,11 +24,11 @@ | |
| 24 | # ── Stage 2: Runtime image ───────────────────────────────────────────────── |
| 25 | |
| 26 | FROM python:3.12-slim-bookworm |
| 27 | |
| 28 | RUN apt-get update && apt-get install -y --no-install-recommends \ |
| 29 | postgresql-client ca-certificates zlib1g libssl3 openssh-server \ |
| 30 | && rm -rf /var/lib/apt/lists/* |
| 31 | |
| 32 | # Copy Fossil binary from builder |
| 33 | COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil |
| 34 | RUN fossil version |
| @@ -42,15 +42,31 @@ | |
| 42 | |
| 43 | COPY . . |
| 44 | |
| 45 | RUN DJANGO_SECRET_KEY=build-placeholder DJANGO_DEBUG=true python manage.py collectstatic --noinput |
| 46 | |
| 47 | # Create data directories |
| 48 | RUN mkdir -p /data/repos /data/trash /data/ssh |
| 49 | |
| 50 | # SSH setup — restricted fossil user + sshd for clone/push |
| 51 | RUN useradd -r -m -d /home/fossil -s /bin/bash fossil \ |
| 52 | && mkdir -p /run/sshd /home/fossil/.ssh \ |
| 53 | && chown fossil:fossil /home/fossil/.ssh \ |
| 54 | && chmod 700 /home/fossil/.ssh |
| 55 | |
| 56 | COPY docker/sshd_config /etc/ssh/sshd_config |
| 57 | COPY docker/fossil-shell /usr/local/bin/fossil-shell |
| 58 | RUN chmod +x /usr/local/bin/fossil-shell |
| 59 | |
| 60 | # Generate host keys if they don't exist (entrypoint will handle persistent keys) |
| 61 | RUN ssh-keygen -A |
| 62 | |
| 63 | ENV PYTHONUNBUFFERED=1 |
| 64 | ENV PYTHONDONTWRITEBYTECODE=1 |
| 65 | ENV DJANGO_SETTINGS_MODULE=config.settings |
| 66 | |
| 67 | EXPOSE 8000 2222 |
| 68 | |
| 69 | COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh |
| 70 | RUN chmod +x /usr/local/bin/entrypoint.sh |
| 71 | |
| 72 | CMD ["/usr/local/bin/entrypoint.sh"] |
| 73 |
+2
| --- auth1/urls.py | ||
| +++ auth1/urls.py | ||
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | app_name = "auth1" |
| 6 | 6 | |
| 7 | 7 | urlpatterns = [ |
| 8 | 8 | path("login/", views.login_view, name="login"), |
| 9 | 9 | path("logout/", views.logout_view, name="logout"), |
| 10 | + path("ssh-keys/", views.ssh_keys, name="ssh_keys"), | |
| 11 | + path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"), | |
| 10 | 12 | ] |
| 11 | 13 |
| --- auth1/urls.py | |
| +++ auth1/urls.py | |
| @@ -5,6 +5,8 @@ | |
| 5 | app_name = "auth1" |
| 6 | |
| 7 | urlpatterns = [ |
| 8 | path("login/", views.login_view, name="login"), |
| 9 | path("logout/", views.logout_view, name="logout"), |
| 10 | ] |
| 11 |
| --- auth1/urls.py | |
| +++ auth1/urls.py | |
| @@ -5,6 +5,8 @@ | |
| 5 | app_name = "auth1" |
| 6 | |
| 7 | urlpatterns = [ |
| 8 | path("login/", views.login_view, name="login"), |
| 9 | path("logout/", views.logout_view, name="logout"), |
| 10 | path("ssh-keys/", views.ssh_keys, name="ssh_keys"), |
| 11 | path("ssh-keys/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"), |
| 12 | ] |
| 13 |
+118
-1
| --- auth1/views.py | ||
| +++ auth1/views.py | ||
| @@ -1,7 +1,10 @@ | ||
| 1 | +from django.contrib import messages | |
| 1 | 2 | from django.contrib.auth import login, logout |
| 2 | -from django.shortcuts import redirect, render | |
| 3 | +from django.contrib.auth.decorators import login_required | |
| 4 | +from django.http import HttpResponse | |
| 5 | +from django.shortcuts import get_object_or_404, redirect, render | |
| 3 | 6 | from django.views.decorators.http import require_POST |
| 4 | 7 | from django_ratelimit.decorators import ratelimit |
| 5 | 8 | |
| 6 | 9 | from .forms import LoginForm |
| 7 | 10 | |
| @@ -25,5 +28,119 @@ | ||
| 25 | 28 | |
| 26 | 29 | @require_POST |
| 27 | 30 | def logout_view(request): |
| 28 | 31 | logout(request) |
| 29 | 32 | return redirect("auth1:login") |
| 33 | + | |
| 34 | + | |
| 35 | +# --------------------------------------------------------------------------- | |
| 36 | +# SSH key management | |
| 37 | +# --------------------------------------------------------------------------- | |
| 38 | + | |
| 39 | + | |
| 40 | +def _parse_key_type(public_key): | |
| 41 | + """Extract key type from public key string.""" | |
| 42 | + parts = public_key.strip().split() | |
| 43 | + if parts: | |
| 44 | + key_prefix = parts[0] | |
| 45 | + type_map = { | |
| 46 | + "ssh-ed25519": "ed25519", | |
| 47 | + "ssh-rsa": "rsa", | |
| 48 | + "ecdsa-sha2-nistp256": "ecdsa", | |
| 49 | + "ecdsa-sha2-nistp384": "ecdsa", | |
| 50 | + "ecdsa-sha2-nistp521": "ecdsa", | |
| 51 | + "ssh-dss": "dsa", | |
| 52 | + } | |
| 53 | + return type_map.get(key_prefix, key_prefix) | |
| 54 | + return "" | |
| 55 | + | |
| 56 | + | |
| 57 | +def _compute_fingerprint(public_key): | |
| 58 | + """Compute SSH key fingerprint (SHA256).""" | |
| 59 | + import base64 | |
| 60 | + import hashlib | |
| 61 | + | |
| 62 | + parts = public_key.strip().split() | |
| 63 | + if len(parts) >= 2: | |
| 64 | + try: | |
| 65 | + key_data = base64.b64decode(parts[1]) | |
| 66 | + digest = hashlib.sha256(key_data).digest() | |
| 67 | + return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode() | |
| 68 | + except Exception: | |
| 69 | + pass | |
| 70 | + return "" | |
| 71 | + | |
| 72 | + | |
| 73 | +def _regenerate_authorized_keys(): | |
| 74 | + """Regenerate the authorized_keys file from all active user SSH keys.""" | |
| 75 | + from pathlib import Path | |
| 76 | + | |
| 77 | + from constance import config | |
| 78 | + | |
| 79 | + from fossil.user_keys import UserSSHKey | |
| 80 | + | |
| 81 | + ssh_dir = Path(config.FOSSIL_DATA_DIR).parent / "ssh" | |
| 82 | + ssh_dir.mkdir(parents=True, exist_ok=True) | |
| 83 | + authorized_keys_path = ssh_dir / "authorized_keys" | |
| 84 | + | |
| 85 | + keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user") | |
| 86 | + | |
| 87 | + lines = [] | |
| 88 | + for key in keys: | |
| 89 | + # Each key gets a forced command that identifies the user | |
| 90 | + forced_cmd = ( | |
| 91 | + f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty' | |
| 92 | + ) | |
| 93 | + lines.append(f"{forced_cmd} {key.public_key.strip()}") | |
| 94 | + | |
| 95 | + authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "") | |
| 96 | + authorized_keys_path.chmod(0o600) | |
| 97 | + | |
| 98 | + | |
| 99 | +@login_required | |
| 100 | +def ssh_keys(request): | |
| 101 | + """List and add SSH keys.""" | |
| 102 | + from fossil.user_keys import UserSSHKey | |
| 103 | + | |
| 104 | + keys = UserSSHKey.objects.filter(user=request.user) | |
| 105 | + | |
| 106 | + if request.method == "POST": | |
| 107 | + title = request.POST.get("title", "").strip() | |
| 108 | + public_key = request.POST.get("public_key", "").strip() | |
| 109 | + | |
| 110 | + if title and public_key: | |
| 111 | + key_type = _parse_key_type(public_key) | |
| 112 | + fingerprint = _compute_fingerprint(public_key) | |
| 113 | + | |
| 114 | + UserSSHKey.objects.create( | |
| 115 | + user=request.user, | |
| 116 | + title=title, | |
| 117 | + public_key=public_key, | |
| 118 | + key_type=key_type, | |
| 119 | + fingerprint=fingerprint, | |
| 120 | + created_by=request.user, | |
| 121 | + ) | |
| 122 | + | |
| 123 | + _regenerate_authorized_keys() | |
| 124 | + | |
| 125 | + messages.success(request, f'SSH key "{title}" added.') | |
| 126 | + return redirect("auth1:ssh_keys") | |
| 127 | + | |
| 128 | + return render(request, "auth1/ssh_keys.html", {"keys": keys}) | |
| 129 | + | |
| 130 | + | |
| 131 | +@login_required | |
| 132 | +@require_POST | |
| 133 | +def ssh_key_delete(request, pk): | |
| 134 | + """Delete an SSH key.""" | |
| 135 | + from fossil.user_keys import UserSSHKey | |
| 136 | + | |
| 137 | + key = get_object_or_404(UserSSHKey, pk=pk, user=request.user) | |
| 138 | + key.soft_delete(user=request.user) | |
| 139 | + _regenerate_authorized_keys() | |
| 140 | + | |
| 141 | + messages.success(request, f'SSH key "{key.title}" removed.') | |
| 142 | + | |
| 143 | + if request.headers.get("HX-Request"): | |
| 144 | + return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"}) | |
| 145 | + | |
| 146 | + return redirect("auth1:ssh_keys") | |
| 30 | 147 | |
| 31 | 148 | ADDED core/fields.py |
| --- auth1/views.py | |
| +++ auth1/views.py | |
| @@ -1,7 +1,10 @@ | |
| 1 | from django.contrib.auth import login, logout |
| 2 | from django.shortcuts import redirect, render |
| 3 | from django.views.decorators.http import require_POST |
| 4 | from django_ratelimit.decorators import ratelimit |
| 5 | |
| 6 | from .forms import LoginForm |
| 7 | |
| @@ -25,5 +28,119 @@ | |
| 25 | |
| 26 | @require_POST |
| 27 | def logout_view(request): |
| 28 | logout(request) |
| 29 | return redirect("auth1:login") |
| 30 | |
| 31 | DDED core/fields.py |
| --- auth1/views.py | |
| +++ auth1/views.py | |
| @@ -1,7 +1,10 @@ | |
| 1 | from django.contrib import messages |
| 2 | from django.contrib.auth import login, logout |
| 3 | from django.contrib.auth.decorators import login_required |
| 4 | from django.http import HttpResponse |
| 5 | from django.shortcuts import get_object_or_404, redirect, render |
| 6 | from django.views.decorators.http import require_POST |
| 7 | from django_ratelimit.decorators import ratelimit |
| 8 | |
| 9 | from .forms import LoginForm |
| 10 | |
| @@ -25,5 +28,119 @@ | |
| 28 | |
| 29 | @require_POST |
| 30 | def logout_view(request): |
| 31 | logout(request) |
| 32 | return redirect("auth1:login") |
| 33 | |
| 34 | |
| 35 | # --------------------------------------------------------------------------- |
| 36 | # SSH key management |
| 37 | # --------------------------------------------------------------------------- |
| 38 | |
| 39 | |
| 40 | def _parse_key_type(public_key): |
| 41 | """Extract key type from public key string.""" |
| 42 | parts = public_key.strip().split() |
| 43 | if parts: |
| 44 | key_prefix = parts[0] |
| 45 | type_map = { |
| 46 | "ssh-ed25519": "ed25519", |
| 47 | "ssh-rsa": "rsa", |
| 48 | "ecdsa-sha2-nistp256": "ecdsa", |
| 49 | "ecdsa-sha2-nistp384": "ecdsa", |
| 50 | "ecdsa-sha2-nistp521": "ecdsa", |
| 51 | "ssh-dss": "dsa", |
| 52 | } |
| 53 | return type_map.get(key_prefix, key_prefix) |
| 54 | return "" |
| 55 | |
| 56 | |
| 57 | def _compute_fingerprint(public_key): |
| 58 | """Compute SSH key fingerprint (SHA256).""" |
| 59 | import base64 |
| 60 | import hashlib |
| 61 | |
| 62 | parts = public_key.strip().split() |
| 63 | if len(parts) >= 2: |
| 64 | try: |
| 65 | key_data = base64.b64decode(parts[1]) |
| 66 | digest = hashlib.sha256(key_data).digest() |
| 67 | return "SHA256:" + base64.b64encode(digest).rstrip(b"=").decode() |
| 68 | except Exception: |
| 69 | pass |
| 70 | return "" |
| 71 | |
| 72 | |
| 73 | def _regenerate_authorized_keys(): |
| 74 | """Regenerate the authorized_keys file from all active user SSH keys.""" |
| 75 | from pathlib import Path |
| 76 | |
| 77 | from constance import config |
| 78 | |
| 79 | from fossil.user_keys import UserSSHKey |
| 80 | |
| 81 | ssh_dir = Path(config.FOSSIL_DATA_DIR).parent / "ssh" |
| 82 | ssh_dir.mkdir(parents=True, exist_ok=True) |
| 83 | authorized_keys_path = ssh_dir / "authorized_keys" |
| 84 | |
| 85 | keys = UserSSHKey.objects.filter(deleted_at__isnull=True).select_related("user") |
| 86 | |
| 87 | lines = [] |
| 88 | for key in keys: |
| 89 | # Each key gets a forced command that identifies the user |
| 90 | forced_cmd = ( |
| 91 | f'command="/usr/local/bin/fossil-shell {key.user.username}",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty' |
| 92 | ) |
| 93 | lines.append(f"{forced_cmd} {key.public_key.strip()}") |
| 94 | |
| 95 | authorized_keys_path.write_text("\n".join(lines) + "\n" if lines else "") |
| 96 | authorized_keys_path.chmod(0o600) |
| 97 | |
| 98 | |
| 99 | @login_required |
| 100 | def ssh_keys(request): |
| 101 | """List and add SSH keys.""" |
| 102 | from fossil.user_keys import UserSSHKey |
| 103 | |
| 104 | keys = UserSSHKey.objects.filter(user=request.user) |
| 105 | |
| 106 | if request.method == "POST": |
| 107 | title = request.POST.get("title", "").strip() |
| 108 | public_key = request.POST.get("public_key", "").strip() |
| 109 | |
| 110 | if title and public_key: |
| 111 | key_type = _parse_key_type(public_key) |
| 112 | fingerprint = _compute_fingerprint(public_key) |
| 113 | |
| 114 | UserSSHKey.objects.create( |
| 115 | user=request.user, |
| 116 | title=title, |
| 117 | public_key=public_key, |
| 118 | key_type=key_type, |
| 119 | fingerprint=fingerprint, |
| 120 | created_by=request.user, |
| 121 | ) |
| 122 | |
| 123 | _regenerate_authorized_keys() |
| 124 | |
| 125 | messages.success(request, f'SSH key "{title}" added.') |
| 126 | return redirect("auth1:ssh_keys") |
| 127 | |
| 128 | return render(request, "auth1/ssh_keys.html", {"keys": keys}) |
| 129 | |
| 130 | |
| 131 | @login_required |
| 132 | @require_POST |
| 133 | def ssh_key_delete(request, pk): |
| 134 | """Delete an SSH key.""" |
| 135 | from fossil.user_keys import UserSSHKey |
| 136 | |
| 137 | key = get_object_or_404(UserSSHKey, pk=pk, user=request.user) |
| 138 | key.soft_delete(user=request.user) |
| 139 | _regenerate_authorized_keys() |
| 140 | |
| 141 | messages.success(request, f'SSH key "{key.title}" removed.') |
| 142 | |
| 143 | if request.headers.get("HX-Request"): |
| 144 | return HttpResponse(status=200, headers={"HX-Redirect": "/auth/ssh-keys/"}) |
| 145 | |
| 146 | return redirect("auth1:ssh_keys") |
| 147 | |
| 148 | DDED core/fields.py |
+42
| --- a/core/fields.py | ||
| +++ b/core/fields.py | ||
| @@ -0,0 +1,42 @@ | ||
| 1 | +"""Custom model fields — encrypted storage using Fernet symmetric encryption.""" | |
| 2 | + | |
| 3 | +import base64 | |
| 4 | +import hashlib | |
| 5 | + | |
| 6 | +from cryptography.fernet import Fernet, InvalidToken | |
| 7 | +from django.conf import settings | |
| 8 | +from django.db import models | |
| 9 | + | |
| 10 | + | |
| 11 | +def _get_fernet(): | |
| 12 | + """Derive a Fernet key from Django's SECRET_KEY.""" | |
| 13 | + key_bytes = hashlib.sha256(settings.SECRET_KEY.encode()).digest() | |
| 14 | + return Fernet(base64.urlsafe_b64encode(key_bytes)) | |
| 15 | + | |
| 16 | + | |
| 17 | +class EncryptedTextField(models.TextField): | |
| 18 | + """TextField that encrypts data at rest using Fernet (AES-128-CBC + HMAC). | |
| 19 | + | |
| 20 | + Values are transparently encrypted on save and decrypted on read. | |
| 21 | + Stored as base64-encoded ciphertext in the database. | |
| 22 | + """ | |
| 23 | + | |
| 24 | + def get_prep_value(self, value): | |
| 25 | + if value is None or value == "": | |
| 26 | + return value | |
| 27 | + f = _get_fernet() | |
| 28 | + return f.encrypt(value.encode("utf-8")).decode("utf-8") | |
| 29 | + | |
| 30 | + def from_db_value(self, value, expression, connection): | |
| 31 | + if value is None or value == "": | |
| 32 | + return value | |
| 33 | + f = _get_fernet() | |
| 34 | + try: | |
| 35 | + return f.decrypt(value.encode("utf-8")).decode("utf-8") | |
| 36 | + except InvalidToken: | |
| 37 | + # Value may not be encrypted (e.g. pre-existing data). | |
| 38 | + return value | |
| 39 | + | |
| 40 | + def deconstruct(self): | |
| 41 | + name, path, args, kwargs = super().deconstruct() | |
| 42 | + return name, path, args, kwargs |
| --- a/core/fields.py | |
| +++ b/core/fields.py | |
| @@ -0,0 +1,42 @@ | |
| --- a/core/fields.py | |
| +++ b/core/fields.py | |
| @@ -0,0 +1,42 @@ | |
| 1 | """Custom model fields — encrypted storage using Fernet symmetric encryption.""" |
| 2 | |
| 3 | import base64 |
| 4 | import hashlib |
| 5 | |
| 6 | from cryptography.fernet import Fernet, InvalidToken |
| 7 | from django.conf import settings |
| 8 | from django.db import models |
| 9 | |
| 10 | |
| 11 | def _get_fernet(): |
| 12 | """Derive a Fernet key from Django's SECRET_KEY.""" |
| 13 | key_bytes = hashlib.sha256(settings.SECRET_KEY.encode()).digest() |
| 14 | return Fernet(base64.urlsafe_b64encode(key_bytes)) |
| 15 | |
| 16 | |
| 17 | class EncryptedTextField(models.TextField): |
| 18 | """TextField that encrypts data at rest using Fernet (AES-128-CBC + HMAC). |
| 19 | |
| 20 | Values are transparently encrypted on save and decrypted on read. |
| 21 | Stored as base64-encoded ciphertext in the database. |
| 22 | """ |
| 23 | |
| 24 | def get_prep_value(self, value): |
| 25 | if value is None or value == "": |
| 26 | return value |
| 27 | f = _get_fernet() |
| 28 | return f.encrypt(value.encode("utf-8")).decode("utf-8") |
| 29 | |
| 30 | def from_db_value(self, value, expression, connection): |
| 31 | if value is None or value == "": |
| 32 | return value |
| 33 | f = _get_fernet() |
| 34 | try: |
| 35 | return f.decrypt(value.encode("utf-8")).decode("utf-8") |
| 36 | except InvalidToken: |
| 37 | # Value may not be encrypted (e.g. pre-existing data). |
| 38 | return value |
| 39 | |
| 40 | def deconstruct(self): |
| 41 | name, path, args, kwargs = super().deconstruct() |
| 42 | return name, path, args, kwargs |
+1
| --- docker-compose.yaml | ||
| +++ docker-compose.yaml | ||
| @@ -2,10 +2,11 @@ | ||
| 2 | 2 | backend: |
| 3 | 3 | build: . |
| 4 | 4 | command: python manage.py runserver 0.0.0.0:8000 |
| 5 | 5 | ports: |
| 6 | 6 | - "8000:8000" |
| 7 | + - "2222:2222" | |
| 7 | 8 | env_file: .env.example |
| 8 | 9 | environment: |
| 9 | 10 | DJANGO_DEBUG: "true" |
| 10 | 11 | POSTGRES_HOST: postgres |
| 11 | 12 | REDIS_URL: redis://redis:6379/1 |
| 12 | 13 | |
| 13 | 14 | ADDED docker/entrypoint.sh |
| 14 | 15 | ADDED docker/fossil-shell |
| 15 | 16 | ADDED docker/sshd_config |
| --- docker-compose.yaml | |
| +++ docker-compose.yaml | |
| @@ -2,10 +2,11 @@ | |
| 2 | backend: |
| 3 | build: . |
| 4 | command: python manage.py runserver 0.0.0.0:8000 |
| 5 | ports: |
| 6 | - "8000:8000" |
| 7 | env_file: .env.example |
| 8 | environment: |
| 9 | DJANGO_DEBUG: "true" |
| 10 | POSTGRES_HOST: postgres |
| 11 | REDIS_URL: redis://redis:6379/1 |
| 12 | |
| 13 | DDED docker/entrypoint.sh |
| 14 | DDED docker/fossil-shell |
| 15 | DDED docker/sshd_config |
| --- docker-compose.yaml | |
| +++ docker-compose.yaml | |
| @@ -2,10 +2,11 @@ | |
| 2 | backend: |
| 3 | build: . |
| 4 | command: python manage.py runserver 0.0.0.0:8000 |
| 5 | ports: |
| 6 | - "8000:8000" |
| 7 | - "2222:2222" |
| 8 | env_file: .env.example |
| 9 | environment: |
| 10 | DJANGO_DEBUG: "true" |
| 11 | POSTGRES_HOST: postgres |
| 12 | REDIS_URL: redis://redis:6379/1 |
| 13 | |
| 14 | DDED docker/entrypoint.sh |
| 15 | DDED docker/fossil-shell |
| 16 | DDED docker/sshd_config |
+39
| --- a/docker/entrypoint.sh | ||
| +++ b/docker/entrypoint.sh | ||
| @@ -0,0 +1,39 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +# fossilrepo + gunicorn. | |
| 3 | +# | |
| 4 | +# sshd runs in th access. | |
| 5 | +# gunicorn runs as the unprivileged 'app' user. | |
| 6 | + | |
| 7 | +set -euo pipefail | |
| 8 | + | |
| 9 | +# Ensure SSH host keys exist (persistent across restarts via volume) | |
| 10 | +if [ ! -f /etc/ssh/ssh_host_ed25519_key ]; then | |
| 11 | + ssh-keygen -A | |
| 12 | +fi | |
| 13 | + | |
| 14 | +# Ensure data dirs exist with correct permissions | |
| 15 | +mkdir -p /data/ssh /data/repos /data/trash | |
| 16 | +touch /data/ssh/authorized_keys | |
| 17 | +chmod 600 /data/ssh/authorized_keys | |
| 18 | +chown -R fossil:fossil /data/ssh | |
| 19 | +chown -R app:app /data/repos /data/trash | |
| 20 | +# fossil user needs read access to repos for SSH sync | |
| 21 | +chmod -R g+r /data/repos | |
| 22 | + | |
| 23 | +# Start sshd in the background (runs as root) | |
| 24 | +/usr/sbin/sshd -p 2222 -e & | |
| 25 | +SSHD_PID=$! | |
| 26 | +echo "sshd started (PID $SSHD_PID) on port 2222" | |
| 27 | + | |
| 28 | +# Trap signals to clean up sshd | |
| 29 | +cleanup() { | |
| 30 | + echo "Shutting down sshd..." | |
| 31 | + kill "$SSHD_PID" 2>/dev/null || true | |
| 32 | + wait "$SSHD_PID" 2>/dev/null || true | |
| 33 | +} | |
| 34 | +trap cleanup EXIT TERM INT | |
| 35 | + | |
| 36 | +# Drop to non-root 'app' user for gunicorn | |
| 37 | +exec gosu app gunicorn config.wsgi:application \ | |
| 38 | + --bind 0.0.0.0:8000 \ | |
| 39 | + --wor |
| --- a/docker/entrypoint.sh | |
| +++ b/docker/entrypoint.sh | |
| @@ -0,0 +1,39 @@ | |
| --- a/docker/entrypoint.sh | |
| +++ b/docker/entrypoint.sh | |
| @@ -0,0 +1,39 @@ | |
| 1 | #!/bin/bash |
| 2 | # fossilrepo + gunicorn. |
| 3 | # |
| 4 | # sshd runs in th access. |
| 5 | # gunicorn runs as the unprivileged 'app' user. |
| 6 | |
| 7 | set -euo pipefail |
| 8 | |
| 9 | # Ensure SSH host keys exist (persistent across restarts via volume) |
| 10 | if [ ! -f /etc/ssh/ssh_host_ed25519_key ]; then |
| 11 | ssh-keygen -A |
| 12 | fi |
| 13 | |
| 14 | # Ensure data dirs exist with correct permissions |
| 15 | mkdir -p /data/ssh /data/repos /data/trash |
| 16 | touch /data/ssh/authorized_keys |
| 17 | chmod 600 /data/ssh/authorized_keys |
| 18 | chown -R fossil:fossil /data/ssh |
| 19 | chown -R app:app /data/repos /data/trash |
| 20 | # fossil user needs read access to repos for SSH sync |
| 21 | chmod -R g+r /data/repos |
| 22 | |
| 23 | # Start sshd in the background (runs as root) |
| 24 | /usr/sbin/sshd -p 2222 -e & |
| 25 | SSHD_PID=$! |
| 26 | echo "sshd started (PID $SSHD_PID) on port 2222" |
| 27 | |
| 28 | # Trap signals to clean up sshd |
| 29 | cleanup() { |
| 30 | echo "Shutting down sshd..." |
| 31 | kill "$SSHD_PID" 2>/dev/null || true |
| 32 | wait "$SSHD_PID" 2>/dev/null || true |
| 33 | } |
| 34 | trap cleanup EXIT TERM INT |
| 35 | |
| 36 | # Drop to non-root 'app' user for gunicorn |
| 37 | exec gosu app gunicorn config.wsgi:application \ |
| 38 | --bind 0.0.0.0:8000 \ |
| 39 | --wor |
+65
| --- a/docker/fossil-shell | ||
| +++ b/docker/fossil-shell | ||
| @@ -0,0 +1,65 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +# fossil-shell — Forced command for SSH-based Fossil clone/push/pull. | |
| 3 | +# | |
| 4 | +# Each authorized_keys entry uses: | |
| 5 | +# command="/usr/local/bin/fossil-shell <username>",no-port-forwarding,... | |
| 6 | +# | |
| 7 | +# When a Fossil client connects via SSH, it sends a command like: | |
| 8 | +# fossil http /path/to/repo.fossil | |
| 9 | +# which arrives in $SSH_ORIGINAL_COMMAND. | |
| 10 | +# | |
| 11 | +# This script: | |
| 12 | +# 1. Extracts the repo name from the SSH command | |
| 13 | +# 2. Maps it to the on-disk .fossil file | |
| 14 | +# 3. Runs fossil http in CGI mode with --localauth | |
| 15 | +# | |
| 16 | +# Auth is already handled by the SSH key → user mapping in authorized_keys. | |
| 17 | + | |
| 18 | +set -euo pipefail | |
| 19 | + | |
| 20 | +FOSSIL_USER="${1:-anonymous}" | |
| 21 | +REPO_DIR="${FOSSIL_DATA_DIR:-/data/repos}" | |
| 22 | + | |
| 23 | +# Validate SSH_ORIGINAL_COMMAND | |
| 24 | +if [ -z "${SSH_ORIGINAL_COMMAND:-}" ]; then | |
| 25 | + echo "Error: Interactive SSH sessions are not supported." >&2 | |
| 26 | + echo "Use: fossil clone ssh://fossil@<host>/<project-slug> local.fossil" >&2 | |
| 27 | + exit 1 | |
| 28 | +fi | |
| 29 | + | |
| 30 | +# Fossil SSH sends: fossil http <repo-path> --args... | |
| 31 | +# We only allow "fossil http" commands. | |
| 32 | +if ! echo "$SSH_ORIGINAL_COMMAND" | grep -qE '^fossil\s+http\s+'; then | |
| 33 | + echo "Error: Only fossil http commands are allowed." >&2 | |
| 34 | + exit 1 | |
| 35 | +fi | |
| 36 | + | |
| 37 | +# Extract the repo identifier (second argument after "fossil http") | |
| 38 | +REPO_ARG=$(echo "$SSH_ORIGINAL_COMMAND" | awk '{print $3}') | |
| 39 | + | |
| 40 | +if [ -z "$REPO_ARG" ]; then | |
| 41 | + echo "Error: No repository specified." >&2 | |
| 42 | + exit 1 | |
| 43 | +fi | |
| 44 | + | |
| 45 | +# Strip any path components — only allow bare slugs or slug.fossil | |
| 46 | +REPO_NAME=$(basename "$REPO_ARG" .fossil) | |
| 47 | + | |
| 48 | +# Sanitize: only allow alphanumeric, hyphens, underscores | |
| 49 | +if ! echo "$REPO_NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then | |
| 50 | + echo "Error: Invalid repository name." >&2 | |
| 51 | + exit 1 | |
| 52 | +fi | |
| 53 | + | |
| 54 | +REPO_PATH="${REPO_DIR}/${REPO_NAME}.fossil" | |
| 55 | + | |
| 56 | +if [ ! -f "$REPO_PATH" ]; then | |
| 57 | + echo "Error: Repository '${REPO_NAME}' not found." >&2 | |
| 58 | + exit 1 | |
| 59 | +fi | |
| 60 | + | |
| 61 | +# Log the access | |
| 62 | +logger -t fossil-shell "user=${FOSSIL_USER} repo=${REPO_NAME} action=ssh-sync" | |
| 63 | + | |
| 64 | +# Run fossil http in CGI mode | |
| 65 | +exec fossil http "$REPO_PATH" --localauth |
| --- a/docker/fossil-shell | |
| +++ b/docker/fossil-shell | |
| @@ -0,0 +1,65 @@ | |
| --- a/docker/fossil-shell | |
| +++ b/docker/fossil-shell | |
| @@ -0,0 +1,65 @@ | |
| 1 | #!/bin/bash |
| 2 | # fossil-shell — Forced command for SSH-based Fossil clone/push/pull. |
| 3 | # |
| 4 | # Each authorized_keys entry uses: |
| 5 | # command="/usr/local/bin/fossil-shell <username>",no-port-forwarding,... |
| 6 | # |
| 7 | # When a Fossil client connects via SSH, it sends a command like: |
| 8 | # fossil http /path/to/repo.fossil |
| 9 | # which arrives in $SSH_ORIGINAL_COMMAND. |
| 10 | # |
| 11 | # This script: |
| 12 | # 1. Extracts the repo name from the SSH command |
| 13 | # 2. Maps it to the on-disk .fossil file |
| 14 | # 3. Runs fossil http in CGI mode with --localauth |
| 15 | # |
| 16 | # Auth is already handled by the SSH key → user mapping in authorized_keys. |
| 17 | |
| 18 | set -euo pipefail |
| 19 | |
| 20 | FOSSIL_USER="${1:-anonymous}" |
| 21 | REPO_DIR="${FOSSIL_DATA_DIR:-/data/repos}" |
| 22 | |
| 23 | # Validate SSH_ORIGINAL_COMMAND |
| 24 | if [ -z "${SSH_ORIGINAL_COMMAND:-}" ]; then |
| 25 | echo "Error: Interactive SSH sessions are not supported." >&2 |
| 26 | echo "Use: fossil clone ssh://fossil@<host>/<project-slug> local.fossil" >&2 |
| 27 | exit 1 |
| 28 | fi |
| 29 | |
| 30 | # Fossil SSH sends: fossil http <repo-path> --args... |
| 31 | # We only allow "fossil http" commands. |
| 32 | if ! echo "$SSH_ORIGINAL_COMMAND" | grep -qE '^fossil\s+http\s+'; then |
| 33 | echo "Error: Only fossil http commands are allowed." >&2 |
| 34 | exit 1 |
| 35 | fi |
| 36 | |
| 37 | # Extract the repo identifier (second argument after "fossil http") |
| 38 | REPO_ARG=$(echo "$SSH_ORIGINAL_COMMAND" | awk '{print $3}') |
| 39 | |
| 40 | if [ -z "$REPO_ARG" ]; then |
| 41 | echo "Error: No repository specified." >&2 |
| 42 | exit 1 |
| 43 | fi |
| 44 | |
| 45 | # Strip any path components — only allow bare slugs or slug.fossil |
| 46 | REPO_NAME=$(basename "$REPO_ARG" .fossil) |
| 47 | |
| 48 | # Sanitize: only allow alphanumeric, hyphens, underscores |
| 49 | if ! echo "$REPO_NAME" | grep -qE '^[a-zA-Z0-9_-]+$'; then |
| 50 | echo "Error: Invalid repository name." >&2 |
| 51 | exit 1 |
| 52 | fi |
| 53 | |
| 54 | REPO_PATH="${REPO_DIR}/${REPO_NAME}.fossil" |
| 55 | |
| 56 | if [ ! -f "$REPO_PATH" ]; then |
| 57 | echo "Error: Repository '${REPO_NAME}' not found." >&2 |
| 58 | exit 1 |
| 59 | fi |
| 60 | |
| 61 | # Log the access |
| 62 | logger -t fossil-shell "user=${FOSSIL_USER} repo=${REPO_NAME} action=ssh-sync" |
| 63 | |
| 64 | # Run fossil http in CGI mode |
| 65 | exec fossil http "$REPO_PATH" --localauth |
+33
| --- a/docker/sshd_config | ||
| +++ b/docker/sshd_config | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +# fossilrepo sshd — restricted config for Fossil SSH access. | |
| 2 | +# | |
| 3 | +# Only the "fossil" system user can log in, and all connections are forced | |
| 4 | +# through fossil-shell via authorized_keys command= directives. | |
| 5 | + | |
| 6 | +Port 22 | |
| 7 | +ListenAddress 0.0.0.0 | |
| 8 | + | |
| 9 | +# Host keys (generated on first boot) | |
| 10 | +HostKey /etc/ssh/ssh_host_ed25519_key | |
| 11 | +HostKey /etc/ssh/ssh_host_rsa_key | |
| 12 | + | |
| 13 | +# Auth | |
| 14 | +PermitRootLogin no | |
| 15 | +PasswordAuthentication no | |
| 16 | +PubkeyAuthentication yes | |
| 17 | +AuthorizedKeysFile /data/ssh/authorized_keys | |
| 18 | + | |
| 19 | +# Only allow the fossil user | |
| 20 | +AllowUsers fossil | |
| 21 | + | |
| 22 | +# /local/bin/fossil-shell | |
| 23 | + | |
| 24 | +# Disable everything except the sync protocol | |
| 25 | +PermitTunnel no | |
| 26 | +AllowTcpForwarding no | |
| 27 | +X11Forwarding no | |
| 28 | +AllowAgentForwarding no | |
| 29 | +GatewayPorts no | |
| 30 | +PrintMotd no | |
| 31 | + | |
| 32 | +# Logging | |
| 33 | +SyslogF |
| --- a/docker/sshd_config | |
| +++ b/docker/sshd_config | |
| @@ -0,0 +1,33 @@ | |
| --- a/docker/sshd_config | |
| +++ b/docker/sshd_config | |
| @@ -0,0 +1,33 @@ | |
| 1 | # fossilrepo sshd — restricted config for Fossil SSH access. |
| 2 | # |
| 3 | # Only the "fossil" system user can log in, and all connections are forced |
| 4 | # through fossil-shell via authorized_keys command= directives. |
| 5 | |
| 6 | Port 22 |
| 7 | ListenAddress 0.0.0.0 |
| 8 | |
| 9 | # Host keys (generated on first boot) |
| 10 | HostKey /etc/ssh/ssh_host_ed25519_key |
| 11 | HostKey /etc/ssh/ssh_host_rsa_key |
| 12 | |
| 13 | # Auth |
| 14 | PermitRootLogin no |
| 15 | PasswordAuthentication no |
| 16 | PubkeyAuthentication yes |
| 17 | AuthorizedKeysFile /data/ssh/authorized_keys |
| 18 | |
| 19 | # Only allow the fossil user |
| 20 | AllowUsers fossil |
| 21 | |
| 22 | # /local/bin/fossil-shell |
| 23 | |
| 24 | # Disable everything except the sync protocol |
| 25 | PermitTunnel no |
| 26 | AllowTcpForwarding no |
| 27 | X11Forwarding no |
| 28 | AllowAgentForwarding no |
| 29 | GatewayPorts no |
| 30 | PrintMotd no |
| 31 | |
| 32 | # Logging |
| 33 | SyslogF |
+9
| --- fossil/admin.py | ||
| +++ fossil/admin.py | ||
| @@ -2,10 +2,11 @@ | ||
| 2 | 2 | |
| 3 | 3 | from core.admin import BaseCoreAdmin |
| 4 | 4 | |
| 5 | 5 | from .models import FossilRepository, FossilSnapshot |
| 6 | 6 | from .sync_models import GitMirror, SSHKey, SyncLog |
| 7 | +from .user_keys import UserSSHKey | |
| 7 | 8 | |
| 8 | 9 | |
| 9 | 10 | class FossilSnapshotInline(admin.TabularInline): |
| 10 | 11 | model = FossilSnapshot |
| 11 | 12 | extra = 0 |
| @@ -42,5 +43,13 @@ | ||
| 42 | 43 | |
| 43 | 44 | @admin.register(SSHKey) |
| 44 | 45 | class SSHKeyAdmin(BaseCoreAdmin): |
| 45 | 46 | list_display = ("name", "fingerprint", "created_at") |
| 46 | 47 | readonly_fields = ("public_key", "fingerprint") |
| 48 | + | |
| 49 | + | |
| 50 | +@admin.register(UserSSHKey) | |
| 51 | +class UserSSHKeyAdmin(BaseCoreAdmin): | |
| 52 | + list_display = ("title", "user", "key_type", "fingerprint", "last_used_at", "created_at") | |
| 53 | + list_filter = ("key_type",) | |
| 54 | + search_fields = ("title", "user__username", "fingerprint") | |
| 55 | + readonly_fields = ("fingerprint", "key_type") | |
| 47 | 56 |
| --- fossil/admin.py | |
| +++ fossil/admin.py | |
| @@ -2,10 +2,11 @@ | |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import FossilRepository, FossilSnapshot |
| 6 | from .sync_models import GitMirror, SSHKey, SyncLog |
| 7 | |
| 8 | |
| 9 | class FossilSnapshotInline(admin.TabularInline): |
| 10 | model = FossilSnapshot |
| 11 | extra = 0 |
| @@ -42,5 +43,13 @@ | |
| 42 | |
| 43 | @admin.register(SSHKey) |
| 44 | class SSHKeyAdmin(BaseCoreAdmin): |
| 45 | list_display = ("name", "fingerprint", "created_at") |
| 46 | readonly_fields = ("public_key", "fingerprint") |
| 47 |
| --- fossil/admin.py | |
| +++ fossil/admin.py | |
| @@ -2,10 +2,11 @@ | |
| 2 | |
| 3 | from core.admin import BaseCoreAdmin |
| 4 | |
| 5 | from .models import FossilRepository, FossilSnapshot |
| 6 | from .sync_models import GitMirror, SSHKey, SyncLog |
| 7 | from .user_keys import UserSSHKey |
| 8 | |
| 9 | |
| 10 | class FossilSnapshotInline(admin.TabularInline): |
| 11 | model = FossilSnapshot |
| 12 | extra = 0 |
| @@ -42,5 +43,13 @@ | |
| 43 | |
| 44 | @admin.register(SSHKey) |
| 45 | class SSHKeyAdmin(BaseCoreAdmin): |
| 46 | list_display = ("name", "fingerprint", "created_at") |
| 47 | readonly_fields = ("public_key", "fingerprint") |
| 48 | |
| 49 | |
| 50 | @admin.register(UserSSHKey) |
| 51 | class UserSSHKeyAdmin(BaseCoreAdmin): |
| 52 | list_display = ("title", "user", "key_type", "fingerprint", "last_used_at", "created_at") |
| 53 | list_filter = ("key_type",) |
| 54 | search_fields = ("title", "user__username", "fingerprint") |
| 55 | readonly_fields = ("fingerprint", "key_type") |
| 56 |
+75
| --- fossil/cli.py | ||
| +++ fossil/cli.py | ||
| @@ -1,9 +1,12 @@ | ||
| 1 | 1 | """Thin wrapper around the fossil binary for write operations.""" |
| 2 | 2 | |
| 3 | +import logging | |
| 3 | 4 | import subprocess |
| 4 | 5 | from pathlib import Path |
| 6 | + | |
| 7 | +logger = logging.getLogger(__name__) | |
| 5 | 8 | |
| 6 | 9 | |
| 7 | 10 | class FossilCLI: |
| 8 | 11 | """Wrapper around the fossil binary for write operations.""" |
| 9 | 12 | |
| @@ -244,5 +247,77 @@ | ||
| 244 | 247 | fingerprint = fp_result.stdout.strip().split()[1] if fp_result.returncode == 0 else "" |
| 245 | 248 | return {"success": True, "public_key": pub_key, "fingerprint": fingerprint} |
| 246 | 249 | except Exception as e: |
| 247 | 250 | return {"success": False, "public_key": "", "fingerprint": "", "error": str(e)} |
| 248 | 251 | return {"success": False, "public_key": "", "fingerprint": ""} |
| 252 | + | |
| 253 | + def http_proxy(self, repo_path: Path, request_body: bytes, content_type: str = "") -> tuple[bytes, str]: | |
| 254 | + """Proxy a single Fossil HTTP sync request via CGI mode. | |
| 255 | + | |
| 256 | + Runs ``fossil http <repo_path> --localauth`` with the request piped to | |
| 257 | + stdin. Fossil writes a full HTTP response (headers + body) to stdout; | |
| 258 | + we split the two apart and return (response_body, response_content_type). | |
| 259 | + | |
| 260 | + ``--localauth`` grants full permissions because Django handles auth | |
| 261 | + before this method is ever called. | |
| 262 | + """ | |
| 263 | + import os | |
| 264 | + | |
| 265 | + env = { | |
| 266 | + **os.environ, | |
| 267 | + **{k: v for k, v in self._env.items() if k not in os.environ or k == "USER"}, | |
| 268 | + "REQUEST_METHOD": "POST", | |
| 269 | + "CONTENT_TYPE": content_type, | |
| 270 | + "CONTENT_LENGTH": str(len(request_body)), | |
| 271 | + "PATH_INFO": "/xfer", | |
| 272 | + "SCRIPT_NAME": "", | |
| 273 | + "HTTP_HOST": "localhost", | |
| 274 | + "GATEWAY_INTERFACE": "CGI/1.1", | |
| 275 | + "SERVER_PROTOCOL": "HTTP/1.1", | |
| 276 | + } | |
| 277 | + | |
| 278 | + cmd = [self.binary, "http", str(repo_path), "--localauth"] | |
| 279 | + | |
| 280 | + try: | |
| 281 | + result = subprocess.run( | |
| 282 | + cmd, | |
| 283 | + input=request_body, | |
| 284 | + capture_output=True, | |
| 285 | + timeout=120, | |
| 286 | + env=env, | |
| 287 | + ) | |
| 288 | + except subprocess.TimeoutExpired: | |
| 289 | + logger.error("fossil http timed out for %s", repo_path) | |
| 290 | + raise | |
| 291 | + except FileNotFoundError: | |
| 292 | + logger.error("fossil binary not found at %s", self.binary) | |
| 293 | + raise | |
| 294 | + | |
| 295 | + if result.returncode != 0: | |
| 296 | + stderr_text = result.stderr.decode("utf-8", errors="replace") | |
| 297 | + logger.warning("fossil http exited %d for %s: %s", result.returncode, repo_path, stderr_text) | |
| 298 | + | |
| 299 | + raw = result.stdout | |
| 300 | + | |
| 301 | + # Fossil CGI output: HTTP headers separated from body by a blank line. | |
| 302 | + # Try \r\n\r\n first (standard HTTP), fall back to \n\n. | |
| 303 | + separator = b"\r\n\r\n" | |
| 304 | + sep_idx = raw.find(separator) | |
| 305 | + if sep_idx == -1: | |
| 306 | + separator = b"\n\n" | |
| 307 | + sep_idx = raw.find(separator) | |
| 308 | + | |
| 309 | + if sep_idx == -1: | |
| 310 | + # No header/body separator found — treat the entire output as body. | |
| 311 | + return raw, "application/x-fossil" | |
| 312 | + | |
| 313 | + header_block = raw[:sep_idx] | |
| 314 | + body = raw[sep_idx + len(separator) :] | |
| 315 | + | |
| 316 | + # Parse Content-Type from the CGI headers. | |
| 317 | + response_content_type = "application/x-fossil" | |
| 318 | + for line in header_block.split(b"\r\n" if b"\r\n" in header_block else b"\n"): | |
| 319 | + if line.lower().startswith(b"content-type:"): | |
| 320 | + response_content_type = line.split(b":", 1)[1].strip().decode("utf-8", errors="replace") | |
| 321 | + break | |
| 322 | + | |
| 323 | + return body, response_content_type | |
| 249 | 324 | |
| 250 | 325 | ADDED fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -1,9 +1,12 @@ | |
| 1 | """Thin wrapper around the fossil binary for write operations.""" |
| 2 | |
| 3 | import subprocess |
| 4 | from pathlib import Path |
| 5 | |
| 6 | |
| 7 | class FossilCLI: |
| 8 | """Wrapper around the fossil binary for write operations.""" |
| 9 | |
| @@ -244,5 +247,77 @@ | |
| 244 | fingerprint = fp_result.stdout.strip().split()[1] if fp_result.returncode == 0 else "" |
| 245 | return {"success": True, "public_key": pub_key, "fingerprint": fingerprint} |
| 246 | except Exception as e: |
| 247 | return {"success": False, "public_key": "", "fingerprint": "", "error": str(e)} |
| 248 | return {"success": False, "public_key": "", "fingerprint": ""} |
| 249 | |
| 250 | DDED fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -1,9 +1,12 @@ | |
| 1 | """Thin wrapper around the fossil binary for write operations.""" |
| 2 | |
| 3 | import logging |
| 4 | import subprocess |
| 5 | from pathlib import Path |
| 6 | |
| 7 | logger = logging.getLogger(__name__) |
| 8 | |
| 9 | |
| 10 | class FossilCLI: |
| 11 | """Wrapper around the fossil binary for write operations.""" |
| 12 | |
| @@ -244,5 +247,77 @@ | |
| 247 | fingerprint = fp_result.stdout.strip().split()[1] if fp_result.returncode == 0 else "" |
| 248 | return {"success": True, "public_key": pub_key, "fingerprint": fingerprint} |
| 249 | except Exception as e: |
| 250 | return {"success": False, "public_key": "", "fingerprint": "", "error": str(e)} |
| 251 | return {"success": False, "public_key": "", "fingerprint": ""} |
| 252 | |
| 253 | def http_proxy(self, repo_path: Path, request_body: bytes, content_type: str = "") -> tuple[bytes, str]: |
| 254 | """Proxy a single Fossil HTTP sync request via CGI mode. |
| 255 | |
| 256 | Runs ``fossil http <repo_path> --localauth`` with the request piped to |
| 257 | stdin. Fossil writes a full HTTP response (headers + body) to stdout; |
| 258 | we split the two apart and return (response_body, response_content_type). |
| 259 | |
| 260 | ``--localauth`` grants full permissions because Django handles auth |
| 261 | before this method is ever called. |
| 262 | """ |
| 263 | import os |
| 264 | |
| 265 | env = { |
| 266 | **os.environ, |
| 267 | **{k: v for k, v in self._env.items() if k not in os.environ or k == "USER"}, |
| 268 | "REQUEST_METHOD": "POST", |
| 269 | "CONTENT_TYPE": content_type, |
| 270 | "CONTENT_LENGTH": str(len(request_body)), |
| 271 | "PATH_INFO": "/xfer", |
| 272 | "SCRIPT_NAME": "", |
| 273 | "HTTP_HOST": "localhost", |
| 274 | "GATEWAY_INTERFACE": "CGI/1.1", |
| 275 | "SERVER_PROTOCOL": "HTTP/1.1", |
| 276 | } |
| 277 | |
| 278 | cmd = [self.binary, "http", str(repo_path), "--localauth"] |
| 279 | |
| 280 | try: |
| 281 | result = subprocess.run( |
| 282 | cmd, |
| 283 | input=request_body, |
| 284 | capture_output=True, |
| 285 | timeout=120, |
| 286 | env=env, |
| 287 | ) |
| 288 | except subprocess.TimeoutExpired: |
| 289 | logger.error("fossil http timed out for %s", repo_path) |
| 290 | raise |
| 291 | except FileNotFoundError: |
| 292 | logger.error("fossil binary not found at %s", self.binary) |
| 293 | raise |
| 294 | |
| 295 | if result.returncode != 0: |
| 296 | stderr_text = result.stderr.decode("utf-8", errors="replace") |
| 297 | logger.warning("fossil http exited %d for %s: %s", result.returncode, repo_path, stderr_text) |
| 298 | |
| 299 | raw = result.stdout |
| 300 | |
| 301 | # Fossil CGI output: HTTP headers separated from body by a blank line. |
| 302 | # Try \r\n\r\n first (standard HTTP), fall back to \n\n. |
| 303 | separator = b"\r\n\r\n" |
| 304 | sep_idx = raw.find(separator) |
| 305 | if sep_idx == -1: |
| 306 | separator = b"\n\n" |
| 307 | sep_idx = raw.find(separator) |
| 308 | |
| 309 | if sep_idx == -1: |
| 310 | # No header/body separator found — treat the entire output as body. |
| 311 | return raw, "application/x-fossil" |
| 312 | |
| 313 | header_block = raw[:sep_idx] |
| 314 | body = raw[sep_idx + len(separator) :] |
| 315 | |
| 316 | # Parse Content-Type from the CGI headers. |
| 317 | response_content_type = "application/x-fossil" |
| 318 | for line in header_block.split(b"\r\n" if b"\r\n" in header_block else b"\n"): |
| 319 | if line.lower().startswith(b"content-type:"): |
| 320 | response_content_type = line.split(b":", 1)[1].strip().decode("utf-8", errors="replace") |
| 321 | break |
| 322 | |
| 323 | return body, response_content_type |
| 324 | |
| 325 | DDED fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py |
| --- a/fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py | ||
| +++ b/fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py | ||
| @@ -0,0 +1,219 @@ | ||
| 1 | +# Generated by Django 5.2.12 on 2026-04-07 05:02 | |
| 2 | + | |
| 3 | +import core.fields | |
| 4 | +import django.db.models.deletion | |
| 5 | +import simple_history.models | |
| 6 | +from django.conf import settings | |
| 7 | +from django.db import migrations, models | |
| 8 | + | |
| 9 | + | |
| 10 | +class Migration(migrations.Migration): | |
| 11 | + | |
| 12 | + dependencies = [ | |
| 13 | + ("fossil", "0004_historicalprojectwatch_notification_projectwatch"), | |
| 14 | + migrations.swappable_dependency(settings.AUTH_USER_MODEL), | |
| 15 | + ] | |
| 16 | + | |
| 17 | + operations = [ | |
| 18 | + migrations.AlterField( | |
| 19 | + model_name="gitmirror", | |
| 20 | + name="auth_credential", | |
| 21 | + field=core.fields.EncryptedTextField( | |
| 22 | + blank=True, | |
| 23 | + default="", | |
| 24 | + help_text="Token or key reference (encrypted at rest)", | |
| 25 | + ), | |
| 26 | + ), | |
| 27 | + migrations.AlterField( | |
| 28 | + model_name="historicalgitmirror", | |
| 29 | + name="auth_credential", | |
| 30 | + field=core.fields.EncryptedTextField( | |
| 31 | + blank=True, | |
| 32 | + default="", | |
| 33 | + help_text="Token or key reference (encrypted at rest)", | |
| 34 | + ), | |
| 35 | + ), | |
| 36 | + migrations.CreateModel( | |
| 37 | + name="HistoricalUserSSHKey", | |
| 38 | + fields=[ | |
| 39 | + ( | |
| 40 | + "id", | |
| 41 | + models.BigIntegerField( | |
| 42 | + auto_created=True, blank=True, db_index=True, verbose_name="ID" | |
| 43 | + ), | |
| 44 | + ), | |
| 45 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 46 | + ("created_at", models.DateTimeField(blank=True, editable=False)), | |
| 47 | + ("updated_at", models.DateTimeField(blank=True, editable=False)), | |
| 48 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 49 | + ( | |
| 50 | + "title", | |
| 51 | + models.CharField( | |
| 52 | + help_text="Label for this key (e.g. 'Work laptop')", | |
| 53 | + max_length=200, | |
| 54 | + ), | |
| 55 | + ), | |
| 56 | + ( | |
| 57 | + "public_key", | |
| 58 | + core.fields.EncryptedTextField( | |
| 59 | + help_text="SSH public key (ssh-ed25519, ssh-rsa, etc.)" | |
| 60 | + ), | |
| 61 | + ), | |
| 62 | + ( | |
| 63 | + "fingerprint", | |
| 64 | + models.CharField(blank=True, default="", max_length=100), | |
| 65 | + ), | |
| 66 | + ("key_type", models.CharField(blank=True, default="", max_length=20)), | |
| 67 | + ("last_used_at", models.DateTimeField(blank=True, null=True)), | |
| 68 | + ("history_id", models.AutoField(primary_key=True, serialize=False)), | |
| 69 | + ("history_date", models.DateTimeField(db_index=True)), | |
| 70 | + ("history_change_reason", models.CharField(max_length=100, null=True)), | |
| 71 | + ( | |
| 72 | + "history_type", | |
| 73 | + models.CharField( | |
| 74 | + choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], | |
| 75 | + max_length=1, | |
| 76 | + ), | |
| 77 | + ), | |
| 78 | + ( | |
| 79 | + "created_by", | |
| 80 | + models.ForeignKey( | |
| 81 | + blank=True, | |
| 82 | + db_constraint=False, | |
| 83 | + null=True, | |
| 84 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 85 | + related_name="+", | |
| 86 | + to=settings.AUTH_USER_MODEL, | |
| 87 | + ), | |
| 88 | + ), | |
| 89 | + ( | |
| 90 | + "deleted_by", | |
| 91 | + models.ForeignKey( | |
| 92 | + blank=True, | |
| 93 | + db_constraint=False, | |
| 94 | + null=True, | |
| 95 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 96 | + related_name="+", | |
| 97 | + to=settings.AUTH_USER_MODEL, | |
| 98 | + ), | |
| 99 | + ), | |
| 100 | + ( | |
| 101 | + "history_user", | |
| 102 | + models.ForeignKey( | |
| 103 | + null=True, | |
| 104 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 105 | + related_name="+", | |
| 106 | + to=settings.AUTH_USER_MODEL, | |
| 107 | + ), | |
| 108 | + ), | |
| 109 | + ( | |
| 110 | + "updated_by", | |
| 111 | + models.ForeignKey( | |
| 112 | + blank=True, | |
| 113 | + db_constraint=False, | |
| 114 | + null=True, | |
| 115 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 116 | + related_name="+", | |
| 117 | + to=settings.AUTH_USER_MODEL, | |
| 118 | + ), | |
| 119 | + ), | |
| 120 | + ( | |
| 121 | + "user", | |
| 122 | + models.ForeignKey( | |
| 123 | + blank=True, | |
| 124 | + db_constraint=False, | |
| 125 | + null=True, | |
| 126 | + on_delete=django.db.models.deletion.DO_NOTHING, | |
| 127 | + related_name="+", | |
| 128 | + to=settings.AUTH_USER_MODEL, | |
| 129 | + ), | |
| 130 | + ), | |
| 131 | + ], | |
| 132 | + options={ | |
| 133 | + "verbose_name": "historical User SSH Key", | |
| 134 | + "verbose_name_plural": "historical User SSH Keys", | |
| 135 | + "ordering": ("-history_date", "-history_id"), | |
| 136 | + "get_latest_by": ("history_date", "history_id"), | |
| 137 | + }, | |
| 138 | + bases=(simple_history.models.HistoricalChanges, models.Model), | |
| 139 | + ), | |
| 140 | + migrations.CreateModel( | |
| 141 | + name="UserSSHKey", | |
| 142 | + fields=[ | |
| 143 | + ( | |
| 144 | + "id", | |
| 145 | + models.BigAutoField( | |
| 146 | + auto_created=True, | |
| 147 | + primary_key=True, | |
| 148 | + serialize=False, | |
| 149 | + verbose_name="ID", | |
| 150 | + ), | |
| 151 | + ), | |
| 152 | + ("version", models.PositiveIntegerField(default=1, editable=False)), | |
| 153 | + ("created_at", models.DateTimeField(auto_now_add=True)), | |
| 154 | + ("updated_at", models.DateTimeField(auto_now=True)), | |
| 155 | + ("deleted_at", models.DateTimeField(blank=True, null=True)), | |
| 156 | + ( | |
| 157 | + "title", | |
| 158 | + models.CharField( | |
| 159 | + help_text="Label for this key (e.g. 'Work laptop')", | |
| 160 | + max_length=200, | |
| 161 | + ), | |
| 162 | + ), | |
| 163 | + ( | |
| 164 | + "public_key", | |
| 165 | + core.fields.EncryptedTextField( | |
| 166 | + help_text="SSH public key (ssh-ed25519, ssh-rsa, etc.)" | |
| 167 | + ), | |
| 168 | + ), | |
| 169 | + ( | |
| 170 | + "fingerprint", | |
| 171 | + models.CharField(blank=True, default="", max_length=100), | |
| 172 | + ), | |
| 173 | + ("key_type", models.CharField(blank=True, default="", max_length=20)), | |
| 174 | + ("last_used_at", models.DateTimeField(blank=True, null=True)), | |
| 175 | + ( | |
| 176 | + "created_by", | |
| 177 | + models.ForeignKey( | |
| 178 | + blank=True, | |
| 179 | + null=True, | |
| 180 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 181 | + related_name="+", | |
| 182 | + to=settings.AUTH_USER_MODEL, | |
| 183 | + ), | |
| 184 | + ), | |
| 185 | + ( | |
| 186 | + "deleted_by", | |
| 187 | + models.ForeignKey( | |
| 188 | + blank=True, | |
| 189 | + null=True, | |
| 190 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 191 | + related_name="+", | |
| 192 | + to=settings.AUTH_USER_MODEL, | |
| 193 | + ), | |
| 194 | + ), | |
| 195 | + ( | |
| 196 | + "updated_by", | |
| 197 | + models.ForeignKey( | |
| 198 | + blank=True, | |
| 199 | + null=True, | |
| 200 | + on_delete=django.db.models.deletion.SET_NULL, | |
| 201 | + related_name="+", | |
| 202 | + to=settings.AUTH_USER_MODEL, | |
| 203 | + ), | |
| 204 | + ), | |
| 205 | + ( | |
| 206 | + "user", | |
| 207 | + models.ForeignKey( | |
| 208 | + on_delete=django.db.models.deletion.CASCADE, | |
| 209 | + related_name="ssh_keys", | |
| 210 | + to=settings.AUTH_USER_MODEL, | |
| 211 | + ), | |
| 212 | + ), | |
| 213 | + ], | |
| 214 | + options={ | |
| 215 | + "verbose_name": "User SSH Key", | |
| 216 | + "ordering": ["-created_at"], | |
| 217 | + }, | |
| 218 | + ), | |
| 219 | + ] |
| --- a/fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py | |
| +++ b/fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py | |
| @@ -0,0 +1,219 @@ | |
| --- a/fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py | |
| +++ b/fossil/migrations/0005_alter_gitmirror_auth_credential_and_more.py | |
| @@ -0,0 +1,219 @@ | |
| 1 | # Generated by Django 5.2.12 on 2026-04-07 05:02 |
| 2 | |
| 3 | import core.fields |
| 4 | import django.db.models.deletion |
| 5 | import simple_history.models |
| 6 | from django.conf import settings |
| 7 | from django.db import migrations, models |
| 8 | |
| 9 | |
| 10 | class Migration(migrations.Migration): |
| 11 | |
| 12 | dependencies = [ |
| 13 | ("fossil", "0004_historicalprojectwatch_notification_projectwatch"), |
| 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), |
| 15 | ] |
| 16 | |
| 17 | operations = [ |
| 18 | migrations.AlterField( |
| 19 | model_name="gitmirror", |
| 20 | name="auth_credential", |
| 21 | field=core.fields.EncryptedTextField( |
| 22 | blank=True, |
| 23 | default="", |
| 24 | help_text="Token or key reference (encrypted at rest)", |
| 25 | ), |
| 26 | ), |
| 27 | migrations.AlterField( |
| 28 | model_name="historicalgitmirror", |
| 29 | name="auth_credential", |
| 30 | field=core.fields.EncryptedTextField( |
| 31 | blank=True, |
| 32 | default="", |
| 33 | help_text="Token or key reference (encrypted at rest)", |
| 34 | ), |
| 35 | ), |
| 36 | migrations.CreateModel( |
| 37 | name="HistoricalUserSSHKey", |
| 38 | fields=[ |
| 39 | ( |
| 40 | "id", |
| 41 | models.BigIntegerField( |
| 42 | auto_created=True, blank=True, db_index=True, verbose_name="ID" |
| 43 | ), |
| 44 | ), |
| 45 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 46 | ("created_at", models.DateTimeField(blank=True, editable=False)), |
| 47 | ("updated_at", models.DateTimeField(blank=True, editable=False)), |
| 48 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 49 | ( |
| 50 | "title", |
| 51 | models.CharField( |
| 52 | help_text="Label for this key (e.g. 'Work laptop')", |
| 53 | max_length=200, |
| 54 | ), |
| 55 | ), |
| 56 | ( |
| 57 | "public_key", |
| 58 | core.fields.EncryptedTextField( |
| 59 | help_text="SSH public key (ssh-ed25519, ssh-rsa, etc.)" |
| 60 | ), |
| 61 | ), |
| 62 | ( |
| 63 | "fingerprint", |
| 64 | models.CharField(blank=True, default="", max_length=100), |
| 65 | ), |
| 66 | ("key_type", models.CharField(blank=True, default="", max_length=20)), |
| 67 | ("last_used_at", models.DateTimeField(blank=True, null=True)), |
| 68 | ("history_id", models.AutoField(primary_key=True, serialize=False)), |
| 69 | ("history_date", models.DateTimeField(db_index=True)), |
| 70 | ("history_change_reason", models.CharField(max_length=100, null=True)), |
| 71 | ( |
| 72 | "history_type", |
| 73 | models.CharField( |
| 74 | choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], |
| 75 | max_length=1, |
| 76 | ), |
| 77 | ), |
| 78 | ( |
| 79 | "created_by", |
| 80 | models.ForeignKey( |
| 81 | blank=True, |
| 82 | db_constraint=False, |
| 83 | null=True, |
| 84 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 85 | related_name="+", |
| 86 | to=settings.AUTH_USER_MODEL, |
| 87 | ), |
| 88 | ), |
| 89 | ( |
| 90 | "deleted_by", |
| 91 | models.ForeignKey( |
| 92 | blank=True, |
| 93 | db_constraint=False, |
| 94 | null=True, |
| 95 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 96 | related_name="+", |
| 97 | to=settings.AUTH_USER_MODEL, |
| 98 | ), |
| 99 | ), |
| 100 | ( |
| 101 | "history_user", |
| 102 | models.ForeignKey( |
| 103 | null=True, |
| 104 | on_delete=django.db.models.deletion.SET_NULL, |
| 105 | related_name="+", |
| 106 | to=settings.AUTH_USER_MODEL, |
| 107 | ), |
| 108 | ), |
| 109 | ( |
| 110 | "updated_by", |
| 111 | models.ForeignKey( |
| 112 | blank=True, |
| 113 | db_constraint=False, |
| 114 | null=True, |
| 115 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 116 | related_name="+", |
| 117 | to=settings.AUTH_USER_MODEL, |
| 118 | ), |
| 119 | ), |
| 120 | ( |
| 121 | "user", |
| 122 | models.ForeignKey( |
| 123 | blank=True, |
| 124 | db_constraint=False, |
| 125 | null=True, |
| 126 | on_delete=django.db.models.deletion.DO_NOTHING, |
| 127 | related_name="+", |
| 128 | to=settings.AUTH_USER_MODEL, |
| 129 | ), |
| 130 | ), |
| 131 | ], |
| 132 | options={ |
| 133 | "verbose_name": "historical User SSH Key", |
| 134 | "verbose_name_plural": "historical User SSH Keys", |
| 135 | "ordering": ("-history_date", "-history_id"), |
| 136 | "get_latest_by": ("history_date", "history_id"), |
| 137 | }, |
| 138 | bases=(simple_history.models.HistoricalChanges, models.Model), |
| 139 | ), |
| 140 | migrations.CreateModel( |
| 141 | name="UserSSHKey", |
| 142 | fields=[ |
| 143 | ( |
| 144 | "id", |
| 145 | models.BigAutoField( |
| 146 | auto_created=True, |
| 147 | primary_key=True, |
| 148 | serialize=False, |
| 149 | verbose_name="ID", |
| 150 | ), |
| 151 | ), |
| 152 | ("version", models.PositiveIntegerField(default=1, editable=False)), |
| 153 | ("created_at", models.DateTimeField(auto_now_add=True)), |
| 154 | ("updated_at", models.DateTimeField(auto_now=True)), |
| 155 | ("deleted_at", models.DateTimeField(blank=True, null=True)), |
| 156 | ( |
| 157 | "title", |
| 158 | models.CharField( |
| 159 | help_text="Label for this key (e.g. 'Work laptop')", |
| 160 | max_length=200, |
| 161 | ), |
| 162 | ), |
| 163 | ( |
| 164 | "public_key", |
| 165 | core.fields.EncryptedTextField( |
| 166 | help_text="SSH public key (ssh-ed25519, ssh-rsa, etc.)" |
| 167 | ), |
| 168 | ), |
| 169 | ( |
| 170 | "fingerprint", |
| 171 | models.CharField(blank=True, default="", max_length=100), |
| 172 | ), |
| 173 | ("key_type", models.CharField(blank=True, default="", max_length=20)), |
| 174 | ("last_used_at", models.DateTimeField(blank=True, null=True)), |
| 175 | ( |
| 176 | "created_by", |
| 177 | models.ForeignKey( |
| 178 | blank=True, |
| 179 | null=True, |
| 180 | on_delete=django.db.models.deletion.SET_NULL, |
| 181 | related_name="+", |
| 182 | to=settings.AUTH_USER_MODEL, |
| 183 | ), |
| 184 | ), |
| 185 | ( |
| 186 | "deleted_by", |
| 187 | models.ForeignKey( |
| 188 | blank=True, |
| 189 | null=True, |
| 190 | on_delete=django.db.models.deletion.SET_NULL, |
| 191 | related_name="+", |
| 192 | to=settings.AUTH_USER_MODEL, |
| 193 | ), |
| 194 | ), |
| 195 | ( |
| 196 | "updated_by", |
| 197 | models.ForeignKey( |
| 198 | blank=True, |
| 199 | null=True, |
| 200 | on_delete=django.db.models.deletion.SET_NULL, |
| 201 | related_name="+", |
| 202 | to=settings.AUTH_USER_MODEL, |
| 203 | ), |
| 204 | ), |
| 205 | ( |
| 206 | "user", |
| 207 | models.ForeignKey( |
| 208 | on_delete=django.db.models.deletion.CASCADE, |
| 209 | related_name="ssh_keys", |
| 210 | to=settings.AUTH_USER_MODEL, |
| 211 | ), |
| 212 | ), |
| 213 | ], |
| 214 | options={ |
| 215 | "verbose_name": "User SSH Key", |
| 216 | "ordering": ["-created_at"], |
| 217 | }, |
| 218 | ), |
| 219 | ] |
+1
| --- fossil/models.py | ||
| +++ fossil/models.py | ||
| @@ -67,5 +67,6 @@ | ||
| 67 | 67 | |
| 68 | 68 | |
| 69 | 69 | # Import related models so they're discoverable by Django |
| 70 | 70 | from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401 |
| 71 | 71 | from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401 |
| 72 | +from fossil.user_keys import UserSSHKey # noqa: E402, F401 | |
| 72 | 73 |
| --- fossil/models.py | |
| +++ fossil/models.py | |
| @@ -67,5 +67,6 @@ | |
| 67 | |
| 68 | |
| 69 | # Import related models so they're discoverable by Django |
| 70 | from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401 |
| 71 | from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401 |
| 72 |
| --- fossil/models.py | |
| +++ fossil/models.py | |
| @@ -67,5 +67,6 @@ | |
| 67 | |
| 68 | |
| 69 | # Import related models so they're discoverable by Django |
| 70 | from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401 |
| 71 | from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401 |
| 72 | from fossil.user_keys import UserSSHKey # noqa: E402, F401 |
| 73 |
+2
-1
| --- fossil/sync_models.py | ||
| +++ fossil/sync_models.py | ||
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | """Git mirror sync models for Fossil-to-Git synchronization.""" |
| 2 | 2 | |
| 3 | 3 | from django.db import models |
| 4 | 4 | |
| 5 | +from core.fields import EncryptedTextField | |
| 5 | 6 | from core.models import ActiveManager, Tracking |
| 6 | 7 | |
| 7 | 8 | |
| 8 | 9 | class GitMirror(Tracking): |
| 9 | 10 | """Configuration for syncing a Fossil repo to a Git remote.""" |
| @@ -26,11 +27,11 @@ | ||
| 26 | 27 | DISABLED = "disabled", "Disabled" |
| 27 | 28 | |
| 28 | 29 | repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="git_mirrors") |
| 29 | 30 | git_remote_url = models.CharField(max_length=500, help_text="Git remote URL (SSH or HTTPS)") |
| 30 | 31 | auth_method = models.CharField(max_length=20, choices=AuthMethod.choices, default=AuthMethod.TOKEN) |
| 31 | - auth_credential = models.TextField(blank=True, default="", help_text="Encrypted token or key reference") | |
| 32 | + auth_credential = EncryptedTextField(blank=True, default="", help_text="Token or key reference (encrypted at rest)") | |
| 32 | 33 | |
| 33 | 34 | sync_direction = models.CharField(max_length=10, choices=SyncDirection.choices, default=SyncDirection.PUSH) |
| 34 | 35 | sync_mode = models.CharField(max_length=20, choices=SyncMode.choices, default=SyncMode.SCHEDULED) |
| 35 | 36 | sync_schedule = models.CharField(max_length=100, blank=True, default="*/15 * * * *", help_text="Cron expression for scheduled sync") |
| 36 | 37 | |
| 37 | 38 |
| --- fossil/sync_models.py | |
| +++ fossil/sync_models.py | |
| @@ -1,9 +1,10 @@ | |
| 1 | """Git mirror sync models for Fossil-to-Git synchronization.""" |
| 2 | |
| 3 | from django.db import models |
| 4 | |
| 5 | from core.models import ActiveManager, Tracking |
| 6 | |
| 7 | |
| 8 | class GitMirror(Tracking): |
| 9 | """Configuration for syncing a Fossil repo to a Git remote.""" |
| @@ -26,11 +27,11 @@ | |
| 26 | DISABLED = "disabled", "Disabled" |
| 27 | |
| 28 | repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="git_mirrors") |
| 29 | git_remote_url = models.CharField(max_length=500, help_text="Git remote URL (SSH or HTTPS)") |
| 30 | auth_method = models.CharField(max_length=20, choices=AuthMethod.choices, default=AuthMethod.TOKEN) |
| 31 | auth_credential = models.TextField(blank=True, default="", help_text="Encrypted token or key reference") |
| 32 | |
| 33 | sync_direction = models.CharField(max_length=10, choices=SyncDirection.choices, default=SyncDirection.PUSH) |
| 34 | sync_mode = models.CharField(max_length=20, choices=SyncMode.choices, default=SyncMode.SCHEDULED) |
| 35 | sync_schedule = models.CharField(max_length=100, blank=True, default="*/15 * * * *", help_text="Cron expression for scheduled sync") |
| 36 | |
| 37 |
| --- fossil/sync_models.py | |
| +++ fossil/sync_models.py | |
| @@ -1,9 +1,10 @@ | |
| 1 | """Git mirror sync models for Fossil-to-Git synchronization.""" |
| 2 | |
| 3 | from django.db import models |
| 4 | |
| 5 | from core.fields import EncryptedTextField |
| 6 | from core.models import ActiveManager, Tracking |
| 7 | |
| 8 | |
| 9 | class GitMirror(Tracking): |
| 10 | """Configuration for syncing a Fossil repo to a Git remote.""" |
| @@ -26,11 +27,11 @@ | |
| 27 | DISABLED = "disabled", "Disabled" |
| 28 | |
| 29 | repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="git_mirrors") |
| 30 | git_remote_url = models.CharField(max_length=500, help_text="Git remote URL (SSH or HTTPS)") |
| 31 | auth_method = models.CharField(max_length=20, choices=AuthMethod.choices, default=AuthMethod.TOKEN) |
| 32 | auth_credential = EncryptedTextField(blank=True, default="", help_text="Token or key reference (encrypted at rest)") |
| 33 | |
| 34 | sync_direction = models.CharField(max_length=10, choices=SyncDirection.choices, default=SyncDirection.PUSH) |
| 35 | sync_mode = models.CharField(max_length=20, choices=SyncMode.choices, default=SyncMode.SCHEDULED) |
| 36 | sync_schedule = models.CharField(max_length=100, blank=True, default="*/15 * * * *", help_text="Cron expression for scheduled sync") |
| 37 | |
| 38 |
+1
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -41,6 +41,7 @@ | ||
| 41 | 41 | path("watch/", views.toggle_watch, name="toggle_watch"), |
| 42 | 42 | path("timeline/rss/", views.timeline_rss, name="timeline_rss"), |
| 43 | 43 | path("tickets/export/", views.tickets_csv, name="tickets_csv"), |
| 44 | 44 | path("docs/", views.fossil_docs, name="docs"), |
| 45 | 45 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 46 | + path("xfer", views.fossil_xfer, name="xfer"), | |
| 46 | 47 | ] |
| 47 | 48 | |
| 48 | 49 | ADDED fossil/user_keys.py |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -41,6 +41,7 @@ | |
| 41 | path("watch/", views.toggle_watch, name="toggle_watch"), |
| 42 | path("timeline/rss/", views.timeline_rss, name="timeline_rss"), |
| 43 | path("tickets/export/", views.tickets_csv, name="tickets_csv"), |
| 44 | path("docs/", views.fossil_docs, name="docs"), |
| 45 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 46 | ] |
| 47 | |
| 48 | DDED fossil/user_keys.py |
| --- fossil/urls.py | |
| +++ fossil/urls.py | |
| @@ -41,6 +41,7 @@ | |
| 41 | path("watch/", views.toggle_watch, name="toggle_watch"), |
| 42 | path("timeline/rss/", views.timeline_rss, name="timeline_rss"), |
| 43 | path("tickets/export/", views.tickets_csv, name="tickets_csv"), |
| 44 | path("docs/", views.fossil_docs, name="docs"), |
| 45 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 46 | path("xfer", views.fossil_xfer, name="xfer"), |
| 47 | ] |
| 48 | |
| 49 | DDED fossil/user_keys.py |
+28
| --- a/fossil/user_keys.py | ||
| +++ b/fossil/user_keys.py | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +"""Per-user SSH public keys for Fossil clone/push over SSH.""" | |
| 2 | + | |
| 3 | +from django.contrib.auth.models import User | |
| 4 | +from django.db import models | |
| 5 | + | |
| 6 | +from core.fields import EncryptedTextField | |
| 7 | +from core.models import ActiveManager, Tracking | |
| 8 | + | |
| 9 | + | |
| 10 | +class UserSSHKey(Tracking): | |
| 11 | + """SSH public key uploaded by a user for Fossil access.""" | |
| 12 | + | |
| 13 | + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="ssh_keys") | |
| 14 | + title = models.CharField(max_length=200, help_text="Label for this key (e.g. 'Work laptop')") | |
| 15 | + public_key = EncryptedTextField(help_text="SSH public key (ssh-ed25519, ssh-rsa, etc.)") | |
| 16 | + fingerprint = models.CharField(max_length=100, blank=True, default="") | |
| 17 | + key_type = models.CharField(max_length=20, blank=True, default="") # ed25519, rsa, etc. | |
| 18 | + last_used_at = models.DateTimeField(null=True, blank=True) | |
| 19 | + | |
| 20 | + objects = ActiveManager() | |
| 21 | + all_objects = models.Manager() | |
| 22 | + | |
| 23 | + class Meta: | |
| 24 | + ordering = ["-created_at"] | |
| 25 | + verbose_name = "User SSH Key" | |
| 26 | + | |
| 27 | + def __str__(self): | |
| 28 | + return f"{self.user.username}: {self.title}" |
| --- a/fossil/user_keys.py | |
| +++ b/fossil/user_keys.py | |
| @@ -0,0 +1,28 @@ | |
| --- a/fossil/user_keys.py | |
| +++ b/fossil/user_keys.py | |
| @@ -0,0 +1,28 @@ | |
| 1 | """Per-user SSH public keys for Fossil clone/push over SSH.""" |
| 2 | |
| 3 | from django.contrib.auth.models import User |
| 4 | from django.db import models |
| 5 | |
| 6 | from core.fields import EncryptedTextField |
| 7 | from core.models import ActiveManager, Tracking |
| 8 | |
| 9 | |
| 10 | class UserSSHKey(Tracking): |
| 11 | """SSH public key uploaded by a user for Fossil access.""" |
| 12 | |
| 13 | user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="ssh_keys") |
| 14 | title = models.CharField(max_length=200, help_text="Label for this key (e.g. 'Work laptop')") |
| 15 | public_key = EncryptedTextField(help_text="SSH public key (ssh-ed25519, ssh-rsa, etc.)") |
| 16 | fingerprint = models.CharField(max_length=100, blank=True, default="") |
| 17 | key_type = models.CharField(max_length=20, blank=True, default="") # ed25519, rsa, etc. |
| 18 | last_used_at = models.DateTimeField(null=True, blank=True) |
| 19 | |
| 20 | objects = ActiveManager() |
| 21 | all_objects = models.Manager() |
| 22 | |
| 23 | class Meta: |
| 24 | ordering = ["-created_at"] |
| 25 | verbose_name = "User SSH Key" |
| 26 | |
| 27 | def __str__(self): |
| 28 | return f"{self.user.username}: {self.title}" |
+54
-1
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -1,13 +1,14 @@ | ||
| 1 | 1 | import contextlib |
| 2 | 2 | import re |
| 3 | 3 | |
| 4 | 4 | import markdown as md |
| 5 | 5 | from django.contrib.auth.decorators import login_required |
| 6 | -from django.http import Http404 | |
| 6 | +from django.http import Http404, HttpResponse | |
| 7 | 7 | from django.shortcuts import get_object_or_404, redirect, render |
| 8 | 8 | from django.utils.safestring import mark_safe |
| 9 | +from django.views.decorators.csrf import csrf_exempt | |
| 9 | 10 | |
| 10 | 11 | from projects.models import Project |
| 11 | 12 | |
| 12 | 13 | from .models import FossilRepository |
| 13 | 14 | from .reader import FossilReader |
| @@ -1122,10 +1123,62 @@ | ||
| 1122 | 1123 | |
| 1123 | 1124 | from django.shortcuts import redirect |
| 1124 | 1125 | |
| 1125 | 1126 | return redirect("fossil:git_mirror", slug=slug) |
| 1126 | 1127 | |
| 1128 | + | |
| 1129 | +# --- Fossil Wire Protocol Proxy (clone / push / pull) --- | |
| 1130 | + | |
| 1131 | + | |
| 1132 | +@csrf_exempt | |
| 1133 | +def fossil_xfer(request, slug): | |
| 1134 | + """Proxy Fossil sync protocol (clone/push/pull) through Django. | |
| 1135 | + | |
| 1136 | + GET — informational page with clone URL. | |
| 1137 | + POST — pipe the request body through ``fossil http`` in CGI mode. | |
| 1138 | + """ | |
| 1139 | + from projects.access import require_project_read, require_project_write | |
| 1140 | + | |
| 1141 | + from .cli import FossilCLI | |
| 1142 | + | |
| 1143 | + project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) | |
| 1144 | + fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) | |
| 1145 | + | |
| 1146 | + if request.method == "GET": | |
| 1147 | + require_project_read(request, project) | |
| 1148 | + clone_url = request.build_absolute_uri() | |
| 1149 | + html = ( | |
| 1150 | + f"<html><head><title>{project.name} — Fossil Sync</title></head>" | |
| 1151 | + f"<body>" | |
| 1152 | + f"<h1>{project.name}</h1>" | |
| 1153 | + f"<p>This is the Fossil sync endpoint for <strong>{project.name}</strong>.</p>" | |
| 1154 | + f"<p>Clone with:</p>" | |
| 1155 | + f"<pre>fossil clone {clone_url} {project.slug}.fossil</pre>" | |
| 1156 | + f"<p>Authentication is required.</p>" | |
| 1157 | + f"</body></html>" | |
| 1158 | + ) | |
| 1159 | + return HttpResponse(html) | |
| 1160 | + | |
| 1161 | + if request.method == "POST": | |
| 1162 | + # All POST sync operations (push/pull/clone) require write access for | |
| 1163 | + # now. We can loosen pull to read-only once we can distinguish the | |
| 1164 | + # operation from the request payload. | |
| 1165 | + require_project_write(request, project) | |
| 1166 | + | |
| 1167 | + if not fossil_repo.exists_on_disk: | |
| 1168 | + raise Http404("Repository file not found on disk.") | |
| 1169 | + | |
| 1170 | + cli = FossilCLI() | |
| 1171 | + body, content_type = cli.http_proxy( | |
| 1172 | + fossil_repo.full_path, | |
| 1173 | + request.body, | |
| 1174 | + request.content_type, | |
| 1175 | + ) | |
| 1176 | + return HttpResponse(body, content_type=content_type) | |
| 1177 | + | |
| 1178 | + return HttpResponse(status=405) | |
| 1179 | + | |
| 1127 | 1180 | |
| 1128 | 1181 | # --- Watch / Notifications --- |
| 1129 | 1182 | |
| 1130 | 1183 | |
| 1131 | 1184 | @login_required |
| 1132 | 1185 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -1,13 +1,14 @@ | |
| 1 | import contextlib |
| 2 | import re |
| 3 | |
| 4 | import markdown as md |
| 5 | from django.contrib.auth.decorators import login_required |
| 6 | from django.http import Http404 |
| 7 | from django.shortcuts import get_object_or_404, redirect, render |
| 8 | from django.utils.safestring import mark_safe |
| 9 | |
| 10 | from projects.models import Project |
| 11 | |
| 12 | from .models import FossilRepository |
| 13 | from .reader import FossilReader |
| @@ -1122,10 +1123,62 @@ | |
| 1122 | |
| 1123 | from django.shortcuts import redirect |
| 1124 | |
| 1125 | return redirect("fossil:git_mirror", slug=slug) |
| 1126 | |
| 1127 | |
| 1128 | # --- Watch / Notifications --- |
| 1129 | |
| 1130 | |
| 1131 | @login_required |
| 1132 |
| --- fossil/views.py | |
| +++ fossil/views.py | |
| @@ -1,13 +1,14 @@ | |
| 1 | import contextlib |
| 2 | import re |
| 3 | |
| 4 | import markdown as md |
| 5 | from django.contrib.auth.decorators import login_required |
| 6 | from django.http import Http404, HttpResponse |
| 7 | from django.shortcuts import get_object_or_404, redirect, render |
| 8 | from django.utils.safestring import mark_safe |
| 9 | from django.views.decorators.csrf import csrf_exempt |
| 10 | |
| 11 | from projects.models import Project |
| 12 | |
| 13 | from .models import FossilRepository |
| 14 | from .reader import FossilReader |
| @@ -1122,10 +1123,62 @@ | |
| 1123 | |
| 1124 | from django.shortcuts import redirect |
| 1125 | |
| 1126 | return redirect("fossil:git_mirror", slug=slug) |
| 1127 | |
| 1128 | |
| 1129 | # --- Fossil Wire Protocol Proxy (clone / push / pull) --- |
| 1130 | |
| 1131 | |
| 1132 | @csrf_exempt |
| 1133 | def fossil_xfer(request, slug): |
| 1134 | """Proxy Fossil sync protocol (clone/push/pull) through Django. |
| 1135 | |
| 1136 | GET — informational page with clone URL. |
| 1137 | POST — pipe the request body through ``fossil http`` in CGI mode. |
| 1138 | """ |
| 1139 | from projects.access import require_project_read, require_project_write |
| 1140 | |
| 1141 | from .cli import FossilCLI |
| 1142 | |
| 1143 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 1144 | fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True) |
| 1145 | |
| 1146 | if request.method == "GET": |
| 1147 | require_project_read(request, project) |
| 1148 | clone_url = request.build_absolute_uri() |
| 1149 | html = ( |
| 1150 | f"<html><head><title>{project.name} — Fossil Sync</title></head>" |
| 1151 | f"<body>" |
| 1152 | f"<h1>{project.name}</h1>" |
| 1153 | f"<p>This is the Fossil sync endpoint for <strong>{project.name}</strong>.</p>" |
| 1154 | f"<p>Clone with:</p>" |
| 1155 | f"<pre>fossil clone {clone_url} {project.slug}.fossil</pre>" |
| 1156 | f"<p>Authentication is required.</p>" |
| 1157 | f"</body></html>" |
| 1158 | ) |
| 1159 | return HttpResponse(html) |
| 1160 | |
| 1161 | if request.method == "POST": |
| 1162 | # All POST sync operations (push/pull/clone) require write access for |
| 1163 | # now. We can loosen pull to read-only once we can distinguish the |
| 1164 | # operation from the request payload. |
| 1165 | require_project_write(request, project) |
| 1166 | |
| 1167 | if not fossil_repo.exists_on_disk: |
| 1168 | raise Http404("Repository file not found on disk.") |
| 1169 | |
| 1170 | cli = FossilCLI() |
| 1171 | body, content_type = cli.http_proxy( |
| 1172 | fossil_repo.full_path, |
| 1173 | request.body, |
| 1174 | request.content_type, |
| 1175 | ) |
| 1176 | return HttpResponse(body, content_type=content_type) |
| 1177 | |
| 1178 | return HttpResponse(status=405) |
| 1179 | |
| 1180 | |
| 1181 | # --- Watch / Notifications --- |
| 1182 | |
| 1183 | |
| 1184 | @login_required |
| 1185 |
+1
| --- pyproject.toml | ||
| +++ pyproject.toml | ||
| @@ -25,10 +25,11 @@ | ||
| 25 | 25 | "sentry-sdk[django]>=2.14", |
| 26 | 26 | "click>=8.1", |
| 27 | 27 | "rich>=13.0", |
| 28 | 28 | "markdown>=3.6", |
| 29 | 29 | "requests>=2.31", |
| 30 | + "cryptography>=43.0", | |
| 30 | 31 | ] |
| 31 | 32 | |
| 32 | 33 | [project.scripts] |
| 33 | 34 | fossilrepo-ctl = "ctl.main:cli" |
| 34 | 35 | |
| 35 | 36 | |
| 36 | 37 | ADDED templates/auth1/ssh_keys.html |
| --- pyproject.toml | |
| +++ pyproject.toml | |
| @@ -25,10 +25,11 @@ | |
| 25 | "sentry-sdk[django]>=2.14", |
| 26 | "click>=8.1", |
| 27 | "rich>=13.0", |
| 28 | "markdown>=3.6", |
| 29 | "requests>=2.31", |
| 30 | ] |
| 31 | |
| 32 | [project.scripts] |
| 33 | fossilrepo-ctl = "ctl.main:cli" |
| 34 | |
| 35 | |
| 36 | DDED templates/auth1/ssh_keys.html |
| --- pyproject.toml | |
| +++ pyproject.toml | |
| @@ -25,10 +25,11 @@ | |
| 25 | "sentry-sdk[django]>=2.14", |
| 26 | "click>=8.1", |
| 27 | "rich>=13.0", |
| 28 | "markdown>=3.6", |
| 29 | "requests>=2.31", |
| 30 | "cryptography>=43.0", |
| 31 | ] |
| 32 | |
| 33 | [project.scripts] |
| 34 | fossilrepo-ctl = "ctl.main:cli" |
| 35 | |
| 36 | |
| 37 | DDED templates/auth1/ssh_keys.html |
| --- a/templates/auth1/ssh_keys.html | ||
| +++ b/templates/auth1/ssh_keys.html | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +{% extends "base.html" %} | |
| 2 | +{% block title %}SSH Keys — Fossilrepo{% endblock %} | |
| 3 | + | |
| 4 | +{% block content %} | |
| 5 | +<h1 class="text-2xl font-bold text-gray-100 mb-6">SSH Keys</h1> | |
| 6 | + | |
| 7 | +<div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> | |
| 8 | + <h2 class="text-lg font-semibold text-gray-200 mb-4">Add SSH Key</h2> | |
| 9 | + <form method="post" class="space-y-4"> | |
| 10 | + {% csrf_token %} | |
| 11 | + <div> | |
| 12 | + <label for="title" class="block text-sm font-medium text-gray-300 mb-1">Title</label> | |
| 13 | + <input type="text" name="title" id="title" required placeholder="e.g. Work laptop" | |
| 14 | + class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> | |
| 15 | + </div> | |
| 16 | + <div> | |
| 17 | + <label for="public_key" class="block text-sm font-medium text-gray-300 mb-1">Public Key</label> | |
| 18 | + <textarea name="public_key" id="public_key" rows="4" required | |
| 19 | + placeholder="ssh-ed25519 AAAA... user@host" | |
| 20 | + class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm font-mono px-3 py-2 focus:border-brand focus:ring-brand"></textarea> | |
| 21 | + <p class="mt-1 text-xs text-gray-500">Paste your public key (usually from ~/.ssh/id_ed25519.pub)</p> | |
| 22 | + </div> | |
| 23 | + <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover"> | |
| 24 | + Add Key | |
| 25 | + </button> | |
| 26 | + </form> | |
| 27 | +</div> | |
| 28 | + | |
| 29 | +{% if keys %} | |
| 30 | +<div class="rounded-lg bg-gray-800 border border-gray-700"> | |
| 31 | + <div class="p-4 border-b border-gray-700"> | |
| 32 | + <h2 class="text-lg font-semibold text-gray-200">Your Keys</h2> | |
| 33 | + </div> | |
| 34 | + <div class="divide-y divide-gray-700"> | |
| 35 | + {% for key in keys %} | |
| 36 | + <div class="p-4 flex items-center justify-between"> | |
| 37 | + <div> | |
| 38 | + <div class="text-sm font-medium text-gray-200">{{ key.title }}</div> | |
| 39 | + <div class="text-xs text-gray-500 font-mono mt-1">{{ key.fingerprint }}</div> | |
| 40 | + <div class="text-xs text-gray-500 mt-1"> | |
| 41 | + {{ key.key_type|upper }} · Added {{ key.created_at|timesince }} ago | |
| 42 | + {% if key.last_used_at %}· Last used {{ key.last_used_at|timesince }} ago{% endif %} | |
| 43 | + </div> | |
| 44 | + </div> | |
| 45 | + <form hx-post="{% url 'auth1:ssh_key_delete' pk=key.pk %}" hx-confirm="Delete SSH key '{{ key.title }}'?"> | |
| 46 | + {% csrf_token %} | |
| 47 | + <button type="submit" class="text-sm text-red-400 hover:text-red-300">Delete</button> | |
| 48 | + </form> | |
| 49 | + </div> | |
| 50 | + {% endfor %} | |
| 51 | + </div> | |
| 52 | +</div> | |
| 53 | +{% else %} | |
| 54 | +<div class="text-center py-12 text-gray-500"> | |
| 55 | + <p class="text-sm">No SSH keys added yet.</p> | |
| 56 | + <p class="text-xs mt-1">Add an SSH key to clone and push Fossil repos over SSH.</p> | |
| 57 | +</div> | |
| 58 | +{% endif %} | |
| 59 | +{% endblock %} |
| --- a/templates/auth1/ssh_keys.html | |
| +++ b/templates/auth1/ssh_keys.html | |
| @@ -0,0 +1,59 @@ | |
| --- a/templates/auth1/ssh_keys.html | |
| +++ b/templates/auth1/ssh_keys.html | |
| @@ -0,0 +1,59 @@ | |
| 1 | {% extends "base.html" %} |
| 2 | {% block title %}SSH Keys — Fossilrepo{% endblock %} |
| 3 | |
| 4 | {% block content %} |
| 5 | <h1 class="text-2xl font-bold text-gray-100 mb-6">SSH Keys</h1> |
| 6 | |
| 7 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> |
| 8 | <h2 class="text-lg font-semibold text-gray-200 mb-4">Add SSH Key</h2> |
| 9 | <form method="post" class="space-y-4"> |
| 10 | {% csrf_token %} |
| 11 | <div> |
| 12 | <label for="title" class="block text-sm font-medium text-gray-300 mb-1">Title</label> |
| 13 | <input type="text" name="title" id="title" required placeholder="e.g. Work laptop" |
| 14 | class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand"> |
| 15 | </div> |
| 16 | <div> |
| 17 | <label for="public_key" class="block text-sm font-medium text-gray-300 mb-1">Public Key</label> |
| 18 | <textarea name="public_key" id="public_key" rows="4" required |
| 19 | placeholder="ssh-ed25519 AAAA... user@host" |
| 20 | class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm font-mono px-3 py-2 focus:border-brand focus:ring-brand"></textarea> |
| 21 | <p class="mt-1 text-xs text-gray-500">Paste your public key (usually from ~/.ssh/id_ed25519.pub)</p> |
| 22 | </div> |
| 23 | <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover"> |
| 24 | Add Key |
| 25 | </button> |
| 26 | </form> |
| 27 | </div> |
| 28 | |
| 29 | {% if keys %} |
| 30 | <div class="rounded-lg bg-gray-800 border border-gray-700"> |
| 31 | <div class="p-4 border-b border-gray-700"> |
| 32 | <h2 class="text-lg font-semibold text-gray-200">Your Keys</h2> |
| 33 | </div> |
| 34 | <div class="divide-y divide-gray-700"> |
| 35 | {% for key in keys %} |
| 36 | <div class="p-4 flex items-center justify-between"> |
| 37 | <div> |
| 38 | <div class="text-sm font-medium text-gray-200">{{ key.title }}</div> |
| 39 | <div class="text-xs text-gray-500 font-mono mt-1">{{ key.fingerprint }}</div> |
| 40 | <div class="text-xs text-gray-500 mt-1"> |
| 41 | {{ key.key_type|upper }} · Added {{ key.created_at|timesince }} ago |
| 42 | {% if key.last_used_at %}· Last used {{ key.last_used_at|timesince }} ago{% endif %} |
| 43 | </div> |
| 44 | </div> |
| 45 | <form hx-post="{% url 'auth1:ssh_key_delete' pk=key.pk %}" hx-confirm="Delete SSH key '{{ key.title }}'?"> |
| 46 | {% csrf_token %} |
| 47 | <button type="submit" class="text-sm text-red-400 hover:text-red-300">Delete</button> |
| 48 | </form> |
| 49 | </div> |
| 50 | {% endfor %} |
| 51 | </div> |
| 52 | </div> |
| 53 | {% else %} |
| 54 | <div class="text-center py-12 text-gray-500"> |
| 55 | <p class="text-sm">No SSH keys added yet.</p> |
| 56 | <p class="text-xs mt-1">Add an SSH key to clone and push Fossil repos over SSH.</p> |
| 57 | </div> |
| 58 | {% endif %} |
| 59 | {% endblock %} |