michal/tit
Browse tree · Show commit · Download archive
Diff
87bb8999bb6a → 4d0156f9c0bd
.github/workflows/ci.yml
Mode 100644 → 100644; object 45c626658dcd → c218933a45ee
@@ -31,7 +31,8 @@
- run: cargo --version
- run: cargo test --locked --all-targets --all-features
- run: cargo test --locked --release --test auth --test ssh
- - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh
+ - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes --test serve
+ - run: cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
- run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture
- run: cargo build --locked --release
- run: wc -c target/release/tit
Cargo.lock
Mode 100644 → 100644; object abedf5827148 → 033fe5aa70e8
@@ -3277,6 +3277,16 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3573,6 +3583,7 @@ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2",
Cargo.toml
Mode 100644 → 100644; object 6e66bc45730b → b63fcde59c22
@@ -27,7 +27,7 @@
sha2 = { version = "0.11", default-features = false }
ssh-key = { version = "=0.7.0-rc.11", default-features = false, features = ["ed25519", "p256", "std"] }
thiserror = "2.0"
-tokio = { version = "1.53", default-features = false, features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] }
+tokio = { version = "1.53", default-features = false, features = ["fs", "io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
tokio-stream = { version = "0.1", default-features = false }
toml = { version = "1.1", default-features = false, features = ["parse", "serde", "std"] }
url = { version = "2.5", features = ["serde"] }
README.md
Mode 100644 → 100644; object e71fe9ba9287 → 5122da99df10
@@ -15,6 +15,25 @@ cargo build --locked ``` +## Run + +Create the first administrator and import a bare repository before you start +the server: + +```text +tit --config /srv/tit/config.toml setup admin alice "SSH_PUBLIC_KEY" +tit --config /srv/tit/config.toml admin repository import alice example /absolute/path/example.git +tit --config /srv/tit/config.toml serve +``` + +The `serve` command starts the HTTP and SSH listeners in one process. It creates +`ssh_host_ed25519_key` with mode 600 during the first start and uses the same +host key during subsequent starts. Keep this file with the instance data. + +The server owns the instance lock until it receives SIGINT or SIGTERM. Stop the +server before you run an offline administrator command. A subsequent milestone +routes necessary online administrator commands through the control socket. + ## Quality gate Install `cargo-deny` version 0.20.2. Then, run this command from the repository
docs/adr/0009-operational-server.md
Mode → 100644; object → d7f230bcee5c
@@ -1,0 +1,45 @@ +# Architectural decision record 0009: operational server + +Status: Accepted + +Date: 2026-07-23 + +## Context + +The Milestone 2 services had test interfaces, but the `tit` executable did not +start them. The Milestone 2 gate requires an operator to import a repository and +let users browse and clone it through HTTP and SSH. + +## Decision + +Add `tit serve`. Hold the exclusive instance lock for the complete server run. +Start the Axum HTTP listener and the Russh SSH listener in one Tokio process. +Stop both listeners after SIGINT or SIGTERM. If one listener cannot start, stop +the listener that already started and return an error. + +At startup, read the active SSH public keys and the active public repositories +from SQLite. Give the SSH transport a fixed map from each owner and repository +slug to its immutable repository ID. Resolve the final path below the canonical +repository directory. The instance lock prevents an offline command from +changing this map during the server run. + +Create an Ed25519 SSH host key in `ssh_host_ed25519_key` during the first start. +Use mode 600 and refuse a symbolic link, a non-file path, unsafe permissions, +an encrypted key, a different key algorithm, and an oversized key file. Read +the same key during subsequent starts. + +## Evidence + +The executable test creates an administrator, imports a repository, and starts +`tit serve`. It browses and clones the managed repository through HTTP. It also +uses the administrator key and stock Git and OpenSSH to clone through SSH. The +test confirms that an offline command cannot acquire the instance lock. It +sends SIGTERM, confirms a clean stop, starts the server again, and confirms that +the SSH host key did not change. + +## Consequences + +The Web UI and both Git transports now use the repository layout that the admin +command creates. An operator must stop the server to use an offline admin +command. Milestone 3.1 adds the owner-only control socket for specified +online operations.
scripts/check-m2-8
Mode 100755 → 100755; object e79d489fd1ee → 5e809e5f5fcb
@@ -4,3 +4,4 @@ ./scripts/check cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats +cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh
src/cli.rs
Mode 100644 → 100644; object daefd6aa2c09 → 4d442cbc6629
@@ -46,6 +46,8 @@
#[derive(Clone, Debug, Subcommand)]
pub(crate) enum Command {
+ /// Start the HTTP and SSH servers
+ Serve,
/// Check the instance database
Doctor,
/// Set up an uninitialized instance
src/git/transport.rs
Mode 100644 → 100644; object 73d8a64ec0bb → 696df1d706f8
@@ -1,5 +1,7 @@
+use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
+use std::sync::Arc;
use thiserror::Error;
use tokio::sync::{AcquireError, OwnedSemaphorePermit, Semaphore};
@@ -9,6 +11,7 @@
#[derive(Clone)]
pub(crate) struct GitRepositories {
root: PathBuf,
+ managed_public: Option<Arc<HashMap<(String, String), String>>>,
push_database: Option<PathBuf>,
push_jobs: std::sync::Arc<Semaphore>,
blocking_jobs: std::sync::Arc<Semaphore>,
@@ -22,6 +25,7 @@
})?;
Ok(Self {
root,
+ managed_public: None,
push_database: None,
push_jobs: std::sync::Arc::new(Semaphore::new(1)),
blocking_jobs: std::sync::Arc::new(Semaphore::new(MAX_BLOCKING_GIT_JOBS)),
@@ -37,6 +41,25 @@
Ok(repositories)
}
+ pub(crate) fn new_managed_public(
+ root: &Path,
+ repositories: impl IntoIterator<Item = (String, String, String)>,
+ ) -> Result<Self, RepositoryPathError> {
+ let mut service = Self::new(root)?;
+ let mut paths = HashMap::new();
+ for (owner, repository, id) in repositories {
+ if !valid_managed_id(&id)
+ || paths
+ .insert((owner.clone(), repository.clone()), id)
+ .is_some()
+ {
+ return Err(RepositoryPathError::InvalidCatalog);
+ }
+ }
+ service.managed_public = Some(Arc::new(paths));
+ Ok(service)
+ }
+
pub(crate) fn resolve(
&self,
owner: &str,
@@ -49,7 +72,21 @@
if !valid_name(repository, 100) {
return Err(RepositoryPathError::InvalidName);
}
- let candidate = self.root.join(owner).join(format!("{repository}.git"));
+ let candidate = match &self.managed_public {
+ Some(repositories) => {
+ let id = repositories
+ .get(&(owner.to_owned(), repository.to_owned()))
+ .ok_or_else(|| RepositoryPathError::Repository {
+ path: self.root.join(owner).join(repository),
+ source: std::io::Error::new(
+ std::io::ErrorKind::NotFound,
+ "repository is not public and active",
+ ),
+ })?;
+ self.root.join(format!("{id}.git"))
+ }
+ None => self.root.join(owner).join(format!("{repository}.git")),
+ };
let candidate =
fs::canonicalize(&candidate).map_err(|source| RepositoryPathError::Repository {
path: candidate,
@@ -131,6 +168,13 @@
}
}
+fn valid_managed_id(value: &str) -> bool {
+ value.len() == 32
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
+}
+
pub(crate) enum GitSshService {
Upload(PathBuf),
Receive(PathBuf),
@@ -166,6 +210,8 @@
},
#[error("repository resolves outside the repository root")]
OutsideRoot,
+ #[error("managed repository catalog is not valid")]
+ InvalidCatalog,
}
#[cfg(test)]
@@ -210,5 +256,33 @@
] {
assert!(repositories.resolve_ssh_command(command).is_err());
}
+ }
+
+ #[test]
+ fn resolves_managed_public_repositories_by_immutable_id() {
+ let directory = TempDir::new().expect("create a managed repository root");
+ let id = "11111111111111111111111111111111";
+ let repository = directory.path().join(format!("{id}.git"));
+ fs::create_dir(&repository).expect("create a managed repository");
+ let repositories = GitRepositories::new_managed_public(
+ directory.path(),
+ [("alice".to_owned(), "example".to_owned(), id.to_owned())],
+ )
+ .expect("open the managed repository root");
+
+ assert_eq!(
+ repositories
+ .resolve_ssh_command(b"git-upload-pack '/alice/example.git'")
+ .expect("resolve a managed repository"),
+ fs::canonicalize(&repository).expect("canonicalize the managed repository")
+ );
+ assert!(repositories.resolve("alice", "missing").is_err());
+ assert!(matches!(
+ GitRepositories::new_managed_public(
+ directory.path(),
+ [("alice".to_owned(), "bad".to_owned(), "not-an-id".to_owned())]
+ ),
+ Err(RepositoryPathError::InvalidCatalog)
+ ));
}
}
src/main.rs
Mode 100644 → 100644; object 04feabf673b1 → 45180fd55873
@@ -9,16 +9,14 @@
mod config;
mod domain;
mod feed;
-#[allow(dead_code, reason = "M1C proves Git reads before the CLI serves them")]
+#[allow(dead_code, reason = "the server uses only part of the shared Git API")]
mod git;
-#[allow(
- dead_code,
- reason = "M2 establishes the Web interface before tit serve calls it"
-)]
+#[allow(dead_code, reason = "the server uses only part of the shared HTTP API")]
mod http;
mod instance;
mod markdown;
-#[allow(dead_code, reason = "M1B proves the SSH server before M2 calls it")]
+mod serve;
+#[allow(dead_code, reason = "the server uses only part of the shared SSH API")]
mod ssh;
mod store;
@@ -29,7 +27,8 @@
use crate::cli::{AdminCommand, Cli, Command, ObjectFormat, RepositoryCommand, SetupCommand};
-fn main() -> ExitCode {
+#[tokio::main]
+async fn main() -> ExitCode {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(error) => {
@@ -42,6 +41,13 @@
match config::load(&cli) {
Ok(config) => match cli.command {
None => ExitCode::SUCCESS,
+ Some(Command::Serve) => match serve::run(&config).await {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("tit: {error}");
+ ExitCode::FAILURE
+ }
+ },
Some(Command::Doctor) => match store::doctor(&config.instance_dir) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
src/serve.rs
Mode → 100644; object → 34a8ef64f4b7
@@ -1,0 +1,250 @@
+use std::fs::{self, File, OpenOptions};
+use std::io::{Read, Write};
+use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
+use std::path::{Path, PathBuf};
+
+use rand::rng;
+use ssh_key::{Algorithm, LineEnding, PrivateKey};
+use thiserror::Error;
+
+use crate::auth::{AuthError, SshPublicKey};
+use crate::config::{Config, ConfigError};
+use crate::git::transport::{GitRepositories, RepositoryPathError};
+use crate::http::{PublicWebConfig, RunningWebServer, WebError};
+use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
+use crate::ssh::{RunningSshServer, SshServerError};
+use crate::store::{Store, StoreError};
+
+pub(crate) async fn run(config: &Config) -> Result<(), ServeError> {
+ let _lock = InstanceLock::acquire(&config.instance_dir)?;
+ let database = prepare_database(&config.instance_dir)?;
+ let repository_root = prepare_repository_root(&config.instance_dir)?;
+ let store = Store::open(&database)?;
+ let keys = store
+ .active_ssh_public_keys()?
+ .into_iter()
+ .map(|key| SshPublicKey::parse(&key))
+ .collect::<Result<Vec<_>, _>>()?;
+ let repositories = store
+ .active_public_repositories()?
+ .into_iter()
+ .map(|repository| (repository.owner, repository.slug, repository.id));
+ let git = GitRepositories::new_managed_public(&repository_root, repositories)?;
+ drop(store);
+
+ let (http_clone_base, ssh_clone_base) = clone_bases(config)?;
+ let host_key = load_or_create_host_key(&config.instance_dir)?;
+ let web = RunningWebServer::start_public(
+ config.http_listen,
+ PublicWebConfig {
+ instance_dir: config.instance_dir.clone(),
+ http_clone_base,
+ ssh_clone_base,
+ },
+ )
+ .await?;
+ let ssh = match RunningSshServer::start_with_git_and_host_key(
+ config.ssh_listen,
+ &keys,
+ git,
+ host_key,
+ )
+ .await
+ {
+ Ok(ssh) => ssh,
+ Err(error) => {
+ web.shutdown().await?;
+ return Err(error.into());
+ }
+ };
+
+ let signal = shutdown_signal().await;
+ let (ssh_result, web_result) = tokio::join!(ssh.shutdown(), web.shutdown());
+ ssh_result?;
+ web_result?;
+ signal.map_err(ServeError::Signal)
+}
+
+const HOST_KEY_FILE: &str = "ssh_host_ed25519_key";
+const MAX_HOST_KEY_BYTES: u64 = 64 * 1024;
+
+fn load_or_create_host_key(instance_dir: &Path) -> Result<PrivateKey, ServeError> {
+ let path = instance_dir.join(HOST_KEY_FILE);
+ match fs::symlink_metadata(&path) {
+ Ok(metadata) => {
+ if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
+ return Err(ServeError::InvalidHostKeyFile(path));
+ }
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ return create_host_key(&path);
+ }
+ Err(source) => return Err(ServeError::HostKeyIo { path, source }),
+ }
+ read_host_key(&path)
+}
+
+fn create_host_key(path: &Path) -> Result<PrivateKey, ServeError> {
+ let key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
+ let encoded = key.to_openssh(LineEnding::LF)?;
+ let mut file = OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(path)
+ .map_err(|source| ServeError::HostKeyIo {
+ path: path.to_owned(),
+ source,
+ })?;
+ file.write_all(encoded.as_bytes())
+ .and_then(|()| file.sync_all())
+ .map_err(|source| ServeError::HostKeyIo {
+ path: path.to_owned(),
+ source,
+ })?;
+ Ok(key)
+}
+
+fn read_host_key(path: &Path) -> Result<PrivateKey, ServeError> {
+ let file = File::open(path).map_err(|source| ServeError::HostKeyIo {
+ path: path.to_owned(),
+ source,
+ })?;
+ let metadata = file.metadata().map_err(|source| ServeError::HostKeyIo {
+ path: path.to_owned(),
+ source,
+ })?;
+ let mode = metadata.permissions().mode() & 0o777;
+ if !metadata.file_type().is_file() {
+ return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
+ }
+ if mode & 0o077 != 0 {
+ return Err(ServeError::HostKeyPermissions {
+ path: path.to_owned(),
+ mode,
+ });
+ }
+ if metadata.len() > MAX_HOST_KEY_BYTES {
+ return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
+ }
+ let capacity = usize::try_from(metadata.len())
+ .map_err(|_| ServeError::InvalidHostKeyFile(path.to_owned()))?;
+ let mut encoded = Vec::with_capacity(capacity);
+ file.take(MAX_HOST_KEY_BYTES + 1)
+ .read_to_end(&mut encoded)
+ .map_err(|source| ServeError::HostKeyIo {
+ path: path.to_owned(),
+ source,
+ })?;
+ if encoded.len() as u64 > MAX_HOST_KEY_BYTES {
+ return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
+ }
+ let key = PrivateKey::from_openssh(&encoded)?;
+ if key.algorithm() != Algorithm::Ed25519 || key.is_encrypted() {
+ return Err(ServeError::InvalidHostKeyFile(path.to_owned()));
+ }
+ Ok(key)
+}
+
+#[cfg(unix)]
+async fn shutdown_signal() -> std::io::Result<()> {
+ let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
+ tokio::select! {
+ result = tokio::signal::ctrl_c() => result,
+ _ = terminate.recv() => Ok(()),
+ }
+}
+
+#[cfg(not(unix))]
+async fn shutdown_signal() -> std::io::Result<()> {
+ tokio::signal::ctrl_c().await
+}
+
+fn clone_bases(config: &Config) -> Result<(String, String), ConfigError> {
+ let (http, ssh) = config.clone_urls("owner", "repository")?;
+ let suffix = "/owner/repository";
+ Ok((
+ http.as_str()
+ .strip_suffix(suffix)
+ .expect("the HTTP clone URL contains the supplied path")
+ .to_owned(),
+ ssh.as_str()
+ .strip_suffix(suffix)
+ .expect("the SSH clone URL contains the supplied path")
+ .to_owned(),
+ ))
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum ServeError {
+ #[error(transparent)]
+ Instance(#[from] InstanceError),
+ #[error(transparent)]
+ Store(#[from] StoreError),
+ #[error(transparent)]
+ Authentication(#[from] AuthError),
+ #[error(transparent)]
+ Repository(#[from] RepositoryPathError),
+ #[error(transparent)]
+ Configuration(#[from] ConfigError),
+ #[error(transparent)]
+ Web(#[from] WebError),
+ #[error(transparent)]
+ Ssh(#[from] SshServerError),
+ #[error("cannot wait for a shutdown signal: {0}")]
+ Signal(std::io::Error),
+ #[error("cannot read or write SSH host key {path}: {source}")]
+ HostKeyIo {
+ path: PathBuf,
+ source: std::io::Error,
+ },
+ #[error("SSH host key path is not a valid Ed25519 private-key file: {0}")]
+ InvalidHostKeyFile(PathBuf),
+ #[error("SSH host key permissions for {path} are {mode:o}, expected 600 or more restrictive")]
+ HostKeyPermissions { path: PathBuf, mode: u32 },
+ #[error(transparent)]
+ HostKey(#[from] ssh_key::Error),
+}
+
+#[cfg(test)]
+mod tests {
+ use std::os::unix::fs::symlink;
+
+ use tempfile::TempDir;
+
+ use super::*;
+
+ #[test]
+ fn persists_a_private_host_key_and_rejects_unsafe_replacements() {
+ let directory = TempDir::new().expect("create a host-key directory");
+ let first = load_or_create_host_key(directory.path()).expect("create a host key");
+ let path = directory.path().join(HOST_KEY_FILE);
+ assert_eq!(
+ fs::metadata(&path)
+ .expect("inspect the host key")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o600
+ );
+ let second = load_or_create_host_key(directory.path()).expect("read the host key");
+ assert_eq!(first.public_key(), second.public_key());
+
+ let mut permissions = fs::metadata(&path)
+ .expect("inspect the host key")
+ .permissions();
+ permissions.set_mode(0o644);
+ fs::set_permissions(&path, permissions).expect("make the host key unsafe");
+ assert!(matches!(
+ load_or_create_host_key(directory.path()),
+ Err(ServeError::HostKeyPermissions { mode: 0o644, .. })
+ ));
+
+ fs::remove_file(&path).expect("remove the host key");
+ symlink(directory.path().join("target"), &path).expect("replace the host key with a link");
+ assert!(matches!(
+ load_or_create_host_key(directory.path()),
+ Err(ServeError::InvalidHostKeyFile(candidate)) if candidate == path
+ ));
+ }
+}
src/ssh.rs
Mode 100644 → 100644; object 456b8083197a → 07a97aa26bc9
@@ -37,7 +37,8 @@
address: SocketAddr,
authorized_keys: &[SshPublicKey],
) -> Result<Self, SshServerError> {
- Self::start_inner(address, authorized_keys, &[], None).await
+ let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
+ Self::start_inner(address, authorized_keys, &[], None, host_key).await
}
pub(crate) async fn start_with_git(
@@ -45,7 +46,17 @@
authorized_keys: &[SshPublicKey],
repositories: GitRepositories,
) -> Result<Self, SshServerError> {
- Self::start_inner(address, authorized_keys, &[], Some(repositories)).await
+ let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
+ Self::start_inner(address, authorized_keys, &[], Some(repositories), host_key).await
+ }
+
+ pub(crate) async fn start_with_git_and_host_key(
+ address: SocketAddr,
+ authorized_keys: &[SshPublicKey],
+ repositories: GitRepositories,
+ host_key: PrivateKey,
+ ) -> Result<Self, SshServerError> {
+ Self::start_inner(address, authorized_keys, &[], Some(repositories), host_key).await
}
pub(crate) async fn start_with_git_writes(
@@ -64,7 +75,15 @@
.await
.map_err(|_| SshServerError::Join)?
.map_err(|error| SshServerError::Recovery(error.to_string()))?;
- Self::start_inner(address, authorized_keys, writable_keys, Some(repositories)).await
+ let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
+ Self::start_inner(
+ address,
+ authorized_keys,
+ writable_keys,
+ Some(repositories),
+ host_key,
+ )
+ .await
}
async fn start_inner(
@@ -72,10 +91,10 @@
authorized_keys: &[SshPublicKey],
writable_keys: &[SshPublicKey],
repositories: Option<GitRepositories>,
+ host_key: PrivateKey,
) -> Result<Self, SshServerError> {
let listener = TcpListener::bind(address).await?;
let address = listener.local_addr()?;
- let host_key = PrivateKey::random(&mut rng(), Algorithm::Ed25519)?;
let mut methods = MethodSet::empty();
methods.push(MethodKind::PublicKey);
let config = Arc::new(russh::server::Config {
src/store/mod.rs
Mode 100644 → 100644; object 7a572023ebd5 → 596b011e44a4
@@ -567,6 +567,44 @@
#[allow(
dead_code,
+ reason = "some integration tests import storage without the server"
+ )]
+ pub(crate) fn active_ssh_public_keys(&self) -> Result<Vec<String>, StoreError> {
+ let mut statement = self.connection.prepare(
+ "SELECT ssh_public_key.canonical_key
+ FROM ssh_public_key
+ JOIN account ON account.id = ssh_public_key.account_id
+ WHERE account.state = 'active'
+ ORDER BY ssh_public_key.id",
+ )?;
+ statement
+ .query_map([], |row| row.get(0))?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(Into::into)
+ }
+
+ #[allow(
+ dead_code,
+ reason = "some integration tests import storage without the server"
+ )]
+ pub(crate) fn active_public_repositories(&self) -> Result<Vec<RepositoryRecord>, StoreError> {
+ let mut statement = self.connection.prepare(
+ "SELECT repository.id, account.username, repository.slug,
+ repository.visibility, repository.state, repository.object_format,
+ repository.created_at, repository.archived_at
+ FROM repository
+ JOIN account ON account.id = repository.owner_account_id
+ WHERE repository.visibility = 'public' AND repository.state = 'active'
+ ORDER BY account.username, repository.slug",
+ )?;
+ statement
+ .query_map([], repository_from_row)?
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(Into::into)
+ }
+
+ #[allow(
+ dead_code,
reason = "some integration tests import storage without public event pages"
)]
pub(crate) fn public_repository_events(
tests/serve.rs
Mode → 100644; object → e59a1887c84c
@@ -1,0 +1,286 @@
+#[allow(
+ dead_code,
+ reason = "the server test uses only part of the shared test support"
+)]
+mod support;
+
+use std::fs;
+use std::io::{Read, Write};
+use std::net::{SocketAddr, TcpStream};
+use std::os::unix::fs::PermissionsExt;
+use std::path::Path;
+use std::process::{Child, Command, Stdio};
+use std::thread;
+use std::time::{Duration, Instant};
+
+use support::{create_ssh_key_fixture, free_address};
+use tempfile::TempDir;
+
+#[test]
+fn serves_an_imported_repository_through_http_and_ssh() {
+ let instance = TempDir::new().expect("create an instance directory");
+ let http = free_address();
+ let ssh = free_address();
+ let config = instance.path().join("config.toml");
+ fs::write(
+ &config,
+ format!(
+ "version = 1\npublic_url = \"http://{http}/\"\n\n[http]\nlisten = \"{http}\"\n\n[ssh]\nlisten = \"{ssh}\"\npublic_host = \"127.0.0.1\"\npublic_port = {}\n",
+ ssh.port()
+ ),
+ )
+ .expect("write the server configuration");
+ let private_key = instance.path().join("administrator");
+ create_ssh_key_fixture(&private_key);
+ let public_key = fs::read_to_string(private_key.with_extension("pub"))
+ .expect("read the administrator public key");
+ command(
+ instance.path(),
+ [
+ "--config",
+ config.to_str().expect("a UTF-8 configuration path"),
+ "setup",
+ "admin",
+ "alice",
+ public_key.trim(),
+ ],
+ );
+
+ let source = create_source_repository(instance.path());
+ command(
+ instance.path(),
+ [
+ "--config",
+ config.to_str().expect("a UTF-8 configuration path"),
+ "admin",
+ "repository",
+ "import",
+ "alice",
+ "example",
+ source.to_str().expect("a UTF-8 source path"),
+ ],
+ );
+
+ let mut server = spawn_server(&config);
+ wait_for_listener(http, &mut server);
+ wait_for_listener(ssh, &mut server);
+
+ let summary = http_get(http, "/alice/example");
+ assert!(summary.starts_with("HTTP/1.1 200"));
+ assert!(summary.contains("serve fixture"));
+
+ let http_clone = instance.path().join("http-clone");
+ command(
+ instance.path(),
+ [
+ "clone",
+ "-q",
+ &format!("http://{http}/alice/example.git"),
+ http_clone.to_str().expect("a UTF-8 HTTP clone path"),
+ ],
+ );
+ assert_eq!(
+ fs::read(http_clone.join("README.md")).expect("read the HTTP clone"),
+ b"serve fixture\n"
+ );
+
+ let ssh_clone = instance.path().join("ssh-clone");
+ let ssh_command = format!(
+ "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
+ private_key.display()
+ );
+ let output = Command::new("git")
+ .args([
+ "clone",
+ "-q",
+ &format!("ssh://ignored@127.0.0.1:{}/alice/example.git", ssh.port()),
+ ])
+ .arg(&ssh_clone)
+ .env("GIT_SSH_COMMAND", ssh_command)
+ .output()
+ .expect("clone through the tit SSH server");
+ assert!(
+ output.status.success(),
+ "SSH clone failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ assert_eq!(
+ fs::read(ssh_clone.join("README.md")).expect("read the SSH clone"),
+ b"serve fixture\n"
+ );
+
+ let locked = Command::new(env!("CARGO_BIN_EXE_tit"))
+ .args([
+ "--config",
+ config.to_str().expect("a UTF-8 configuration path"),
+ "admin",
+ "repository",
+ "inspect",
+ "alice",
+ "example",
+ ])
+ .output()
+ .expect("run an offline command while the server owns the instance");
+ assert_eq!(locked.status.code(), Some(1));
+ assert!(String::from_utf8_lossy(&locked.stderr).contains("owns the instance lock"));
+
+ server.terminate();
+ let host_key = fs::read(instance.path().join("ssh_host_ed25519_key"))
+ .expect("read the generated SSH host key");
+ assert_eq!(
+ fs::metadata(instance.path().join("ssh_host_ed25519_key"))
+ .expect("inspect the generated SSH host key")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o600
+ );
+ let mut restarted = spawn_server(&config);
+ wait_for_listener(http, &mut restarted);
+ wait_for_listener(ssh, &mut restarted);
+ assert_eq!(
+ fs::read(instance.path().join("ssh_host_ed25519_key"))
+ .expect("read the reused SSH host key"),
+ host_key
+ );
+ restarted.terminate();
+}
+
+fn spawn_server(config: &Path) -> ChildGuard {
+ let child = Command::new(env!("CARGO_BIN_EXE_tit"))
+ .args([
+ "--config",
+ config.to_str().expect("a UTF-8 configuration path"),
+ "serve",
+ ])
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("start the tit server");
+ ChildGuard(Some(child))
+}
+
+fn create_source_repository(parent: &Path) -> std::path::PathBuf {
+ let worktree = parent.join("source-worktree");
+ command(
+ parent,
+ [
+ "init",
+ "-q",
+ "-b",
+ "main",
+ worktree.to_str().expect("a UTF-8 worktree path"),
+ ],
+ );
+ fs::write(worktree.join("README.md"), b"serve fixture\n").expect("write source content");
+ command(&worktree, ["add", "."]);
+ let output = Command::new("git")
+ .args(["commit", "-q", "-m", "initial"])
+ .env("GIT_AUTHOR_NAME", "Tit Test")
+ .env("GIT_AUTHOR_EMAIL", "tit@example.test")
+ .env("GIT_COMMITTER_NAME", "Tit Test")
+ .env("GIT_COMMITTER_EMAIL", "tit@example.test")
+ .env("GIT_CONFIG_COUNT", "1")
+ .env("GIT_CONFIG_KEY_0", "commit.gpgsign")
+ .env("GIT_CONFIG_VALUE_0", "false")
+ .current_dir(&worktree)
+ .output()
+ .expect("commit source content");
+ assert!(output.status.success(), "Git commit failed");
+ let bare = parent.join("source.git");
+ command(
+ parent,
+ [
+ "clone",
+ "-q",
+ "--bare",
+ worktree.to_str().expect("a UTF-8 worktree path"),
+ bare.to_str().expect("a UTF-8 bare path"),
+ ],
+ );
+ bare
+}
+
+fn command<const N: usize>(directory: &Path, arguments: [&str; N]) {
+ let executable = if matches!(arguments.first(), Some(&"--config")) {
+ env!("CARGO_BIN_EXE_tit")
+ } else {
+ "git"
+ };
+ let output = Command::new(executable)
+ .args(arguments)
+ .current_dir(directory)
+ .output()
+ .expect("run a fixture command");
+ assert!(
+ output.status.success(),
+ "fixture command failed: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+}
+
+fn wait_for_listener(address: SocketAddr, server: &mut ChildGuard) {
+ let deadline = Instant::now() + Duration::from_secs(10);
+ loop {
+ if TcpStream::connect(address).is_ok() {
+ return;
+ }
+ if let Some(status) = server
+ .0
+ .as_mut()
+ .expect("a running server")
+ .try_wait()
+ .expect("check the server")
+ {
+ panic!("tit serve stopped early with {status}");
+ }
+ assert!(
+ Instant::now() < deadline,
+ "listener {address} did not start"
+ );
+ thread::sleep(Duration::from_millis(20));
+ }
+}
+
+fn http_get(address: SocketAddr, path: &str) -> String {
+ let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
+ write!(
+ stream,
+ "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n"
+ )
+ .expect("write an HTTP request");
+ let mut response = String::new();
+ stream
+ .read_to_string(&mut response)
+ .expect("read an HTTP response");
+ response
+}
+
+struct ChildGuard(Option<Child>);
+
+impl ChildGuard {
+ fn terminate(&mut self) {
+ if let Some(mut child) = self.0.take() {
+ let signal = Command::new("kill")
+ .args(["-TERM", &child.id().to_string()])
+ .output()
+ .expect("send SIGTERM to the tit server");
+ assert!(signal.status.success(), "cannot send SIGTERM");
+ let status = child.wait().expect("wait for the tit server");
+ assert!(status.success(), "tit serve did not stop cleanly: {status}");
+ }
+ }
+
+ fn stop(&mut self) {
+ if let Some(mut child) = self.0.take() {
+ child.kill().expect("stop the tit server");
+ child.wait().expect("wait for the tit server");
+ }
+ }
+}
+
+impl Drop for ChildGuard {
+ fn drop(&mut self) {
+ self.stop();
+ }
+}