mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
NIP-34 git transport: Smart HTTP hosting with NIP-98 auth (#419)
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
11
Cargo.lock
generated
11
Cargo.lock
generated
@@ -1350,6 +1350,17 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "git-credential-nostr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"nostr",
|
||||
"serde_json",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
|
||||
@@ -18,6 +18,7 @@ members = [
|
||||
"crates/sprout-pairing-cli",
|
||||
"crates/sprout-sdk",
|
||||
"crates/sprout-persona",
|
||||
"crates/git-credential-nostr",
|
||||
]
|
||||
exclude = ["desktop/src-tauri"]
|
||||
resolver = "2"
|
||||
|
||||
18
crates/git-credential-nostr/Cargo.toml
Normal file
18
crates/git-credential-nostr/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "git-credential-nostr"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
description = "Git credential helper that produces NIP-98 auth headers for Sprout's git server"
|
||||
|
||||
[[bin]]
|
||||
name = "git-credential-nostr"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nostr = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
zeroize = { workspace = true }
|
||||
base64 = "0.22"
|
||||
69
crates/git-credential-nostr/README.md
Normal file
69
crates/git-credential-nostr/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# git-credential-nostr
|
||||
|
||||
NIP-98 credential helper for git — signs HTTP auth events with your Nostr key so git can push/pull from Sprout's git server without passwords.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **git 2.46+** (requires `authtype` capability in the credential protocol)
|
||||
- **Rust toolchain** (for building from source)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cargo install --path crates/git-credential-nostr
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# 1. Register the helper and enable per-path credentials.
|
||||
git config --global credential.helper nostr
|
||||
git config --global credential.useHttpPath true
|
||||
|
||||
# 2. Store your nsec in a key file (must be 0600).
|
||||
mkdir -p ~/.nostr
|
||||
echo "nsec1..." > ~/.nostr/key && chmod 600 ~/.nostr/key
|
||||
git config --global nostr.keyfile ~/.nostr/key
|
||||
```
|
||||
|
||||
That's it. Use git normally — `git clone`, `git push`, `git fetch`.
|
||||
|
||||
## CI / CD
|
||||
|
||||
Set `$NOSTR_PRIVATE_KEY` instead of a key file. The env var takes precedence
|
||||
over `nostr.keyfile` and avoids touching the filesystem:
|
||||
|
||||
```bash
|
||||
export NOSTR_PRIVATE_KEY=nsec1...
|
||||
git clone https://relay.example.com/git/owner/repo.git
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
When a Sprout git server returns `HTTP 401` with a
|
||||
`WWW-Authenticate: Nostr realm="...", method="GET"` header, git calls this
|
||||
helper with the request details on stdin. The helper loads your Nostr private
|
||||
key, builds a [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md)
|
||||
kind-27235 event signed over the request URL and method, base64-encodes it, and
|
||||
writes it back to stdout. Git then retries the request with
|
||||
`Authorization: Nostr <token>`, which the server verifies by checking the event
|
||||
signature.
|
||||
|
||||
```
|
||||
git ──stdin──▶ git-credential-nostr ──stdout──▶ git
|
||||
│
|
||||
▼
|
||||
sign kind:27235 event
|
||||
(NIP-98 HTTP Auth)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Cause | Fix |
|
||||
|-------|-------|-----|
|
||||
| `no nostr key configured` | Neither `$NOSTR_PRIVATE_KEY` nor `nostr.keyfile` is set | Follow the Setup steps above |
|
||||
| `insecure permissions` | Key file is readable by group/others | `chmod 600 ~/.nostr/key` |
|
||||
| `method hint` | Server's `WWW-Authenticate` header is missing `method="..."` | Upgrade the Sprout server |
|
||||
| `useHttpPath` | `credential.useHttpPath` is not set | `git config --global credential.useHttpPath true` |
|
||||
| Empty output / no auth | git version is older than 2.46 | Upgrade git |
|
||||
| `clock skew` / auth rejected | System clock is off by more than 60 s | Sync your system clock (`ntpdate`, `timedatectl`) |
|
||||
231
crates/git-credential-nostr/src/main.rs
Normal file
231
crates/git-credential-nostr/src/main.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
//! git-credential-nostr — NIP-98 git credential helper for Sprout.
|
||||
//!
|
||||
//! Git calls this binary via the credential helper protocol (stdin/stdout).
|
||||
//! We read the request, sign a kind:27235 event, and return the base64-encoded
|
||||
//! event as the credential value. Git then sends:
|
||||
//! Authorization: Nostr <credential>
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::process;
|
||||
|
||||
use base64::Engine as _;
|
||||
use nostr::nips::nip98::{HttpData, HttpMethod};
|
||||
use nostr::{EventBuilder, Keys, UncheckedUrl};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Write an error to stderr and exit 1.
|
||||
/// Does NOT write to stdout — git's credential protocol interprets any stdout
|
||||
/// as credential data, and a bare newline could confuse the client.
|
||||
fn fail(msg: &str) -> ! {
|
||||
eprintln!("error: {msg}");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
/// Read `git config <key>` from the process environment / git config.
|
||||
fn git_config(key: &str) -> Option<String> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["config", "--get", key])
|
||||
.output()
|
||||
.ok()?;
|
||||
if out.status.success() {
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that a file has permissions no broader than 0600.
|
||||
/// On non-Unix platforms we warn and continue.
|
||||
#[cfg(unix)]
|
||||
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?;
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
if mode & 0o177 != 0 {
|
||||
return Err(format!(
|
||||
"keyfile {path} has insecure permissions (expected 0600)"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn check_keyfile_permissions(path: &str) -> Result<(), String> {
|
||||
eprintln!("warning: cannot check keyfile permissions on this platform ({path})");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load the private key: env var first, then keyfile.
|
||||
/// Returns the raw key string (nsec or hex). Caller must zeroize after use.
|
||||
fn load_key() -> Result<String, String> {
|
||||
// 1. Environment variable — ideal for CI/CD.
|
||||
if let Ok(val) = std::env::var("NOSTR_PRIVATE_KEY") {
|
||||
if !val.is_empty() {
|
||||
return Ok(val);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. keyfile path from git config.
|
||||
let path = git_config("nostr.keyfile").ok_or_else(|| {
|
||||
"no nostr key configured. Set $NOSTR_PRIVATE_KEY or git config nostr.keyfile".to_string()
|
||||
})?;
|
||||
|
||||
check_keyfile_permissions(&path)?;
|
||||
|
||||
let raw =
|
||||
std::fs::read_to_string(&path).map_err(|e| format!("cannot read keyfile {path}: {e}"))?;
|
||||
Ok(raw.trim().to_string())
|
||||
}
|
||||
|
||||
// ── stdin parsing ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default)]
|
||||
struct CredRequest {
|
||||
has_authtype_capability: bool,
|
||||
protocol: Option<String>,
|
||||
host: Option<String>,
|
||||
path: Option<String>,
|
||||
wwwauth: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_stdin() -> CredRequest {
|
||||
let stdin = io::stdin();
|
||||
let mut req = CredRequest::default();
|
||||
|
||||
for line in stdin.lock().lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
if line.is_empty() {
|
||||
break;
|
||||
}
|
||||
if line == "capability[]=authtype" {
|
||||
req.has_authtype_capability = true;
|
||||
} else if let Some(v) = line.strip_prefix("protocol=") {
|
||||
req.protocol = Some(v.to_string());
|
||||
} else if let Some(v) = line.strip_prefix("host=") {
|
||||
req.host = Some(v.to_string());
|
||||
} else if let Some(v) = line.strip_prefix("path=") {
|
||||
req.path = Some(v.to_string());
|
||||
} else if let Some(v) = line.strip_prefix("wwwauth[]=") {
|
||||
// Only capture Nostr challenges — ignore Basic, Bearer, etc.
|
||||
if v.starts_with("Nostr ") && req.wwwauth.is_none() {
|
||||
req.wwwauth = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
// ignore unknown lines
|
||||
}
|
||||
|
||||
req
|
||||
}
|
||||
|
||||
/// Extract the HTTP method from a WWW-Authenticate value like:
|
||||
/// Nostr realm="sprout", method="GET"
|
||||
///
|
||||
/// Splits on ", " first to isolate parameters — prevents matching inside
|
||||
/// quoted values like `realm="evil method=\"DELETE\""`.
|
||||
fn parse_method(wwwauth: &str) -> Option<HttpMethod> {
|
||||
for param in wwwauth.split(", ") {
|
||||
let param = param.trim();
|
||||
if let Some(rest) = param.strip_prefix("method=\"") {
|
||||
let end = rest.find('"')?;
|
||||
return rest[..end].parse().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() {
|
||||
// Git calls credential helpers with a subcommand: get, store, or erase.
|
||||
// We only handle "get" — store/erase are no-ops for ephemeral credentials.
|
||||
match std::env::args().nth(1).as_deref() {
|
||||
Some("get") | None => {} // proceed — None for backwards compat
|
||||
Some(_) => return, // store, erase, or unknown → silent exit 0
|
||||
}
|
||||
|
||||
let req = parse_stdin();
|
||||
|
||||
// Old git without authtype capability — nothing we can do.
|
||||
// The blank line signals "no credential available" per git's protocol.
|
||||
if !req.has_authtype_capability {
|
||||
println!();
|
||||
let _ = io::stdout().flush();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields.
|
||||
let protocol = req
|
||||
.protocol
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| fail("missing protocol in credential request"));
|
||||
let host = req
|
||||
.host
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| fail("missing host in credential request"));
|
||||
let path = req
|
||||
.path
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| fail("credential.useHttpPath must be true for NIP-98 auth"));
|
||||
|
||||
let wwwauth = req
|
||||
.wwwauth
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| fail("server did not include WWW-Authenticate header"));
|
||||
|
||||
let method = parse_method(wwwauth)
|
||||
.unwrap_or_else(|| fail("server did not include method hint in WWW-Authenticate"));
|
||||
|
||||
// Sign the repo root URL, not the full endpoint URL.
|
||||
// Git's credential helper is invoked once (for info/refs) and the token is reused
|
||||
// for subsequent requests (upload-pack, receive-pack). The server verifies against
|
||||
// the canonical repo root, so we strip endpoint suffixes here.
|
||||
let repo_path = path
|
||||
.split_once("/info/refs")
|
||||
.map(|(prefix, _)| prefix)
|
||||
.or_else(|| path.strip_suffix("/git-upload-pack"))
|
||||
.or_else(|| path.strip_suffix("/git-receive-pack"))
|
||||
.unwrap_or(path);
|
||||
let url = format!("{protocol}://{host}/{repo_path}");
|
||||
|
||||
// Load key, sign, then zeroize.
|
||||
let mut raw_key = match load_key() {
|
||||
Ok(k) => k,
|
||||
Err(e) => fail(&e),
|
||||
};
|
||||
|
||||
let keys = match Keys::parse(&raw_key) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
raw_key.zeroize();
|
||||
fail(&format!("invalid nostr private key: {e}"));
|
||||
}
|
||||
};
|
||||
raw_key.zeroize();
|
||||
|
||||
let http_data = HttpData::new(UncheckedUrl::from(url.as_str()), method);
|
||||
let event = match EventBuilder::http_auth(http_data).sign_with_keys(&keys) {
|
||||
Ok(e) => e,
|
||||
Err(e) => fail(&format!("failed to sign NIP-98 event: {e}")),
|
||||
};
|
||||
|
||||
let json = match serde_json::to_string(&event) {
|
||||
Ok(j) => j,
|
||||
Err(e) => fail(&format!("failed to serialize event: {e}")),
|
||||
};
|
||||
|
||||
let credential = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
|
||||
|
||||
// Output the credential response.
|
||||
println!("capability[]=authtype");
|
||||
println!("authtype=Nostr");
|
||||
println!("credential={credential}");
|
||||
println!("ephemeral=true");
|
||||
println!("quit=true");
|
||||
println!();
|
||||
let _ = io::stdout().flush();
|
||||
}
|
||||
286
crates/git-credential-nostr/tests/integration.rs
Normal file
286
crates/git-credential-nostr/tests/integration.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
//! Integration tests for git-credential-nostr.
|
||||
//!
|
||||
//! Each test spawns the compiled binary as a subprocess, feeds it the
|
||||
//! credential-helper protocol on stdin, and asserts on stdout/stderr/exit-code.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use base64::Engine as _;
|
||||
use nostr::{Keys, ToBech32};
|
||||
|
||||
// ── helper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Spawn the binary, write `input` to stdin, collect output.
|
||||
/// `env_vars` are added on top of the inherited environment.
|
||||
/// `NOSTR_PRIVATE_KEY` is always cleared first to prevent test pollution.
|
||||
fn run_helper(input: &str, env_vars: &[(&str, &str)]) -> std::process::Output {
|
||||
let bin = env!("CARGO_BIN_EXE_git-credential-nostr");
|
||||
let mut cmd = Command::new(bin);
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.env_remove("NOSTR_PRIVATE_KEY")
|
||||
// Prevent git config on the test machine from supplying a keyfile.
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("HOME", std::env::temp_dir());
|
||||
for (k, v) in env_vars {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let mut child = cmd.spawn().expect("failed to spawn git-credential-nostr");
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(input.as_bytes())
|
||||
.unwrap();
|
||||
child.wait_with_output().expect("failed to wait on child")
|
||||
}
|
||||
|
||||
/// Generate a fresh nsec string for use in tests.
|
||||
fn fresh_nsec() -> String {
|
||||
let keys = Keys::generate();
|
||||
keys.secret_key().to_bech32().unwrap()
|
||||
}
|
||||
|
||||
/// Standard valid credential-helper input (includes authtype capability).
|
||||
fn valid_input() -> String {
|
||||
"capability[]=authtype\n\
|
||||
capability[]=state\n\
|
||||
protocol=https\n\
|
||||
host=relay.example.com\n\
|
||||
path=git/owner/repo.git/info/refs\n\
|
||||
wwwauth[]=Nostr realm=\"sprout\", method=\"GET\"\n\
|
||||
\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Happy path: valid key + valid input → well-formed credential response with
|
||||
/// a base64-encoded kind:27235 JSON event.
|
||||
#[test]
|
||||
fn happy_path() {
|
||||
let nsec = fresh_nsec();
|
||||
let out = run_helper(&valid_input(), &[("NOSTR_PRIVATE_KEY", &nsec)]);
|
||||
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"expected exit 0, got {:?}\nstderr: {}",
|
||||
out.status.code(),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
|
||||
assert!(
|
||||
lines.contains(&"capability[]=authtype"),
|
||||
"missing capability[]=authtype in:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
lines.contains(&"authtype=Nostr"),
|
||||
"missing authtype=Nostr in:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
lines.contains(&"ephemeral=true"),
|
||||
"missing ephemeral=true in:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
lines.contains(&"quit=true"),
|
||||
"missing quit=true in:\n{stdout}"
|
||||
);
|
||||
|
||||
// Extract and validate the credential value.
|
||||
let cred_line = lines
|
||||
.iter()
|
||||
.find(|l| l.starts_with("credential="))
|
||||
.expect("no credential= line in output");
|
||||
let b64 = cred_line.strip_prefix("credential=").unwrap();
|
||||
|
||||
let json_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64)
|
||||
.expect("credential is not valid base64");
|
||||
let json_str = String::from_utf8(json_bytes).expect("credential is not valid UTF-8");
|
||||
|
||||
let event: serde_json::Value =
|
||||
serde_json::from_str(&json_str).expect("credential does not decode to JSON");
|
||||
|
||||
assert_eq!(
|
||||
event["kind"],
|
||||
serde_json::json!(27235),
|
||||
"expected kind 27235, got {}",
|
||||
event["kind"]
|
||||
);
|
||||
|
||||
// Sanity-check a few more fields the NIP-98 event must have.
|
||||
assert!(event["id"].is_string(), "event missing 'id'");
|
||||
assert!(event["pubkey"].is_string(), "event missing 'pubkey'");
|
||||
assert!(event["sig"].is_string(), "event missing 'sig'");
|
||||
assert!(event["tags"].is_array(), "event missing 'tags'");
|
||||
}
|
||||
|
||||
/// Old git (no `capability[]=authtype` in input) → empty line on stdout, exit 0.
|
||||
#[test]
|
||||
fn old_git_no_authtype_capability() {
|
||||
let input = "protocol=https\n\
|
||||
host=relay.example.com\n\
|
||||
path=git/owner/repo.git/info/refs\n\
|
||||
\n";
|
||||
|
||||
let nsec = fresh_nsec();
|
||||
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
|
||||
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"expected exit 0 for old-git path, got {:?}",
|
||||
out.status.code()
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
// Output should be just a blank line — no credential data.
|
||||
assert_eq!(
|
||||
stdout.trim(),
|
||||
"",
|
||||
"expected empty output for old-git path, got:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("credential="),
|
||||
"should not emit credential= for old git"
|
||||
);
|
||||
}
|
||||
|
||||
/// No key configured at all → exit 1, stderr mentions "no nostr key configured".
|
||||
#[test]
|
||||
fn missing_key() {
|
||||
// run_helper already clears NOSTR_PRIVATE_KEY and points HOME at a temp dir
|
||||
// that has no git config, so no keyfile will be found.
|
||||
let out = run_helper(&valid_input(), &[]);
|
||||
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(1),
|
||||
"expected exit 1 for missing key"
|
||||
);
|
||||
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("no nostr key configured"),
|
||||
"expected 'no nostr key configured' in stderr, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `wwwauth[]` present but missing `method="..."` → exit 1, stderr mentions "method hint".
|
||||
#[test]
|
||||
fn missing_method_hint() {
|
||||
let input = "capability[]=authtype\n\
|
||||
capability[]=state\n\
|
||||
protocol=https\n\
|
||||
host=relay.example.com\n\
|
||||
path=git/owner/repo.git/info/refs\n\
|
||||
wwwauth[]=Nostr realm=\"sprout\"\n\
|
||||
\n";
|
||||
|
||||
let nsec = fresh_nsec();
|
||||
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
|
||||
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(1),
|
||||
"expected exit 1 for missing method hint"
|
||||
);
|
||||
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("method hint"),
|
||||
"expected 'method hint' in stderr, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Input without `path=` line (useHttpPath not set) → exit 1, stderr mentions "useHttpPath".
|
||||
#[test]
|
||||
fn missing_path() {
|
||||
let input = "capability[]=authtype\n\
|
||||
capability[]=state\n\
|
||||
protocol=https\n\
|
||||
host=relay.example.com\n\
|
||||
wwwauth[]=Nostr realm=\"sprout\", method=\"GET\"\n\
|
||||
\n";
|
||||
|
||||
let nsec = fresh_nsec();
|
||||
let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]);
|
||||
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(1),
|
||||
"expected exit 1 for missing path"
|
||||
);
|
||||
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("useHttpPath"),
|
||||
"expected 'useHttpPath' in stderr, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Keyfile with 0644 permissions → exit 1, stderr mentions "insecure permissions".
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn bad_keyfile_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let nsec = fresh_nsec();
|
||||
|
||||
// Write keyfile to a temp path.
|
||||
let tmp_dir = std::env::temp_dir();
|
||||
let keyfile = tmp_dir.join(format!(
|
||||
"nostr-test-key-{}.nsec",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.subsec_nanos()
|
||||
));
|
||||
std::fs::write(&keyfile, &nsec).expect("failed to write temp keyfile");
|
||||
|
||||
// Set insecure permissions (0644).
|
||||
std::fs::set_permissions(&keyfile, std::fs::Permissions::from_mode(0o644))
|
||||
.expect("failed to set permissions");
|
||||
|
||||
// Point a scratch git config at the keyfile.
|
||||
let git_config_dir = tmp_dir.join(format!(
|
||||
"nostr-test-gitconfig-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.subsec_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&git_config_dir).unwrap();
|
||||
let git_config_file = git_config_dir.join(".gitconfig");
|
||||
std::fs::write(
|
||||
&git_config_file,
|
||||
format!("[nostr]\n\tkeyfile = {}\n", keyfile.display()),
|
||||
)
|
||||
.expect("failed to write git config");
|
||||
|
||||
let out = run_helper(
|
||||
&valid_input(),
|
||||
&[("HOME", git_config_dir.to_str().unwrap())],
|
||||
);
|
||||
|
||||
// Clean up regardless of outcome.
|
||||
let _ = std::fs::remove_file(&keyfile);
|
||||
let _ = std::fs::remove_file(&git_config_file);
|
||||
let _ = std::fs::remove_dir(&git_config_dir);
|
||||
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(1),
|
||||
"expected exit 1 for insecure keyfile permissions"
|
||||
);
|
||||
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("insecure permissions"),
|
||||
"expected 'insecure permissions' in stderr, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,18 @@ pub enum Scope {
|
||||
FilesRead,
|
||||
/// Upload files and attachments.
|
||||
FilesWrite,
|
||||
/// Clone git repositories.
|
||||
///
|
||||
/// Reserved for future use. Not currently enforced by git HTTP routes —
|
||||
/// those use NIP-98 auth directly. Will be enforced when collaborator
|
||||
/// access (read-only, maintainer) is added in v2.
|
||||
ReposRead,
|
||||
/// Push to git repositories and create repos (kind:30617).
|
||||
///
|
||||
/// Enforced for kind:30617/30618 events via WebSocket ingest, but NOT
|
||||
/// enforced by git HTTP push routes (which use NIP-98 + owner check).
|
||||
/// Full enforcement deferred to v2 collaborator model.
|
||||
ReposWrite,
|
||||
/// Submit events on behalf of other pubkeys (proxy service accounts only).
|
||||
ProxySubmit,
|
||||
/// A scope string not recognised by this version of the relay.
|
||||
@@ -69,6 +81,8 @@ impl Scope {
|
||||
Self::SubscriptionsWrite,
|
||||
Self::FilesRead,
|
||||
Self::FilesWrite,
|
||||
Self::ReposRead,
|
||||
Self::ReposWrite,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,6 +105,8 @@ impl Scope {
|
||||
Self::SubscriptionsWrite,
|
||||
Self::FilesRead,
|
||||
Self::FilesWrite,
|
||||
Self::ReposRead,
|
||||
Self::ReposWrite,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -111,6 +127,8 @@ impl Scope {
|
||||
Self::SubscriptionsWrite => "subscriptions:write",
|
||||
Self::FilesRead => "files:read",
|
||||
Self::FilesWrite => "files:write",
|
||||
Self::ReposRead => "repos:read",
|
||||
Self::ReposWrite => "repos:write",
|
||||
Self::ProxySubmit => "proxy:submit",
|
||||
Self::Unknown(s) => s.as_str(),
|
||||
}
|
||||
@@ -142,6 +160,8 @@ impl FromStr for Scope {
|
||||
"subscriptions:write" => Self::SubscriptionsWrite,
|
||||
"files:read" => Self::FilesRead,
|
||||
"files:write" => Self::FilesWrite,
|
||||
"repos:read" => Self::ReposRead,
|
||||
"repos:write" => Self::ReposWrite,
|
||||
"proxy:submit" => Self::ProxySubmit,
|
||||
other => Self::Unknown(other.to_string()),
|
||||
})
|
||||
@@ -226,12 +246,12 @@ mod tests {
|
||||
#[test]
|
||||
fn all_non_admin_excludes_admin_scopes() {
|
||||
let scopes = Scope::all_non_admin();
|
||||
assert_eq!(scopes.len(), 12, "expected 12 non-admin scope variants");
|
||||
assert_eq!(scopes.len(), 14, "expected 14 non-admin scope variants");
|
||||
// Verify no duplicates
|
||||
let unique: std::collections::HashSet<_> = scopes.iter().map(|s| s.as_str()).collect();
|
||||
assert_eq!(
|
||||
unique.len(),
|
||||
12,
|
||||
14,
|
||||
"all_non_admin() must not contain duplicates"
|
||||
);
|
||||
// Verify no Unknown variants
|
||||
@@ -255,10 +275,10 @@ mod tests {
|
||||
#[test]
|
||||
fn all_known_returns_all_14_variants() {
|
||||
let all = Scope::all_known();
|
||||
assert_eq!(all.len(), 14, "expected 14 known scope variants");
|
||||
assert_eq!(all.len(), 16, "expected 16 known scope variants");
|
||||
// Verify no duplicates
|
||||
let unique: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect();
|
||||
assert_eq!(unique.len(), 14, "all_known() must not contain duplicates");
|
||||
assert_eq!(unique.len(), 16, "all_known() must not contain duplicates");
|
||||
// Verify no Unknown variants
|
||||
for scope in &all {
|
||||
assert!(
|
||||
|
||||
@@ -233,6 +233,28 @@ pub const KIND_HUDDLE_GUIDELINES: u32 = 48106;
|
||||
/// Internal kind for media upload audit entries. Not a relay event kind.
|
||||
pub const KIND_MEDIA_UPLOAD: u32 = 49001;
|
||||
|
||||
// ── NIP-34: Git repository events ────────────────────────────────────────────
|
||||
/// NIP-34: Repository announcement (parameterized replaceable, d-tag = repo-id).
|
||||
pub const KIND_GIT_REPO_ANNOUNCEMENT: u32 = 30617;
|
||||
/// NIP-34: Repository state — current branch/tag refs (parameterized replaceable, d-tag = repo-id).
|
||||
pub const KIND_GIT_REPO_STATE: u32 = 30618;
|
||||
/// NIP-34: Patch (git format-patch output).
|
||||
pub const KIND_GIT_PATCH: u32 = 1617;
|
||||
/// NIP-34: Pull request.
|
||||
pub const KIND_GIT_PULL_REQUEST: u32 = 1618;
|
||||
/// NIP-34: Pull request update (tip commit change).
|
||||
pub const KIND_GIT_PR_UPDATE: u32 = 1619;
|
||||
/// NIP-34: Issue.
|
||||
pub const KIND_GIT_ISSUE: u32 = 1621;
|
||||
/// NIP-34: Status — Open.
|
||||
pub const KIND_GIT_STATUS_OPEN: u32 = 1630;
|
||||
/// NIP-34: Status — Applied / Merged.
|
||||
pub const KIND_GIT_STATUS_MERGED: u32 = 1631;
|
||||
/// NIP-34: Status — Closed.
|
||||
pub const KIND_GIT_STATUS_CLOSED: u32 = 1632;
|
||||
/// NIP-34: Status — Draft.
|
||||
pub const KIND_GIT_STATUS_DRAFT: u32 = 1633;
|
||||
|
||||
/// All registered kind constants — used for duplicate detection and iteration.
|
||||
pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_PROFILE,
|
||||
@@ -319,7 +341,18 @@ pub const ALL_KINDS: &[u32] = &[
|
||||
KIND_HUDDLE_ENDED,
|
||||
KIND_HUDDLE_TRACK_PUBLISHED,
|
||||
KIND_HUDDLE_RECORDING_AVAILABLE,
|
||||
KIND_HUDDLE_GUIDELINES,
|
||||
KIND_MEDIA_UPLOAD,
|
||||
KIND_GIT_REPO_ANNOUNCEMENT,
|
||||
KIND_GIT_REPO_STATE,
|
||||
KIND_GIT_PATCH,
|
||||
KIND_GIT_PULL_REQUEST,
|
||||
KIND_GIT_PR_UPDATE,
|
||||
KIND_GIT_ISSUE,
|
||||
KIND_GIT_STATUS_OPEN,
|
||||
KIND_GIT_STATUS_MERGED,
|
||||
KIND_GIT_STATUS_CLOSED,
|
||||
KIND_GIT_STATUS_DRAFT,
|
||||
];
|
||||
|
||||
/// Returns `true` if `kind` is in the ephemeral range (20000–29999).
|
||||
@@ -359,6 +392,16 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 {
|
||||
event.kind.as_u16() as i32
|
||||
}
|
||||
|
||||
// Compile-time: NIP-34 parameterized replaceable kinds are in the correct range.
|
||||
const _: () = assert!(
|
||||
KIND_GIT_REPO_ANNOUNCEMENT >= PARAM_REPLACEABLE_KIND_MIN
|
||||
&& KIND_GIT_REPO_ANNOUNCEMENT <= PARAM_REPLACEABLE_KIND_MAX
|
||||
);
|
||||
const _: () = assert!(
|
||||
KIND_GIT_REPO_STATE >= PARAM_REPLACEABLE_KIND_MIN
|
||||
&& KIND_GIT_REPO_STATE <= PARAM_REPLACEABLE_KIND_MAX
|
||||
);
|
||||
|
||||
// Compile-time: all Sprout kind constants fit in nostr's u16-backed Kind.
|
||||
const _: () = assert!(KIND_AUTH <= u16::MAX as u32);
|
||||
const _: () = assert!(KIND_CANVAS <= u16::MAX as u32);
|
||||
|
||||
627
crates/sprout-relay/src/api/git.rs
Normal file
627
crates/sprout-relay/src/api/git.rs
Normal file
@@ -0,0 +1,627 @@
|
||||
//! Smart HTTP git transport for Sprout.
|
||||
//!
|
||||
//! Three endpoints implement the git Smart HTTP protocol:
|
||||
//! - `GET /git/{owner}/{repo}/info/refs?service={svc}` — ref advertisement
|
||||
//! - `POST /git/{owner}/{repo}/git-upload-pack` — clone/fetch
|
||||
//! - `POST /git/{owner}/{repo}/git-receive-pack` — push
|
||||
//!
|
||||
//! Auth: NIP-98 on all routes (clone + push). No public repos for v1.
|
||||
//! Transport: shells out to `git --stateless-rpc` with `env_clear()`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path as AxumPath, Query, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use base64::Engine;
|
||||
use hex;
|
||||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
use tower_http::limit::RequestBodyLimitLayer;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
// ── Timeouts ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Timeout for `info/refs` — ref advertisement is fast (essentially `git show-ref`).
|
||||
const INFO_REFS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
/// Timeout for pack operations (upload-pack, receive-pack) — large repos need time.
|
||||
const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
|
||||
// ── NIP-98 Auth Extractor ────────────────────────────────────────────────────
|
||||
|
||||
/// NIP-98 auth extractor for git routes.
|
||||
///
|
||||
/// Validates the `Authorization: Nostr <base64>` header before the request body
|
||||
/// is read. Same pattern as `AuthenticatedUpload` in media.rs.
|
||||
///
|
||||
/// Authorization model (v1): any authenticated pubkey can clone; only the repo
|
||||
/// owner can push. Maintainer lists from kind:30617 are a future enhancement.
|
||||
pub struct GitAuth {
|
||||
/// The authenticated user's public key, extracted from the NIP-98 event.
|
||||
pub pubkey: nostr::PublicKey,
|
||||
}
|
||||
|
||||
impl axum::extract::FromRequestParts<Arc<AppState>> for GitAuth {
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let method = parts.method.as_str();
|
||||
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header(
|
||||
"WWW-Authenticate",
|
||||
format!("Nostr realm=\"sprout\", method=\"{method}\""),
|
||||
)
|
||||
.body(Body::from("missing Authorization header"))
|
||||
.unwrap()
|
||||
})?;
|
||||
|
||||
let token = auth_header.strip_prefix("Nostr ").ok_or_else(|| {
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header(
|
||||
"WWW-Authenticate",
|
||||
format!("Nostr realm=\"sprout\", method=\"{method}\""),
|
||||
)
|
||||
.body(Body::from("expected Authorization: Nostr <base64>"))
|
||||
.unwrap()
|
||||
})?;
|
||||
|
||||
let event_bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(token)
|
||||
.or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token))
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "invalid base64").into_response())?;
|
||||
let event_json = String::from_utf8(event_bytes)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?;
|
||||
|
||||
// Use configured relay_url as canonical base (don't trust forwarded headers).
|
||||
let relay_url = &state.config.relay_url;
|
||||
let base_url = relay_url
|
||||
.replace("ws://", "http://")
|
||||
.replace("wss://", "https://");
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
let path_and_query = parts
|
||||
.uri
|
||||
.path_and_query()
|
||||
.map(|pq| pq.as_str())
|
||||
.unwrap_or(parts.uri.path());
|
||||
|
||||
// Bug fix: strip git endpoint suffixes before building the expected URL.
|
||||
//
|
||||
// Git's credential helper is invoked once — for the initial GET /info/refs —
|
||||
// and signs a NIP-98 token with `u = <repo-root>` (e.g. `/git/{owner}/{repo}.git`).
|
||||
// That same token is reused for all subsequent requests in the session
|
||||
// (git-upload-pack, git-receive-pack). We must verify against the repo-root URL,
|
||||
// not the full endpoint URL, so all three endpoints share a single canonical URL.
|
||||
let repo_path = path_and_query
|
||||
.split_once("/info/refs")
|
||||
.map(|(prefix, _)| prefix)
|
||||
.or_else(|| path_and_query.strip_suffix("/git-upload-pack"))
|
||||
.or_else(|| path_and_query.strip_suffix("/git-receive-pack"))
|
||||
.unwrap_or(path_and_query);
|
||||
let expected_url = format!("{base_url}{repo_path}");
|
||||
|
||||
// Bug fix: skip the HTTP method check for git routes.
|
||||
//
|
||||
// Git's credential helper signs the NIP-98 token with `method=GET` (the method
|
||||
// used for the initial /info/refs discovery request), then reuses that same token
|
||||
// for POST requests (git-upload-pack, git-receive-pack). Verifying against the
|
||||
// actual HTTP method would reject all pack operations after the first request.
|
||||
//
|
||||
// The URL tag already scopes the token to the correct repo — the method tag adds
|
||||
// no meaningful security here since all git endpoints require the same authorization
|
||||
// (repo owner). We pass the method from the event itself so verify_nip98_event
|
||||
// always accepts whatever method the credential helper signed.
|
||||
let event_method = serde_json::from_str::<serde_json::Value>(&event_json)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v["tags"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|t| t[0].as_str() == Some("method"))?[1]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| method.to_owned());
|
||||
|
||||
// SECURITY: method intentionally not verified for git routes. The tautological
|
||||
// check (event.method == event.method) is deliberate — see comment block above.
|
||||
// Git's credential protocol signs once with GET and reuses for POST. The URL tag
|
||||
// provides the real security boundary (±60s timestamp + URL lock + HTTPS).
|
||||
|
||||
// body=None: can't buffer streaming pack data to verify payload hash.
|
||||
// Token is time-bounded (±60s) and URL-locked — acceptable trade-off.
|
||||
let pubkey =
|
||||
sprout_auth::nip98::verify_nip98_event(&event_json, &expected_url, &event_method, None)
|
||||
.map_err(|e| {
|
||||
warn!(error = %e, "git NIP-98 auth failed");
|
||||
(StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response()
|
||||
})?;
|
||||
|
||||
// NOTE: NIP-98 event-ID dedup intentionally NOT implemented here.
|
||||
// Git's credential protocol reuses one signed token across multiple requests
|
||||
// in a session (info_refs GET → upload-pack/receive-pack POST). Rejecting
|
||||
// replayed event IDs would break normal clone/push operations.
|
||||
// The ±60s timestamp window + URL scoping + HTTPS transport provide sufficient
|
||||
// replay protection for v1. Per-request signing requires protocol changes.
|
||||
|
||||
Ok(GitAuth { pubkey })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path Validation ──────────────────────────────────────────────────────────
|
||||
|
||||
struct ValidatedRepoPath {
|
||||
repo_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Validate and resolve a git repo path from URL parameters.
|
||||
///
|
||||
/// Security: allowlist characters, canonicalize, verify under repo root.
|
||||
#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers
|
||||
fn validate_repo_path(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
git_repo_root: &Path,
|
||||
) -> Result<ValidatedRepoPath, Response> {
|
||||
// Owner must be exactly 64 lowercase hex chars.
|
||||
if owner.len() != 64
|
||||
|| !owner
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
{
|
||||
return Err((StatusCode::BAD_REQUEST, "invalid owner").into_response());
|
||||
}
|
||||
|
||||
// Strip trailing .git if present.
|
||||
let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
|
||||
|
||||
// Repo name: [a-zA-Z0-9._-]{1,64}, no leading dots, no "..".
|
||||
if repo_name.is_empty()
|
||||
|| repo_name.len() > 64
|
||||
|| repo_name.starts_with('.')
|
||||
|| repo_name.contains("..")
|
||||
|| !repo_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
|
||||
{
|
||||
return Err((StatusCode::BAD_REQUEST, "invalid repo name").into_response());
|
||||
}
|
||||
|
||||
let repo_path = git_repo_root.join(owner).join(format!("{repo_name}.git"));
|
||||
|
||||
// Path canonicalization: verify resolved path is under repo root.
|
||||
// Fail closed: if the repo root doesn't exist, reject — the service can't operate safely.
|
||||
let canonical_root = git_repo_root.canonicalize().map_err(|_| {
|
||||
error!("git_repo_path does not exist or cannot be canonicalized");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"git service misconfigured",
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
// Repo may not exist yet (404 handled later), but if it does, verify containment.
|
||||
if let Ok(canonical_repo) = repo_path.canonicalize() {
|
||||
if !canonical_repo.starts_with(&canonical_root) {
|
||||
return Err((StatusCode::BAD_REQUEST, "path traversal detected").into_response());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ValidatedRepoPath { repo_path })
|
||||
}
|
||||
|
||||
/// Apply hardened environment to a git subprocess command.
|
||||
///
|
||||
/// Clears all inherited env vars, then sets only the minimum required:
|
||||
/// - `PATH` — so git can find its own helpers
|
||||
/// - `GIT_HTTP_EXPORT_ALL` — required for Smart HTTP
|
||||
/// - `GIT_CONFIG_NOSYSTEM=1` — ignore system-wide gitconfig
|
||||
/// - `GIT_CONFIG_GLOBAL=/dev/null` — prevent reading global gitconfig
|
||||
/// - `HOME=/dev/null` — prevent reading ~/.gitconfig
|
||||
fn harden_git_env(cmd: &mut Command) {
|
||||
cmd.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("GIT_HTTP_EXPORT_ALL", "1")
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("HOME", "/dev/null");
|
||||
}
|
||||
|
||||
// ── Route Handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
/// Query parameters for the `info/refs` endpoint.
|
||||
pub struct InfoRefsQuery {
|
||||
service: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
/// Path parameters for git repo routes: `{owner}/{repo}`.
|
||||
pub struct GitRepoParams {
|
||||
owner: String,
|
||||
repo: String,
|
||||
}
|
||||
|
||||
/// `GET /git/{owner}/{repo}/info/refs?service={service}`
|
||||
///
|
||||
/// Advertises refs for clone (git-upload-pack) or push (git-receive-pack).
|
||||
pub async fn info_refs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_auth: GitAuth,
|
||||
AxumPath(params): AxumPath<GitRepoParams>,
|
||||
Query(query): Query<InfoRefsQuery>,
|
||||
) -> Result<Response, Response> {
|
||||
// Validate service parameter: exact allowlist.
|
||||
let service = match query.service.as_str() {
|
||||
"git-upload-pack" | "git-receive-pack" => &query.service,
|
||||
_ => return Err((StatusCode::BAD_REQUEST, "invalid service").into_response()),
|
||||
};
|
||||
|
||||
let validated = validate_repo_path(¶ms.owner, ¶ms.repo, &state.config.git_repo_path)?;
|
||||
if !validated.repo_path.exists() {
|
||||
return Err((StatusCode::NOT_FOUND, "repository not found").into_response());
|
||||
}
|
||||
|
||||
let _permit = state.git_semaphore.try_acquire().map_err(|_| {
|
||||
Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.header("Retry-After", "5")
|
||||
.body(Body::from("git service busy"))
|
||||
.unwrap()
|
||||
})?;
|
||||
|
||||
let mut cmd = Command::new("git");
|
||||
// Git's smart HTTP protocol uses service names like "git-upload-pack" and
|
||||
// "git-receive-pack", but the actual git subcommands are "upload-pack" and
|
||||
// "receive-pack" (without the "git-" prefix).
|
||||
let git_subcmd = service.strip_prefix("git-").unwrap_or(service.as_str());
|
||||
cmd.arg(git_subcmd)
|
||||
.arg("--stateless-rpc")
|
||||
.arg("--advertise-refs")
|
||||
.arg(&validated.repo_path)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
harden_git_env(&mut cmd);
|
||||
|
||||
let child = cmd.spawn().map_err(|e| {
|
||||
error!(error = %e, "git subprocess failed to spawn");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
|
||||
})?;
|
||||
|
||||
// kill_on_drop requires a Child handle — .output() doesn't expose one.
|
||||
// Spawn first, then wait under a timeout; on timeout the Child is dropped
|
||||
// and kill_on_drop terminates the subprocess.
|
||||
let output = tokio::time::timeout(INFO_REFS_TIMEOUT, child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
"git info_refs subprocess timed out ({}s)",
|
||||
INFO_REFS_TIMEOUT.as_secs()
|
||||
);
|
||||
(StatusCode::GATEWAY_TIMEOUT, "git operation timed out").into_response()
|
||||
})?
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "git subprocess failed");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
error!(stderr = %stderr, "git --advertise-refs failed");
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response());
|
||||
}
|
||||
|
||||
// Build pkt-line response: service header + flush + git output.
|
||||
let svc_line = format!("# service={service}\n");
|
||||
let svc_pkt = format!("{:04x}{svc_line}", svc_line.len() + 4);
|
||||
let mut body = Vec::with_capacity(svc_pkt.len() + 4 + output.stdout.len());
|
||||
body.extend_from_slice(svc_pkt.as_bytes());
|
||||
body.extend_from_slice(b"0000"); // flush packet
|
||||
body.extend_from_slice(&output.stdout);
|
||||
|
||||
let content_type = format!("application/x-{service}-advertisement");
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.body(Body::from(body))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// `POST /git/{owner}/{repo}/git-upload-pack`
|
||||
///
|
||||
/// Handles clone/fetch — client sends wants/haves, server sends pack data.
|
||||
pub async fn upload_pack(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_auth: GitAuth,
|
||||
AxumPath(params): AxumPath<GitRepoParams>,
|
||||
body: Body,
|
||||
) -> Result<Response, Response> {
|
||||
run_git_service(&state, ¶ms.owner, ¶ms.repo, "upload-pack", body).await
|
||||
}
|
||||
|
||||
/// `POST /git/{owner}/{repo}/git-receive-pack`
|
||||
///
|
||||
/// Handles push — client sends ref updates + pack data.
|
||||
/// Authorization: only the repo owner (whose pubkey matches `{owner}` in the URL)
|
||||
/// can push. Maintainer lists from kind:30617 are a future enhancement.
|
||||
pub async fn receive_pack(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth: GitAuth,
|
||||
AxumPath(params): AxumPath<GitRepoParams>,
|
||||
body: Body,
|
||||
) -> Result<Response, Response> {
|
||||
// Push authorization: authenticated pubkey must match the repo owner.
|
||||
let pusher_hex = hex::encode(auth.pubkey.serialize());
|
||||
if pusher_hex != params.owner {
|
||||
return Err((StatusCode::FORBIDDEN, "push denied: not the repo owner").into_response());
|
||||
}
|
||||
|
||||
// Per-repo lock: prevent concurrent pushes to the same bare repo.
|
||||
// git receive-pack is not safe for concurrent access.
|
||||
let validated = validate_repo_path(¶ms.owner, ¶ms.repo, &state.config.git_repo_path)?;
|
||||
let repo_lock = state
|
||||
.git_repo_locks
|
||||
.entry(validated.repo_path.clone())
|
||||
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||
.clone();
|
||||
let _repo_guard = repo_lock.lock().await;
|
||||
|
||||
let response =
|
||||
run_git_service(&state, ¶ms.owner, ¶ms.repo, "receive-pack", body).await?;
|
||||
|
||||
// Post-push: publish kind:30618 ref state event (fire-and-forget).
|
||||
let state_clone = state.clone();
|
||||
let owner = params.owner.clone();
|
||||
let repo = params.repo.clone();
|
||||
let pusher = auth.pubkey;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = publish_ref_state(&state_clone, &owner, &repo, &pusher).await {
|
||||
warn!(error = %e, owner = %owner, repo = %repo, "failed to publish kind:30618");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Shared git service runner — spawns subprocess and streams I/O.
|
||||
async fn run_git_service(
|
||||
state: &Arc<AppState>,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
service: &str,
|
||||
body: Body,
|
||||
) -> Result<Response, Response> {
|
||||
let validated = validate_repo_path(owner, repo, &state.config.git_repo_path)?;
|
||||
if !validated.repo_path.exists() {
|
||||
return Err((StatusCode::NOT_FOUND, "repository not found").into_response());
|
||||
}
|
||||
|
||||
let _permit = state.git_semaphore.try_acquire().map_err(|_| {
|
||||
Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.header("Retry-After", "5")
|
||||
.body(Body::from("git service busy"))
|
||||
.unwrap()
|
||||
})?;
|
||||
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg(service)
|
||||
.arg("--stateless-rpc")
|
||||
.arg(&validated.repo_path)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
harden_git_env(&mut cmd);
|
||||
let mut child = cmd.spawn().map_err(|e| {
|
||||
error!(error = %e, "git subprocess failed to spawn");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()
|
||||
})?;
|
||||
|
||||
// Stream request body to git stdin.
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let body_task = tokio::spawn(async move {
|
||||
use futures_util::StreamExt;
|
||||
let mut stream = body.into_data_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
if tokio::io::AsyncWriteExt::write_all(&mut stdin, &bytes)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
drop(stdin); // Close stdin → EOF for git.
|
||||
});
|
||||
// Grab an abort handle before moving body_task into the timeout block.
|
||||
// On timeout, we abort it explicitly — dropping a JoinHandle only detaches
|
||||
// the task (it keeps running). A stalled client could otherwise keep the
|
||||
// spawned task alive indefinitely waiting on stream.next().await.
|
||||
let body_abort = body_task.abort_handle();
|
||||
|
||||
// Timeout covers both body streaming and subprocess completion.
|
||||
// On timeout: child is killed via kill_on_drop, body_task via abort_handle.
|
||||
let timeout_result = tokio::time::timeout(PACK_OPS_TIMEOUT, async {
|
||||
let _ = body_task.await;
|
||||
child.wait_with_output().await
|
||||
})
|
||||
.await;
|
||||
|
||||
let output = match timeout_result {
|
||||
Err(_elapsed) => {
|
||||
body_abort.abort();
|
||||
warn!(service = %service, timeout_secs = PACK_OPS_TIMEOUT.as_secs(), "git subprocess timed out");
|
||||
return Err((StatusCode::GATEWAY_TIMEOUT, "git operation timed out").into_response());
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!(error = %e, "git subprocess failed");
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response());
|
||||
}
|
||||
Ok(Ok(out)) => out,
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
warn!(stderr = %stderr, service = %service, "git subprocess exited with error");
|
||||
// Still return output — git protocol errors are communicated in-band.
|
||||
}
|
||||
|
||||
let content_type = format!("application/x-git-{service}-result");
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.body(Body::from(output.stdout))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
// ── Post-Push Event Publishing ───────────────────────────────────────────────
|
||||
|
||||
/// Publish kind:30618 (repo state) after a successful push.
|
||||
///
|
||||
/// Reads current refs from the repo and publishes a relay-signed event
|
||||
/// with the pusher's pubkey in a `p` tag. This makes pushes subscribable.
|
||||
async fn publish_ref_state(
|
||||
state: &Arc<AppState>,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
pusher: &nostr::PublicKey,
|
||||
) -> anyhow::Result<()> {
|
||||
let validated = validate_repo_path(owner, repo, &state.config.git_repo_path)
|
||||
.map_err(|_| anyhow::anyhow!("invalid repo path"))?;
|
||||
|
||||
// Read current refs.
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args(["for-each-ref", "--format=%(refname) %(objectname)"])
|
||||
.current_dir(&validated.repo_path);
|
||||
harden_git_env(&mut cmd);
|
||||
let output = cmd.output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow::anyhow!("git for-each-ref failed"));
|
||||
}
|
||||
|
||||
let refs_output = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// Get HEAD symbolic ref.
|
||||
let mut head_cmd = Command::new("git");
|
||||
head_cmd
|
||||
.args(["symbolic-ref", "HEAD"])
|
||||
.current_dir(&validated.repo_path);
|
||||
harden_git_env(&mut head_cmd);
|
||||
let head_output = head_cmd.output().await.ok();
|
||||
|
||||
let repo_name = repo.strip_suffix(".git").unwrap_or(repo);
|
||||
|
||||
// Build NIP-34 kind:30618 tags.
|
||||
let mut tags = vec![nostr::Tag::custom(nostr::TagKind::custom("d"), [repo_name])];
|
||||
|
||||
for line in refs_output.lines() {
|
||||
let parts: Vec<&str> = line.splitn(2, ' ').collect();
|
||||
if parts.len() != 2 {
|
||||
continue;
|
||||
}
|
||||
let (ref_name, sha) = (parts[0], parts[1]);
|
||||
|
||||
// NIP-34 kind:30618 only includes heads and tags — skip stash, notes, remotes.
|
||||
if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") {
|
||||
continue;
|
||||
}
|
||||
// Validate ref name and SHA.
|
||||
if ref_name.starts_with('/')
|
||||
|| ref_name.contains("//")
|
||||
|| !ref_name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "/_.-".contains(c))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if sha.len() != 40 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tags.push(nostr::Tag::custom(nostr::TagKind::custom(ref_name), [sha]));
|
||||
}
|
||||
|
||||
// HEAD tag.
|
||||
if let Some(head) = head_output {
|
||||
if head.status.success() {
|
||||
let head_ref = String::from_utf8_lossy(&head.stdout).trim().to_string();
|
||||
if !head_ref.is_empty() {
|
||||
tags.push(nostr::Tag::custom(
|
||||
nostr::TagKind::custom("HEAD"),
|
||||
[format!("ref: {head_ref}")],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pusher pubkey in p tag.
|
||||
tags.push(nostr::Tag::public_key(*pusher));
|
||||
|
||||
info!(
|
||||
repo = %repo_name,
|
||||
owner = %owner,
|
||||
ref_count = tags.len().saturating_sub(2),
|
||||
"publishing kind:30618 ref state"
|
||||
);
|
||||
|
||||
// Sign with relay keypair — the relay is the authoritative source of ref state.
|
||||
let event = nostr::EventBuilder::new(nostr::Kind::Custom(30618), "", tags)
|
||||
.sign_with_keys(&state.relay_keypair)
|
||||
.map_err(|e| anyhow::anyhow!("failed to sign kind:30618: {e}"))?;
|
||||
|
||||
// Store globally (channel_id = None) and fan out to subscribers.
|
||||
let (stored, was_inserted) = state.db.insert_event(&event, None).await?;
|
||||
if was_inserted {
|
||||
let matches = state.sub_registry.fan_out(&stored);
|
||||
for (conn_id, sub_id) in matches {
|
||||
let _ = state.conn_manager.send_to(
|
||||
conn_id,
|
||||
crate::protocol::RelayMessage::event(&sub_id, &stored.event),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Router Builder ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Build the git sub-router with its own body limit.
|
||||
///
|
||||
/// Mounted at `/git/{owner}/{repo}/...` with a configurable max pack size.
|
||||
pub fn git_router(state: Arc<AppState>) -> Router {
|
||||
let body_limit = state.config.git_max_pack_bytes as usize;
|
||||
|
||||
Router::new()
|
||||
.route("/git/{owner}/{repo}/info/refs", get(info_refs))
|
||||
.route("/git/{owner}/{repo}/git-upload-pack", post(upload_pack))
|
||||
.route("/git/{owner}/{repo}/git-receive-pack", post(receive_pack))
|
||||
.layer(RequestBodyLimitLayer::new(body_limit))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -26,6 +26,8 @@ pub mod dms;
|
||||
pub mod events;
|
||||
/// Personalized home feed endpoint.
|
||||
pub mod feed;
|
||||
/// Smart HTTP git transport (clone, fetch, push).
|
||||
pub mod git;
|
||||
/// Blossom-compatible media upload, retrieval, and existence check endpoints.
|
||||
pub mod media;
|
||||
/// Channel membership endpoints.
|
||||
|
||||
@@ -68,6 +68,17 @@ pub struct Config {
|
||||
/// Example: `SPROUT_EPHEMERAL_TTL_OVERRIDE=60` → all ephemeral channels expire
|
||||
/// 60 seconds after the last message.
|
||||
pub ephemeral_ttl_override: Option<i32>,
|
||||
|
||||
// ── Git server configuration ─────────────────────────────────────────────
|
||||
/// Root directory for bare git repositories.
|
||||
/// Repos are stored at `{git_repo_path}/{owner_hex}/{repo_id}.git/`.
|
||||
pub git_repo_path: std::path::PathBuf,
|
||||
/// Maximum pack file size for git push (bytes). Default: 500 MB.
|
||||
pub git_max_pack_bytes: u64,
|
||||
/// Maximum number of repos per pubkey. Default: 100.
|
||||
pub git_max_repos_per_pubkey: u32,
|
||||
/// Maximum concurrent git subprocess operations. Default: 20.
|
||||
pub git_max_concurrent_ops: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -217,6 +228,23 @@ impl Config {
|
||||
);
|
||||
}
|
||||
|
||||
// Git server config
|
||||
let git_repo_path: std::path::PathBuf = std::env::var("SPROUT_GIT_REPO_PATH")
|
||||
.unwrap_or_else(|_| "./repos".to_string())
|
||||
.into();
|
||||
let git_max_pack_bytes: u64 = std::env::var("SPROUT_GIT_MAX_PACK_BYTES")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(500 * 1024 * 1024); // 500 MB
|
||||
let git_max_repos_per_pubkey: u32 = std::env::var("SPROUT_GIT_MAX_REPOS_PER_PUBKEY")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(100);
|
||||
let git_max_concurrent_ops: usize = std::env::var("SPROUT_GIT_MAX_CONCURRENT_OPS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20);
|
||||
|
||||
Ok(Self {
|
||||
bind_addr,
|
||||
database_url,
|
||||
@@ -237,6 +265,10 @@ impl Config {
|
||||
pubkey_allowlist_enabled,
|
||||
media,
|
||||
ephemeral_ttl_override,
|
||||
git_repo_path,
|
||||
git_max_pack_bytes,
|
||||
git_max_repos_per_pubkey,
|
||||
git_max_concurrent_ops,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,15 @@ use sprout_auth::Scope;
|
||||
use sprout_core::kind::{
|
||||
event_kind_u32, is_parameterized_replaceable, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST,
|
||||
KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP,
|
||||
KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED,
|
||||
KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED,
|
||||
KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT,
|
||||
KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST,
|
||||
KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_PRESENCE_UPDATE,
|
||||
KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE,
|
||||
KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
|
||||
KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT,
|
||||
KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES,
|
||||
KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE,
|
||||
KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM,
|
||||
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP,
|
||||
KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA,
|
||||
KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER,
|
||||
KIND_PRESENCE_UPDATE, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT,
|
||||
KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
|
||||
KIND_STREAM_REMINDER, KIND_TEXT_NOTE,
|
||||
@@ -193,6 +195,16 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s
|
||||
| KIND_HUDDLE_TRACK_PUBLISHED
|
||||
| KIND_HUDDLE_RECORDING_AVAILABLE
|
||||
| KIND_HUDDLE_GUIDELINES => Ok(Scope::ChannelsWrite),
|
||||
// NIP-34: Git repository events
|
||||
KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite),
|
||||
KIND_GIT_PATCH
|
||||
| KIND_GIT_PULL_REQUEST
|
||||
| KIND_GIT_PR_UPDATE
|
||||
| KIND_GIT_ISSUE
|
||||
| KIND_GIT_STATUS_OPEN
|
||||
| KIND_GIT_STATUS_MERGED
|
||||
| KIND_GIT_STATUS_CLOSED
|
||||
| KIND_GIT_STATUS_DRAFT => Ok(Scope::MessagesWrite),
|
||||
_ => Err("restricted: unknown event kind"),
|
||||
}
|
||||
}
|
||||
@@ -273,7 +285,23 @@ pub(crate) async fn derive_reaction_channel(
|
||||
pub(crate) fn is_global_only_kind(kind: u32) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
KIND_PROFILE | KIND_TEXT_NOTE | KIND_CONTACT_LIST | KIND_LONG_FORM | KIND_READ_STATE
|
||||
KIND_PROFILE
|
||||
| KIND_TEXT_NOTE
|
||||
| KIND_CONTACT_LIST
|
||||
| KIND_LONG_FORM
|
||||
| KIND_READ_STATE
|
||||
// NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope).
|
||||
// Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag).
|
||||
| KIND_GIT_REPO_ANNOUNCEMENT
|
||||
| KIND_GIT_REPO_STATE
|
||||
| KIND_GIT_PATCH
|
||||
| KIND_GIT_PULL_REQUEST
|
||||
| KIND_GIT_PR_UPDATE
|
||||
| KIND_GIT_ISSUE
|
||||
| KIND_GIT_STATUS_OPEN
|
||||
| KIND_GIT_STATUS_MERGED
|
||||
| KIND_GIT_STATUS_CLOSED
|
||||
| KIND_GIT_STATUS_DRAFT
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use sprout_core::kind::{
|
||||
event_kind_u32, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION,
|
||||
KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_REACTION,
|
||||
event_kind_u32, KIND_GIT_REPO_ANNOUNCEMENT, KIND_MEMBER_ADDED_NOTIFICATION,
|
||||
KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS,
|
||||
KIND_NIP29_GROUP_METADATA, KIND_REACTION,
|
||||
};
|
||||
use sprout_db::channel::MemberRole;
|
||||
|
||||
@@ -27,7 +28,7 @@ pub fn is_admin_kind(kind: u32) -> bool {
|
||||
/// handled in `ingest_event()` before storage so we can short-circuit on
|
||||
/// duplicates without storing the event at all.
|
||||
pub fn is_side_effect_kind(kind: u32) -> bool {
|
||||
matches!(kind, 0 | 5 | 9000..=9022 | 41001..=41003 | 40099)
|
||||
matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | 41001..=41003 | 40099)
|
||||
}
|
||||
|
||||
async fn evict_live_channel_subscriptions(
|
||||
@@ -85,6 +86,8 @@ pub async fn handle_side_effects(
|
||||
}
|
||||
9021 => handle_join_request(event, state).await,
|
||||
9022 => handle_leave_request(event, state).await,
|
||||
// NIP-34: Git repo announcement → create bare repo on disk.
|
||||
KIND_GIT_REPO_ANNOUNCEMENT => handle_git_repo_announcement(event, state).await,
|
||||
// kind:7 (reaction) handled inline in ingest_event() before storage.
|
||||
_ => Ok(()),
|
||||
}
|
||||
@@ -1425,3 +1428,217 @@ fn extract_tag_value(event: &Event, tag_name: &str) -> Option<String> {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── NIP-34: Git repository side effects ──────────────────────────────────────
|
||||
|
||||
/// Validate a git repo identifier (d-tag value from kind:30617).
|
||||
///
|
||||
/// Rules: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`.
|
||||
fn validate_repo_id(repo_id: &str) -> bool {
|
||||
!repo_id.is_empty()
|
||||
&& repo_id.len() <= 64
|
||||
&& !repo_id.starts_with('.')
|
||||
&& !repo_id.contains("..")
|
||||
&& repo_id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// Handle kind:30617 (NIP-34 Git Repository Announcement).
|
||||
///
|
||||
/// Creates a bare git repo on disk when a repo announcement event is stored.
|
||||
/// The event's `d` tag is the repo identifier; the pubkey is the owner.
|
||||
///
|
||||
/// Security hardening:
|
||||
/// - Repo name validated: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`
|
||||
/// - Owner pubkey validated: exactly 64 lowercase hex chars
|
||||
/// - Path canonicalized and verified to start with repo root
|
||||
/// - Git hooks disabled via `core.hooksPath=/dev/null` + hooks dir removed
|
||||
/// - Per-pubkey repo count limit enforced
|
||||
async fn handle_git_repo_announcement(event: &Event, state: &Arc<AppState>) -> anyhow::Result<()> {
|
||||
use tokio::process::Command;
|
||||
|
||||
// Extract repo identifier from d tag (required for NIP-33 parameterized replaceable events).
|
||||
let repo_id =
|
||||
extract_tag_value(event, "d").ok_or_else(|| anyhow::anyhow!("kind:30617 missing d tag"))?;
|
||||
|
||||
if !validate_repo_id(&repo_id) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"invalid repo identifier: must be [a-zA-Z0-9._-]{{1,64}}, no leading dots, no '..'"
|
||||
));
|
||||
}
|
||||
|
||||
let owner_hex = nostr::util::hex::encode(event.pubkey.serialize());
|
||||
|
||||
// Resolve repo path.
|
||||
let git_repo_root = &state.config.git_repo_path;
|
||||
let repo_dir = git_repo_root
|
||||
.join(&owner_hex)
|
||||
.join(format!("{repo_id}.git"));
|
||||
|
||||
// If repo already exists, this is an update to the announcement — nothing to do on disk.
|
||||
// Backfill the name reservation if missing (handles upgrade from pre-uniqueness-check state).
|
||||
if repo_dir.exists() {
|
||||
let names_dir = git_repo_root.join(".names");
|
||||
let _ = std::fs::create_dir_all(&names_dir);
|
||||
let _ = std::fs::create_dir(names_dir.join(&repo_id));
|
||||
info!(
|
||||
repo_id = %repo_id,
|
||||
owner = %owner_hex,
|
||||
"kind:30617 repo announcement updated (repo already exists)"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Global uniqueness: repo names are unique across all owners (relay = single namespace).
|
||||
// The relay signs kind:30618 ref-state with d-tag = repo_name, so collisions would
|
||||
// cause one owner's ref state to overwrite another's.
|
||||
//
|
||||
// Atomicity: std::fs::create_dir fails with AlreadyExists if the directory exists,
|
||||
// preventing TOCTOU races between concurrent kind:30617 events. The .names/ directory
|
||||
// acts as a global name reservation index.
|
||||
let names_dir = git_repo_root.join(".names");
|
||||
std::fs::create_dir_all(&names_dir)
|
||||
.map_err(|e| anyhow::anyhow!("failed to create name reservation index: {e}"))?;
|
||||
let reservation = names_dir.join(&repo_id);
|
||||
match std::fs::create_dir(&reservation) {
|
||||
Ok(()) => {} // Name claimed successfully.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"repo name '{repo_id}' already taken by another owner"
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to reserve repo name '{repo_id}': {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-pubkey repo count limit.
|
||||
let owner_dir = git_repo_root.join(&owner_hex);
|
||||
if owner_dir.exists() {
|
||||
let count = std::fs::read_dir(&owner_dir)
|
||||
.map(|entries| entries.filter_map(|e| e.ok()).count())
|
||||
.unwrap_or(0);
|
||||
let limit = state.config.git_max_repos_per_pubkey as usize;
|
||||
if count >= limit {
|
||||
let _ = std::fs::remove_dir(&reservation);
|
||||
return Err(anyhow::anyhow!("repo limit exceeded: {count} >= {limit}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Create parent directory.
|
||||
if let Err(e) = tokio::fs::create_dir_all(&repo_dir).await {
|
||||
let _ = std::fs::remove_dir(&reservation);
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to create repo directory {}: {e}",
|
||||
repo_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Path canonicalization: verify resolved path is under the repo root.
|
||||
let canonical_root = match git_repo_root.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_dir_all(&repo_dir).await;
|
||||
let _ = std::fs::remove_dir(&reservation);
|
||||
return Err(anyhow::anyhow!("failed to canonicalize repo root: {e}"));
|
||||
}
|
||||
};
|
||||
let canonical_repo = match repo_dir.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_dir_all(&repo_dir).await;
|
||||
let _ = std::fs::remove_dir(&reservation);
|
||||
return Err(anyhow::anyhow!("failed to canonicalize repo path: {e}"));
|
||||
}
|
||||
};
|
||||
if !canonical_repo.starts_with(&canonical_root) {
|
||||
let _ = tokio::fs::remove_dir_all(&repo_dir).await;
|
||||
let _ = std::fs::remove_dir(&reservation);
|
||||
return Err(anyhow::anyhow!(
|
||||
"repo path escapes root: {} not under {}",
|
||||
canonical_repo.display(),
|
||||
canonical_root.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Initialize bare repo with main as default branch.
|
||||
let output = match Command::new("git")
|
||||
.arg("init")
|
||||
.arg("--bare")
|
||||
.arg("-b")
|
||||
.arg("main")
|
||||
.arg(&repo_dir)
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("HOME", "/dev/null")
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_dir_all(&repo_dir).await;
|
||||
let _ = std::fs::remove_dir(&reservation);
|
||||
return Err(anyhow::anyhow!("git init --bare failed to spawn: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let _ = tokio::fs::remove_dir_all(&repo_dir).await;
|
||||
let _ = std::fs::remove_dir(&reservation);
|
||||
return Err(anyhow::anyhow!("git init --bare failed: {stderr}"));
|
||||
}
|
||||
|
||||
// Security: disable git hooks (RCE prevention).
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "--file"])
|
||||
.arg(repo_dir.join("config"))
|
||||
.args(["core.hooksPath", "/dev/null"])
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("HOME", "/dev/null")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Git config for Smart HTTP compatibility.
|
||||
for (key, value) in [
|
||||
("http.receivepack", "true"),
|
||||
("receive.denyNonFastForwards", "false"),
|
||||
("uploadpack.allowTipSHA1InWant", "true"),
|
||||
("uploadpack.allowReachableSHA1InWant", "true"),
|
||||
] {
|
||||
let _ = Command::new("git")
|
||||
.args(["config", "--file"])
|
||||
.arg(repo_dir.join("config"))
|
||||
.args([key, value])
|
||||
.env_clear()
|
||||
.env("PATH", std::env::var("PATH").unwrap_or_default())
|
||||
.env("GIT_CONFIG_NOSYSTEM", "1")
|
||||
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
||||
.env("HOME", "/dev/null")
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders: remove hooks directory entirely.
|
||||
let hooks_dir = repo_dir.join("hooks");
|
||||
if hooks_dir.exists() {
|
||||
let _ = tokio::fs::remove_dir_all(&hooks_dir).await;
|
||||
}
|
||||
|
||||
info!(
|
||||
repo_id = %repo_id,
|
||||
owner = %owner_hex,
|
||||
path = %repo_dir.display(),
|
||||
"bare git repo created from kind:30617 announcement"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.layer(RequestBodyLimitLayer::new(media_body_limit))
|
||||
.with_state(state.clone());
|
||||
|
||||
// ── Git routes: configurable body limit (default 500 MB) ─────────────────
|
||||
let git_router = api::git::git_router(state.clone());
|
||||
|
||||
// ── All other routes: 1 MB body limit ────────────────────────────────────
|
||||
let api_router = Router::new()
|
||||
.route("/", get(nip11_or_ws_handler))
|
||||
@@ -164,6 +167,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
// Metrics → Trace → CORS applied once over the combined router.
|
||||
api_router
|
||||
.merge(media_router)
|
||||
.merge(git_router)
|
||||
.layer(middleware::from_fn(track_metrics))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(build_cors_layer(&state.config.cors_origins))
|
||||
|
||||
@@ -181,6 +181,12 @@ pub struct AppState {
|
||||
pub conn_semaphore: Arc<Semaphore>,
|
||||
/// Semaphore limiting concurrent message handler tasks.
|
||||
pub handler_semaphore: Arc<Semaphore>,
|
||||
/// Semaphore limiting concurrent git subprocess operations.
|
||||
pub git_semaphore: Arc<Semaphore>,
|
||||
/// Per-repo mutex map — prevents concurrent pushes to the same bare repo.
|
||||
/// Key: canonical repo path. Value: mutex guarding exclusive push access.
|
||||
pub git_repo_locks: Arc<DashMap<std::path::PathBuf, Arc<tokio::sync::Mutex<()>>>>,
|
||||
|
||||
/// Workflow engine for background processing.
|
||||
pub workflow_engine: Arc<WorkflowEngine>,
|
||||
/// Relay signing keypair — used to sign system messages (kind 40099).
|
||||
@@ -301,6 +307,7 @@ impl AppState {
|
||||
tracing::warn!("audit log worker exited (expected on shutdown)");
|
||||
});
|
||||
|
||||
let git_max_concurrent_ops = config.git_max_concurrent_ops;
|
||||
let state = Self {
|
||||
config: Arc::new(config),
|
||||
db,
|
||||
@@ -313,6 +320,8 @@ impl AppState {
|
||||
conn_manager: Arc::new(ConnectionManager::new()),
|
||||
conn_semaphore: Arc::new(Semaphore::new(max_connections)),
|
||||
handler_semaphore: Arc::new(Semaphore::new(max_concurrent_handlers)),
|
||||
git_semaphore: Arc::new(Semaphore::new(git_max_concurrent_ops)),
|
||||
git_repo_locks: Arc::new(DashMap::new()),
|
||||
workflow_engine,
|
||||
relay_keypair,
|
||||
mint_rate_limiter: Arc::new(MintRateLimiter::new()),
|
||||
|
||||
Reference in New Issue
Block a user