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.

lmata 2026-04-07 05:07 trunk
Commit a1351527f88202320670bec83a314dae53ffb64078973cbaf990b4cebd2ce892
+21 -5
--- Dockerfile
+++ Dockerfile
@@ -24,11 +24,11 @@
2424
# ── Stage 2: Runtime image ─────────────────────────────────────────────────
2525
2626
FROM python:3.12-slim-bookworm
2727
2828
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 \
3030
&& rm -rf /var/lib/apt/lists/*
3131
3232
# Copy Fossil binary from builder
3333
COPY --from=fossil-builder /usr/local/bin/fossil /usr/local/bin/fossil
3434
RUN fossil version
@@ -42,15 +42,31 @@
4242
4343
COPY . .
4444
4545
RUN DJANGO_SECRET_KEY=build-placeholder DJANGO_DEBUG=true python manage.py collectstatic --noinput
4646
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
4962
5063
ENV PYTHONUNBUFFERED=1
5164
ENV PYTHONDONTWRITEBYTECODE=1
5265
ENV DJANGO_SETTINGS_MODULE=config.settings
5366
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
5571
56
-CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "120"]
72
+CMD ["/usr/local/bin/entrypoint.sh"]
5773
--- 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
--- auth1/urls.py
+++ auth1/urls.py
@@ -5,6 +5,8 @@
55
app_name = "auth1"
66
77
urlpatterns = [
88
path("login/", views.login_view, name="login"),
99
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"),
1012
]
1113
--- 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
12
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
36
from django.views.decorators.http import require_POST
47
from django_ratelimit.decorators import ratelimit
58
69
from .forms import LoginForm
710
@@ -25,5 +28,119 @@
2528
2629
@require_POST
2730
def logout_view(request):
2831
logout(request)
2932
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")
30147
31148
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
--- 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
--- docker-compose.yaml
+++ docker-compose.yaml
@@ -2,10 +2,11 @@
22
backend:
33
build: .
44
command: python manage.py runserver 0.0.0.0:8000
55
ports:
66
- "8000:8000"
7
+ - "2222:2222"
78
env_file: .env.example
89
environment:
910
DJANGO_DEBUG: "true"
1011
POSTGRES_HOST: postgres
1112
REDIS_URL: redis://redis:6379/1
1213
1314
ADDED docker/entrypoint.sh
1415
ADDED docker/fossil-shell
1516
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
--- 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
--- 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
--- 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
--- fossil/admin.py
+++ fossil/admin.py
@@ -2,10 +2,11 @@
22
33
from core.admin import BaseCoreAdmin
44
55
from .models import FossilRepository, FossilSnapshot
66
from .sync_models import GitMirror, SSHKey, SyncLog
7
+from .user_keys import UserSSHKey
78
89
910
class FossilSnapshotInline(admin.TabularInline):
1011
model = FossilSnapshot
1112
extra = 0
@@ -42,5 +43,13 @@
4243
4344
@admin.register(SSHKey)
4445
class SSHKeyAdmin(BaseCoreAdmin):
4546
list_display = ("name", "fingerprint", "created_at")
4647
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")
4756
--- 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
--- fossil/cli.py
+++ fossil/cli.py
@@ -1,9 +1,12 @@
11
"""Thin wrapper around the fossil binary for write operations."""
22
3
+import logging
34
import subprocess
45
from pathlib import Path
6
+
7
+logger = logging.getLogger(__name__)
58
69
710
class FossilCLI:
811
"""Wrapper around the fossil binary for write operations."""
912
@@ -244,5 +247,77 @@
244247
fingerprint = fp_result.stdout.strip().split()[1] if fp_result.returncode == 0 else ""
245248
return {"success": True, "public_key": pub_key, "fingerprint": fingerprint}
246249
except Exception as e:
247250
return {"success": False, "public_key": "", "fingerprint": "", "error": str(e)}
248251
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
249324
250325
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 ]
--- fossil/models.py
+++ fossil/models.py
@@ -67,5 +67,6 @@
6767
6868
6969
# Import related models so they're discoverable by Django
7070
from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
7171
from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
72
+from fossil.user_keys import UserSSHKey # noqa: E402, F401
7273
--- 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
--- fossil/sync_models.py
+++ fossil/sync_models.py
@@ -1,9 +1,10 @@
11
"""Git mirror sync models for Fossil-to-Git synchronization."""
22
33
from django.db import models
44
5
+from core.fields import EncryptedTextField
56
from core.models import ActiveManager, Tracking
67
78
89
class GitMirror(Tracking):
910
"""Configuration for syncing a Fossil repo to a Git remote."""
@@ -26,11 +27,11 @@
2627
DISABLED = "disabled", "Disabled"
2728
2829
repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="git_mirrors")
2930
git_remote_url = models.CharField(max_length=500, help_text="Git remote URL (SSH or HTTPS)")
3031
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)")
3233
3334
sync_direction = models.CharField(max_length=10, choices=SyncDirection.choices, default=SyncDirection.PUSH)
3435
sync_mode = models.CharField(max_length=20, choices=SyncMode.choices, default=SyncMode.SCHEDULED)
3536
sync_schedule = models.CharField(max_length=100, blank=True, default="*/15 * * * *", help_text="Cron expression for scheduled sync")
3637
3738
--- 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
--- fossil/urls.py
+++ fossil/urls.py
@@ -41,6 +41,7 @@
4141
path("watch/", views.toggle_watch, name="toggle_watch"),
4242
path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
4343
path("tickets/export/", views.tickets_csv, name="tickets_csv"),
4444
path("docs/", views.fossil_docs, name="docs"),
4545
path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
46
+ path("xfer", views.fossil_xfer, name="xfer"),
4647
]
4748
4849
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
--- 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 @@
11
import contextlib
22
import re
33
44
import markdown as md
55
from django.contrib.auth.decorators import login_required
6
-from django.http import Http404
6
+from django.http import Http404, HttpResponse
77
from django.shortcuts import get_object_or_404, redirect, render
88
from django.utils.safestring import mark_safe
9
+from django.views.decorators.csrf import csrf_exempt
910
1011
from projects.models import Project
1112
1213
from .models import FossilRepository
1314
from .reader import FossilReader
@@ -1122,10 +1123,62 @@
11221123
11231124
from django.shortcuts import redirect
11241125
11251126
return redirect("fossil:git_mirror", slug=slug)
11261127
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
+
11271180
11281181
# --- Watch / Notifications ---
11291182
11301183
11311184
@login_required
11321185
--- 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
--- pyproject.toml
+++ pyproject.toml
@@ -25,10 +25,11 @@
2525
"sentry-sdk[django]>=2.14",
2626
"click>=8.1",
2727
"rich>=13.0",
2828
"markdown>=3.6",
2929
"requests>=2.31",
30
+ "cryptography>=43.0",
3031
]
3132
3233
[project.scripts]
3334
fossilrepo-ctl = "ctl.main:cli"
3435
3536
3637
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 }} &middot; Added {{ key.created_at|timesince }} ago
42
+ {% if key.last_used_at %}&middot; 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 }} &middot; Added {{ key.created_at|timesince }} ago
42 {% if key.last_used_at %}&middot; 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 %}

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button