14 Commits

19 changed files with 667 additions and 775 deletions
Vendored
BIN
View File
Binary file not shown.
+7 -7
View File
@@ -21,7 +21,7 @@ NATS_URL=nats://localhost:4222
# ─── Federation (ActivityPub) ─────────────────────────────────────────────────
# Public URL of this instance (no trailing slash)
INSTANCE_URL=https://forgebucket.asgardlabs.net
INSTANCE_URL=https://forgebucket.dokploy.second-breakfast.dev
INSTANCE_NAME=ForgeBucket
# ─── OIDC / OAuth2 (optional) ────────────────────────────────────────────────
@@ -31,16 +31,16 @@ INSTANCE_NAME=ForgeBucket
# ─── Dev only ─────────────────────────────────────────────────────────────────
# Set to true to disable Secure cookies and enable verbose logging
DEBUG=true
DEBUG=false
# PEM-encoded ECDSA P-256 private key. If empty, an ephemeral key is generated
# at startup (signatures will not survive restart). Generate with:
# openssl ecparam -genkey -name prime256v1 -noout -out signing-key.pem
ARTIFACT_SIGNING_KEY="-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIKGMjCu0NdczHQ7BRDeo0hTOLauF9vOenWl0HlyN4bzToAoGCCqGSM49
AwEHoUQDQgAE+VL1HhQ1us0QfNH+5Var8lo5Oww83B+QDQ2obzHL4JZl0UM3kVAB
SePwUlkfdW6u4a0KYMYf3Op6wsXTp0kA2g==
-----END EC PRIVATE KEY-----"
# ARTIFACT_SIGNING_KEY="-----BEGIN EC PRIVATE KEY-----
# MHcCAQEEIKGMjCu0NdczHQ7BRDeo0hTOLauF9vOenWl0HlyN4bzToAoGCCqGSM49
# AwEHoUQDQgAE+VL1HhQ1us0QfNH+5Var8lo5Oww83B+QDQ2obzHL4JZl0UM3kVAB
# SePwUlkfdW6u4a0KYMYf3Op6wsXTp0kA2g==
# -----END EC PRIVATE KEY-----"
# ─── OCI Registry (Phase 4) ───────────────────────────────────────────────────
# Root directory for the OCI Distribution Spec blob and upload storage.
+1 -2
View File
@@ -13,5 +13,4 @@ uploads
# Database
*.db
ai_agent_master_prompt_for_building_modern_git_platform.md
html docs/
+1 -1
View File
@@ -99,7 +99,7 @@ Logger → RealIP → Recoverer → Metrics → CORS → CSRF → SessionAuth
| 3E | Observability (Prometheus `/metrics`, structured `/health`, repo health API) | **Complete** |
| 3F | Federation handlers (ActivityPub WebFinger, actor, inbox/outbox, HTTP signatures, Follow/Accept) | **Complete** |
| 4 | SBOM generation, secret scanning, vuln scanning, signed artifacts, OCI registry, security page | **Complete** |
| 5 | AI diagnostics, deployment promotions, rollback visualization | Planned |
| 5 | Deployment promotions, rollback visualization | Planned |
---
+1 -2
View File
@@ -9,8 +9,7 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Planned — Phase 5 (AI Diagnostics + Deployment Promotions + Rollback Visualization)
- AI-powered pipeline failure diagnostics
### Planned — Phase 5 (Deployment Promotions + Rollback Visualization)
- Deployment promotion workflows (manual + automated)
- Rollback visualization and timeline
@@ -1,741 +0,0 @@
# MASTER IMPLEMENTATION PROMPT
## Building a Modern Git Platform with CI/CD + GitOps + Operational UX
You are an elite senior staff engineer, platform architect, DevOps architect, distributed systems engineer, and product UX engineer.
You are tasked with designing and implementing a next-generation self-hosted Git platform.
This platform exists in the category of:
- GitHub
- GitLab
- Forgejo
- Gitea
- Bitbucket
BUT:
You must NOT merely clone existing platforms.
The goal is to create:
> a modern developer operations platform
that deeply integrates:
- Git hosting
- pull requests
- CI/CD
- GitOps
- deployments
- environments
- observability
- security
- operational awareness
- developer productivity
The platform should feel:
- fast
- operational
- developer-first
- low cognitive load
- modern
- reliable
- modular
- keyboard-first
- context-aware
The product philosophy is:
> repositories are operational systems
NOT:
> collections of files.
==================================================
HIGH LEVEL PRODUCT GOALS
==================================================
The platform should optimize for:
- immediate situational awareness
- workflow continuity
- deployment visibility
- operational clarity
- reliability
- developer flow
- observability
- intelligent automation
- security by default
- excellent UX
Users should always be able to answer:
- what changed?
- what failed?
- what deployed?
- what is unhealthy?
- what needs my attention?
- what environments are affected?
- what should I review next?
- what caused this incident?
without navigating deeply.
==================================================
CORE PRODUCT PRINCIPLES
==================================================
1. UX FIRST
Most Git platforms prioritize functionality over usability.
This platform must prioritize:
- readability
- discoverability
- contextual awareness
- progressive disclosure
- speed
- low noise
- operational understanding
Avoid:
- tab overload
- enterprise clutter
- giant forms
- notification spam
- YAML-centric UX
- log-centric UX
The system should feel:
- intentional
- calm
- efficient
- operationally intelligent
--------------------------------------------------
2. OPERATIONAL AWARENESS
The platform must continuously surface:
- failing pipelines
- deployment health
- environment drift
- flaky tests
- security issues
- review bottlenecks
- dependency risks
- deployment risks
This information should appear naturally throughout the UI.
--------------------------------------------------
3. REPOSITORIES ARE RUNTIME SYSTEMS
The repository page should not simply display files.
It should display:
- deployments
- environments
- active PRs
- operational health
- timelines
- incidents
- ownership
- risk
- observability
The file tree is secondary.
--------------------------------------------------
4. GITOPS IS FIRST CLASS
Git should become:
- source of truth
- deployment history
- environment state definition
- rollback system
- audit log
The system must support:
- reconciliation
- drift detection
- declarative environments
- promotion workflows
- rollback visualization
- environment topology
--------------------------------------------------
5. RELIABILITY IS A PRIMARY FEATURE
The platform should optimize for:
- deterministic execution
- idempotency
- isolation
- resilience
- queue durability
- observability
- safe rollbacks
- fault tolerance
==================================================
ARCHITECTURE REQUIREMENTS
==================================================
Design the platform using modular services.
Suggested architecture:
```txt
Platform
├── API Gateway
├── Auth Service
├── Repository Service
├── Pull Request Service
├── Issue Service
├── Event Bus
├── CI Orchestrator
├── Runner Manager
├── Artifact Registry
├── Package Registry
├── Deployment Engine
├── GitOps Controller
├── Environment Service
├── Secret Manager
├── Notification Service
├── Search Service
├── Observability Layer
├── Metrics Service
├── Audit Service
└── Web Frontend
```
==================================================
EVENT DRIVEN ARCHITECTURE
==================================================
The platform should be event-driven.
Everything emits events.
Examples:
```txt
repo.created
push.created
pr.opened
review.requested
pipeline.started
pipeline.failed
artifact.published
deployment.started
deployment.failed
environment.drift_detected
incident.created
```
Requirements:
- durable events
- replay support
- auditability
- timeline reconstruction
- reactive UI updates
Recommended technologies:
- NATS
- Kafka
- RabbitMQ
Prefer NATS initially for simplicity.
==================================================
CI/CD SYSTEM REQUIREMENTS
==================================================
The CI/CD system must support:
- DAG pipelines
- matrix builds
- reusable workflows
- workflow templates
- conditional execution
- parallel execution
- artifacts
- caches
- retries
- concurrency controls
- cancellation
- pipeline graphs
- environment promotion
- rollback support
- preview environments
- flaky test detection
- deployment visualization
The orchestrator should:
- schedule
- coordinate
- monitor
NOT directly execute jobs.
==================================================
RUNNER SYSTEM REQUIREMENTS
==================================================
Runners should:
- be ephemeral
- isolated
- disposable
- reproducible
- stateless
Support execution backends:
1. Docker containers
2. Kubernetes jobs
3. Firecracker microVMs
4. Bare metal runners
Security is critical.
Forked PRs must never automatically receive secrets.
==================================================
SECRET MANAGEMENT
==================================================
Implement scoped secret management.
Secret hierarchy:
```txt
Global
→ Organization
→ Repository
→ Environment
→ Runtime ephemeral injection
```
Support:
- Vault
- OIDC federation
- cloud secret providers
- encrypted secret storage
Secrets must:
- never leak to logs
- be masked automatically
- support audit trails
- support rotation
==================================================
ARTIFACT + PACKAGE MANAGEMENT
==================================================
Implement:
- build artifact storage
- OCI registry
- package registries
- retention policies
- artifact provenance
- signing support
- SBOM support
Support:
- container images
- npm
- cargo
- pip
- maven
- generic artifacts
==================================================
GITOPS REQUIREMENTS
==================================================
GitOps must be deeply integrated.
Support:
- reconciliation loops
- drift detection
- sync visualization
- environment topology
- deployment promotion
- rollback flows
- canary releases
- blue/green deployments
- environment history
The UI should make GitOps understandable.
Avoid overwhelming Kubernetes-centric UX.
==================================================
DATABASE + STORAGE DESIGN
==================================================
Separate:
- application code
- repository storage
- artifacts
- caches
- logs
- uploads
Repository storage should NOT exist in the app repository.
Recommended:
```txt
/data/repos
/data/artifacts
/data/cache
/data/uploads
```
Repository storage is runtime instance data.
Treat it like:
- database files
- object storage
- runtime state
==================================================
DASHBOARD REQUIREMENTS
==================================================
The dashboard should behave like:
> an operational command center
NOT:
> a repository list.
Include:
1. Attention Required
- failing pipelines
- stale PRs
- security alerts
- deployment failures
- environment drift
2. Active Workspaces
- active repos
- active branches
- assigned reviews
- recent deployments
3. Team Activity
- merges
- releases
- deployments
- incidents
4. CI/CD Overview
- pipeline health
- queue health
- flaky tests
- deployment status
5. Review Dashboard
- pending reviews
- high risk PRs
- stale reviews
6. Security Overview
- dependency vulnerabilities
- secret leaks
- suspicious pushes
==================================================
REPOSITORY PAGE REQUIREMENTS
==================================================
The repository page must feel operational.
Top area should include:
```txt
Repo Name
Production: Healthy
CI: Passing
Deployments: 3 active
Risk: Medium
```
The page should include:
- active PRs
- deployments
- environments
- repository health
- security alerts
- ownership
- recent activity
- operational timeline
- preview environments
- CI/CD overview
- deployment history
The README should not dominate the page.
==================================================
UNIFIED OPERATIONAL TIMELINE
==================================================
Implement a unified timeline merging:
- commits
- deployments
- incidents
- rollbacks
- CI failures
- security events
- alerts
- releases
This is one of the most important features.
Users should easily answer:
> what changed before things broke?
==================================================
PIPELINE UX REQUIREMENTS
==================================================
Pipelines should be visual DAGs.
Support:
- dependency visualization
- execution timing
- bottleneck detection
- retry controls
- artifact visibility
- live execution state
- grouped logs
- semantic errors
Logs should support:
- filtering
- collapsing
- syntax highlighting
- structured parsing
- AI summarization
==================================================
DEPLOYMENT UX REQUIREMENTS
==================================================
Deployments must feel first-class.
Each environment should expose:
```txt
Production
Healthy
v1.4.2
3 pods
0.1% errors
Last deploy 14m ago
```
Support:
- rollback
- traffic shifting
- canary visibility
- deployment timelines
- release notes
- environment logs
- health checks
==================================================
COMMAND PALETTE REQUIREMENTS
==================================================
Implement a global command palette.
Inspired by:
- VS Code
- Raycast
- Linear
Examples:
```txt
/retry failed jobs
/deploy staging
/open logs
/review next
/show flaky tests
/open production incidents
```
Keyboard-first navigation is critical.
==================================================
AI-ASSISTED FEATURES
==================================================
Implement optional AI-assisted operational tooling.
Examples:
- failure diagnosis
- flaky test detection
- architecture summaries
- impact analysis
- deployment risk scoring
- review summaries
- onboarding explanations
Example:
```txt
Likely failure cause:
Database migration introduced lock contention.
```
==================================================
OBSERVABILITY REQUIREMENTS
==================================================
Integrate:
- metrics
- traces
- logs
- deployment state
- incident state
- service topology
Do not isolate observability into external systems.
Expose operational health directly in repository pages.
==================================================
RELIABILITY REQUIREMENTS
==================================================
Implement:
- durable queues
- retries
- dead-letter queues
- resumable pipelines
- distributed scheduling
- concurrency controls
- checkpointing
- idempotent operations
The platform should survive:
- runner crashes
- orchestrator crashes
- network partitions
- deployment interruptions
==================================================
SECURITY REQUIREMENTS
==================================================
Implement:
- signed artifacts
- provenance verification
- branch protections
- permission scopes
- audit logging
- secret scanning
- dependency scanning
- runner isolation
- sandboxing
Support:
- Sigstore
- Cosign
- SLSA concepts
==================================================
UI/UX DESIGN LANGUAGE
==================================================
The UI should feel:
- modern
- minimal
- operational
- clean
- responsive
- keyboard-first
- information dense but calm
Visual inspirations:
- Linear
- Datadog
- Grafana
- Raycast
- Arc Browser
- VS Code
- modern observability dashboards
Avoid:
- clutter
- giant tables
- excessive modal workflows
- deeply nested navigation
==================================================
IMPLEMENTATION EXPECTATIONS
==================================================
You should:
- think deeply about architecture
- optimize for maintainability
- optimize for extensibility
- prioritize UX heavily
- prioritize reliability heavily
- explain tradeoffs
- avoid overengineering early
- support future scalability
You should continuously ask:
- does this reduce cognitive load?
- does this improve operational awareness?
- does this improve developer flow?
- does this improve reliability?
- does this make debugging easier?
==================================================
DELIVERABLES
==================================================
When implementing features:
Provide:
- architecture reasoning
- schema design
- API design
- event definitions
- service boundaries
- UI mockups
- workflow diagrams
- UX rationale
- security implications
- scaling considerations
- operational implications
Always optimize for:
- clarity
- reliability
- developer experience
- operational intelligence
- future extensibility
The platform should ultimately feel like:
> a unified operating system for software delivery
rather than:
> a Git repository viewer with CI attached.
+4
View File
@@ -21,6 +21,7 @@ import (
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
"github.com/forgeo/forgebucket/internal/domain/gitops"
"github.com/forgeo/forgebucket/internal/domain/oci"
"github.com/forgeo/forgebucket/internal/domain/sshserver"
"github.com/forgeo/forgebucket/internal/domain/scanning"
"github.com/forgeo/forgebucket/internal/domain/vulnscan"
"github.com/forgeo/forgebucket/internal/domain/sbom"
@@ -94,6 +95,9 @@ func main() {
go observability.StartNATSWatcher(ciCtx, bus)
sshSrv := sshserver.New(engine, cfg)
go sshSrv.ListenAndServe(ciCtx) //nolint:errcheck
// Initialise artifact signing key store.
var keyStore *signing.KeyStore
if cfg.ArtifactSigningKey != "" {
+22 -6
View File
@@ -1,21 +1,36 @@
services:
postgres:
image: postgres:18.3
container_name: fb-postgres
restart: unless-stopped
environment:
POSTGRES_DB: forgebucket
POSTGRES_USER: forgebucket
POSTGRES_PASSWORD: forgebucket
volumes:
- postgres_data:/var/lib/postgresql
- fb_pg_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U forgebucket"]
interval: 5s
timeout: 5s
retries: 10
nats:
image: mirror.gcr.io/nats:2-alpine
restart: unless-stopped
command: ["-js", "-m", "8222"]
ports:
- "4222:4222" # client connections
# "8222:8222" # monitoring HTTP
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8222/healthz"]
interval: 5s
timeout: 5s
retries: 10
app:
build: .
container_name: fb-app
restart: unless-stopped
depends_on:
postgres:
@@ -25,11 +40,12 @@ services:
DATABASE_URL: postgres://forgebucket:forgebucket@postgres:5432/forgebucket?sslmode=disable
ports:
- "8080:8080"
- "2222:22"
volumes:
- repo_data:/tmp/forgebucket/repos
- oci_data:/tmp/forgebucket/oci
- fb_repo_data:/tmp/forgebucket/repos
- fb_oci_data:/tmp/forgebucket/oci
volumes:
postgres_data:
repo_data:
oci_data:
fb_pg_data:
fb_repo_data:
fb_oci_data:
+5
View File
@@ -32,5 +32,10 @@ services:
timeout: 5s
retries: 10
dbgate:
image: dbgate/dbgate
ports:
- "3000:3000"
volumes:
postgres_data:
+23
View File
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
export interface InstanceConfig {
sshHost: string
sshPort: string
instanceName: string
}
const instanceSchema = z.object({
sshHost: z.string(),
sshPort: z.string(),
instanceName: z.string(),
})
export function useInstance() {
return useQuery<InstanceConfig>({
queryKey: ['instance'],
queryFn: () => api.get<InstanceConfig>('/api/v1/instance', instanceSchema),
staleTime: Infinity,
})
}
+80 -16
View File
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
import { useEnvironments } from '../api/queries/environments'
import { useInstance } from '../api/queries/instance'
import { TreeBrowser } from '../components/repos/TreeBrowser'
import { RepoListSkeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar'
@@ -14,6 +15,7 @@ export default function RepoPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [showBranches, setShowBranches] = useState(false)
const [showClone, setShowClone] = useState(false)
const [cloneTab, setCloneTab] = useState<'https' | 'ssh'>('https')
const branchRef = useRef<HTMLDivElement>(null)
const cloneRef = useRef<HTMLDivElement>(null)
@@ -23,6 +25,7 @@ export default function RepoPage() {
const { data: repo, isLoading, isError } = useRepo(owner, repoName)
const { data: branches } = useRepoBranches(owner, repoName)
const { data: environments } = useEnvironments(owner, repoName)
const { data: instance } = useInstance()
const { track } = useRecentRepos()
useEffect(() => { if (owner && repoName) track(owner, repoName) }, [owner, repoName])
@@ -42,6 +45,14 @@ export default function RepoPage() {
const branch = ref || repo.defaultBranch
const cloneUrl = `${window.location.origin}/${owner}/${repoName}.git`
const sshHost = instance?.sshHost ?? window.location.hostname
const sshPort = instance?.sshPort ?? '2222'
const sshUrl = sshPort === '22'
? `git@${sshHost}:${owner}/${repoName}.git`
: `ssh://git@${sshHost}:${sshPort}/${owner}/${repoName}.git`
const archiveBase = `/api/v1/repos/${owner}/${repoName}/archive?ref=${encodeURIComponent(branch)}`
function switchBranch(b: string) {
setSearchParams({ ref: b, ...(path ? { path } : {}) })
setShowBranches(false)
@@ -123,17 +134,64 @@ export default function RepoPage() {
</svg>
</button>
{showClone && (
<div className="absolute right-0 top-full mt-1 w-80 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-50 p-4">
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTP</p>
<div className="flex items-center gap-2 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2">
<code className="text-xs text-[var(--c-text)] flex-1 truncate">{cloneUrl}</code>
<button
onClick={() => navigator.clipboard.writeText(cloneUrl)}
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0"
>
Copy
</button>
<div className="absolute right-0 top-full mt-1 w-96 bg-[var(--c-surface)] border border-[var(--c-border)] rounded-lg shadow-xl z-50 p-4 space-y-3">
{/* Clone URL tabs */}
<div>
<div className="flex gap-1 mb-2">
{(['https', 'ssh'] as const).map(tab => (
<button
key={tab}
onClick={() => setCloneTab(tab)}
className={`px-2.5 py-1 rounded text-xs font-medium transition-colors ${
cloneTab === tab
? 'bg-[var(--c-brand)] text-white'
: 'text-[var(--c-muted)] hover:text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]'
}`}
>
{tab.toUpperCase()}
</button>
))}
</div>
<div className="flex items-center gap-2 bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2">
<code className="text-xs text-[var(--c-text)] flex-1 truncate">
{cloneTab === 'https' ? cloneUrl : sshUrl}
</code>
<button
onClick={() => navigator.clipboard.writeText(cloneTab === 'https' ? cloneUrl : sshUrl)}
className="text-[10px] text-[var(--c-brand)] hover:underline shrink-0"
>
Copy
</button>
</div>
{cloneTab === 'ssh' && (
<p className="text-[10px] text-[var(--c-muted)] mt-1.5">
Requires an SSH key added to your account settings.
</p>
)}
</div>
{/* Archive download */}
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-1.5">Download</p>
<div className="flex gap-2">
{[
{ label: 'ZIP', format: 'zip' },
{ label: 'tar.gz', format: 'tar.gz' },
{ label: 'Bundle', format: 'bundle' },
].map(({ label, format }) => (
<a
key={format}
href={`${archiveBase}&format=${format}`}
download
className="flex-1 text-center px-2 py-1.5 text-xs font-medium border border-[var(--c-border)] rounded hover:bg-[var(--c-surface-muted)] text-[var(--c-text)] transition-colors"
>
{label}
</a>
))}
</div>
</div>
</div>
)}
</div>
@@ -141,7 +199,7 @@ export default function RepoPage() {
</div>
{repo.isEmpty ? (
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} />
<GettingStarted repoName={repoName} branch={branch} cloneUrl={cloneUrl} sshUrl={sshUrl} />
) : (
<>
{/* Branch selector */}
@@ -231,8 +289,8 @@ function ReadmePreview({ owner, repo, ref }: { owner: string; repo: string; ref:
)
}
function GettingStarted({ repoName, branch, cloneUrl }: {
repoName: string; branch: string; cloneUrl: string
function GettingStarted({ repoName, branch, cloneUrl, sshUrl }: {
repoName: string; branch: string; cloneUrl: string; sshUrl: string
}) {
return (
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
@@ -241,9 +299,15 @@ function GettingStarted({ repoName, branch, cloneUrl }: {
<p className="text-xs text-[var(--c-muted)] mt-0.5">Push your first commit to get started.</p>
</div>
<div className="px-5 py-5 space-y-6 text-sm">
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTP</p>
<CopyBlock value={cloneUrl} />
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over HTTPS</p>
<CopyBlock value={cloneUrl} />
</div>
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">Clone over SSH</p>
<CopyBlock value={sshUrl} />
</div>
</div>
<div>
<p className="text-xs font-semibold text-[var(--c-muted)] uppercase tracking-wide mb-2">or push an existing repository</p>
+61
View File
@@ -0,0 +1,61 @@
package handlers
import (
"fmt"
"net/http"
"xorm.io/xorm"
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
)
type ArchiveHandler struct {
db *xorm.Engine
}
func NewArchiveHandler(db *xorm.Engine) *ArchiveHandler {
return &ArchiveHandler{db: db}
}
var archiveFormats = map[string]struct {
contentType string
ext string
}{
"zip": {"application/zip", "zip"},
"tar.gz": {"application/x-tar", "tar.gz"},
"bundle": {"application/octet-stream", "bundle"},
}
func (h *ArchiveHandler) Download(w http.ResponseWriter, r *http.Request) {
repo, ok := resolveRepo(h.db, w, r)
if !ok {
return
}
format := r.URL.Query().Get("format")
if format == "" {
format = "zip"
}
meta, allowed := archiveFormats[format]
if !allowed {
jsonError(w, "format must be zip, tar.gz, or bundle", http.StatusBadRequest)
return
}
ref := r.URL.Query().Get("ref")
if ref == "" {
ref = repo.DefaultBranch
}
if ref == "" {
ref = "HEAD"
}
filename := fmt.Sprintf("%s-%s.%s", repo.Name, ref, meta.ext)
w.Header().Set("Content-Type", meta.contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
if err := gitdomain.ArchiveStream(repo.DiskPath, ref, format, w); err != nil {
// Headers already written — can't change status code; just log and close.
_ = err
}
}
+45
View File
@@ -0,0 +1,45 @@
package handlers
import (
"encoding/json"
"net/http"
"net/url"
"github.com/forgeo/forgebucket/internal/config"
)
type InstanceHandler struct {
cfg *config.Config
}
func NewInstanceHandler(cfg *config.Config) *InstanceHandler {
return &InstanceHandler{cfg: cfg}
}
// Get returns the public instance configuration needed by the frontend to
// construct clone URLs (SSH host, SSH port, instance name).
func (h *InstanceHandler) Get(w http.ResponseWriter, r *http.Request) {
sshHost := h.sshHost(r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck
"sshHost": sshHost,
"sshPort": h.cfg.SSHPort,
"instanceName": h.cfg.InstanceName,
})
}
// sshHost extracts the hostname from InstanceURL. Falls back to the request
// host when InstanceURL is unset (common in local development).
func (h *InstanceHandler) sshHost(r *http.Request) string {
if h.cfg.InstanceURL != "" {
if u, err := url.Parse(h.cfg.InstanceURL); err == nil && u.Hostname() != "" {
return u.Hostname()
}
}
// Strip port from Host header if present.
host := r.Host
if u, err := url.Parse("http://" + host); err == nil {
return u.Hostname()
}
return host
}
+4
View File
@@ -79,6 +79,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
ociH := handlers.NewOCIRegistryHandler(engine, ociRegistry)
scanH := handlers.NewScanningHandler(engine, scanner)
vulnH := handlers.NewVulnScanHandler(engine, vulnScanner)
archiveH := handlers.NewArchiveHandler(engine)
instanceH := handlers.NewInstanceHandler(cfg)
// ── Git smart-HTTP transport ───────────────────────────────────────────────
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
@@ -99,6 +101,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
// ── Public ────────────────────────────────────────────────────────────
r.Get("/explore/repos", exploreH.Repos)
r.Get("/explore/users", exploreH.Users)
r.Get("/instance", instanceH.Get)
// Generates a CSRF token + cookie. SPA calls this once on load.
r.Get("/csrf", func(w http.ResponseWriter, r *http.Request) {
@@ -177,6 +180,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
r.With(csrf).Put("/blob", repoH.UpdateBlob)
r.Get("/commits", repoH.Commits)
r.Get("/branches", repoH.Branches)
r.Get("/archive", archiveH.Download)
r.Get("/diff", repoH.Diff)
r.Route("/pulls", func(r chi.Router) {
r.Get("/", prH.List)
+7
View File
@@ -44,6 +44,10 @@ type Config struct {
// OCI Registry
OCIRoot string
// SSH server
SSHPort string // env: SSH_PORT, default "2222"
SSHHostKeyPath string // env: SSH_HOST_KEY_PATH, empty = generate ephemeral
// Dev
Debug bool
}
@@ -68,6 +72,9 @@ func Load() (*Config, error) {
cfg.SessionSecret = requireEnv("SESSION_SECRET", &missing)
cfg.CSRFSecret = requireEnv("CSRF_SECRET", &missing)
cfg.SSHPort = getEnv("SSH_PORT", "2222")
cfg.SSHHostKeyPath = os.Getenv("SSH_HOST_KEY_PATH")
// Optional signing key
cfg.ArtifactSigningKey = os.Getenv("ARTIFACT_SIGNING_KEY")
cfg.OCIRoot = getEnv("OCI_ROOT", filepath.Join(filepath.Dir(cfg.RepoRoot), "oci"))
+41
View File
@@ -3,6 +3,7 @@ package git
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -467,3 +468,43 @@ func parseUnifiedDiff(raw string) []FileDiff {
commit()
return files
}
// ArchiveStream writes a git archive of ref in the requested format to w.
// format must be one of "zip", "tar.gz", or "bundle".
// Output is streamed directly to w without buffering.
func ArchiveStream(repoPath string, ref string, format string, w io.Writer) error {
clean := filepath.Clean(repoPath)
if repoRoot != "" {
root := repoRoot + string(filepath.Separator)
if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot {
return ErrPathTraversal
}
}
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
var cmd *exec.Cmd
switch format {
case "zip", "tar.gz":
cmd = exec.Command("git", "archive", "--format="+format, ref)
case "bundle":
cmd = exec.Command("git", "bundle", "create", "-", "--all")
default:
return fmt.Errorf("git archive: unsupported format %q", format)
}
cmd.Dir = clean
cmd.Env = baseEnv
cmd.Stdout = w
var errBuf strings.Builder
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
if errBuf.Len() > 0 {
return fmt.Errorf("git archive: %w: %s", err, errBuf.String())
}
return fmt.Errorf("git archive: %w", err)
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package sshserver
import (
"crypto/md5"
"fmt"
"strings"
"golang.org/x/crypto/ssh"
"github.com/forgeo/forgebucket/internal/models"
)
// lookupKey is the SSH PublicKeyCallback. It computes the MD5 fingerprint of
// the presented key (matching the format stored by the SSH key registration
// handler) and looks it up in the database.
func (s *Server) lookupKey(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
fp := fingerprintMD5(key)
var sshKey models.SSHKey
if found, _ := s.db.Where("fingerprint = ?", fp).Get(&sshKey); !found {
return nil, fmt.Errorf("unknown key")
}
// Resolve the username so the session handler can use it for permission checks.
var user models.User
if found, _ := s.db.ID(sshKey.UserID).Get(&user); !found {
return nil, fmt.Errorf("user not found")
}
return &ssh.Permissions{
Extensions: map[string]string{
"username": user.Username,
"user_id": fmt.Sprintf("%d", user.ID),
},
}, nil
}
func fingerprintMD5(pub ssh.PublicKey) string {
hash := md5.Sum(pub.Marshal())
parts := make([]string, len(hash))
for i, b := range hash {
parts[i] = fmt.Sprintf("%02x", b)
}
return strings.Join(parts, ":")
}
+121
View File
@@ -0,0 +1,121 @@
// Package sshserver implements an SSH server for git clone/push/pull operations.
// It authenticates users via their stored SSH public keys and executes
// git-upload-pack / git-receive-pack as subprocesses.
package sshserver
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"net"
"os"
"golang.org/x/crypto/ssh"
"xorm.io/xorm"
"github.com/forgeo/forgebucket/internal/config"
)
// Server is the SSH git server.
type Server struct {
db *xorm.Engine
cfg *config.Config
}
func New(db *xorm.Engine, cfg *config.Config) *Server {
return &Server{db: db, cfg: cfg}
}
// ListenAndServe binds to cfg.SSHPort, loads or generates a host key, and accepts
// connections until ctx is cancelled. Returns nil when the context is done.
func (s *Server) ListenAndServe(ctx context.Context) error {
hostKey, err := s.loadOrGenerateHostKey()
if err != nil {
return fmt.Errorf("sshserver: host key: %w", err)
}
srvConfig := &ssh.ServerConfig{
PublicKeyCallback: s.lookupKey,
}
srvConfig.AddHostKey(hostKey)
addr := ":" + s.cfg.SSHPort
ln, err := net.Listen("tcp", addr)
if err != nil {
log.Printf("sshserver: cannot bind %s — SSH transport disabled: %v", addr, err)
return nil
}
log.Printf("sshserver: listening on %s", addr)
go func() {
<-ctx.Done()
ln.Close()
}()
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
log.Printf("sshserver: accept: %v", err)
continue
}
}
go s.handleConn(conn, srvConfig)
}
}
func (s *Server) handleConn(netConn net.Conn, srvConfig *ssh.ServerConfig) {
defer netConn.Close()
sshConn, chans, reqs, err := ssh.NewServerConn(netConn, srvConfig)
if err != nil {
return
}
defer sshConn.Close()
go ssh.DiscardRequests(reqs)
username, _ := sshConn.Permissions.Extensions["username"]
for newChan := range chans {
if newChan.ChannelType() != "session" {
newChan.Reject(ssh.UnknownChannelType, "unknown channel type") //nolint:errcheck
continue
}
ch, requests, err := newChan.Accept()
if err != nil {
return
}
go s.handleSession(ch, requests, username)
}
}
// loadOrGenerateHostKey loads the host key from SSHHostKeyPath if set,
// otherwise generates an ephemeral RSA-4096 key (lost on restart).
func (s *Server) loadOrGenerateHostKey() (ssh.Signer, error) {
if s.cfg.SSHHostKeyPath != "" {
data, err := os.ReadFile(s.cfg.SSHHostKeyPath)
if err != nil {
return nil, fmt.Errorf("read host key %s: %w", s.cfg.SSHHostKeyPath, err)
}
return ssh.ParsePrivateKey(data)
}
log.Printf("sshserver: SSH_HOST_KEY_PATH not set — generating ephemeral host key (host key changes on restart)")
privKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, fmt.Errorf("generate host key: %w", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
})
return ssh.ParsePrivateKey(keyPEM)
}
+199
View File
@@ -0,0 +1,199 @@
package sshserver
import (
"encoding/binary"
"fmt"
"io"
"log"
"os/exec"
"path/filepath"
"strings"
"golang.org/x/crypto/ssh"
"github.com/forgeo/forgebucket/internal/models"
)
// handleSession processes a single SSH session channel: waits for an exec
// request, dispatches to the appropriate git subcommand, then exits.
func (s *Server) handleSession(ch ssh.Channel, reqs <-chan *ssh.Request, username string) {
defer ch.Close()
for req := range reqs {
if req.Type != "exec" {
if req.WantReply {
req.Reply(false, nil) //nolint:errcheck
}
continue
}
cmdStr, err := parseExecPayload(req.Payload)
if err != nil {
req.Reply(false, nil) //nolint:errcheck
return
}
req.Reply(true, nil) //nolint:errcheck
exitCode := s.runGitCommand(ch, username, cmdStr)
sendExitStatus(ch, uint32(exitCode))
return
}
}
// runGitCommand parses the SSH exec command string, validates it, resolves the
// repo, checks permissions, and runs the git subprocess.
func (s *Server) runGitCommand(ch ssh.Channel, username, cmdStr string) int {
gitCmd, repoArg, err := parseGitCommand(cmdStr)
if err != nil {
fmt.Fprintf(ch.Stderr(), "error: %v\n", err)
return 1
}
// Resolve owner/repo from the path argument (e.g. "/alice/myrepo.git" or "alice/myrepo.git")
path := strings.TrimPrefix(strings.TrimSuffix(repoArg, ".git"), "/")
parts := strings.SplitN(path, "/", 2)
if len(parts) != 2 {
fmt.Fprintf(ch.Stderr(), "error: invalid repository path\n")
return 1
}
ownerName, repoName := parts[0], parts[1]
repo, err := s.resolveRepo(ownerName, repoName)
if err != nil {
fmt.Fprintf(ch.Stderr(), "error: repository not found\n")
return 1
}
// Check permissions.
if gitCmd == "receive-pack" {
if !s.hasPermission(repo, username, "write") {
fmt.Fprintf(ch.Stderr(), "error: you do not have write access to this repository\n")
return 1
}
} else {
// upload-pack: public repos are accessible to all; private repos require read.
if repo.IsPrivate && !s.hasPermission(repo, username, "read") {
fmt.Fprintf(ch.Stderr(), "error: you do not have read access to this repository\n")
return 1
}
}
// Exec the git subcommand against the bare repo path on disk.
// The disk path comes from the DB — never from user input.
cmd := exec.Command("git", gitCmd, repo.DiskPath)
cmd.Dir = filepath.Clean(repo.DiskPath)
cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
cmd.Stdin = ch
cmd.Stdout = ch
cmd.Stderr = ch.Stderr()
if err := cmd.Run(); err != nil {
log.Printf("sshserver: git %s for %s/%s: %v", gitCmd, ownerName, repoName, err)
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
return 1
}
return 0
}
// resolveRepo looks up a repository by owner name (user or workspace) and repo name.
func (s *Server) resolveRepo(ownerName, repoName string) (*models.Repository, error) {
var u models.User
if found, _ := s.db.Where("username = ?", ownerName).Get(&u); found {
var repo models.Repository
if found2, _ := s.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); found2 {
return &repo, nil
}
}
var ws models.Workspace
if found, _ := s.db.Where("handle = ?", ownerName).Get(&ws); found {
var repo models.Repository
if found2, _ := s.db.Where("workspace_id = ? AND name = ?", ws.ID, repoName).Get(&repo); found2 {
return &repo, nil
}
}
return nil, fmt.Errorf("not found")
}
// hasPermission checks whether username has at least the required permission on repo.
func (s *Server) hasPermission(repo *models.Repository, username, required string) bool {
var u models.User
if found, _ := s.db.Where("username = ?", username).Get(&u); !found {
return false
}
if u.ID == repo.OwnerID {
return true
}
var m models.RepoMember
if found, _ := s.db.Where("repo_id = ? AND user_id = ?", repo.ID, u.ID).Get(&m); !found {
return false
}
rank := map[string]int{"read": 1, "write": 2, "admin": 3}
return rank[m.Permission] >= rank[required]
}
// parseGitCommand splits the SSH exec command string into the git subcommand
// and the repo path argument. Only upload-pack and receive-pack are permitted.
//
// Accepts both "git-upload-pack '/path'" and "git upload-pack /path" forms.
func parseGitCommand(cmdStr string) (gitCmd string, repoPath string, err error) {
cmdStr = strings.TrimSpace(cmdStr)
var candidate string
var rest string
if strings.HasPrefix(cmdStr, "git-upload-pack") {
candidate = "upload-pack"
rest = strings.TrimPrefix(cmdStr, "git-upload-pack")
} else if strings.HasPrefix(cmdStr, "git-receive-pack") {
candidate = "receive-pack"
rest = strings.TrimPrefix(cmdStr, "git-receive-pack")
} else if strings.HasPrefix(cmdStr, "git upload-pack") {
candidate = "upload-pack"
rest = strings.TrimPrefix(cmdStr, "git upload-pack")
} else if strings.HasPrefix(cmdStr, "git receive-pack") {
candidate = "receive-pack"
rest = strings.TrimPrefix(cmdStr, "git receive-pack")
} else {
return "", "", fmt.Errorf("unsupported command: only git-upload-pack and git-receive-pack are allowed")
}
// Strip surrounding whitespace and single quotes from the path argument.
rest = strings.TrimSpace(rest)
rest = strings.Trim(rest, "'\"")
if rest == "" {
return "", "", fmt.Errorf("missing repository path argument")
}
return candidate, rest, nil
}
// parseExecPayload decodes the SSH exec request payload: 4-byte big-endian
// length followed by the command string.
func parseExecPayload(payload []byte) (string, error) {
if len(payload) < 4 {
return "", fmt.Errorf("exec payload too short")
}
length := binary.BigEndian.Uint32(payload[:4])
if int(length) > len(payload)-4 {
return "", fmt.Errorf("exec payload length mismatch")
}
return string(payload[4 : 4+length]), nil
}
// sendExitStatus sends an SSH exit-status channel request.
func sendExitStatus(ch ssh.Channel, code uint32) {
msg := struct{ Status uint32 }{code}
ch.SendRequest("exit-status", false, ssh.Marshal(msg)) //nolint:errcheck
}
// Stderr returns the stderr stream of an SSH channel.
// The ssh.Channel type embeds io.ReadWriteCloser for stdout/stdin;
// Stderr() is defined on *ssh.channel but not the interface — use a type assertion.
func init() {
// Compile-time interface check: ssh.Channel must have Stderr() method.
var _ interface{ Stderr() io.ReadWriter } = (ssh.Channel)(nil)
}