├── rust-toolchain ├── .dockerignore ├── CODEOWNERS ├── .env ├── .env.seed ├── .gitignore ├── .env.maple ├── .env.pine ├── .env.willow ├── http-api ├── README.md ├── src │ ├── v1.rs │ ├── v1 │ │ ├── peer.rs │ │ ├── stats.rs │ │ ├── sessions.rs │ │ └── delegates.rs │ ├── commit.rs │ ├── project.rs │ ├── main.rs │ ├── auth.rs │ ├── axum_extra.rs │ ├── test_extra.rs │ ├── error.rs │ └── lib.rs ├── Dockerfile └── Cargo.toml ├── scripts ├── post-receive-ok └── reset.sh ├── shared ├── Cargo.toml └── src │ ├── logging.rs │ ├── signer.rs │ ├── lib.rs │ └── identity.rs ├── git-server ├── bin │ ├── pre_receive.rs │ └── post_receive.rs ├── docker │ └── radicle-git-server.sh ├── src │ ├── hooks │ │ ├── mod.rs │ │ ├── pre_receive.rs │ │ ├── types.rs │ │ ├── storage.rs │ │ └── post_receive.rs │ ├── main.rs │ ├── error.rs │ └── lib.rs ├── Dockerfile ├── Cargo.toml └── README.md ├── LICENSE-MIT ├── DCO ├── linkd └── Dockerfile ├── Cargo.toml ├── docker-compose.seed.yml ├── .github └── workflows │ ├── release.yml │ ├── actions.yml │ └── deployables.yml ├── docker-compose.yml ├── LOCAL.md ├── DESIGN.md ├── README.md └── LICENSE-APACHE /rust-toolchain: -------------------------------------------------------------------------------- 1 | 1.64 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cloudhead 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | RADICLE_DOMAIN= 2 | RADICLE_GIT_SERVER_OPTS= 3 | -------------------------------------------------------------------------------- /.env.seed: -------------------------------------------------------------------------------- 1 | RADICLE_DOMAIN=clients.radicle.xyz 2 | RADICLE_GIT_SERVER_OPTS= 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | errors.err 3 | store.json 4 | 5 | # Mac OS 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /.env.maple: -------------------------------------------------------------------------------- 1 | RADICLE_DOMAIN=maple.radicle.garden 2 | RADICLE_GIT_SERVER_OPTS=--git-receive-pack --allow-unauthorized-keys 3 | -------------------------------------------------------------------------------- /.env.pine: -------------------------------------------------------------------------------- 1 | RADICLE_DOMAIN=pine.radicle.garden 2 | RADICLE_GIT_SERVER_OPTS=--git-receive-pack --allow-unauthorized-keys 3 | -------------------------------------------------------------------------------- /.env.willow: -------------------------------------------------------------------------------- 1 | RADICLE_DOMAIN=willow.radicle.garden 2 | RADICLE_GIT_SERVER_OPTS=--git-receive-pack --allow-unauthorized-keys 3 | -------------------------------------------------------------------------------- /http-api/README.md: -------------------------------------------------------------------------------- 1 | # Radicle HTTP API 2 | 3 | > ✨ Interact with Radicle, via HTTP. 4 | 5 | # Running 6 | 7 | $ radicle-http-api --root ~/.radicle 8 | -------------------------------------------------------------------------------- /scripts/post-receive-ok: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "hook: I am the receive hook!" 4 | echo "hook: Project name is $RADICLE_NAME" 5 | 6 | while read line 7 | do 8 | echo "hook: stdin: $line" 9 | done 10 | 11 | echo "hook: Exiting..." 12 | exit 0 13 | -------------------------------------------------------------------------------- /shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shared" 3 | version = "0.1.0" 4 | edition = "2018" 5 | 6 | [dependencies] 7 | anyhow = "1" 8 | async-trait = "0.1.53" 9 | base64 = "0.13" 10 | byteorder = "1.4" 11 | librad = "0" 12 | sha2 = { version = "0.10.2" } 13 | tracing = "0.1" 14 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 15 | tracing-logfmt = "0.1.2" 16 | radicle-common = { version = "0.1.0" } 17 | 18 | [features] 19 | default = [] 20 | -------------------------------------------------------------------------------- /http-api/src/v1.rs: -------------------------------------------------------------------------------- 1 | mod delegates; 2 | mod peer; 3 | mod projects; 4 | mod sessions; 5 | mod stats; 6 | 7 | use axum::Router; 8 | 9 | use crate::Context; 10 | 11 | pub fn router(ctx: Context) -> Router { 12 | let routes = Router::new() 13 | .merge(peer::router(ctx.clone())) 14 | .merge(stats::router(ctx.clone())) 15 | .merge(projects::router(ctx.clone())) 16 | .merge(sessions::router(ctx.clone())) 17 | .merge(delegates::router(ctx)); 18 | 19 | Router::new().nest("/v1", routes) 20 | } 21 | -------------------------------------------------------------------------------- /shared/src/logging.rs: -------------------------------------------------------------------------------- 1 | use tracing::dispatcher::{self, Dispatch}; 2 | use tracing_subscriber::layer::SubscriberExt; 3 | use tracing_subscriber::EnvFilter; 4 | use tracing_subscriber::Registry; 5 | 6 | pub fn init_logger() { 7 | let subscriber = Registry::default() 8 | .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) 9 | .with(tracing_logfmt::layer()); 10 | 11 | dispatcher::set_global_default(Dispatch::new(subscriber)) 12 | .expect("Global logger has already been set!"); 13 | } 14 | -------------------------------------------------------------------------------- /git-server/bin/pre_receive.rs: -------------------------------------------------------------------------------- 1 | //! `pre-receive` git hook binary. 2 | 3 | use radicle_git_server::error::Error; 4 | 5 | #[cfg(feature = "hooks")] 6 | fn main() -> Result<(), Error> { 7 | use radicle_git_server::hooks::pre_receive::PreReceive; 8 | 9 | match PreReceive::hook() { 10 | Ok(()) => { 11 | eprintln!("Pre-receive hook success."); 12 | std::process::exit(0); 13 | } 14 | Err(e) => { 15 | eprintln!("Error: {}", e); 16 | std::process::exit(1); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /git-server/bin/post_receive.rs: -------------------------------------------------------------------------------- 1 | //! `post-receive` git hook binary. 2 | 3 | use radicle_git_server::error::Error; 4 | 5 | #[cfg(feature = "hooks")] 6 | fn main() -> Result<(), Error> { 7 | use radicle_git_server::hooks::post_receive::PostReceive; 8 | 9 | match PostReceive::hook() { 10 | Ok(()) => { 11 | eprintln!("Post-receive hook success."); 12 | std::process::exit(0) 13 | } 14 | Err(e) => { 15 | eprintln!("Error: {}", e); 16 | std::process::exit(1) 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /git-server/docker/radicle-git-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o errexit 4 | set -o nounset 5 | set -o pipefail 6 | set -o errtrace 7 | 8 | main () { 9 | if [[ -z ${RAD_HOME:-} ]]; then 10 | echo "RAD_HOME is unset" 11 | return 1 12 | fi 13 | rad_profile=$(cat "${RAD_HOME}/active_profile") 14 | cp --force /usr/local/bin/pre-receive "${RAD_HOME}/${rad_profile}/git/hooks/pre-receive" 15 | cp --force /usr/local/bin/post-receive "${RAD_HOME}/${rad_profile}/git/hooks/post-receive" 16 | exec /usr/local/bin/radicle-git-server "$@" 17 | } 18 | 19 | main "$@" 20 | -------------------------------------------------------------------------------- /http-api/src/v1/peer.rs: -------------------------------------------------------------------------------- 1 | use axum::response::IntoResponse; 2 | use axum::routing::get; 3 | use axum::{Extension, Json, Router}; 4 | use serde_json::json; 5 | 6 | use librad::PeerId; 7 | 8 | use crate::Context; 9 | 10 | pub fn router(ctx: Context) -> Router { 11 | let peer_id = ctx.peer_id; 12 | 13 | Router::new() 14 | .route("/peer", get(peer_handler)) 15 | .layer(Extension(peer_id)) 16 | } 17 | 18 | /// Return the peer id for the node identity. 19 | /// `GET /peer` 20 | async fn peer_handler(Extension(peer_id): Extension) -> impl IntoResponse { 21 | let response = json!({ 22 | "id": peer_id.to_string(), 23 | }); 24 | 25 | Json(response) 26 | } 27 | -------------------------------------------------------------------------------- /scripts/reset.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | confirm() { 3 | read -p "$1 [y/n] " choice 4 | case "$choice" in 5 | y|Y) ;; 6 | *) exit 1 ;; 7 | esac 8 | } 9 | 10 | unset SSH_AGENT_PID 11 | unset SSH_AUTH_SOCK 12 | export RAD_HOME=${1:-root} 13 | 14 | ### Delete old identity ### 15 | 16 | MONOREPO=$(rad path 2>/dev/null) 17 | RESULT=$? 18 | 19 | set -e 20 | 21 | if [ $RESULT -eq 0 ]; then 22 | echo "Identity exists..." 23 | if [ -d $MONOREPO ]; then 24 | confirm "Delete $MONOREPO?" 25 | rm -rf $MONOREPO 26 | fi 27 | fi 28 | 29 | ### Initialize new identity ### 30 | 31 | rad auth --init --name "seed" --passphrase "seed" 32 | echo 33 | echo "Initialized $(rad path)" 34 | 35 | MONOREPO=$(rad path) 36 | 37 | set -x 38 | cp target/release/{pre,post}-receive $MONOREPO/hooks 39 | cp scripts/post-receive-ok $MONOREPO/hooks 40 | cp authorized-keys $MONOREPO/ 41 | -------------------------------------------------------------------------------- /http-api/src/commit.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use radicle_common::project::PeerInfo; 4 | use radicle_source::commit::Header; 5 | 6 | #[derive(Serialize, Deserialize, Clone)] 7 | #[serde(rename_all = "kebab-case")] 8 | pub struct CommitsQueryString { 9 | pub parent: Option, 10 | pub since: Option, 11 | pub until: Option, 12 | pub page: Option, 13 | pub per_page: Option, 14 | pub verified: Option, 15 | } 16 | 17 | #[derive(Serialize)] 18 | pub struct CommitTeaser { 19 | pub header: Header, 20 | pub context: CommitContext, 21 | } 22 | 23 | #[derive(Serialize)] 24 | pub struct Commit { 25 | pub header: Header, 26 | pub stats: radicle_source::commit::Stats, 27 | pub diff: radicle_surf::diff::Diff, 28 | pub branches: Vec, 29 | pub context: CommitContext, 30 | } 31 | 32 | #[derive(Serialize)] 33 | pub struct CommitContext { 34 | pub committer: Option, 35 | } 36 | 37 | #[derive(Serialize)] 38 | pub struct Committer { 39 | pub peer: PeerInfo, 40 | } 41 | -------------------------------------------------------------------------------- /http-api/src/v1/stats.rs: -------------------------------------------------------------------------------- 1 | use axum::response::IntoResponse; 2 | use axum::routing::get; 3 | use axum::{Extension, Json, Router}; 4 | use librad::git::identities::{self, SomeIdentity}; 5 | use serde_json::json; 6 | 7 | use crate::Context; 8 | use crate::Error; 9 | 10 | pub fn router(ctx: Context) -> Router { 11 | Router::new() 12 | .route("/stats", get(stats_handler)) 13 | .layer(Extension(ctx)) 14 | } 15 | 16 | /// Return the stats for the node. 17 | /// `GET /stats` 18 | async fn stats_handler(Extension(ctx): Extension) -> impl IntoResponse { 19 | let storage = ctx.storage().await?; 20 | let (projects, persons): (Vec<_>, Vec<_>) = identities::any::list(storage.read_only()) 21 | .map_err(Error::from)? 22 | .partition(|identity| match identity { 23 | Ok(SomeIdentity::Project(_)) => true, 24 | Ok(SomeIdentity::Person(_)) => false, 25 | _ => panic!("Error while listing identities"), 26 | }); 27 | 28 | Ok::<_, Error>(Json( 29 | json!({ "projects": { "count": projects.len() }, "users": { "count": persons.len() } }), 30 | )) 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 The Radicle Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /http-api/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM rust:1.61.0-slim@sha256:91ab0966aa0d8eff103f42c04e0f4dd0bc628d1330942616a94bbe260f26fe6e as build 3 | 4 | RUN apt-get update && apt-get install -y pkg-config libssl-dev git cmake 5 | 6 | WORKDIR /usr/src/radicle-client-services 7 | COPY . . 8 | 9 | WORKDIR /usr/src/radicle-client-services/http-api 10 | RUN set -eux; \ 11 | cargo install --profile=container --all-features --locked --path .; \ 12 | objcopy --compress-debug-sections /usr/local/cargo/bin/radicle-http-api /usr/local/cargo/bin/radicle-http-api.compressed 13 | 14 | # Run 15 | FROM debian:bullseye-slim@sha256:4c25ffa6ef572cf0d57da8c634769a08ae94529f7de5be5587ec8ce7b9b50f9c 16 | 17 | EXPOSE 8777/tcp 18 | RUN echo deb http://deb.debian.org/debian bullseye-backports main contrib non-free >/etc/apt/sources.list.d/backports.list 19 | RUN apt-get update && apt-get install -y libssl1.1 && apt -t bullseye-backports install --yes git && rm -rf /var/lib/apt/lists/* 20 | COPY --from=build /usr/local/cargo/bin/radicle-http-api.compressed /usr/local/bin/radicle-http-api 21 | WORKDIR /app/radicle 22 | ENTRYPOINT ["/usr/local/bin/radicle-http-api", "--listen", "0.0.0.0:8777", "--root", "/app/radicle"] 23 | -------------------------------------------------------------------------------- /DCO: -------------------------------------------------------------------------------- 1 | Developer's Certificate of Origin 1.1 2 | Copyright © 2004, 2006 The Linux Foundation and its contributors. 3 | 4 | --- 5 | 6 | By making a contribution to this project, I certify that: 7 | 8 | (a) The contribution was created in whole or in part by me and I 9 | have the right to submit it under the open source license 10 | indicated in the file; or 11 | 12 | (b) The contribution is based upon previous work that, to the best 13 | of my knowledge, is covered under an appropriate open source 14 | license and I have the right under that license to submit that 15 | work with modifications, whether created in whole or in part 16 | by me, under the same open source license (unless I am 17 | permitted to submit under a different license), as indicated 18 | in the file; or 19 | 20 | (c) The contribution was provided directly to me by some other 21 | person who certified (a), (b) or (c) and I have not modified 22 | it. 23 | 24 | (d) I understand and agree that this project and the contribution 25 | are public and that a record of the contribution (including all 26 | personal information I submit with it, including my sign-off) is 27 | maintained indefinitely and may be redistributed consistent with 28 | this project or the open source license(s) involved. 29 | -------------------------------------------------------------------------------- /linkd/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM rust:1.61.0-slim@sha256:91ab0966aa0d8eff103f42c04e0f4dd0bc628d1330942616a94bbe260f26fe6e as build 3 | 4 | RUN apt-get update && apt-get install -y pkg-config libssl-dev git cmake 5 | 6 | RUN git clone https://github.com/radicle-dev/radicle-link.git /usr/src/radicle-link 7 | WORKDIR /usr/src/radicle-link 8 | RUN git reset --hard 622c1bcd59a6ce584f957ffe6b874b2af0b207fd 9 | 10 | WORKDIR /usr/src/radicle-link/bins/linkd 11 | RUN set -eux; \ 12 | cargo install --locked --path .; \ 13 | objcopy --compress-debug-sections /usr/local/cargo/bin/linkd /usr/local/cargo/bin/radicle-linkd.compressed 14 | 15 | # Run 16 | FROM debian:bullseye-slim@sha256:4c25ffa6ef572cf0d57da8c634769a08ae94529f7de5be5587ec8ce7b9b50f9c 17 | 18 | EXPOSE 8777/tcp 19 | RUN echo deb http://deb.debian.org/debian bullseye-backports main contrib non-free >/etc/apt/sources.list.d/backports.list 20 | RUN apt-get update && apt-get install -y libssl1.1 && apt -t bullseye-backports install --yes git && rm -rf /var/lib/apt/lists/* 21 | COPY --from=build /usr/local/cargo/bin/radicle-linkd.compressed /usr/local/bin/radicle-linkd 22 | WORKDIR /app/radicle 23 | ENTRYPOINT ["/usr/local/bin/radicle-linkd", "--protocol-listen", "0.0.0.0:8776", "--lnk-home", "/app/radicle", "--track", "everything", "--signer", "key", "--key-format", "binary", "--key-source", "file", "--key-file-path", "/app/radicle/linkd.key"] 24 | -------------------------------------------------------------------------------- /git-server/src/hooks/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod post_receive; 2 | pub mod pre_receive; 3 | pub mod storage; 4 | pub mod types; 5 | 6 | use crate::error::Error; 7 | 8 | /// Trait for shared default methods for accessing 9 | /// GPG signed push certificate detail information, such as 10 | /// signer name and email set in the `$GIT_PUSH_CERT_SIGNER` env. 11 | /// 12 | /// e.g. `First Last ` 13 | pub trait CertSignerDetails { 14 | /// returns the name of the GPG signer set from the `$GIT_PUSH_CERT_SIGNER` env. 15 | fn signer_name(cert_signer: Option) -> Result { 16 | if let Some(signer) = cert_signer { 17 | let end = signer.find('<').unwrap_or(signer.len()) - 1; 18 | 19 | return Ok(signer[0..end].to_owned()); 20 | } 21 | 22 | Err(Error::MissingCertificateSignerCredentials( 23 | "name".to_string(), 24 | )) 25 | } 26 | 27 | /// returns the email of the GPG signer set from the `$GIT_PUSH_CERT_SIGNER` env. 28 | fn signer_email(cert_signer: Option) -> Result { 29 | if let Some(signer) = cert_signer { 30 | if let Some(start) = signer.find('<') { 31 | if let Some(end) = signer.find('>') { 32 | return Ok(signer[start + 1..end].to_owned()); 33 | } 34 | } 35 | } 36 | 37 | Err(Error::MissingCertificateSignerCredentials( 38 | "email".to_string(), 39 | )) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "http-api", 4 | "git-server", 5 | "shared", 6 | ] 7 | 8 | [patch.crates-io.librad] 9 | git = "https://github.com/radicle-dev/radicle-link" 10 | tag = "cycle/2022-07-12" 11 | 12 | [patch.crates-io.lnk-clib] 13 | git = "https://github.com/radicle-dev/radicle-link" 14 | tag = "cycle/2022-07-12" 15 | 16 | [patch.crates-io.lnk-identities] 17 | git = "https://github.com/radicle-dev/radicle-link" 18 | tag = "cycle/2022-07-12" 19 | 20 | [patch.crates-io.lnk-sync] 21 | git = "https://github.com/radicle-dev/radicle-link" 22 | tag = "cycle/2022-07-12" 23 | 24 | [patch.crates-io.radicle-git-ext] 25 | git = "https://github.com/radicle-dev/radicle-link" 26 | tag = "cycle/2022-07-12" 27 | 28 | [patch.crates-io.git-trailers] 29 | git = "https://github.com/radicle-dev/radicle-link" 30 | tag = "cycle/2022-07-12" 31 | 32 | [patch.crates-io.git-ref-format] 33 | git = "https://github.com/radicle-dev/radicle-link" 34 | tag = "cycle/2022-07-12" 35 | 36 | [patch.crates-io.link-async] 37 | git = "https://github.com/radicle-dev/radicle-link" 38 | tag = "cycle/2022-07-12" 39 | 40 | [patch.crates-io.link-replication] 41 | git = "https://github.com/radicle-dev/radicle-link" 42 | tag = "cycle/2022-07-12" 43 | 44 | [patch.crates-io.radicle-common] 45 | git = "https://github.com/radicle-dev/radicle-cli" 46 | rev = "ea07d1c1327fd05e7792b771436744d415bf4fd9" 47 | 48 | [patch.crates-io.automerge] 49 | git = "https://github.com/automerge/automerge-rs" 50 | rev = "291557a019acac283e54ea31a9fad81ed65736ab" 51 | 52 | [profile.release] 53 | debug = true 54 | 55 | [profile.container] 56 | inherits = "release" 57 | incremental = false 58 | -------------------------------------------------------------------------------- /docker-compose.seed.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | linkd: 4 | image: gcr.io/radicle-services/linkd:${RADICLE_IMAGE_TAG:-latest} 5 | ports: 6 | - 8776:8776/udp 7 | entrypoint: /usr/local/bin/radicle-linkd --protocol-listen 0.0.0.0:8776 --lnk-home /app/radicle --track everything --signer key --key-format binary --key-source file --key-file-path /app/radicle/linkd.key 8 | build: 9 | dockerfile: ./linkd/Dockerfile 10 | context: . 11 | volumes: 12 | - /var/opt/radicle:/app/radicle 13 | environment: 14 | RUST_LOG: info 15 | RAD_HOME: /app/radicle 16 | init: true 17 | container_name: linkd 18 | restart: unless-stopped 19 | networks: 20 | - radicle-services 21 | caddy: 22 | image: caddy:2.4.5 23 | entrypoint: 24 | - sh 25 | - -euc 26 | - | 27 | cat </etc/caddy/Caddyfile 28 | seed.alt-clients.radicle.xyz { 29 | reverse_proxy git-server:8778 30 | } 31 | 32 | seed.alt-clients.radicle.xyz:8777 { 33 | reverse_proxy http-api:8777 34 | } 35 | 36 | clients.radicle.xyz { 37 | reverse_proxy git-server:8778 38 | } 39 | 40 | clients.radicle.xyz:8777 { 41 | reverse_proxy http-api:8777 42 | } 43 | EOF 44 | caddy run --config /etc/caddy/Caddyfile --adapter caddyfile 45 | ports: 46 | - 80:80 47 | - 443:443 48 | - 8777:8777 49 | - 8086:8086 50 | container_name: caddy 51 | restart: unless-stopped 52 | networks: 53 | - radicle-services 54 | 55 | networks: 56 | radicle-services: 57 | name: radicle-services 58 | -------------------------------------------------------------------------------- /http-api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "radicle-http-api" 3 | license = "MIT OR Apache-2.0" 4 | version = "0.2.0" 5 | authors = ["Alexis Sellier "] 6 | edition = "2018" 7 | build = "../build.rs" 8 | 9 | [dependencies] 10 | anyhow = "1" 11 | deadpool = "0.7.0" 12 | librad = { version = "0.1" } 13 | lnk-identities = { version = "0" } 14 | shared = { path = "../shared", default-features = false } 15 | serde = { version = "1", features = ["derive"] } 16 | serde_json = { version = "1", features = ["preserve_order"] } 17 | serde_urlencoded = { version = "0.7.0" } 18 | radicle-source = { version = "0.4.0", features = ["syntax"] } 19 | radicle-surf = { version = "0.8.0", features = ["serialize"] } 20 | radicle-common = { version = "0.1.0", features = [] } 21 | siwe = "0.2" 22 | thiserror = { version = "1" } 23 | git2 = { version = "0.13", default-features = false, features = [] } 24 | tokio = { version = "1.2", features = ["macros", "rt", "sync"] } 25 | futures = "0.3.23" 26 | argh = { version = "0.1.4" } 27 | either = { version = "1.6" } 28 | tracing = "0.1" 29 | tracing-subscriber = "0.2" 30 | async-trait = "0.1" 31 | ethers-core = "0.6.3" 32 | fastrand = "1.7.0" 33 | chrono = { version = "0.4.19", features = ["serde"] } 34 | axum = { version = "0.5.3", default-features = false, features = ["json", "headers", "query"] } 35 | axum-server = { version = "0.3", default-features = false, features = ["tls-rustls"] } 36 | hyper = { version ="0.14.17", default-features = false, features = ["server"] } 37 | tower-http = { version = "0.3.0", default-features = false, features = ["trace", "cors", "set-header"] } 38 | 39 | [dev-dependencies] 40 | tower = { version = "0.4", features = ["util"] } 41 | -------------------------------------------------------------------------------- /shared/src/signer.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use librad::crypto::keystore; 4 | use librad::crypto::keystore::sign::ed25519; 5 | use librad::{PeerId, SecStr, SecretKey}; 6 | 7 | #[derive(Clone)] 8 | pub struct Signer { 9 | pub(super) key: SecretKey, 10 | } 11 | 12 | impl From for Signer { 13 | fn from(key: SecretKey) -> Self { 14 | Self { key } 15 | } 16 | } 17 | 18 | impl From for PeerId { 19 | fn from(signer: Signer) -> Self { 20 | signer.key.into() 21 | } 22 | } 23 | 24 | impl Signer { 25 | pub fn new(mut r: R) -> Result { 26 | use librad::crypto::keystore::SecretKeyExt; 27 | 28 | let mut bytes = Vec::new(); 29 | r.read_to_end(&mut bytes)?; 30 | 31 | let sbytes: SecStr = bytes.into(); 32 | match SecretKey::from_bytes_and_meta(sbytes, &()) { 33 | Ok(key) => Ok(Self { key }), 34 | Err(err) => Err(io::Error::new(io::ErrorKind::InvalidData, err)), 35 | } 36 | } 37 | } 38 | 39 | #[async_trait::async_trait] 40 | impl ed25519::Signer for Signer { 41 | type Error = std::convert::Infallible; 42 | 43 | fn public_key(&self) -> ed25519::PublicKey { 44 | self.key.public_key() 45 | } 46 | 47 | async fn sign(&self, data: &[u8]) -> Result { 48 | ::sign(&self.key, data).await 49 | } 50 | } 51 | 52 | impl librad::Signer for Signer { 53 | fn sign_blocking( 54 | &self, 55 | data: &[u8], 56 | ) -> Result::Error> { 57 | self.key.sign_blocking(data) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod identity; 2 | pub mod signer; 3 | 4 | mod logging; 5 | pub use logging::init_logger; 6 | 7 | use std::path::PathBuf; 8 | 9 | use librad::crypto::BoxedSigner; 10 | use radicle_common::keys; 11 | use radicle_common::profile; 12 | use radicle_common::profile::{LnkHome, Profile}; 13 | use radicle_common::signer::ToSigner; 14 | 15 | /// Load or create a profile, given an optional root path and passphrase. 16 | pub fn profile( 17 | root: Option, 18 | passphrase: Option, 19 | ) -> anyhow::Result<(LnkHome, Profile, BoxedSigner)> { 20 | let home = if let Some(root) = root { 21 | LnkHome::Root(root.canonicalize()?) 22 | } else { 23 | LnkHome::default() 24 | }; 25 | 26 | // If a profile isn't found, create one. 27 | let profile = if let Some(profile) = Profile::active(&home)? { 28 | profile 29 | } else if let Some(ref pass) = passphrase { 30 | let pwhash = keys::pwhash(pass.clone().into()); 31 | let (profile, _) = profile::create(home.clone(), pwhash)?; 32 | 33 | profile 34 | } else { 35 | anyhow::bail!("No active profile and no passphrase supplied"); 36 | }; 37 | tracing::info!("Profile {} loaded...", profile.id()); 38 | 39 | // Get the signer, either from the passphrase and secret key, or from ssh-agent. 40 | let signer = if let Some(pass) = passphrase { 41 | keys::load_secret_key(&profile, pass.into())?.to_signer(&profile)? 42 | } else if let Ok(sock) = keys::ssh_auth_sock() { 43 | sock.to_signer(&profile)? 44 | } else { 45 | anyhow::bail!("No signer found: ssh-agent isn't running, and no passphrase was supplied"); 46 | }; 47 | 48 | Ok((home, profile, signer)) 49 | } 50 | -------------------------------------------------------------------------------- /git-server/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM rust:1.61.0-slim@sha256:91ab0966aa0d8eff103f42c04e0f4dd0bc628d1330942616a94bbe260f26fe6e as build 3 | 4 | RUN apt-get update && apt-get install -y pkg-config libssl-dev git cmake 5 | 6 | WORKDIR /usr/src/radicle-client-services 7 | COPY . . 8 | 9 | WORKDIR /usr/src/radicle-client-services/git-server 10 | RUN set -eux; \ 11 | cargo install --profile=container --all-features --locked --path .; \ 12 | objcopy --compress-debug-sections /usr/local/cargo/bin/radicle-git-server /usr/local/cargo/bin/radicle-git-server.compressed; \ 13 | objcopy --compress-debug-sections /usr/local/cargo/bin/pre-receive /usr/local/cargo/bin/pre-receive.compressed; \ 14 | objcopy --compress-debug-sections /usr/local/cargo/bin/post-receive /usr/local/cargo/bin/post-receive.compressed 15 | 16 | # Run 17 | FROM debian:bullseye-slim@sha256:4c25ffa6ef572cf0d57da8c634769a08ae94529f7de5be5587ec8ce7b9b50f9c 18 | 19 | RUN echo deb http://deb.debian.org/debian bullseye-backports main contrib non-free >/etc/apt/sources.list.d/backports.list 20 | RUN apt-get update && apt-get install -y libssl1.1 && apt -t bullseye-backports install --yes git && rm -rf /var/lib/apt/lists/* 21 | COPY --from=build /usr/local/cargo/bin/radicle-git-server.compressed /usr/local/bin/radicle-git-server 22 | COPY --from=build /usr/local/cargo/bin/pre-receive.compressed /usr/local/bin/pre-receive 23 | COPY --from=build /usr/local/cargo/bin/post-receive.compressed /usr/local/bin/post-receive 24 | COPY --from=build /usr/src/radicle-client-services/git-server/docker/radicle-git-server.sh /usr/local/bin/radicle-git-server.sh 25 | 26 | WORKDIR /app/radicle 27 | 28 | ENTRYPOINT ["/usr/local/bin/radicle-git-server.sh", "--listen", "0.0.0.0:8778", "--git-receive-pack", "--root", "/app/radicle"] 29 | -------------------------------------------------------------------------------- /git-server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "radicle-git-server" 3 | license = "MIT OR Apache-2.0" 4 | version = "0.1.0" 5 | authors = ["Alexis Sellier "] 6 | edition = "2018" 7 | build = "../build.rs" 8 | default-run = "radicle-git-server" 9 | 10 | [[bin]] 11 | name = "pre-receive" 12 | path = "bin/pre_receive.rs" 13 | required-features = ["hooks"] 14 | 15 | [[bin]] 16 | name = "post-receive" 17 | path = "bin/post_receive.rs" 18 | required-features = ["hooks"] 19 | 20 | [dependencies] 21 | argh = { version = "0.1.4" } 22 | anyhow = { version = "1.0" } 23 | base64 = { version = "0.13" } 24 | byteorder = { version = "1.4" } 25 | either = { version = "1.6" } 26 | flate2 = { version = "1.0" } 27 | fastrand = { version = "1.5" } 28 | git2 = { version = "0.13" } 29 | git-ref-format = { version = "0" } 30 | http = { version = "0.2" } 31 | librad = { version = "0" } 32 | shared = { path = "../shared", default-features = false } 33 | sha2 = { version = "0.9" } 34 | thiserror = { version = "1" } 35 | tokio = { version = "1.2", features = ["macros", "rt", "rt-multi-thread", "sync"] } 36 | tracing = "0.1" 37 | tracing-subscriber = "0.2" 38 | radicle-source = { version = "0.3.0" } 39 | axum = { version = "0.5.3", default-features = false, features = ["json", "headers", "query"] } 40 | axum-server = { version = "0.3", default-features = false, features = ["tls-rustls"] } 41 | hyper = { version ="0.14.17", default-features = false, features = ["server"] } 42 | tower-http = { version = "0.3.0", default-features = false, features = ["trace", "cors"] } 43 | 44 | # hooks feature enabled dependencies 45 | envconfig = { version = "0.10.0", optional = true } 46 | hex = { version = "0.4.3", optional = true } 47 | 48 | [features] 49 | default = ["hooks"] 50 | hooks = ["envconfig", "hex"] 51 | 52 | -------------------------------------------------------------------------------- /http-api/src/project.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use librad::git::storage::ReadOnly; 4 | use librad::git::tracking; 5 | 6 | pub use radicle_common::project::{Delegate, Metadata, PeerInfo}; 7 | 8 | use crate::Error; 9 | 10 | #[derive(Serialize, Deserialize, Clone)] 11 | #[serde(rename_all = "kebab-case")] 12 | pub struct ProjectsQueryString { 13 | pub page: Option, 14 | pub per_page: Option, 15 | } 16 | 17 | /// Project info. 18 | #[derive(Serialize)] 19 | #[serde(rename_all = "camelCase")] 20 | pub struct Info { 21 | /// Project metadata. 22 | #[serde(flatten)] 23 | pub meta: Metadata, 24 | /// Project HEAD commit. If empty, it's likely that no delegate 25 | /// branches have been replicated on this node. 26 | #[serde(with = "option")] 27 | pub head: Option, 28 | pub patches: usize, 29 | pub issues: usize, 30 | } 31 | 32 | pub fn tracked>(meta: &Metadata, storage: &S) -> Result, Error> { 33 | let tracked = 34 | tracking::tracked(storage.as_ref(), Some(&meta.urn)).map_err(|_| Error::NotFound)?; 35 | let result = tracked 36 | .collect::, _>>() 37 | .map_err(Error::from)?; 38 | 39 | let result = result 40 | .into_iter() 41 | .filter_map(|t| t.peer_id()) 42 | .map(|id| PeerInfo::get(&id, meta, storage)) 43 | .collect::>(); 44 | 45 | Ok(result) 46 | } 47 | 48 | mod option { 49 | use std::fmt::Display; 50 | 51 | use serde::Serializer; 52 | 53 | pub fn serialize(value: &Option, serializer: S) -> Result 54 | where 55 | T: Display, 56 | S: Serializer, 57 | { 58 | if let Some(value) = value { 59 | serializer.collect_str(value) 60 | } else { 61 | serializer.serialize_none() 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /http-api/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::net; 2 | use std::path::PathBuf; 3 | use std::process; 4 | 5 | use radicle_http_api as api; 6 | 7 | use argh::FromArgs; 8 | 9 | /// Radicle HTTP API. 10 | #[derive(FromArgs)] 11 | pub struct Options { 12 | /// listen on the following address for HTTP connections (default: 0.0.0.0:8777) 13 | #[argh(option, default = "std::net::SocketAddr::from(([0, 0, 0, 0], 8777))")] 14 | pub listen: net::SocketAddr, 15 | 16 | /// radicle root path, for key and git storage 17 | #[argh(option)] 18 | pub root: Option, 19 | 20 | /// radicle encrypted key passphrase 21 | #[argh(option)] 22 | pub passphrase: Option, 23 | 24 | /// TLS certificate path 25 | #[argh(option)] 26 | pub tls_cert: Option, 27 | 28 | /// TLS key path 29 | #[argh(option)] 30 | pub tls_key: Option, 31 | 32 | /// syntax highlight theme 33 | #[argh(option, default = r#"String::from("base16-ocean.dark")"#)] 34 | pub theme: String, 35 | } 36 | 37 | impl Options { 38 | pub fn from_env() -> Self { 39 | argh::from_env() 40 | } 41 | } 42 | 43 | impl From for api::Options { 44 | fn from(other: Options) -> Self { 45 | Self { 46 | root: other.root, 47 | passphrase: other.passphrase, 48 | tls_cert: other.tls_cert, 49 | tls_key: other.tls_key, 50 | listen: other.listen, 51 | theme: other.theme, 52 | } 53 | } 54 | } 55 | 56 | #[tokio::main] 57 | async fn main() { 58 | let options = Options::from_env(); 59 | 60 | shared::init_logger(); 61 | tracing::info!("version {}-{}", env!("CARGO_PKG_VERSION"), env!("GIT_HEAD")); 62 | 63 | match api::run(options.into()).await { 64 | Ok(()) => {} 65 | Err(err) => { 66 | tracing::error!("{:#}", err); 67 | process::exit(1); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | upload-binaries-to-gcs: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: 'read' 12 | id-token: 'write' 13 | steps: 14 | - name: Update packages 15 | run: sudo apt-get update 16 | - name: Install dependencies 17 | run: sudo apt-get install -y pkg-config libudev-dev 18 | - uses: actions/checkout@v2 19 | - uses: actions-rs/toolchain@v1 20 | with: 21 | profile: minimal 22 | - name: Configure build cache 23 | uses: actions/cache@v2 24 | with: 25 | path: | 26 | ~/.cargo/registry 27 | ~/.cargo/git 28 | target 29 | key: cargo-${{ matrix.os }}-${{ hashFiles('**/Cargo.lock') }} 30 | - name: Build 31 | run: cargo build --release --verbose --all-features 32 | env: 33 | RUSTFLAGS: -D warnings 34 | - name: Run tests 35 | run: cargo test --all --verbose --all-features 36 | - name: Create artifacts directory 37 | run: mkdir artifacts 38 | - name: Copy binaries to a separate directory 39 | run: find target/release -maxdepth 1 -type f -executable | xargs --replace cp '{}' artifacts 40 | - name: Strip binaries of debugging symbols 41 | run: strip artifacts/* 42 | - id: 'auth' 43 | uses: 'google-github-actions/auth@v0' 44 | with: 45 | workload_identity_provider: 'projects/281042598092/locations/global/workloadIdentityPools/github-actions/providers/google-cloud' 46 | service_account: 'github-actions@radicle-services.iam.gserviceaccount.com' 47 | - name: Upload binaries to Google Cloud Storage 48 | uses: 'google-github-actions/upload-cloud-storage@v0' 49 | with: 50 | path: artifacts 51 | destination: radicle-client-services/ 52 | parent: false 53 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | http-api: 4 | image: gcr.io/radicle-services/http-api:${RADICLE_IMAGE_TAG:-latest} 5 | entrypoint: /usr/local/bin/radicle-http-api --listen 0.0.0.0:8777 --root /app/radicle --passphrase seed 6 | build: 7 | dockerfile: ./http-api/Dockerfile 8 | context: . 9 | volumes: 10 | - /var/opt/radicle:/app/radicle 11 | environment: 12 | RUST_LOG: info 13 | RAD_HOME: /app/radicle 14 | init: true 15 | container_name: http-api 16 | restart: unless-stopped 17 | networks: 18 | - radicle-services 19 | depends_on: 20 | - git-server 21 | git-server: 22 | image: gcr.io/radicle-services/git-server:${RADICLE_IMAGE_TAG:-latest} 23 | entrypoint: /usr/local/bin/radicle-git-server.sh $RADICLE_GIT_SERVER_OPTS --root /app/radicle --passphrase seed 24 | build: 25 | dockerfile: ./git-server/Dockerfile 26 | context: . 27 | volumes: 28 | - /var/opt/radicle:/app/radicle 29 | environment: 30 | RUST_LOG: hyper=warn,debug 31 | RAD_HOME: /app/radicle 32 | init: true 33 | container_name: git-server 34 | restart: unless-stopped 35 | networks: 36 | - radicle-services 37 | deploy: 38 | resources: 39 | limits: 40 | memory: 6gb 41 | caddy: 42 | image: caddy:2.4.5 43 | entrypoint: 44 | - sh 45 | - -euc 46 | - | 47 | cat </etc/caddy/Caddyfile 48 | $RADICLE_DOMAIN { 49 | reverse_proxy git-server:8778 50 | } 51 | 52 | $RADICLE_DOMAIN:8777 { 53 | reverse_proxy http-api:8777 54 | } 55 | EOF 56 | caddy run --config /etc/caddy/Caddyfile --adapter caddyfile 57 | ports: 58 | - 80:80 59 | - 443:443 60 | - 8777:8777 61 | - 8086:8086 62 | environment: 63 | RADICLE_DOMAIN: $RADICLE_DOMAIN 64 | container_name: caddy 65 | restart: unless-stopped 66 | networks: 67 | - radicle-services 68 | 69 | networks: 70 | radicle-services: 71 | name: radicle-services 72 | -------------------------------------------------------------------------------- /shared/src/identity.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs, 3 | fs::File, 4 | io::{self, Read as _}, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | use librad::{ 9 | crypto::keystore::{ 10 | crypto::{KdfParams, Pwhash}, 11 | pinentry::SecUtf8, 12 | FileStorage, Keystore as _, 13 | }, 14 | PublicKey, SecStr, SecretKey, 15 | }; 16 | 17 | use crate::signer::Signer; 18 | 19 | pub enum Identity { 20 | Plain(PathBuf), 21 | Encrypted { path: PathBuf, passphrase: SecUtf8 }, 22 | } 23 | 24 | impl Identity { 25 | pub fn signer(self) -> Result { 26 | match self { 27 | Self::Plain(path) => { 28 | use librad::crypto::keystore::SecretKeyExt; 29 | 30 | let mut r = File::open(path)?; 31 | 32 | let mut bytes = Vec::new(); 33 | r.read_to_end(&mut bytes)?; 34 | 35 | let sbytes: SecStr = bytes.into(); 36 | match SecretKey::from_bytes_and_meta(sbytes, &()) { 37 | Ok(key) => Ok(key.into()), 38 | Err(err) => Err(io::Error::new(io::ErrorKind::InvalidData, err)), 39 | } 40 | } 41 | Self::Encrypted { path, passphrase } => { 42 | let crypto = Pwhash::new(passphrase, KdfParams::recommended()); 43 | let store: FileStorage<_, PublicKey, SecretKey, _> = 44 | FileStorage::new(&path, crypto); 45 | store 46 | .get_key() 47 | .map(|pair| pair.secret_key.into()) 48 | .map_err(|err| io::Error::new(io::ErrorKind::PermissionDenied, err)) 49 | } 50 | } 51 | } 52 | } 53 | 54 | /// Generate an identity file at the given path. 55 | pub fn generate(path: &Path) -> io::Result<()> { 56 | use std::io::Write; 57 | use std::os::unix::fs::PermissionsExt; 58 | 59 | let mut file = File::create(path)?; 60 | let metadata = file.metadata()?; 61 | let mut permissions = metadata.permissions(); 62 | 63 | permissions.set_mode(0o600); 64 | fs::set_permissions(path, permissions)?; 65 | 66 | let secret_key = SecretKey::new(); 67 | file.write_all(secret_key.as_ref())?; 68 | 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | name: Test & Lint 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | name: Build & Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Update packages 15 | run: sudo apt-get update 16 | - name: Install dependencies 17 | run: sudo apt-get install -y pkg-config libudev-dev 18 | - uses: actions/checkout@v2 19 | - uses: actions-rs/toolchain@v1 20 | with: 21 | profile: minimal 22 | - name: Build 23 | run: cargo build --verbose --all-features 24 | env: 25 | RUSTFLAGS: -D warnings 26 | - name: Run tests 27 | run: cargo test --all --verbose --all-features 28 | 29 | docs: 30 | name: Docs 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Update packages 34 | run: sudo apt-get update 35 | - name: Install dependencies 36 | run: sudo apt-get install -y pkg-config libudev-dev 37 | - uses: actions/checkout@v2 38 | - uses: actions-rs/toolchain@v1 39 | - name: Docs 40 | run: cargo doc --all --all-features 41 | env: 42 | RUSTDOCFLAGS: -D warnings 43 | 44 | lint: 45 | name: Lint 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Update packages 49 | run: sudo apt-get update 50 | - name: Install dependencies 51 | run: sudo apt-get install -y pkg-config libudev-dev 52 | - uses: actions/checkout@v2 53 | - uses: actions-rs/toolchain@v1 54 | with: 55 | profile: minimal 56 | components: clippy, rustfmt 57 | toolchain: 1.64 58 | - name: Cache cargo registry 59 | uses: actions/cache@v1 60 | with: 61 | path: ~/.cargo/registry 62 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 63 | - name: Run clippy 64 | uses: actions-rs/cargo@v1 65 | with: 66 | command: clippy 67 | args: --all --tests 68 | env: 69 | RUSTFLAGS: -D warnings 70 | - name: Check formating 71 | uses: actions-rs/cargo@v1 72 | with: 73 | command: fmt 74 | args: --all -- --check 75 | -------------------------------------------------------------------------------- /git-server/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::{net, process}; 3 | 4 | use radicle_git_server as server; 5 | 6 | use argh::FromArgs; 7 | 8 | /// Radicle Git Server. 9 | #[derive(FromArgs)] 10 | pub struct Options { 11 | /// listen on the following address for HTTP connections (default: 0.0.0.0:8778) 12 | #[argh(option, default = "std::net::SocketAddr::from(([0, 0, 0, 0], 8778))")] 13 | pub listen: net::SocketAddr, 14 | 15 | /// radicle root path, for key and git storage 16 | #[argh(option)] 17 | pub root: Option, 18 | 19 | /// radicle encrypted key passphrase 20 | #[argh(option)] 21 | pub passphrase: Option, 22 | 23 | /// TLS certificate path 24 | #[argh(option)] 25 | pub tls_cert: Option, 26 | 27 | /// TLS key path 28 | #[argh(option)] 29 | pub tls_key: Option, 30 | 31 | /// service 'git-receive-pack' operations, eg. resulting from a `git push` (default: false) 32 | #[argh(switch)] 33 | pub git_receive_pack: bool, 34 | 35 | /// certificate nonce seed used to enable `push --signed` 36 | #[argh(option)] 37 | pub cert_nonce_seed: Option, 38 | 39 | /// allow unauthorized keys, ignores gpg certificate verification 40 | #[argh(switch)] 41 | pub allow_unauthorized_keys: bool, 42 | } 43 | 44 | impl Options { 45 | pub fn from_env() -> Self { 46 | argh::from_env() 47 | } 48 | } 49 | 50 | impl From for server::Options { 51 | fn from(other: Options) -> Self { 52 | Self { 53 | root: other.root, 54 | passphrase: other.passphrase, 55 | tls_cert: other.tls_cert, 56 | tls_key: other.tls_key, 57 | listen: other.listen, 58 | git_receive_pack: other.git_receive_pack, 59 | cert_nonce_seed: other.cert_nonce_seed, 60 | allow_unauthorized_keys: other.allow_unauthorized_keys, 61 | } 62 | } 63 | } 64 | 65 | #[tokio::main] 66 | async fn main() { 67 | let options = Options::from_env(); 68 | 69 | shared::init_logger(); 70 | tracing::info!("version {}-{}", env!("CARGO_PKG_VERSION"), env!("GIT_HEAD")); 71 | 72 | match server::run(options.into()).await { 73 | Ok(()) => {} 74 | Err(err) => { 75 | tracing::error!("Fatal: {:#}", err); 76 | process::exit(1); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /git-server/README.md: -------------------------------------------------------------------------------- 1 | # Radicle Git Server 2 | 3 | > ✨ Serve Radicle Git repositories via HTTP 4 | 5 | # Running 6 | 7 | $ radicle-git-server --root ~/.radicle 8 | 9 | # Git Hooks 10 | 11 | Git [hooks](https://git-scm.com/book/en/v2/Customizing-Git-Git-Hooks) are used by the git http backend to manage requests made to a repository, such as a `push` action. Hooks are executable files that accept standard input, perform some action and return an exit status back to the sender of the request, either successfully completing the request or declining. 12 | 13 | This crate includes a `bin` folder that contains hooks used by the `radicle-git-server` for authorizing requests and performing other tasks. 14 | 15 | In order to use these hooks, the binaries in the `bin` folder must be built using `cargo build --bin pre-receive` and `cargo build --bin post-receive` and moved to the radicle root under `git/hooks/` (e.g. `~/.radicle/git/hooks/`) folder. These executables will automatically be called by the git-http-backend. 16 | 17 | ## Authorizing Signed Push Certificates in `pre-receive` Hook 18 | 19 | The `pre-receive` hook is the first hook invoked by the git http-backend when a `receive-pack` event is triggered from a `git push` client request. 20 | 21 | The `pre-receive` hook is used to verify the git signed push certificates from the sender, e.g. `git push --signed ...`. Unless the `git-server` is run with the `--allow-unauthorized-keys` flag, any unsigned git push will be denied by the `pre-receive` hook. 22 | 23 | ### Allow Unauthorized Keys 24 | 25 | While developing it can sometimes be useful to disable certificate verification and key authorization. To disable certificate checking, run the `git-server` with the following command: 26 | 27 | ``` 28 | radicle-git-server ... --allow-unauthorized-keys 29 | ``` 30 | 31 | ### Using `authorized-keys` for Authorization 32 | 33 | By default, the `pre-receive` hook will check the mono-repository for a `authorized-keys` public key file on push. If it exists, it will check the public key's fingerprint matches the `$GIT_PUSH_CERT_KEY` set by the http-backend. The `$GIT_PUSH_CERT_KEY` is used to find the file in the namespace tree, comparing the fingerprint in the authorized keyring against the signed certificate. 34 | 35 | No `git-server` command arguments are needed to perform this check. 36 | 37 | In order to setup your `.rad/keys/` keyring, there is a CLI tool, `rad-auth-keys`, in `radicle-client-tools/authorized-keys` that provides helper commands for exporting your gpg key and placing it into your `.rad/keys/` keyring. 38 | -------------------------------------------------------------------------------- /http-api/src/auth.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use std::str::FromStr; 3 | 4 | use chrono::{DateTime, Utc}; 5 | use ethers_core::types::{Signature, H160}; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | use crate::error::Error; 9 | 10 | #[derive(Deserialize, Serialize)] 11 | pub struct AuthRequest { 12 | pub message: String, 13 | #[serde(deserialize_with = "deserialize_signature")] 14 | pub signature: Signature, 15 | } 16 | 17 | fn deserialize_signature<'de, D>(deserializer: D) -> Result 18 | where 19 | D: serde::de::Deserializer<'de>, 20 | { 21 | let buf = String::deserialize(deserializer)?; 22 | Signature::from_str(&buf).map_err(serde::de::Error::custom) 23 | } 24 | 25 | pub enum AuthState { 26 | Authorized(Session), 27 | Unauthorized { 28 | nonce: String, 29 | expiration_time: DateTime, 30 | }, 31 | } 32 | 33 | // We copy the implementation of siwe::Message here to derive Serialization and Debug 34 | #[derive(Clone, Serialize)] 35 | #[serde(rename_all = "camelCase")] 36 | pub struct Session { 37 | pub domain: String, 38 | pub address: H160, 39 | pub statement: Option, 40 | pub uri: String, 41 | pub version: u64, 42 | pub chain_id: u64, 43 | pub nonce: String, 44 | pub issued_at: DateTime, 45 | pub expiration_time: Option>, 46 | pub resources: Vec, 47 | } 48 | 49 | impl TryFrom for Session { 50 | type Error = Error; 51 | 52 | fn try_from(message: siwe::Message) -> Result { 53 | Ok(Session { 54 | domain: message.domain.host().to_string(), 55 | address: H160(message.address), 56 | statement: None, 57 | uri: message.uri.to_string(), 58 | version: message.version as u64, 59 | chain_id: message.chain_id, 60 | nonce: message.nonce, 61 | issued_at: message.issued_at.as_ref().with_timezone(&Utc), 62 | expiration_time: message 63 | .expiration_time 64 | .map(|x| x.as_ref().with_timezone(&Utc)), 65 | resources: message.resources.iter().map(|r| r.to_string()).collect(), 66 | }) 67 | } 68 | } 69 | 70 | #[cfg(test)] 71 | mod test { 72 | #[test] 73 | fn test_auth_request_de() { 74 | let json = serde_json::json!({ 75 | "message": "Hello World!", 76 | "signature": "20096c6ed2bcccb88c9cafbbbbda7a5a3cff6d0ca318c07faa58464083ca40a92f899fbeb26a4c763a7004b13fd0f1ba6c321d4e3a023e30f63c40d4154b99a41c" 77 | }); 78 | 79 | let _req: super::AuthRequest = serde_json::from_value(json).unwrap(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /http-api/src/axum_extra.rs: -------------------------------------------------------------------------------- 1 | use axum::extract::path::ErrorKind; 2 | use axum::extract::rejection::{PathRejection, QueryRejection}; 3 | use axum::extract::{FromRequest, RequestParts}; 4 | use axum::http::StatusCode; 5 | use axum::{async_trait, Json}; 6 | 7 | use serde::de::DeserializeOwned; 8 | use serde::Serialize; 9 | 10 | pub struct Path(pub T); 11 | 12 | #[async_trait] 13 | impl FromRequest for Path 14 | where 15 | T: DeserializeOwned + Send, 16 | B: Send, 17 | { 18 | type Rejection = (StatusCode, axum::Json); 19 | 20 | async fn from_request(req: &mut RequestParts) -> Result { 21 | match axum::extract::Path::::from_request(req).await { 22 | Ok(value) => Ok(Self(value.0)), 23 | Err(rejection) => { 24 | let status = StatusCode::BAD_REQUEST; 25 | let body = match rejection { 26 | PathRejection::FailedToDeserializePathParams(inner) => { 27 | let kind = inner.into_kind(); 28 | match &kind { 29 | ErrorKind::Message(msg) => Json(Error { 30 | success: false, 31 | error: msg.to_string(), 32 | }), 33 | _ => Json(Error { 34 | success: false, 35 | error: kind.to_string(), 36 | }), 37 | } 38 | } 39 | _ => Json(Error { 40 | success: false, 41 | error: format!("{}", rejection), 42 | }), 43 | }; 44 | 45 | Err((status, body)) 46 | } 47 | } 48 | } 49 | } 50 | 51 | #[derive(Default)] 52 | pub struct Query(pub T); 53 | 54 | #[async_trait] 55 | impl FromRequest for Query 56 | where 57 | T: DeserializeOwned + Send, 58 | B: Send, 59 | { 60 | type Rejection = (StatusCode, axum::Json); 61 | 62 | async fn from_request(req: &mut RequestParts) -> Result { 63 | match axum::extract::Query::::from_request(req).await { 64 | Ok(value) => Ok(Self(value.0)), 65 | Err(rejection) => { 66 | let status = StatusCode::BAD_REQUEST; 67 | let body = match rejection { 68 | QueryRejection::FailedToDeserializeQueryString(inner) => Json(Error { 69 | success: false, 70 | error: inner.to_string(), 71 | }), 72 | _ => Json(Error { 73 | success: false, 74 | error: format!("{}", rejection), 75 | }), 76 | }; 77 | 78 | Err((status, body)) 79 | } 80 | } 81 | } 82 | } 83 | 84 | #[derive(Serialize)] 85 | pub struct Error { 86 | success: bool, 87 | error: String, 88 | } 89 | -------------------------------------------------------------------------------- /LOCAL.md: -------------------------------------------------------------------------------- 1 | # Running locally 2 | 3 | You might need to run a seed node locally to experiment or for development purposes. Here's how you can get set up. 4 | 5 | ## Insall `rad` 6 | 7 | Install `rad` binary from [radicle-link](https://github.com/radicle-dev/radicle-link/tree/master/bins/rad) repo. You need it to create identities for yourself, and the projects you'll be creating. 8 | 9 | All your data would live in a monorepo (root), so designate a folder for it and then set: 10 | 11 | ``` 12 | $ export RAD_HOME=/path/to/folder 13 | ``` 14 | 15 | Then (replace values between `< >` with your own): 16 | 17 | ``` 18 | $ rad profile create 19 | please enter your passphrase: (leave empty) 20 | profile id: 21 | peer id: 22 | 23 | $ rad profile ssh add 24 | please enter your passphrase: (press enter) 25 | added key for profile id `` 26 | 27 | $ rad identities person create new --payload '{"name": ""}' 28 | {"urn":"","payload":{"https://radicle.xyz/link/identities/person/v1":{"name":""}}} 29 | 30 | $ rad identities local set --urn 31 | set default identity to `` 32 | 33 | # your git project should be at /path/to/working-dir// 34 | $ rad identities project create existing --path /path/to/working-dir --payload '{"name": "", "default_branch": "master"}' 35 | {"urn":"","payload":{"https://radicle.xyz/link/identities/project/v1":{"name":"","description":null,"default_branch":"master"}}} 36 | ``` 37 | 38 | Now create another env variable pointing to the profile you created in first step: 39 | ``` 40 | $ ls $RAD_HOME 41 | / active_profile 42 | 43 | $ export LOCAL_ROOT=$RAD_HOME// 44 | ``` 45 | 46 | All set. 47 | 48 | ## `org-node` 49 | 50 | You can now run `org-node` with: 51 | 52 | ``` 53 | $ target/debug/radicle-org-node --subgraph https://api.thegraph.com/subgraphs/name/radicle-dev/radicle-orgs --rpc-url wss://eth-rinkeby.alchemyapi.io/v2/ --root $LOCAL_ROOT --identity $LOCAL_ROOT/keys/librad.key --identity-passphrase '' 54 | ``` 55 | 56 | ## `git-server` 57 | 58 | For a fully working [`git-server`](https://github.com/radicle-dev/radicle-client-services/tree/master/git-server) you'd need to also compile `pre-receive` and `post-receive` binaries and copy them into: 59 | 60 | ``` 61 | $ cp target/debug/{pre,post}-receive $LOCAL_ROOT/git/hooks/ 62 | ``` 63 | 64 | These binaries are responsible for authentication which is through GPG keys, make sure you have one and: 65 | 66 | ``` 67 | $ gpg --list-keys --keyid-format=long 68 | pub rsa3072/ 2020-10-10 [SC] [expires: 2023-10-10] 69 | ... 70 | ``` 71 | 72 | Finally you can run: 73 | 74 | ``` 75 | $ target/debug/radicle-git-server --root $LOCAL_ROOT --git-receive-pack --authorized-keys 76 | ``` 77 | 78 | Which will accept signed pushes (using `git push --signed`) from you and reject all else. To simplify your workflow you can add your key locally to your project as well using [`rad-auth-keys`](https://github.com/radicle-dev/radicle-client-tools): 79 | 80 | ``` 81 | $ gpg --armor --export | rad-auth-keys add 82 | ``` 83 | 84 | If you have trouble with `gpg` and `git` you can also run: 85 | 86 | ``` 87 | $ GIT_TRACE=1 git ... 88 | ``` 89 | 90 | To determine where the problem lies, e.g. a silent issue I was encountering was not having permission to access `secring.gpg`: 91 | 92 | ``` 93 | $ sudo chown user:user ~/.gnupg/secring.gpg 94 | ``` 95 | 96 | ## `http-api` 97 | 98 | Similar to `git-server` but with less parameters: 99 | 100 | ``` 101 | $ target/debug/radicle-http-api --root $LOCAL_ROOT 102 | ``` 103 | 104 | -------------------------------------------------------------------------------- /http-api/src/test_extra.rs: -------------------------------------------------------------------------------- 1 | pub mod setup { 2 | use std::path::Path; 3 | use std::{env, fs}; 4 | 5 | use git2::Oid; 6 | 7 | use librad::crypto::keystore::crypto::{Pwhash, KDF_PARAMS_TEST}; 8 | use librad::crypto::keystore::pinentry::SecUtf8; 9 | use librad::crypto::BoxedSigner; 10 | use librad::git::identities::Project; 11 | use librad::git::util; 12 | use librad::git_ext::tree; 13 | use librad::profile::{Profile, LNK_HOME}; 14 | 15 | use radicle_common::cobs::patch::MergeTarget; 16 | use radicle_common::cobs::shared::Store; 17 | use radicle_common::{keys, person, profile, project, test}; 18 | 19 | #[allow(dead_code)] 20 | pub fn env() -> (Profile, BoxedSigner, Project, Oid) { 21 | let tempdir = env::temp_dir().join("rad").join("home").join("api"); 22 | let home = env::var(LNK_HOME) 23 | .map(|s| Path::new(&s).to_path_buf()) 24 | .unwrap_or_else(|_| tempdir.to_path_buf()); 25 | 26 | env::set_var(LNK_HOME, home); 27 | 28 | let name = "cloudhead"; 29 | let pass = Pwhash::new(SecUtf8::from(test::USER_PASS), *KDF_PARAMS_TEST); 30 | let (profile, peer_id) = profile::create(profile::home(), pass.clone()).unwrap(); 31 | let signer = test::signer(&profile, pass).unwrap(); 32 | let storage = keys::storage(&profile, signer.clone()).unwrap(); 33 | let person = person::create(&profile, name, signer.clone(), &storage).unwrap(); 34 | 35 | person::set_local(&storage, &person).unwrap(); 36 | 37 | let whoami = person::local(&storage).unwrap(); 38 | let payload = project::payload( 39 | "nakamoto".to_owned(), 40 | "Bitcoin light-client".to_owned(), 41 | "master".to_owned(), 42 | ); 43 | let project = project::create(payload, &storage).unwrap(); 44 | 45 | let commit = util::quick_commit( 46 | &storage, 47 | &project.urn(), 48 | vec![ 49 | ("HI", tree::blob(b"Hi Bob")), 50 | ("README", tree::blob(b"This is a readme")), 51 | ] 52 | .into_iter() 53 | .collect(), 54 | "say hi to bob", 55 | ) 56 | .unwrap(); 57 | 58 | // create remote head 59 | // otherwise testing `.../patches/:id` would fail 60 | fs::create_dir_all( 61 | profile 62 | .paths() 63 | .git_dir() 64 | .join("refs") 65 | .join("namespaces") 66 | .join(&project.urn().encode_id()) 67 | .join("refs") 68 | .join("remotes") 69 | .join(peer_id.to_string()) 70 | .join("heads"), 71 | ) 72 | .unwrap(); 73 | 74 | fs::write( 75 | profile 76 | .paths() 77 | .git_dir() 78 | .join("refs") 79 | .join("namespaces") 80 | .join(&project.urn().encode_id()) 81 | .join("refs") 82 | .join("remotes") 83 | .join(peer_id.to_string()) 84 | .join("heads") 85 | .join("master"), 86 | format!("{}", commit), 87 | ) 88 | .unwrap(); 89 | 90 | // add a patch 91 | let cobs = Store::new(whoami, profile.paths(), &storage); 92 | let patches = cobs.patches(); 93 | let target = MergeTarget::Upstream; 94 | let base = Oid::from_str("af08e95ada2bb38aadd8e6cef0963ce37a87add3").unwrap(); 95 | let rev0_oid = Oid::from_str("518d5069f94c03427f694bb494ac1cd7d1339380").unwrap(); 96 | let project_urn = &project.urn(); 97 | let _patch_id = patches 98 | .create( 99 | project_urn, 100 | "My first patch", 101 | "Blah blah blah.", 102 | target, 103 | base, 104 | rev0_oid, 105 | &[], 106 | ) 107 | .unwrap(); 108 | 109 | // add an issue 110 | let issues = cobs.issues(); 111 | let issue_id = issues 112 | .create(&project.urn(), "My first issue", "Blah blah blah.", &[]) 113 | .unwrap(); 114 | issues 115 | .comment(&project.urn(), &issue_id, "Ho ho ho.") 116 | .unwrap(); 117 | 118 | (profile, signer, project, commit) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /http-api/src/v1/sessions.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::convert::TryInto; 3 | use std::env; 4 | use std::iter::repeat_with; 5 | use std::str::FromStr; 6 | use std::time::Duration; 7 | 8 | use axum::response::IntoResponse; 9 | use axum::routing::{get, post}; 10 | use axum::{Extension, Json, Router}; 11 | use chrono::{DateTime, Utc}; 12 | use ethers_core::utils::hex; 13 | use hyper::http::uri::Authority; 14 | use serde_json::json; 15 | use siwe::Message; 16 | 17 | use crate::auth::{AuthRequest, AuthState, Session}; 18 | use crate::axum_extra::Path; 19 | use crate::{Context, Error}; 20 | 21 | pub const UNAUTHORIZED_SESSIONS_EXPIRATION: Duration = Duration::from_secs(60); 22 | 23 | pub fn router(ctx: Context) -> Router { 24 | Router::new() 25 | .route("/sessions", post(session_create_handler)) 26 | .route( 27 | "/sessions/:id", 28 | get(session_get_handler).put(session_signin_handler), 29 | ) 30 | .layer(Extension(ctx)) 31 | } 32 | 33 | /// Create session. 34 | /// `POST /sessions` 35 | async fn session_create_handler(Extension(ctx): Extension) -> impl IntoResponse { 36 | let expiration_time = 37 | Utc::now() + chrono::Duration::from_std(UNAUTHORIZED_SESSIONS_EXPIRATION).unwrap(); 38 | let mut sessions = ctx.sessions.write().await; 39 | let (session_id, nonce) = create_session(&mut sessions, expiration_time); 40 | 41 | Json(json!({ "id": session_id, "nonce": nonce })) 42 | } 43 | 44 | /// Get session. 45 | /// `GET /sessions/:id` 46 | async fn session_get_handler( 47 | Extension(ctx): Extension, 48 | Path(id): Path, 49 | ) -> impl IntoResponse { 50 | let sessions = ctx.sessions.read().await; 51 | let session = sessions.get(&id).ok_or(Error::NotFound)?; 52 | 53 | match session { 54 | AuthState::Authorized(session) => { 55 | Ok::<_, Error>(Json(json!({ "id": id, "session": session }))) 56 | } 57 | AuthState::Unauthorized { 58 | nonce, 59 | expiration_time, 60 | } => Ok::<_, Error>(Json( 61 | json!({ "id": id, "nonce": nonce, "expirationTime": expiration_time }), 62 | )), 63 | } 64 | } 65 | 66 | /// Update session. 67 | /// `PUT /sessions/:id` 68 | async fn session_signin_handler( 69 | Extension(ctx): Extension, 70 | Path(id): Path, 71 | Json(request): Json, 72 | ) -> impl IntoResponse { 73 | // Get unauthenticated session data, return early if not found 74 | let mut sessions = ctx.sessions.write().await; 75 | let session = sessions.get(&id).ok_or(Error::NotFound)?; 76 | 77 | if let AuthState::Unauthorized { nonce, .. } = session { 78 | let message = Message::from_str(request.message.as_str()).map_err(Error::from)?; 79 | 80 | let host = env::var("RADICLE_DOMAIN").map_err(Error::from)?; 81 | 82 | // Validate nonce 83 | if *nonce != message.nonce { 84 | return Err(Error::Auth("Invalid nonce")); 85 | } 86 | 87 | // Verify that domain is the correct one 88 | let authority = Authority::from_str(&host).map_err(|_| Error::Auth("Invalid host"))?; 89 | if authority != message.domain { 90 | return Err(Error::Auth("Invalid domain")); 91 | } 92 | 93 | // Verifies the following: 94 | // - AuthRequest sig matches the address passed in the AuthRequest message. 95 | // - expirationTime is not in the past. 96 | // - notBefore time is in the future. 97 | message 98 | .verify(request.signature.into()) 99 | .map_err(Error::from)?; 100 | 101 | let session: Session = message.try_into()?; 102 | sessions.insert(id.clone(), AuthState::Authorized(session.clone())); 103 | 104 | return Ok::<_, Error>(Json(json!({ "id": id, "session": session }))); 105 | } 106 | 107 | Err(Error::Auth("Session already authorized")) 108 | } 109 | 110 | fn create_session( 111 | map: &mut HashMap, 112 | expiration_time: DateTime, 113 | ) -> (String, String) { 114 | let nonce = siwe::nonce::generate_nonce(); 115 | 116 | // We generate a value from the RNG for the session id 117 | let rng = fastrand::Rng::new(); 118 | let id = hex::encode(repeat_with(|| rng.u8(..)).take(32).collect::>()); 119 | 120 | let auth_state = AuthState::Unauthorized { 121 | nonce: nonce.clone(), 122 | expiration_time, 123 | }; 124 | 125 | map.insert(id.clone(), auth_state); 126 | 127 | (id, nonce) 128 | } 129 | -------------------------------------------------------------------------------- /http-api/src/v1/delegates.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryInto; 2 | 3 | use axum::response::IntoResponse; 4 | use axum::routing::get; 5 | use axum::{Extension, Json, Router}; 6 | 7 | use librad::git::identities::{self, SomeIdentity}; 8 | use librad::git::Urn; 9 | 10 | use radicle_common::{cobs, person}; 11 | 12 | use crate::axum_extra::{Path, Query}; 13 | use crate::project::{self, Info}; 14 | use crate::{get_head_commit, Context, Error}; 15 | 16 | pub fn router(ctx: Context) -> Router { 17 | Router::new() 18 | .route( 19 | "/delegates/:delegate/projects", 20 | get(delegates_projects_handler), 21 | ) 22 | .layer(Extension(ctx)) 23 | } 24 | 25 | /// List all projects that delegate is a part of. 26 | /// `GET /delegates/:delegate/projects` 27 | async fn delegates_projects_handler( 28 | Extension(ctx): Extension, 29 | Path(delegate): Path, 30 | Query(qs): Query, 31 | ) -> impl IntoResponse { 32 | let project::ProjectsQueryString { page, per_page } = qs; 33 | let page = page.unwrap_or(0); 34 | let per_page = per_page.unwrap_or(10); 35 | 36 | let storage = ctx.storage().await?; 37 | let repo = git2::Repository::open_bare(&ctx.paths.git_dir()).map_err(Error::from)?; 38 | let whoami = person::local(&*storage).map_err(Error::LocalIdentity)?; 39 | let cobs = cobs::Store::new(whoami, &ctx.paths, &storage); 40 | let issues = cobs.issues(); 41 | let patches = cobs.patches(); 42 | let projects = identities::any::list(storage.read_only()) 43 | .map_err(Error::from)? 44 | .filter_map(|res| { 45 | res.map(|id| match id { 46 | SomeIdentity::Project(project) => { 47 | use either::Either; 48 | 49 | if !project.delegations().iter().any(|d| match d { 50 | Either::Right(indirect) => indirect.urn() == delegate, 51 | Either::Left(_) => false, 52 | }) { 53 | return None; 54 | } 55 | 56 | let meta: project::Metadata = project.try_into().ok()?; 57 | let head = 58 | get_head_commit(&repo, &meta.urn, &meta.default_branch, &meta.delegates) 59 | .map(|h| h.id) 60 | .ok(); 61 | 62 | let issues = issues.count(&meta.urn).map_err(Error::Cobs).ok()?; 63 | let patches = patches.count(&meta.urn).map_err(Error::Cobs).ok()?; 64 | 65 | Some(Info { 66 | meta, 67 | head, 68 | issues, 69 | patches, 70 | }) 71 | } 72 | _ => None, 73 | }) 74 | .transpose() 75 | }) 76 | .skip(page * per_page) 77 | .take(per_page) 78 | .collect::, _>>() 79 | .map_err(Error::from)?; 80 | 81 | Ok::<_, Error>(Json(projects)) 82 | } 83 | 84 | #[cfg(test)] 85 | mod routes { 86 | use axum::body::Body; 87 | use axum::http::{Request, StatusCode}; 88 | use serde_json::Value; 89 | use tower::ServiceExt; 90 | 91 | use super::*; 92 | use crate::test_extra::setup; 93 | 94 | const THEME: &str = "base16-ocean.dark"; 95 | const PROJECT_NAME: &str = "nakamoto"; 96 | 97 | #[tokio::test] 98 | async fn test_delegates_projects_route() { 99 | let (profile, signer, project, _) = setup::env(); 100 | let delegate = project 101 | .delegations() 102 | .iter() 103 | .next() 104 | .unwrap() 105 | .unwrap_right() 106 | .urn(); 107 | let ctx = Context::new(profile.paths().to_owned(), signer, THEME.to_string()); 108 | let app = router(ctx); 109 | let response = app 110 | .oneshot( 111 | Request::builder() 112 | .uri(format!("/delegates/{}/projects", delegate)) 113 | .body(Body::empty()) 114 | .unwrap(), 115 | ) 116 | .await 117 | .unwrap(); 118 | 119 | assert_eq!(response.status(), StatusCode::OK); 120 | 121 | let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); 122 | let body: Value = serde_json::from_slice(&body).unwrap(); 123 | 124 | assert_eq!(body[0]["name"], PROJECT_NAME); 125 | assert_eq!(body[1], Value::Null); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /DESIGN.md: -------------------------------------------------------------------------------- 1 | # Design of the radicle client services 2 | 3 | The radicle client services are a set of networked daemons that offer data 4 | availability, code distribution and repository hosting for Radicle communities. 5 | 6 | They are designed to work in tandem with each other, each providing a specific 7 | functionality, such as HTTP access, Git support or Ethereum integration. 8 | 9 | Each organization, team, community or user can deploy the client services to 10 | participate in the radicle network. They are the backbone of the radicle 11 | network. For the purposes of this document, we shall call such a deployment a 12 | *seed*, and use the terms "community", "org" and "team" interchangeably. 13 | 14 | Seeds are configured to track radicle projects via various mechanisms. 15 | Currently, two mechanisms are supported: Ethereum-based tracking (requires a 16 | radicle org), and URN-based. The former works by listening to events 17 | occurring in smart contracts on chain, while the latter allows the operator of 18 | the seed to specify which projects should be tracked by providing a list of URNs. 19 | 20 | Projects that are tracked by a seed will be fetched from the network and replicated 21 | by the seed, as well as served over HTTP and Git. 22 | 23 | Once deployed, a seed can serve its purpose of hosting code and providing API access 24 | to the chosen repositories, but for it to be easily discoverable and usable by 25 | client applications, it's recommended to register an ENS name. 26 | 27 | By registering an ENS name, for example under the `.radicle.eth` domain, a 28 | community can specify the follow things: 29 | 30 | * A profile, with for eg. a name, avatar, website and twitter handle 31 | * A seed host, which associates a physical seed address to the community 32 | * A seed ID, which specifies the public network identity of the associated seed 33 | * An anchors address, which tells clients where to look for project anchors 34 | 35 | For now, radicle orgs are the only mechanism supported for storing project anchors, 36 | which are `(project-id, project-commit-hash)` tuples that represent the community's 37 | shared understanding of the state of a project. In the future, it will be possible 38 | to anchor projects on Layer 2 as well as on other mediums. 39 | 40 | Although seeds are compatible with the radicle link peer-to-peer replication 41 | protocol, they also have their own mechanism for sharing and distributing code. 42 | Seeds come with a *git bridge* which sits in front of the radicle link state and 43 | offers read and write git access without any special tooling. 44 | 45 | Read and write access are offered via the Git HTTP backend, using GPG keys for 46 | authentication. Namely, signed git pushes are used for writing to the seed 47 | node state. The set of keys authorized to push to a project can be stored in 48 | the repository itself, under the `.rad/keys` directory. Other mechanisms for 49 | configuring authorized keys, eg. using Ethereum addresses or Radicle IDs are 50 | in the works. 51 | 52 | Pushes to the seed end up under the seed's local project tree, which is offered 53 | to the network along with other trees, and is signed by the seed's private key. 54 | 55 | While the Git bridge offers git access to the project state, the HTTP API offers 56 | a RESTful JSON API usable by web clients. 57 | 58 | ## Compared to Radicle Link 59 | 60 | Though the client services interoperate with Link, by allowing read and write 61 | access to the state, and participating in the gossip network, they offer an 62 | additional method of distribution which has one basic advantage: no special 63 | tooling is needed to push or pull code from a seed. Only a recent version of 64 | Git is required. This promises to reduce friction for new users, or users 65 | outside of the radicle network. 66 | 67 | Since the seed node participates in the peer to peer network, it does not 68 | represent a single point of failure for the community or team, when it comes 69 | to repository access. If the seed goes down, users can fallback to the peer-to-peer 70 | network. Hence, we see the client services as an additional method of distribution 71 | on top of Link. 72 | 73 | ## Compared to self-hosted forges 74 | 75 | Certain forges offer self-hosting, for example GitLab or Gitea. This allows 76 | communities to run their own instance of the platform on their servers. 77 | Self-hosted forges have a major drawback, which is that they require users 78 | to create accounts on each of the instances, and there is no global social 79 | layer: users of different instances cannot interact with each other on the 80 | same website or platform. Each instance has a completely closed and isolated 81 | user base. There is no way to build an identity across multiple projects and 82 | communities. Furthermore, project discovery is complicated due to there being 83 | no possibility of a shared database of projects. 84 | 85 | -------------------------------------------------------------------------------- /http-api/src/error.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::large_enum_variant)] 2 | use axum::http::StatusCode; 3 | use axum::response::{IntoResponse, Response}; 4 | use axum::Json; 5 | use serde_json::json; 6 | 7 | use radicle_source::surf; 8 | 9 | /// Errors that may occur when interacting with [`librad::net::peer::Peer`]. 10 | #[derive(Debug, thiserror::Error)] 11 | pub enum Error { 12 | /// An error occurred when performing git operations. 13 | #[error(transparent)] 14 | Git(#[from] git2::Error), 15 | 16 | /// The namespace was expected in a reference but was not found. 17 | #[error("missing namespace in reference")] 18 | MissingNamespace, 19 | 20 | /// The project does not have a default branch. 21 | #[error("missing default branch in project")] 22 | MissingDefaultBranch, 23 | 24 | /// Error related to tracking. 25 | #[error("tracking: {0}")] 26 | Tracking(#[from] librad::git::tracking::error::Tracked), 27 | 28 | /// Error relating to local identities. 29 | #[error(transparent)] 30 | LocalIdentity(#[from] lnk_identities::local::Error), 31 | 32 | /// The entity was not found. 33 | #[error("entity not found")] 34 | NotFound, 35 | 36 | /// No local head found and unable to resolve from delegates. 37 | #[error("could not resolve head: {0}")] 38 | NoHead(&'static str), 39 | 40 | /// An error occurred during an authentication process. 41 | #[error("could not authenticate: {0}")] 42 | Auth(&'static str), 43 | 44 | /// An error occurred while verifying the siwe message. 45 | #[error(transparent)] 46 | SiweVerification(#[from] siwe::VerificationError), 47 | 48 | /// An error occurred while parsing the siwe message. 49 | #[error(transparent)] 50 | SiweParse(#[from] siwe::ParseError), 51 | 52 | /// An error occurred with radicle identities. 53 | #[error(transparent)] 54 | Identities(#[from] librad::git::identities::Error), 55 | 56 | /// An error occurred with radicle surf. 57 | #[error(transparent)] 58 | Surf(#[from] surf::git::error::Error), 59 | 60 | /// An error occurred with radicle storage. 61 | #[error(transparent)] 62 | Storage(#[from] librad::git::storage::Error), 63 | 64 | /// An error occurred with the storage pool. 65 | #[error("{0}")] 66 | Pool(String), 67 | 68 | /// An error occurred with a project. 69 | #[error(transparent)] 70 | Project(#[from] radicle_common::project::Error), 71 | 72 | /// An error occurred with radicle storage. 73 | #[error("{0}: {1}")] 74 | Io(&'static str, std::io::Error), 75 | 76 | /// An error occurred with initializing read-only storage. 77 | #[error(transparent)] 78 | Init(#[from] librad::git::storage::read::error::Init), 79 | 80 | /// An error occurred with radicle source. 81 | #[error(transparent)] 82 | Source(#[from] radicle_source::error::Error), 83 | 84 | /// An error occurred with env variables. 85 | #[error(transparent)] 86 | Env(#[from] std::env::VarError), 87 | 88 | /// An error occurred during the identity resolving. 89 | #[error(transparent)] 90 | IdentityResolve(#[from] radicle_common::cobs::ResolveError), 91 | 92 | /// An error occurred with COB stores. 93 | #[error(transparent)] 94 | Cobs(#[from] radicle_common::cobs::Error), 95 | 96 | /// An async task was either cancelled or panic'ed. 97 | #[error(transparent)] 98 | TokioJoinError(#[from] tokio::task::JoinError), 99 | 100 | /// An anyhow error originated from radicle-common 101 | #[error("radicle-common: {0}")] 102 | Common(#[from] anyhow::Error), 103 | } 104 | 105 | impl IntoResponse for Error { 106 | fn into_response(self) -> Response { 107 | let (status, msg) = match &self { 108 | Error::NotFound => (StatusCode::NOT_FOUND, None), 109 | Error::NoHead(msg) => (StatusCode::NOT_FOUND, Some(msg.to_string())), 110 | Error::Auth(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())), 111 | Error::SiweParse(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())), 112 | Error::SiweVerification(msg) => (StatusCode::BAD_REQUEST, Some(msg.to_string())), 113 | Error::Git(e) => ( 114 | StatusCode::INTERNAL_SERVER_ERROR, 115 | Some(e.message().to_owned()), 116 | ), 117 | _ => { 118 | tracing::error!("Error: {:?}", &self); 119 | 120 | (StatusCode::INTERNAL_SERVER_ERROR, None) 121 | } 122 | }; 123 | 124 | let body = Json(json!({ 125 | "error": msg.or_else(|| status.canonical_reason().map(|r| r.to_string())), 126 | "code": status.as_u16() 127 | })); 128 | 129 | (status, body).into_response() 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /git-server/src/error.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::large_enum_variant)] 2 | use axum::response::{IntoResponse, Response}; 3 | 4 | /// Errors that may occur when interacting with the radicle git server or git hooks. 5 | #[derive(Debug, thiserror::Error)] 6 | pub enum Error { 7 | /// I/O error. 8 | #[error("i/o error: {0}")] 9 | Io(#[from] std::io::Error), 10 | 11 | /// The content encoding is not supported. 12 | #[error("content encoding '{0}' not supported")] 13 | UnsupportedContentEncoding(&'static str), 14 | 15 | /// The service is not available. 16 | #[error("service '{0}' not available")] 17 | ServiceUnavailable(&'static str), 18 | 19 | /// HTTP error. 20 | #[error("HTTP error: {0}")] 21 | Http(#[from] http::Error), 22 | 23 | /// Git backend error. 24 | #[error("backend error")] 25 | Backend, 26 | 27 | /// Project has no default branch. 28 | #[error("project has no default branch")] 29 | NoDefaultBranch, 30 | 31 | /// Custom hook failed to spawn. 32 | #[error("custom hook failed to spawn: {0}")] 33 | CustomHook(std::io::Error), 34 | 35 | /// Failed certificate verification. 36 | #[error("failed certification verification")] 37 | FailedCertificateVerification, 38 | 39 | /// Unauthorized. 40 | #[error("unauthorized: {0}")] 41 | Unauthorized(&'static str), 42 | 43 | /// Post-receive hook error. 44 | #[error("{0}")] 45 | PostReceive(&'static str), 46 | 47 | /// Signer key mismatch. 48 | #[error("signer key mismatch: expected {expected}, got {actual}")] 49 | KeyMismatch { actual: String, expected: String }, 50 | 51 | /// Project alias not found. 52 | #[error("alias does not exist")] 53 | AliasNotFound, 54 | 55 | /// Id is not valid. 56 | #[error("id is not valid")] 57 | InvalidId, 58 | 59 | /// Peer ID is invalid. 60 | #[error("peer-id is invalid")] 61 | InvalidPeerId, 62 | 63 | /// Invalid ref pushed. 64 | #[error("invalid ref pushed: {0}")] 65 | InvalidRefPushed(String), 66 | 67 | /// Namespace not found. 68 | #[error("namespace does not exist")] 69 | NamespaceNotFound, 70 | 71 | /// Reference not found. 72 | #[error("reference not found")] 73 | ReferenceNotFound, 74 | 75 | /// Radicle identity not found for project. 76 | #[error("radicle identity is not found for project")] 77 | RadicleIdentityNotFound, 78 | 79 | /// Environmental variable error. 80 | #[error("environmental variable error: {0}")] 81 | VarError(#[from] std::env::VarError), 82 | 83 | /// Git config parser error. 84 | #[error("git2 error: {0}")] 85 | Git2Error(#[from] git2::Error), 86 | 87 | /// Missing certification signer credentials. 88 | #[error("missing certificate signer credentials: {0}")] 89 | MissingCertificateSignerCredentials(String), 90 | 91 | /// Missing environmental variable. 92 | #[cfg(feature = "hooks")] 93 | #[error("missing environmental config variable: {0}")] 94 | EnvConfigError(#[from] envconfig::Error), 95 | 96 | /// Failed to parse byte data into string. 97 | #[error(transparent)] 98 | Utf8Error(#[from] std::str::Utf8Error), 99 | 100 | /// Librad profile error. 101 | #[error(transparent)] 102 | Profile(#[from] librad::profile::Error), 103 | 104 | /// Failed to connect to unix socket. 105 | #[error("failed to connect to unix socket")] 106 | UnixSocket, 107 | 108 | /// An error occured with initializing read-only storage. 109 | #[error(transparent)] 110 | Init(#[from] librad::git::storage::read::error::Init), 111 | 112 | /// An error occured with radicle identities. 113 | #[error(transparent)] 114 | Identities(#[from] librad::git::identities::Error), 115 | 116 | /// An error occured while verifying an identity. 117 | #[error("error verifying identity: {0}")] 118 | VerifyIdentity(String), 119 | 120 | /// An error occured with a git storage pool. 121 | #[error("storage error: {0}")] 122 | Pool(#[from] librad::git::storage::pool::PoolError), 123 | 124 | /// Stored refs error. 125 | #[error(transparent)] 126 | Stored(#[from] librad::git::refs::stored::Error), 127 | 128 | /// Tracking error. 129 | #[error(transparent)] 130 | Track(#[from] librad::git::tracking::error::Track), 131 | 132 | /// Tracking error (inner). 133 | #[error(transparent)] 134 | PreviousError(#[from] librad::git::tracking::git::refdb::PreviousError), 135 | 136 | /// HeaderName error. 137 | #[error(transparent)] 138 | InvalidHeaderName(#[from] axum::http::header::InvalidHeaderName), 139 | 140 | /// HeaderValue error. 141 | #[error(transparent)] 142 | InvalidHeaderValue(#[from] axum::http::header::InvalidHeaderValue), 143 | } 144 | 145 | impl Error { 146 | pub fn status(&self) -> http::StatusCode { 147 | match self { 148 | Error::UnsupportedContentEncoding(_) => http::StatusCode::NOT_IMPLEMENTED, 149 | Error::ServiceUnavailable(_) => http::StatusCode::SERVICE_UNAVAILABLE, 150 | Error::Unauthorized(_) => http::StatusCode::UNAUTHORIZED, 151 | Error::KeyMismatch { .. } => http::StatusCode::UNAUTHORIZED, 152 | Error::AliasNotFound => http::StatusCode::NOT_FOUND, 153 | Error::InvalidId => http::StatusCode::NOT_FOUND, 154 | _ => http::StatusCode::INTERNAL_SERVER_ERROR, 155 | } 156 | } 157 | } 158 | 159 | impl IntoResponse for Error { 160 | fn into_response(self) -> Response { 161 | tracing::error!("{}", self); 162 | 163 | self.status().into_response() 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Radicle Client Services 2 | 3 | 🏕️ Services backing the Radicle client interfaces. 4 | 5 | ## Setting up a *Seed Node* 6 | 7 | A *seed node* is a type of node that replicates and distributes Radicle 8 | projects making them freely and publicly accessible on the web, and via 9 | peer-to-peer protocols. 10 | 11 | Though it's possible to rely on shared infrastructure and community seed nodes, 12 | it is recommend for most teams and communities to self-host their projects in true 13 | peer-to-peer fashion. This can be achieved by running `radicle-http-api` and 14 | `radicle-git-server` on a server or instance in the cloud. 15 | 16 | ### `radicle-http-api` 17 | 18 | The Radicle HTTP API is a lightweight HTTP service that runs on top of a Radicle 19 | *monorepo*. As a reminder, the "monorepo" is the repository that contains all 20 | projects and associated metadata stored and replicated by Radicle Link. 21 | 22 | By running the API, projects stored in the local monorepo are exposed via a 23 | JSON HTTP API. This can enable clients to query project source code directly 24 | via HTTP, without having to run a node themselves. In particular, the Radicle 25 | web client was built around this API. 26 | 27 | ### `radicle-git-server` 28 | 29 | The Radicle Git Server is an HTTP server that can serve repositories managed 30 | by Radicle. Since Radicle projects are stored in a shared, monolithic repository, 31 | commands like `git clone` cannot work out of the box. The Radicle Git Server 32 | maps requests to specific namespaces in the shared repository and allows a Radicle 33 | node to act as a typical Git server. It is then possible to clone a project 34 | by simply specifying its ID, for example: 35 | 36 | git clone https://seed.alt-clients.radicle.xyz/hnrkyghsrokxzxpy9pww69xr11dr9q7edbxfo.git 37 | 38 | ### Service setup 39 | 40 | The services should have access to the same file-system. The git server requires 41 | *write* access to the file system, while the HTTP API only requires *read* access. 42 | 43 | For this setup to work, it's import to point all services to the same *root*, 44 | which is the path to the monorepo, eg.: 45 | 46 | $ radicle-http-api --root ~/.radicle/root … 47 | $ radicle-git-server --root ~/.radicle/root … 48 | 49 | This ensures the API and Git server can read the same state. 50 | 51 | #### Identity file 52 | 53 | Nodes on the Radicle peer-to-peer network are identified with a *Peer ID*, 54 | which is essentially an encoding of a public key. This identity needs to 55 | be specified on the CLI via the `--identity` flag, similar to SSH's `-i` 56 | flag. Specifically, the path to the private key file should be used. If 57 | no private key is found at that path, a new key will be generated. 58 | 59 | #### Firewall configuration 60 | 61 | For `radicle-http-api`, an HTTP port of your choosing should be opened. This port 62 | can then be specified via the `--listen` parameter, eg. 63 | `radicle-http-api --listen 0.0.0.0:8777`. The default port is `8777`. 64 | 65 | For `radicle-git-server`, it is recommended that port `443` be open. 66 | 67 | #### Using authorized-keys for Authorization 68 | 69 | By default, the pre-receive hook will check the mono-repository for a authorized-keys 70 | public key file on push. If it exists, it will check the public key's fingerprint 71 | matches the $GIT_PUSH_CERT_KEY set by the http-backend. 72 | The $GIT_PUSH_CERT_KEY is used to find the file in the namespace tree, 73 | comparing the fingerprint in the authorized keyring against the signed certificate. 74 | 75 | #### TLS 76 | 77 | For `radicle-http-api` and `radicle-git-server`, it's important to setup TLS 78 | when running in production. This is to allow for compatibility with web 79 | clients that will mostly be using the `https` protocol. Web browser nowadays do 80 | not allow requests to unencrypted HTTP servers from websites using TLS. 81 | 82 | The API service has built-in support for TLS, so there is no need to set up 83 | HTTPS termination via a separate service. Simply pass in the `--tls-cert` 84 | and `--tls-key` flags to enable TLS. 85 | 86 | Certificates can be obtained from *Let's Encrypt*, using [Certbot](https://certbot.eff.org/). 87 | 88 | #### Logging 89 | 90 | To enable logging for either service, set the `RUST_LOG` environment variable. 91 | Setting it to `info` is usually enough, but `debug` is also possible. 92 | 93 | ### ENS setup 94 | 95 | Once these services are running, users wishing to point Radicle clients to them 96 | for project browsing should set the relevant records on ENS. This requires 97 | an ENS name to be registered for each seed node. 98 | 99 | To point Radicle clients to the right seed endpoint, use the 100 | `eth.radicle.seed.host` text record, usually labeled "Seed Host" to the 101 | seed host, eg `seed.acme.org`. 102 | 103 | These records can be set on the web client. For example, the records for the 104 | Alt.-clients org can be found at . 105 | 106 | ### Docker 107 | 108 | There are `Dockerfile` provided for both services in the respective directories. 109 | 110 | #### Building 111 | 112 | To build the containers, run: 113 | 114 | $ docker build -t radicle-services/http-api -f http-api/Dockerfile . 115 | $ docker build -t radicle-services/git-server -f git-server/Dockerfile . 116 | 117 | #### Running 118 | 119 | To run the HTTP API, run: 120 | 121 | $ docker run \ 122 | --init \ 123 | -e RUST_LOG=info \ 124 | -p 8777:8777 \ 125 | -v $HOME/.radicle:/app/radicle radicle-services/http-api \ 126 | --tls-cert /app/radicle/fullchain.pem \ 127 | --tls-key /app/radicle/privkey.pem 128 | 129 | Make sure your TLS certificate files can be found under `$HOME/.radicle`. If you 130 | are not using TLS termination, simply omit the `--tls-*` arguments. 131 | 132 | Running `radicle-git-server` is more or less identical to running the HTTP API. 133 | 134 | You may also want to detach the process (`-d`) and run with a TTY in interactive 135 | mode (`-it`). 136 | 137 | ### Docker Compose 138 | 139 | As an alternative to building the containers yourself, a `docker-compose.yml` 140 | file is included in the repository. To run the services via Docker Compose, you 141 | have to: 142 | 143 | 1. Install Docker and Docker Compose 144 | 2. Clone this repository 145 | 3. Set the necessary environment variables 146 | 4. Start the radicle client services via Docker Compose 147 | 148 | To install Docker Compose, run: 149 | 150 | sudo apt-get install docker 151 | pip install docker-compose 152 | 153 | Then clone this repository and `cd` into it: 154 | 155 | git clone radicle-client-services 156 | cd radicle-client-service 157 | 158 | Then set `RADICLE_DOMAIN` to your seed node's domain, eg. `seed.cloudhead.io`. 159 | These can be set in the environment, or in a `.env` file in the current 160 | directory. 161 | 162 | Finally, pull the containers and start the services: 163 | 164 | docker-compose pull 165 | docker-compose up --detach 166 | 167 | -------------------------------------------------------------------------------- /.github/workflows/deployables.yml: -------------------------------------------------------------------------------- 1 | name: Build and push container images 2 | 3 | on: 4 | push: 5 | branches: 6 | - deploy/* 7 | 8 | jobs: 9 | build-and-push-images: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Set up Docker Buildx 13 | uses: docker/setup-buildx-action@v1 14 | - name: Login to the container registry 15 | uses: docker/login-action@v1 16 | with: 17 | registry: gcr.io 18 | username: _json_key 19 | password: ${{ secrets.GCR_JSON_KEY }} 20 | - name: Checkout code 21 | uses: actions/checkout@v2 22 | - name: Build and push linkd 23 | id: linkd 24 | uses: docker/build-push-action@v2 25 | with: 26 | context: . 27 | file: linkd/Dockerfile 28 | push: true 29 | tags: gcr.io/radicle-services/linkd:latest,gcr.io/radicle-services/linkd:${{ github.sha }} 30 | cache-from: type=registry,ref=gcr.io/radicle-services/linkd:latest 31 | cache-to: type=inline 32 | - name: Build and push http-api 33 | id: http_api 34 | uses: docker/build-push-action@v2 35 | with: 36 | context: . 37 | file: http-api/Dockerfile 38 | push: true 39 | tags: gcr.io/radicle-services/http-api:latest,gcr.io/radicle-services/http-api:${{ github.sha }} 40 | cache-from: type=registry,ref=gcr.io/radicle-services/http-api:latest 41 | cache-to: type=inline 42 | - name: Build and push git-server 43 | id: git_server 44 | uses: docker/build-push-action@v2 45 | with: 46 | context: . 47 | file: git-server/Dockerfile 48 | push: true 49 | tags: gcr.io/radicle-services/git-server:latest,gcr.io/radicle-services/git-server:${{ github.sha }} 50 | cache-from: type=registry,ref=gcr.io/radicle-services/git-server:latest 51 | cache-to: type=inline 52 | 53 | deploy-seed-node: 54 | runs-on: ubuntu-latest 55 | needs: build-and-push-images 56 | permissions: 57 | contents: 'read' 58 | id-token: 'write' 59 | strategy: 60 | matrix: 61 | host: [seed] 62 | include: 63 | - host: seed 64 | zone: europe-west4-c 65 | steps: 66 | - id: 'auth' 67 | uses: 'google-github-actions/auth@v0' 68 | with: 69 | workload_identity_provider: 'projects/281042598092/locations/global/workloadIdentityPools/github-actions/providers/google-cloud' 70 | service_account: 'github-actions@radicle-services.iam.gserviceaccount.com' 71 | - name: Fetch host .env file 72 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="curl https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/.env.${{ matrix.host }} >.env" 73 | - name: Fetch docker-compose.yml 74 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="curl https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/docker-compose.yml >docker-compose.yml" 75 | - name: Fetch docker-compose.seed.yml 76 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="curl https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/docker-compose.seed.yml >docker-compose.seed.yml" 77 | - name: Make room for new images 78 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="docker system prune --all --force" 79 | - name: Pull container images 80 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="RADICLE_IMAGE_TAG=${{ github.sha }} docker-compose --file docker-compose.yml --file docker-compose.seed.yml pull" 81 | - name: Stop services 82 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="RADICLE_IMAGE_TAG=${{ github.sha }} docker-compose --file docker-compose.yml --file docker-compose.seed.yml down" 83 | - name: Restart services 84 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="RADICLE_IMAGE_TAG=${{ github.sha }} docker-compose --file docker-compose.yml --file docker-compose.seed.yml up --detach" 85 | 86 | deploy-garden-nodes: 87 | runs-on: ubuntu-latest 88 | needs: deploy-seed-node 89 | permissions: 90 | contents: 'read' 91 | id-token: 'write' 92 | strategy: 93 | matrix: 94 | host: [pine, willow, maple] 95 | include: 96 | - host: pine 97 | zone: europe-north1-a 98 | - host: willow 99 | zone: europe-north1-a 100 | - host: maple 101 | zone: europe-north1-a 102 | steps: 103 | - id: 'auth' 104 | uses: 'google-github-actions/auth@v0' 105 | with: 106 | workload_identity_provider: 'projects/281042598092/locations/global/workloadIdentityPools/github-actions/providers/google-cloud' 107 | service_account: 'github-actions@radicle-services.iam.gserviceaccount.com' 108 | - name: Fetch host .env file 109 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="curl https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/.env.${{ matrix.host }} >.env" 110 | - name: Fetch docker-compose.yml 111 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="curl https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/docker-compose.yml >docker-compose.yml" 112 | - name: Make room for new images 113 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="docker system prune --all --force" 114 | - name: Pull container images 115 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="RADICLE_IMAGE_TAG=${{ github.sha }} docker-compose --file docker-compose.yml pull" 116 | - name: Stop services 117 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="RADICLE_IMAGE_TAG=${{ github.sha }} docker-compose --file docker-compose.yml down" 118 | - name: Restart services 119 | run: gcloud beta compute ssh --zone ${{ matrix.zone }} "github-actions@alt-clients-${{ matrix.host }}" --project "radicle-services" --command="RADICLE_IMAGE_TAG=${{ github.sha }} docker-compose --file docker-compose.yml up --detach" 120 | -------------------------------------------------------------------------------- /git-server/src/hooks/pre_receive.rs: -------------------------------------------------------------------------------- 1 | //! # PRE-RECEIVE HOOK 2 | //! 3 | //! Before any ref is updated, if $GIT_DIR/hooks/pre-receive file exists and is executable, 4 | //! it will be invoked once with no parameters. 5 | //! 6 | //! The standard input of the hook will be one line per ref to be updated: 7 | 8 | //! `sha1-old SP sha1-new SP refname LF` 9 | //! 10 | //! The refname value is relative to $GIT_DIR; e.g. for the master head this is "refs/heads/master". 11 | //! The two sha1 values before each refname are the object names for the refname before and after the update. 12 | //! Refs to be created will have sha1-old equal to 0{40}, while refs to be deleted will have sha1-new equal to 0{40}, 13 | //! otherwise sha1-old and sha1-new should be valid objects in the repository. 14 | //! 15 | //! # Use by Radicle Git-Server 16 | //! 17 | //! The `pre-receive` git hook provides access to GPG certificates for a signed push, useful for authorizing an 18 | //! update the repository. 19 | use std::io; 20 | use std::io::prelude::*; 21 | use std::io::stdin; 22 | use std::str::FromStr; 23 | 24 | use envconfig::Envconfig; 25 | use git2::{Oid, Repository}; 26 | use librad::PeerId; 27 | 28 | use super::{ 29 | types::{CertNonceStatus, CertStatus, ReceivePackEnv}, 30 | CertSignerDetails, 31 | }; 32 | use crate::error::Error; 33 | 34 | pub type KeyRing = Vec; 35 | 36 | /// `PreReceive` provides access to the standard input values passed into the `pre-receive` 37 | /// git hook, as well as parses environmental variables that may be used to process the hook. 38 | #[derive(Debug, Clone)] 39 | pub struct PreReceive { 40 | /// Environmental Variables. 41 | env: ReceivePackEnv, 42 | /// Ref updates. 43 | updates: Vec<(String, Oid, Oid)>, 44 | /// Authorized keys as SSH key fingerprints. 45 | authorized_keys: Vec, 46 | /// SSH key fingerprint of pusher. 47 | key_fingerprint: String, 48 | } 49 | 50 | // use cert signer details default utility implementations. 51 | impl CertSignerDetails for PreReceive {} 52 | 53 | impl PreReceive { 54 | /// Instantiate from standard input. 55 | pub fn from_stdin() -> Result { 56 | let env = ReceivePackEnv::init_from_env()?; 57 | let mut updates = Vec::new(); 58 | 59 | for line in stdin().lock().lines() { 60 | let line = line?; 61 | let input = line.split(' ').collect::>(); 62 | 63 | let old = Oid::from_str(input[0])?; 64 | let new = Oid::from_str(input[1])?; 65 | let refname = input[2].to_owned(); 66 | 67 | updates.push((refname, old, new)); 68 | } 69 | 70 | let authorized_keys = env 71 | .authorized_keys 72 | .clone() 73 | .map(|k| k.split(',').map(|k| k.to_owned()).collect::()) 74 | .unwrap_or_default(); 75 | 76 | let key_fingerprint = env 77 | .cert_key 78 | .as_ref() 79 | .ok_or(Error::Unauthorized("push certificate is not available"))? 80 | .to_owned(); 81 | 82 | Ok(Self { 83 | env, 84 | updates, 85 | authorized_keys, 86 | key_fingerprint, 87 | }) 88 | } 89 | 90 | /// The main process used by `pre-receive` hook log 91 | pub fn hook() -> Result<(), Error> { 92 | eprintln!("Running pre-receive hook..."); 93 | 94 | let pre_receive = Self::from_stdin()?; 95 | let repo = Repository::open_bare(&pre_receive.env.git_dir)?; 96 | 97 | // Set the namespace we're going to be working from. 98 | repo.set_namespace(&pre_receive.env.git_namespace) 99 | .map_err(Error::from)?; 100 | 101 | pre_receive.verify_certificate()?; 102 | pre_receive.check_authorized_key()?; 103 | pre_receive.authorize_ref_updates()?; 104 | 105 | Ok(()) 106 | } 107 | 108 | /// Authorizes each ref update, making sure the push certificate is signed by the same 109 | /// key as the owner/parent of the ref. 110 | fn authorize_ref_updates(&self) -> Result<(), Error> { 111 | // This is the fingerprint of the key used to sign the push certificate. 112 | let key_fingerprint = self 113 | .key_fingerprint 114 | .strip_prefix("SHA256:") 115 | .ok_or(Error::Unauthorized("key fingerprint is not a SHA-256 hash"))?; 116 | let key_fingerprint = base64::decode(key_fingerprint) 117 | .map_err(|_| Error::Unauthorized("key fingerprint is not valid"))?; 118 | 119 | // We iterate over each ref update and make sure they are all authorized. We need 120 | // to check that updates are only done to refs under `/refs/remotes/` 121 | // for any give ``, where `` is the identity of the signer. 122 | for (refname, _, _) in self.updates.iter() { 123 | // Get the peer/remote we are attempting to push to, and convert it to an SSH 124 | // key fingerpint. 125 | let (peer_id, _) = crate::parse_ref(refname) 126 | .map_err(|_| Error::InvalidRefPushed(refname.to_owned()))?; 127 | let peer_fingerprint = to_ssh_fingerprint(&peer_id)?; 128 | 129 | if key_fingerprint[..] != peer_fingerprint[..] { 130 | return Err(Error::KeyMismatch { 131 | actual: self.key_fingerprint.clone(), 132 | expected: base64::encode(peer_fingerprint), 133 | }); 134 | } 135 | } 136 | Ok(()) 137 | } 138 | 139 | /// This method will succeed iff the cert status is "OK" 140 | fn verify_certificate(&self) -> Result<(), Error> { 141 | eprintln!("Verifying certificate..."); 142 | 143 | let status = CertStatus::from_str(self.env.cert_status.as_deref().unwrap_or_default())?; 144 | if !status.is_ok() { 145 | eprintln!("Bad signature for push certificate: {:?}", status); 146 | return Err(Error::FailedCertificateVerification); 147 | } 148 | 149 | let nonce_status = 150 | CertNonceStatus::from_str(self.env.cert_nonce_status.as_deref().unwrap_or_default())?; 151 | match nonce_status { 152 | // If we receive "OK", the certificate is verified using GPG. 153 | CertNonceStatus::OK => return Ok(()), 154 | // Received an invalid certificate status 155 | CertNonceStatus::UNKNOWN => { 156 | eprintln!("Invalid request, please sign push, i.e. `git push --sign ...`"); 157 | } 158 | CertNonceStatus::SLOP => { 159 | eprintln!("Received `SLOP` certificate status, please re-submit signed push to request new certificate"); 160 | } 161 | _ => { 162 | eprintln!( 163 | "Received invalid certificate nonce status: {:?}", 164 | nonce_status 165 | ); 166 | } 167 | } 168 | 169 | Err(Error::FailedCertificateVerification) 170 | } 171 | 172 | /// Check if the cert_key is found in an authorized keyring 173 | fn check_authorized_key(&self) -> Result<(), Error> { 174 | eprintln!("Authorizing..."); 175 | 176 | if let Some(key) = &self.env.cert_key { 177 | if self.env.allow_unauthorized_keys.unwrap_or_default() { 178 | eprintln!("Unauthorized keys allowed."); 179 | return Ok(()); 180 | } 181 | eprintln!("Checking provided key {}...", key); 182 | 183 | if self.authorized_keys.contains(key) { 184 | eprintln!("Key {} is authorized to push.", key); 185 | return Ok(()); 186 | } 187 | } 188 | 189 | Err(Error::Unauthorized("key is not authorized to push")) 190 | } 191 | } 192 | 193 | /// Get the SSH key fingerprint from a peer id. 194 | fn to_ssh_fingerprint(peer_id: &PeerId) -> Result, io::Error> { 195 | use byteorder::{BigEndian, WriteBytesExt}; 196 | use sha2::Digest; 197 | 198 | let mut buf = Vec::new(); 199 | let name = b"ssh-ed25519"; 200 | let key = peer_id.as_public_key().as_ref(); 201 | 202 | buf.write_u32::(name.len() as u32)?; 203 | buf.extend_from_slice(name); 204 | buf.write_u32::(key.len() as u32)?; 205 | buf.extend_from_slice(key); 206 | 207 | Ok(sha2::Sha256::digest(&buf).to_vec()) 208 | } 209 | -------------------------------------------------------------------------------- /git-server/src/hooks/types.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::str::FromStr; 3 | 4 | use crate::error::Error; 5 | 6 | use envconfig::Envconfig; 7 | use librad::PeerId; 8 | 9 | /// `CertNonceStatus` describes the status of verifying the signed nonce from 10 | /// the user. If it does not match "OK", the `pre-receive` hook should fail unsuccessfully 11 | #[derive(Debug, Clone)] 12 | pub enum CertNonceStatus { 13 | /// "git push --signed" sent a nonce when we did not ask it to send one. 14 | UNSOLICITED, 15 | /// "git push --signed" did not send any nonce header. 16 | MISSING, 17 | /// "git push --signed" sent a bogus nonce. 18 | BAD, 19 | /// "git push --signed" sent the nonce we asked it to send. 20 | OK, 21 | /// "git push --signed" sent a nonce different from what we asked it to send now, but in a previous session. 22 | /// See GIT_PUSH_CERT_NONCE_SLOP environment variable. 23 | SLOP, 24 | /// Unknown type; not associated with git. 25 | UNKNOWN, 26 | } 27 | 28 | impl Default for CertNonceStatus { 29 | fn default() -> Self { 30 | Self::UNKNOWN 31 | } 32 | } 33 | 34 | impl FromStr for CertNonceStatus { 35 | type Err = Error; 36 | 37 | fn from_str(s: &str) -> Result { 38 | Ok(match s { 39 | "UNSOLICITED" => Self::UNSOLICITED, 40 | "MISSING" => Self::MISSING, 41 | "BAD" => Self::BAD, 42 | "OK" => Self::OK, 43 | "SLOP" => Self::SLOP, 44 | _ => Self::UNKNOWN, 45 | }) 46 | } 47 | } 48 | 49 | /// `CertStatus` describes the status of verifying the GPG signature. 50 | #[derive(Debug, Clone)] 51 | pub enum CertStatus { 52 | GoodValid, 53 | GoodUnknown, 54 | GoodExpiredSignature, 55 | GoodExpiredKey, 56 | GoodRevokedKey, 57 | Bad, 58 | Error, 59 | NoSignature, 60 | Unknown, 61 | } 62 | 63 | impl CertStatus { 64 | /// Check whether we should authorize this cert signature. 65 | pub fn is_ok(&self) -> bool { 66 | matches!(self, Self::GoodValid | Self::GoodUnknown) 67 | } 68 | } 69 | 70 | impl FromStr for CertStatus { 71 | type Err = Error; 72 | 73 | // From the git docs: 74 | // 75 | // Show "G" for a good (valid) signature, "B" for a bad signature, "U" for a good 76 | // signature with unknown validity, "X" for a good signature that has expired, "Y" for a 77 | // good signature made by an expired key, "R" for a good signature made by a revoked key, 78 | // "E" if the signature cannot be checked (e.g. missing key) and "N" for no signature 79 | // 80 | fn from_str(s: &str) -> Result { 81 | Ok(match s { 82 | "G" => Self::GoodValid, 83 | "U" => Self::GoodUnknown, 84 | "X" => Self::GoodExpiredSignature, 85 | "Y" => Self::GoodExpiredKey, 86 | "R" => Self::GoodRevokedKey, 87 | "B" => Self::Bad, 88 | "E" => Self::Error, 89 | "N" => Self::NoSignature, 90 | _ => Self::Unknown, 91 | }) 92 | } 93 | } 94 | 95 | /// `ReceivePackEnv` provides access to environmental variables set and used by git-http-backend 96 | /// when a `receive-pack` event is triggered. The values are used by both the `pre-receive` and `post-receive` 97 | /// hooks within the `receive-pack` hook lifecycle. 98 | /// 99 | /// Certificate variables are set by the git-http-backend process, while other variables are set 100 | /// when receiving a new git client request and passing those variables to http-backend. 101 | /// 102 | /// These variables are not exhaustive; other variables may be set by the backend process, but are unused. 103 | #[derive(Debug, Default, Clone, Envconfig)] 104 | pub struct ReceivePackEnv { 105 | /// Object Id of the blob where the signed certificate exists. 106 | #[envconfig(from = "GIT_PUSH_CERT")] 107 | pub cert: Option, 108 | 109 | /// The name and the e-mail address of the owner of the key that signed the push certificate. 110 | #[envconfig(from = "GIT_PUSH_CERT_SIGNER")] 111 | pub cert_signer: Option, 112 | 113 | /// The GPG key ID of the key that signed the push certificate. 114 | #[envconfig(from = "GIT_PUSH_CERT_KEY")] 115 | pub cert_key: Option, 116 | 117 | /// The status of GPG verification of the push certificate, 118 | /// using the same mnemonic as used in %G? format of git log family of commands (see [git-log](https://git-scm.com/docs/git-log)). 119 | #[envconfig(from = "GIT_PUSH_CERT_STATUS")] 120 | pub cert_status: Option, 121 | 122 | /// The nonce string the process asked the signer to include in the push certificate. 123 | /// If this does not match the value recorded on the "nonce" header in the push certificate, 124 | /// it may indicate that the certificate is a valid one that is being replayed from a separate "git push" session. 125 | #[envconfig(from = "GIT_PUSH_CERT_NONCE")] 126 | pub cert_nonce: Option, 127 | 128 | /// cert nonce status is used as a determinant whether the certificate was correctly signed by the user. 129 | /// only an `OK` status will be accepted for authorization. 130 | #[envconfig(from = "GIT_PUSH_CERT_NONCE_STATUS")] 131 | pub cert_nonce_status: Option, 132 | 133 | /// comma delimited list of SSH key fingerprints authorized for the push. 134 | #[envconfig(from = "RADICLE_AUTHORIZED_KEYS")] 135 | pub authorized_keys: Option, 136 | 137 | /// comma delimited list of project delegates in default `PeerId` encoding. 138 | #[envconfig(from = "RADICLE_DELEGATES")] 139 | pub delegates: Option, 140 | 141 | /// allow unauthorized keys, ignores push certificate verification. 142 | #[envconfig(from = "RADICLE_ALLOW_UNAUTHORIZED_KEYS")] 143 | pub allow_unauthorized_keys: Option, 144 | 145 | /// name of identity being pushed. 146 | #[envconfig(from = "RADICLE_NAME")] 147 | pub name: Option, 148 | 149 | /// peer-id specified for push. 150 | #[envconfig(from = "RADICLE_PEER_ID")] 151 | pub peer_id: Option, 152 | 153 | /// path to "on receive" hook 154 | #[envconfig(from = "RADICLE_RECEIVE_HOOK")] 155 | pub receive_hook: Option, 156 | 157 | /// project default branch. 158 | #[envconfig(from = "RADICLE_DEFAULT_BRANCH")] 159 | pub default_branch: Option, 160 | 161 | /// root directory where `git` directory is found. 162 | #[envconfig(from = "RADICLE_ROOT")] 163 | pub root: Option, 164 | 165 | /// namespace of the target repository. 166 | #[envconfig(from = "GIT_NAMESPACE")] 167 | pub git_namespace: String, 168 | 169 | /// The backend process sets GIT_COMMITTER_NAME to $REMOTE_USER 170 | /// and GIT_COMMITTER_EMAIL to ${REMOTE_USER}@http.${REMOTE_ADDR}, 171 | /// ensuring that any reflogs created by git-receive-pack contain 172 | /// some identifying information of the remote user who performed 173 | /// the push. 174 | /// 175 | /// NOTE: `remote_user` and `remote_addr` are set by http basic 176 | /// authentication: i.e. username and password 177 | /// 178 | /// `git-receive-pack` and `pre-receive` hook check for a signed push, 179 | /// which if remote user is set, it should match the signer of the push 180 | /// to verify basic authentication matches signer email. 181 | #[envconfig(from = "REMOTE_USER")] 182 | pub remote_user: Option, 183 | 184 | /// remote address of the socket making the request to the git-server. 185 | /// set by the git-server. 186 | #[envconfig(from = "REMOTE_ADDR")] 187 | pub remote_addr: Option, 188 | 189 | /// should match `REMOTE_USER` 190 | #[envconfig(from = "GIT_COMMITTER_NAME")] 191 | pub git_committer_name: Option, 192 | 193 | /// GIT_COMMITTER_EMAIL is set to ${REMOTE_USER}@http.${REMOTE_ADDR} by the git-http-backend; 194 | /// NOTE: it will likely not match the certificate signer email, as these values are set 195 | /// by different tools and services. 196 | #[envconfig(from = "GIT_COMMITTER_EMAIL")] 197 | pub git_committer_email: Option, 198 | 199 | /// HTTP header set by the git-server. 200 | #[envconfig(from = "CONTENT_TYPE")] 201 | pub content_type: String, 202 | 203 | /// HTTP query string set by the git-server. 204 | #[envconfig(from = "QUERY_STRING")] 205 | pub query_string: String, 206 | 207 | /// top-level git directory, set by the git-http-backend. 208 | #[envconfig(from = "GIT_DIR")] 209 | pub git_dir: PathBuf, 210 | } 211 | -------------------------------------------------------------------------------- /git-server/src/hooks/storage.rs: -------------------------------------------------------------------------------- 1 | use librad::canonical::Canonical as _; 2 | use librad::git::tracking::git::odb; 3 | use librad::git::tracking::git::odb::Read as _; 4 | use librad::git::tracking::git::refdb; 5 | use librad::git::tracking::reference::RefName; 6 | use librad::git::tracking::Config; 7 | use librad::git_ext::{is_not_found_err, Oid, RefLike}; 8 | use librad::paths::Paths; 9 | 10 | use git_ref_format::{refspec, RefString}; 11 | 12 | use std::convert::Infallible; 13 | 14 | use crate::error::Error; 15 | 16 | type Ref<'a> = refdb::Ref<'a, Oid>; 17 | 18 | pub struct Storage { 19 | pub backend: git2::Repository, 20 | pub paths: Paths, 21 | 22 | ro: librad::git::storage::ReadOnly, 23 | } 24 | 25 | impl AsRef for Storage { 26 | fn as_ref(&self) -> &librad::git::storage::ReadOnly { 27 | &self.ro 28 | } 29 | } 30 | 31 | impl Storage { 32 | pub fn open(paths: &Paths) -> Result { 33 | let backend = git2::Repository::open_bare(paths.git_dir())?; 34 | let ro = librad::git::storage::ReadOnly::open(paths)?; 35 | 36 | Ok(Self { 37 | backend, 38 | paths: paths.clone(), 39 | ro, 40 | }) 41 | } 42 | 43 | fn reference<'a, 'b, Ref: 'b>( 44 | &'a self, 45 | reference: &'b Ref, 46 | ) -> Result>, librad::git::storage::read::Error> 47 | where 48 | RefLike: From<&'b Ref>, 49 | Ref: std::fmt::Debug, 50 | { 51 | self.backend 52 | .find_reference(RefLike::from(reference).as_str()) 53 | .map(Some) 54 | .or_else(|e| { 55 | if is_not_found_err(&e) { 56 | Ok(None) 57 | } else { 58 | Err(e.into()) 59 | } 60 | }) 61 | } 62 | } 63 | 64 | impl odb::Read for Storage { 65 | type FindError = Infallible; 66 | type Oid = Oid; 67 | 68 | fn find_config(&self, _oid: &Self::Oid) -> Result, Self::FindError> { 69 | unimplemented!() 70 | } 71 | } 72 | 73 | impl<'a> refdb::Read<'a> for Storage { 74 | type FindError = Infallible; 75 | type ReferencesError = Infallible; 76 | type IterError = Infallible; 77 | type Oid = Oid; 78 | type References = std::iter::Empty, Infallible>>; 79 | 80 | fn find_reference( 81 | &self, 82 | _reference: &RefName<'_, Self::Oid>, 83 | ) -> Result>, Self::FindError> { 84 | unimplemented!() 85 | } 86 | 87 | fn references( 88 | &'a self, 89 | _spec: impl AsRef, 90 | ) -> Result { 91 | unimplemented!() 92 | } 93 | } 94 | 95 | impl odb::Write for Storage { 96 | type ModifyError = Infallible; 97 | type WriteError = Infallible; 98 | type Oid = Oid; 99 | 100 | fn write_config(&self, config: &Config) -> Result { 101 | // unwrap is safe since Error is Infallible 102 | Ok(self 103 | .backend 104 | .blob(&config.canonical_form().unwrap()) 105 | .map(Oid::from) 106 | .unwrap()) 107 | } 108 | 109 | fn modify_config(&self, oid: &Self::Oid, f: F) -> Result 110 | where 111 | F: FnOnce(Config) -> Config, 112 | { 113 | let config = self 114 | .find_config(oid) 115 | .expect("Storage::modify_config: config search should succeed") 116 | .expect("Storage::modify_config: config should exist"); 117 | 118 | Ok(self 119 | .write_config(&f(config)) 120 | .expect("Storage::modify_config: config should be written successfully")) 121 | } 122 | } 123 | 124 | mod error { 125 | use super::Oid; 126 | use thiserror::Error; 127 | 128 | #[derive(Debug, Error)] 129 | #[error("the reference was symbolic, but it is expected to be direct")] 130 | pub struct SymbolicRef; 131 | 132 | #[derive(Debug, Error)] 133 | pub enum Txn { 134 | #[error("failed to initialise git transaction")] 135 | Acquire(#[source] git2::Error), 136 | #[error("failed to commit git transaction")] 137 | Commit(#[source] git2::Error), 138 | #[error("failed to delete reference `{refname}`")] 139 | Delete { 140 | refname: String, 141 | #[source] 142 | source: git2::Error, 143 | }, 144 | #[error("failed while acquiring lock for `{refname}`")] 145 | Lock { 146 | refname: String, 147 | #[source] 148 | source: git2::Error, 149 | }, 150 | #[error(transparent)] 151 | Read(#[from] librad::git::storage::read::Error), 152 | #[error(transparent)] 153 | SymbolicRef(#[from] SymbolicRef), 154 | #[error("failed to write reference `{refname}` with target `{target}`")] 155 | Write { 156 | refname: String, 157 | target: Oid, 158 | #[source] 159 | source: git2::Error, 160 | }, 161 | } 162 | } 163 | 164 | // NOTE: Copied from `radicle-link`. 165 | // If we find a better way to have a storage instance with read/write capabilities without a 166 | // signer instance, we can replace this. 167 | impl refdb::Write for Storage { 168 | type TxnError = error::Txn; 169 | type Oid = Oid; 170 | 171 | fn update<'a, I>(&self, updates: I) -> Result, Self::TxnError> 172 | where 173 | I: IntoIterator>, 174 | { 175 | use refdb::{Applied, PreviousError, Update, Updated}; 176 | 177 | let raw = &self.backend; 178 | let mut txn = raw.transaction().map_err(error::Txn::Acquire)?; 179 | let mut applied = Applied::default(); 180 | let mut reject_or_update = 181 | |apply: Result, PreviousError>| match apply { 182 | Ok(update) => applied.updates.push(update), 183 | Err(rejection) => applied.rejections.push(rejection), 184 | }; 185 | 186 | for update in updates { 187 | match update { 188 | Update::Write { 189 | name, 190 | target, 191 | previous, 192 | } => { 193 | let refname = name.to_string(); 194 | let message = &format!("writing reference with target `{}`", target); 195 | txn.lock_ref(&refname).map_err(|err| error::Txn::Lock { 196 | refname: refname.clone(), 197 | source: err, 198 | })?; 199 | let set = || -> Result<(), Self::TxnError> { 200 | txn.set_target(&refname, target.into(), None, message) 201 | .map_err(|err| error::Txn::Write { 202 | refname, 203 | target, 204 | source: err, 205 | }) 206 | }; 207 | match self.reference(&RefString::from(&name))? { 208 | Some(r) => reject_or_update( 209 | previous 210 | .guard(r.target().map(Oid::from).as_ref(), set)? 211 | .map_or(Ok(Updated::Written { name, target }), Err), 212 | ), 213 | None => reject_or_update( 214 | previous 215 | .guard(None, set)? 216 | .map_or(Ok(Updated::Written { name, target }), Err), 217 | ), 218 | } 219 | } 220 | Update::Delete { name, previous } => { 221 | let refname = name.to_string(); 222 | txn.lock_ref(&refname).map_err(|err| error::Txn::Lock { 223 | refname: refname.clone(), 224 | source: err, 225 | })?; 226 | let delete = || -> Result<(), Self::TxnError> { 227 | txn.remove(&refname).map_err(|err| error::Txn::Delete { 228 | refname, 229 | source: err, 230 | }) 231 | }; 232 | match self.reference(&RefString::from(&name))? { 233 | Some(r) => reject_or_update( 234 | previous 235 | .guard(r.target().map(Oid::from).as_ref(), delete)? 236 | .map_or( 237 | Ok(Updated::Deleted { 238 | name, 239 | previous: r 240 | .target() 241 | .map(Ok) 242 | .unwrap_or(Err(error::SymbolicRef))? 243 | .into(), 244 | }), 245 | Err, 246 | ), 247 | ), 248 | None => match previous { 249 | refdb::PreviousValue::Any 250 | | refdb::PreviousValue::MustNotExist 251 | | refdb::PreviousValue::IfExistsMustMatch(_) => { /* no-op */ } 252 | _ => reject_or_update(Err(PreviousError::DidNotExist)), 253 | }, 254 | } 255 | } 256 | } 257 | } 258 | txn.commit().map_err(error::Txn::Commit)?; 259 | 260 | Ok(applied) 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /git-server/src/hooks/post_receive.rs: -------------------------------------------------------------------------------- 1 | //! # POST-RECEIVE HOOK 2 | //! 3 | //! 4 | //! 5 | use std::io::prelude::*; 6 | use std::io::{stdin, ErrorKind, Write}; 7 | use std::path::Path; 8 | use std::str; 9 | use std::str::FromStr; 10 | 11 | use either::Either; 12 | use envconfig::Envconfig; 13 | use git2::{Oid, Repository}; 14 | use librad::git; 15 | use librad::git::identities; 16 | use librad::git::identities::SomeIdentity; 17 | use librad::git::storage::read::ReadOnlyStorage as _; 18 | use librad::git::tracking; 19 | use librad::git::Urn; 20 | use librad::paths::Paths; 21 | use librad::profile::Profile; 22 | use librad::PeerId; 23 | 24 | use super::storage::Storage; 25 | use super::{types::ReceivePackEnv, CertSignerDetails}; 26 | use crate::error::Error; 27 | 28 | pub const RAD_ID_REF: &str = "rad/id"; 29 | 30 | /// `PostReceive` provides access to the standard input values passed into the `post-receive` 31 | /// git hook, as well as parses environmental variables that may be used to process the hook. 32 | #[derive(Debug, Clone)] 33 | pub struct PostReceive { 34 | /// Project URN being pushed. 35 | urn: Urn, 36 | /// Project delegates. 37 | delegates: Vec, 38 | /// Radicle paths. 39 | paths: Paths, 40 | /// SSH key fingerprint of pusher. 41 | key_fingerprint: String, 42 | /// Ref updates. 43 | updates: Vec<(String, Oid, Oid)>, 44 | // Environmental variables. 45 | env: ReceivePackEnv, 46 | } 47 | 48 | // use cert signer details default utility implementations. 49 | impl CertSignerDetails for PostReceive {} 50 | 51 | impl PostReceive { 52 | /// Instantiate from standard input. 53 | pub fn from_stdin() -> Result { 54 | let mut updates = Vec::new(); 55 | 56 | for line in stdin().lock().lines() { 57 | let line = line?; 58 | let input = line.split(' ').collect::>(); 59 | 60 | let old = Oid::from_str(input[0])?; 61 | let new = Oid::from_str(input[1])?; 62 | let refname = input[2].to_owned(); 63 | 64 | updates.push((refname, old, new)); 65 | } 66 | 67 | let env = ReceivePackEnv::init_from_env()?; 68 | let urn = Urn::try_from_id(&env.git_namespace).map_err(|_| Error::InvalidId)?; 69 | let paths = if let Some(root) = &env.root { 70 | Profile::from_root(Path::new(root), None)?.paths().clone() 71 | } else { 72 | Profile::load()?.paths().clone() 73 | }; 74 | let delegates = if let Some(keys) = &env.delegates { 75 | keys.split(',') 76 | .map(PeerId::from_str) 77 | .collect::>() 78 | .map_err(|_| Error::InvalidPeerId)? 79 | } else { 80 | Vec::new() 81 | }; 82 | let key_fingerprint = env 83 | .cert_key 84 | .as_ref() 85 | .ok_or(Error::PostReceive("push certificate is not available"))? 86 | .to_owned(); 87 | 88 | Ok(Self { 89 | urn, 90 | delegates, 91 | key_fingerprint, 92 | paths, 93 | updates, 94 | env, 95 | }) 96 | } 97 | 98 | /// The main process used by `post-receive` hook. 99 | pub fn hook() -> Result<(), Error> { 100 | println!("Running post-receive hook..."); 101 | 102 | let mut post_receive = Self::from_stdin()?; 103 | let repo = Repository::open_bare(&post_receive.env.git_dir)?; 104 | let identity_exists = repo 105 | .find_reference(&post_receive.namespace_ref(RAD_ID_REF)) 106 | .is_ok(); 107 | 108 | if identity_exists { 109 | println!("Pushing to existing identity..."); 110 | 111 | post_receive.update_refs(&repo)?; 112 | 113 | if let Some((refname, _, _)) = post_receive.updates.first() { 114 | let (peer_id, _) = crate::parse_ref(refname)?; 115 | 116 | post_receive.track_identity(Some(peer_id))?; 117 | post_receive.update_identity(&repo)?; 118 | post_receive.receive_hook()?; 119 | } 120 | } else { 121 | println!("Pushing new identity..."); 122 | 123 | post_receive.initialize_identity(&repo)?; 124 | post_receive.track_identity(None)?; 125 | } 126 | 127 | Ok(()) 128 | } 129 | 130 | pub fn update_refs(&self, repo: &Repository) -> Result<(), Error> { 131 | // If there is no default branch, it means we're pushing a personal identity. 132 | // In that case there is nothing to do. 133 | if let Some(default_branch) = &self.env.default_branch { 134 | let suffix = format!("heads/{}", default_branch); 135 | 136 | for (refname, from, _) in self.updates.iter() { 137 | let (peer_id, rest) = crate::parse_ref(refname)?; 138 | 139 | if from.is_zero() { 140 | println!("Deleted ref {} for {}", rest, peer_id); 141 | } else { 142 | println!("Updated ref {} for {}", rest, peer_id); 143 | } 144 | 145 | // Only delegates can update HEAD. 146 | if !self.delegates.contains(&peer_id) { 147 | continue; 148 | } 149 | // For now, we only create a ref for the default branch. 150 | if rest != suffix { 151 | continue; 152 | } 153 | println!("Update to default branch detected, setting HEAD..."); 154 | 155 | // TODO: This should only update when a quorum is reached between delegates. 156 | // For a single delegate, we can just always allow it. 157 | if self.delegates.len() == 1 { 158 | self.set_head(refname.as_str(), default_branch, repo)?; 159 | } else { 160 | println!("Cannot set head for multi-delegate project: not supported."); 161 | } 162 | // TODO 163 | // 164 | // For non-default-branch refs, we can add them as: 165 | // 166 | // `refs/remotes/cloudhead@/` 167 | } 168 | } 169 | 170 | Ok(()) 171 | } 172 | 173 | /// Set the 'HEAD' of a project. 174 | /// 175 | /// Creates the necessary refs so that a `git clone` may succeed and checkout the correct 176 | /// branch. 177 | fn set_head( 178 | &self, 179 | branch_ref: &str, 180 | branch: &str, 181 | repo: &Repository, 182 | ) -> Result { 183 | let urn = &self.urn; 184 | let namespace = urn.encode_id(); 185 | 186 | println!("Setting repository head for {} to {}.", urn, branch_ref); 187 | 188 | // eg. refs/namespaces/ 189 | let namespace_path = format!("refs/namespaces/{}", namespace); 190 | // eg. refs/namespaces//refs/remotes//heads/master 191 | let branch_ref = format!("{}/{}", namespace_path, branch_ref); 192 | let reference = repo.find_reference(&branch_ref)?; 193 | let oid = reference.target().expect("reference target must exist"); 194 | 195 | // eg. refs/namespaces//HEAD 196 | let head_ref = format!("{}/HEAD", namespace_path); 197 | // eg. refs/namespaces//refs/heads/master 198 | let local_branch_ref = &format!("{}/refs/heads/{}", namespace_path, branch); 199 | 200 | println!("Setting ref {:?} -> {:?}", &local_branch_ref, oid); 201 | repo.reference(local_branch_ref, oid, true, "set-local-branch (radicle)")?; 202 | 203 | println!("Setting ref {:?} -> {:?}", head_ref, local_branch_ref); 204 | repo.reference_symbolic(&head_ref, local_branch_ref, true, "set-head (radicle)")?; 205 | 206 | Ok(oid) 207 | } 208 | 209 | fn update_identity(&mut self, repo: &Repository) -> Result<(), Error> { 210 | if let Some(oid) = self.find_identity_update() { 211 | eprintln!("Updating identity to {}...", oid); 212 | self.set_identity_ref(oid, repo) 213 | } else { 214 | Ok(()) 215 | } 216 | } 217 | 218 | fn find_identity_update(&self) -> Option { 219 | if let Some(update) = self 220 | .updates 221 | .iter() 222 | .find(|(refname, _, _)| refname.ends_with(RAD_ID_REF)) 223 | { 224 | let (_, _, identity_oid) = update; 225 | 226 | Some(*identity_oid) 227 | } else { 228 | None 229 | } 230 | } 231 | 232 | fn initialize_identity(&mut self, repo: &Repository) -> Result<(), Error> { 233 | eprintln!("Initializing identity..."); 234 | 235 | // Make sure one of the ref updates is initializing `rad/id`. 236 | let identity_oid = if let Some(oid) = self.find_identity_update() { 237 | oid 238 | } else { 239 | return Err(Error::PostReceive( 240 | "identity ref 'rad/id' not found in updates", 241 | )); 242 | }; 243 | 244 | // When initializing a new identity, We shouldn't be updating anything, we should be 245 | // creating new refs. 246 | if !self.updates.iter().all(|(_, from, _)| from.is_zero()) { 247 | return Err(Error::PostReceive("identity old ref already exists")); 248 | } 249 | 250 | self.set_identity_ref(identity_oid, repo) 251 | } 252 | 253 | fn set_identity_ref(&self, identity_oid: Oid, repo: &Repository) -> Result<(), Error> { 254 | let storage = git::storage::ReadOnly::open(&self.paths)?; 255 | let lookup = |urn| { 256 | let refname = git::types::Reference::rad_id(git::types::Namespace::from(urn)); 257 | storage.reference_oid(&refname).map(|oid| oid.into()) 258 | }; 259 | 260 | let identity = storage 261 | .identities::() 262 | .some_identity(identity_oid) 263 | .map_err(|_| Error::NamespaceNotFound)?; 264 | 265 | // Make sure that the identity we're pushing matches the namespace 266 | // we're pushing to. 267 | if identity.urn() != self.urn { 268 | return Err(Error::PostReceive( 269 | "identity document doesn't match project id", 270 | )); 271 | } 272 | 273 | match identity { 274 | identities::SomeIdentity::Person(_) => { 275 | storage 276 | .identities::() 277 | .verify(identity_oid) 278 | .map_err(|e| Error::VerifyIdentity(e.to_string()))?; 279 | } 280 | identities::SomeIdentity::Project(_) => { 281 | storage 282 | .identities::() 283 | .verify(identity_oid, lookup) 284 | .map_err(|e| Error::VerifyIdentity(e.to_string()))?; 285 | } 286 | _ => { 287 | return Err(Error::PostReceive("unknown identity type")); 288 | } 289 | } 290 | 291 | // Set local identity to point to the verified commit pushed by the user. 292 | repo.reference( 293 | &self.namespace_ref(RAD_ID_REF), 294 | identity_oid, 295 | true, 296 | &format!("set-id ({})", self.key_fingerprint), 297 | )?; 298 | 299 | Ok(()) 300 | } 301 | 302 | fn namespace_ref(&self, refname: &str) -> String { 303 | format!( 304 | "refs/namespaces/{}/refs/{}", 305 | &self.env.git_namespace, refname 306 | ) 307 | } 308 | 309 | fn track_identity(&self, peer_id: Option) -> Result<(), Error> { 310 | let cfg = tracking::config::Config::default(); 311 | let storage = Storage::open(&self.paths)?; 312 | 313 | if let Some(peer) = peer_id { 314 | println!("Tracking {}...", peer); 315 | 316 | tracking::track( 317 | &storage, 318 | &self.urn, 319 | Some(peer), 320 | cfg, 321 | tracking::policy::Track::Any, 322 | )??; 323 | } else { 324 | println!("Fetching project delegates..."); 325 | 326 | let identity = identities::any::get(&storage, &self.urn)? 327 | .ok_or(Error::PostReceive("identity could not be found"))?; 328 | let mut delegates: Vec = Vec::new(); 329 | 330 | match identity { 331 | SomeIdentity::Person(doc) => { 332 | for key in doc.delegations() { 333 | delegates.push(PeerId::from(*key)); 334 | } 335 | } 336 | SomeIdentity::Project(doc) => { 337 | for d in doc.delegations() { 338 | match d { 339 | Either::Left(key) => { 340 | delegates.push(PeerId::from(*key)); 341 | } 342 | Either::Right(indirect) => { 343 | for key in indirect.delegations() { 344 | delegates.push(PeerId::from(*key)); 345 | } 346 | } 347 | } 348 | } 349 | } 350 | _ => {} 351 | } 352 | 353 | // TODO: We shouldn't track all delegates because we don't have their branches/remotes! 354 | // We should only track the peer that is pushing. 355 | for peer in delegates { 356 | println!("Tracking {}...", peer); 357 | 358 | tracking::track( 359 | &storage, 360 | &self.urn, 361 | Some(peer), 362 | cfg.clone(), 363 | tracking::policy::Track::Any, 364 | )??; 365 | } 366 | } 367 | println!("Tracking successful."); 368 | 369 | Ok(()) 370 | } 371 | 372 | pub fn receive_hook(&self) -> Result<(), Error> { 373 | use std::process::{Command, Stdio}; 374 | 375 | let hook = if let Some(path) = &self.env.receive_hook { 376 | path 377 | } else { 378 | return Ok(()); 379 | }; 380 | println!("Running custom receive hook..."); 381 | 382 | let child = Command::new(hook) 383 | .stderr(Stdio::inherit()) 384 | .stdout(Stdio::inherit()) 385 | .stdin(Stdio::piped()) 386 | .spawn() 387 | .map_err(Error::CustomHook); 388 | 389 | if let Err(Error::CustomHook(ref err)) = child { 390 | if err.kind() == ErrorKind::NotFound { 391 | println!("Custom receive hook not found in {:?}, skipping...", hook); 392 | return Ok(()); 393 | } 394 | } 395 | 396 | let mut child = child?; 397 | 398 | if let Some(mut stdin) = child.stdin.take() { 399 | for (refname, old, new) in self.updates.iter() { 400 | let (peer_id, refname) = crate::parse_ref(refname)?; 401 | 402 | if let Some(branch) = refname.strip_prefix("heads/") { 403 | writeln!(&mut stdin, "{} {} {} {}", peer_id, old, new, branch)?; 404 | } 405 | } 406 | } 407 | 408 | match child.wait() { 409 | Ok(status) => { 410 | if status.success() { 411 | println!("Custom receive hook success."); 412 | } else { 413 | println!("Custom receive hook failed."); 414 | } 415 | } 416 | Err(err) => return Err(err.into()), 417 | } 418 | 419 | Ok(()) 420 | } 421 | } 422 | -------------------------------------------------------------------------------- /http-api/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::if_same_then_else)] 2 | mod auth; 3 | mod axum_extra; 4 | mod commit; 5 | mod error; 6 | mod project; 7 | mod v1; 8 | 9 | #[cfg(test)] 10 | mod test_extra; 11 | 12 | use std::collections::hash_map::Entry; 13 | use std::collections::HashMap; 14 | use std::convert::{TryFrom, TryInto as _}; 15 | use std::path::PathBuf; 16 | use std::sync::Arc; 17 | use std::time::{self, Duration}; 18 | use std::{env, net}; 19 | 20 | use axum::body::BoxBody; 21 | use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; 22 | use axum::http::Method; 23 | use axum::response::{IntoResponse, Json}; 24 | use axum::routing::get; 25 | use axum::{Extension, Router}; 26 | use axum_server::tls_rustls::RustlsConfig; 27 | use chrono::Utc; 28 | use hyper::http::{Request, Response}; 29 | use hyper::Body; 30 | use serde_json::json; 31 | use tokio::sync::RwLock; 32 | use tower_http::cors::{self, CorsLayer}; 33 | use tower_http::trace::TraceLayer; 34 | use tracing::Span; 35 | 36 | use librad::crypto::BoxedSigner; 37 | use librad::git::identities::{self, SomeIdentity}; 38 | use librad::git::storage::pool::{InitError, Initialised}; 39 | use librad::git::storage::{self, Pool, Storage}; 40 | use librad::git::types::{Namespace, One, Reference}; 41 | use librad::git::Urn; 42 | use librad::paths::Paths; 43 | use librad::PeerId; 44 | 45 | use radicle_common::{cobs, keys, person}; 46 | use radicle_source::surf::vcs::git; 47 | 48 | use crate::auth::AuthState; 49 | use crate::project::{Info, PeerInfo}; 50 | 51 | use error::Error; 52 | 53 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 54 | pub const POPULATE_FINGERPRINTS_INTERVAL: time::Duration = time::Duration::from_secs(180); 55 | pub const CLEANUP_SESSIONS_INTERVAL: time::Duration = time::Duration::from_secs(60); 56 | pub const STORAGE_POOL_SIZE: usize = 10; 57 | 58 | #[derive(Debug, Clone)] 59 | pub struct Options { 60 | pub root: Option, 61 | pub passphrase: Option, 62 | pub listen: net::SocketAddr, 63 | pub tls_cert: Option, 64 | pub tls_key: Option, 65 | pub theme: String, 66 | } 67 | 68 | /// SSH Key fingerprint. 69 | type Fingerprint = String; 70 | /// Mapping between fingerprints and users. 71 | type Fingerprints = HashMap; 72 | /// Identifier for sessions 73 | type SessionId = String; 74 | 75 | #[derive(Clone)] 76 | pub struct Context { 77 | paths: Paths, 78 | theme: String, 79 | pool: Pool, 80 | peer_id: PeerId, 81 | aliases: Arc>>, 82 | projects: Arc>>, 83 | sessions: Arc>>, 84 | } 85 | 86 | impl Context { 87 | fn new(paths: Paths, signer: BoxedSigner, theme: String) -> Self { 88 | let peer_id = signer.peer_id(); 89 | let pool = storage::Pool::new( 90 | storage::pool::ReadWriteConfig::new(paths.clone(), signer, Initialised::no()), 91 | STORAGE_POOL_SIZE, 92 | ); 93 | 94 | Self { 95 | paths, 96 | pool, 97 | theme, 98 | peer_id, 99 | aliases: Default::default(), 100 | projects: Default::default(), 101 | sessions: Default::default(), 102 | } 103 | } 104 | 105 | async fn storage(&self) -> Result, Error> { 106 | self.pool 107 | .get() 108 | .await 109 | .map_err(|e| Error::Pool(e.to_string())) 110 | } 111 | 112 | /// Populates alias map with unique projects' names and their urns 113 | async fn populate_aliases(&self, map: &mut HashMap) -> Result<(), Error> { 114 | use librad::git::identities::SomeIdentity::Project; 115 | 116 | let storage = self.storage().await?; 117 | let identities = identities::any::list(storage.read_only())?; 118 | 119 | for identity in identities.flatten() { 120 | if let Project(project) = identity { 121 | let urn = project.urn(); 122 | let name = project.payload().subject.name.to_string(); 123 | 124 | if let Entry::Vacant(e) = map.entry(name.clone()) { 125 | e.insert(urn); 126 | } 127 | } 128 | } 129 | 130 | Ok(()) 131 | } 132 | 133 | fn cleanup_sessions(&self, map: &mut HashMap) -> Result<(), Error> { 134 | let mut to_remove: Vec = Vec::new(); 135 | 136 | for (key, value) in map.iter() { 137 | let current_time = Utc::now(); 138 | match value { 139 | AuthState::Authorized(auth) => { 140 | if let Some(exp_time) = auth.expiration_time { 141 | if current_time >= exp_time { 142 | to_remove.push(key.clone()); 143 | } 144 | } 145 | } 146 | AuthState::Unauthorized { 147 | expiration_time, .. 148 | } => { 149 | if current_time >= *expiration_time { 150 | to_remove.push(key.clone()) 151 | } 152 | } 153 | } 154 | } 155 | 156 | for key in to_remove { 157 | map.remove(&key); 158 | } 159 | 160 | Ok(()) 161 | } 162 | 163 | /// Populate a map between SSH fingerprints and their peer identities 164 | fn populate_fingerprints( 165 | map: &mut tokio::sync::RwLockWriteGuard>, 166 | storage: deadpool::managed::Object, 167 | ) -> Result<(), Error> { 168 | let identities = identities::any::list(storage.read_only())?; 169 | 170 | for identity in identities.flatten() { 171 | if let SomeIdentity::Project(project) = identity { 172 | let meta = project::Metadata::try_from(project)?; 173 | let fingerprints = map.entry(meta.urn.clone()).or_default(); 174 | let tracked = project::tracked(&meta, storage.read_only())?; 175 | 176 | for peer in tracked { 177 | let fp = keys::to_ssh_fingerprint(&peer.id).expect("Conversion cannot fail"); 178 | fingerprints.insert(fp, peer); 179 | } 180 | } 181 | } 182 | 183 | Ok(()) 184 | } 185 | 186 | /// From a commit hash, return the signer's fingerprint, if any. 187 | fn commit_ssh_fingerprint(&self, sha1: &str) -> Result, Error> { 188 | radicle_common::git::commit_ssh_fingerprint(self.paths.git_dir(), sha1) 189 | .map_err(|e| Error::Io("failed to get commit's ssh fingerprint", e)) 190 | } 191 | 192 | async fn project_info(&self, urn: Urn) -> Result { 193 | let storage = self.storage().await?; 194 | let project = identities::project::get(&*storage, &urn)?.ok_or(Error::NotFound)?; 195 | let meta: project::Metadata = project.try_into()?; 196 | 197 | let repo = git2::Repository::open_bare(self.paths.git_dir())?; 198 | let head = get_head_commit(&repo, &urn, &meta.default_branch, &meta.delegates) 199 | .map(|h| h.id) 200 | .ok(); 201 | 202 | let whoami = person::local(&*storage).map_err(Error::LocalIdentity)?; 203 | let cobs = cobs::Store::new(whoami, &self.paths, &storage); 204 | let issues = cobs.issues(); 205 | let issues = issues.count(&urn).map_err(Error::Cobs)?; 206 | 207 | let patches = cobs.patches(); 208 | let patches = patches.count(&urn).map_err(Error::Cobs)?; 209 | 210 | Ok(Info { 211 | head, 212 | meta, 213 | issues, 214 | patches, 215 | }) 216 | } 217 | } 218 | 219 | /// Run the HTTP API. 220 | pub async fn run(options: Options) -> anyhow::Result<()> { 221 | let (_, profile, signer) = shared::profile(options.root, options.passphrase)?; 222 | let paths = profile.paths(); 223 | let ctx = Context::new(paths.clone(), signer, options.theme); 224 | let peer_id = ctx.peer_id; 225 | 226 | // Populate fingerprints 227 | tokio::spawn(populate_fingerprints_job( 228 | ctx.clone(), 229 | POPULATE_FINGERPRINTS_INTERVAL, 230 | )); 231 | // Cleanup sessions 232 | tokio::spawn(cleanup_sessions_job(ctx.clone(), CLEANUP_SESSIONS_INTERVAL)); 233 | 234 | let root_router = Router::new() 235 | .route("/", get(root_handler)) 236 | .layer(Extension(peer_id)); 237 | 238 | let app = Router::new() 239 | .merge(root_router) 240 | .merge(v1::router(ctx.clone())) 241 | .layer( 242 | CorsLayer::new() 243 | .max_age(Duration::from_secs(86400)) 244 | .allow_origin(cors::Any) 245 | .allow_methods([Method::GET, Method::POST, Method::PUT]) 246 | .allow_headers([CONTENT_TYPE, AUTHORIZATION]), 247 | ) 248 | .layer( 249 | TraceLayer::new_for_http() 250 | .make_span_with(|request: &Request| { 251 | tracing::info_span!( 252 | "request", 253 | method = %request.method(), 254 | uri = %request.uri(), 255 | status = tracing::field::Empty, 256 | latency = tracing::field::Empty, 257 | ) 258 | }) 259 | .on_response( 260 | |response: &Response, latency: Duration, span: &Span| { 261 | span.record("status", &tracing::field::debug(response.status())); 262 | span.record("latency", &tracing::field::debug(latency)); 263 | 264 | tracing::info!("Processed"); 265 | }, 266 | ), 267 | ); 268 | 269 | if let (Some(cert), Some(key)) = (options.tls_cert, options.tls_key) { 270 | let config = RustlsConfig::from_pem_file(cert, key).await.unwrap(); 271 | 272 | tracing::info!("listening on https://{}", options.listen); 273 | axum_server::bind_rustls(options.listen, config) 274 | .serve(app.into_make_service()) 275 | .await?; 276 | } else { 277 | tracing::info!("listening on http://{}", options.listen); 278 | axum::Server::bind(&options.listen) 279 | .serve(app.into_make_service()) 280 | .await?; 281 | } 282 | 283 | Ok(()) 284 | } 285 | 286 | async fn cleanup_sessions_job(ctx: Context, interval: time::Duration) { 287 | let mut timer = tokio::time::interval(interval); 288 | 289 | loop { 290 | timer.tick().await; // Returns immediately the first time. 291 | 292 | let mut sessions = ctx.sessions.write().await; 293 | if let Err(err) = ctx.cleanup_sessions(&mut sessions) { 294 | tracing::error!("Failed to cleanup sessions: {}", err); 295 | } 296 | } 297 | } 298 | 299 | async fn populate_fingerprints_job(ctx: Context, interval: time::Duration) -> Result<(), Error> { 300 | let mut timer = tokio::time::interval(interval); 301 | 302 | loop { 303 | timer.tick().await; // Returns immediately the first time. 304 | 305 | let storage = ctx.storage().await?; 306 | let mut projects_guard: tokio::sync::RwLockWriteGuard<_> = ctx.projects.write().await; 307 | if let Err(err) = tokio::task::block_in_place(|| { 308 | Context::populate_fingerprints(&mut projects_guard, storage) 309 | }) { 310 | tracing::error!("Failed to populate project fingerprints: {}", err); 311 | } 312 | } 313 | } 314 | 315 | async fn root_handler(Extension(peer_id): Extension) -> impl IntoResponse { 316 | let response = json!({ 317 | "message": "Welcome!", 318 | "service": "radicle-http-api", 319 | "version": format!("{}-{}", VERSION, env!("GIT_HEAD")), 320 | "peer": { "id": peer_id }, 321 | "path": "/", 322 | "links": [ 323 | { 324 | "href": "/v1/projects", 325 | "rel": "projects", 326 | "type": "GET" 327 | }, 328 | { 329 | "href": "/v1/peer", 330 | "rel": "peer", 331 | "type": "GET" 332 | }, 333 | { 334 | "href": "/v1/delegates/:urn/projects", 335 | "rel": "projects", 336 | "type": "GET" 337 | } 338 | ] 339 | }); 340 | 341 | Json(response) 342 | } 343 | 344 | fn get_head_commit( 345 | repo: &git2::Repository, 346 | urn: &Urn, 347 | default_branch: &str, 348 | delegates: &[project::Delegate], 349 | ) -> Result { 350 | let namespace = Namespace::try_from(urn).map_err(|_| Error::MissingNamespace)?; 351 | let branch = One::try_from(default_branch).map_err(|_| Error::MissingDefaultBranch)?; 352 | let local = Reference::head(namespace.clone(), None, branch.clone()).to_string(); 353 | let result = repo.find_reference(&local); 354 | 355 | let head = match result { 356 | Ok(b) => b, 357 | Err(_) => { 358 | tracing::debug!("No local head, falling back to project delegates"); 359 | let resolved_default_delegate = match delegates { 360 | [project::Delegate::Direct { id }] => Ok(id), 361 | [project::Delegate::Indirect { ids, .. }] => { 362 | let ids: Vec<&PeerId> = ids.iter().collect(); 363 | if let [id] = ids.as_slice() { 364 | Ok(*id) 365 | } else { 366 | Err(Error::NoHead("project has single indirect delegate with zero or more than one direct delegate")) 367 | } 368 | } 369 | other => { 370 | if other.len() > 1 { 371 | Err(Error::NoHead("project has multiple delegates")) 372 | } else { 373 | Err(Error::NoHead("project has no delegates")) 374 | } 375 | } 376 | }?; 377 | let remote = Reference::head(namespace, *resolved_default_delegate, branch).to_string(); 378 | 379 | repo.find_reference(&remote) 380 | .map_err(|_| Error::NoHead("history lookup failed"))? 381 | } 382 | }; 383 | let oid = head 384 | .target() 385 | .ok_or(Error::NoHead("head target not found"))?; 386 | let commit = repo.find_commit(oid)?.try_into()?; 387 | 388 | Ok(commit) 389 | } 390 | 391 | #[cfg(test)] 392 | mod test { 393 | use super::*; 394 | 395 | #[test] 396 | fn test_local_head() { 397 | use std::convert::TryFrom; 398 | 399 | let branch = String::from("master"); 400 | let branch_ref = One::try_from(branch.as_str()).unwrap(); 401 | let urn = Urn::try_from_id("hnrkfbrd7y9674d8ow8uioki16fniwcyoz67y").unwrap(); 402 | let namespace = Namespace::try_from(urn).unwrap(); 403 | let local = Reference::head(namespace, None, branch_ref); 404 | 405 | assert_eq!( 406 | local.to_string(), 407 | "refs/namespaces/hnrkfbrd7y9674d8ow8uioki16fniwcyoz67y/refs/heads/master" 408 | ); 409 | } 410 | 411 | #[test] 412 | fn test_remote_head() { 413 | use std::convert::TryFrom; 414 | use std::str::FromStr; 415 | 416 | let branch = String::from("master"); 417 | let branch_ref = One::try_from(branch.as_str()).unwrap(); 418 | let urn = Urn::try_from_id("hnrkfbrd7y9674d8ow8uioki16fniwcyoz67y").unwrap(); 419 | let namespace = Namespace::try_from(urn).unwrap(); 420 | let peer = 421 | PeerId::from_str("hyypw8z5g7tbui9ceh6tng58i1qk696isjnzix9fq9g41fzgjgqk8g").unwrap(); 422 | let remote = Reference::head(namespace, peer, branch_ref); 423 | 424 | assert_eq!( 425 | remote.to_string(), 426 | "refs/namespaces/hnrkfbrd7y9674d8ow8uioki16fniwcyoz67y/refs/remotes/hyypw8z5g7tbui9ceh6tng58i1qk696isjnzix9fq9g41fzgjgqk8g/heads/master" 427 | ); 428 | } 429 | } 430 | -------------------------------------------------------------------------------- /git-server/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::type_complexity)] 2 | #![allow(clippy::too_many_arguments)] 3 | pub mod error; 4 | 5 | #[cfg(feature = "hooks")] 6 | pub mod hooks; 7 | 8 | use std::collections::{HashMap, HashSet}; 9 | use std::convert::TryInto; 10 | use std::fs::File; 11 | use std::io::prelude::*; 12 | use std::net::SocketAddr; 13 | use std::path::{Path, PathBuf}; 14 | use std::process::{Command, Stdio}; 15 | use std::sync::Arc; 16 | use std::time::Duration; 17 | use std::{io, net}; 18 | 19 | use anyhow::bail; 20 | use anyhow::Context as _; 21 | use axum::body::{BoxBody, Bytes}; 22 | use axum::extract::{ConnectInfo, Path as AxumPath, RawQuery}; 23 | use axum::http::{Method, StatusCode}; 24 | use axum::response::IntoResponse; 25 | use axum::routing::any; 26 | use axum::{Extension, Router}; 27 | use axum_server::tls_rustls::RustlsConfig; 28 | use either::Either; 29 | use flate2::write::GzDecoder; 30 | use http::header::HeaderName; 31 | use http::HeaderMap; 32 | use hyper::body::Buf; 33 | use hyper::http::{Request, Response}; 34 | use hyper::Body; 35 | use tokio::sync::RwLock; 36 | use tower_http::trace::TraceLayer; 37 | use tracing::Span; 38 | 39 | use librad::git::identities; 40 | use librad::git::storage::Pool; 41 | use librad::git::{self, Urn}; 42 | use librad::identities::SomeIdentity; 43 | use librad::paths::Paths; 44 | use librad::profile::LnkHome; 45 | use librad::PeerId; 46 | 47 | use error::Error; 48 | 49 | pub const VERSION: &str = env!("CARGO_PKG_VERSION"); 50 | pub const STORAGE_POOL_SIZE: usize = 3; 51 | pub const AUTHORIZED_KEYS_FILE: &str = "authorized-keys"; 52 | pub const POST_RECEIVE_OK_HOOK: &str = "post-receive-ok"; 53 | 54 | #[derive(Debug, Clone)] 55 | pub struct Options { 56 | pub root: Option, 57 | pub passphrase: Option, 58 | pub listen: net::SocketAddr, 59 | pub tls_cert: Option, 60 | pub tls_key: Option, 61 | pub git_receive_pack: bool, 62 | pub cert_nonce_seed: Option, 63 | pub allow_unauthorized_keys: bool, 64 | } 65 | 66 | #[derive(Clone)] 67 | pub struct Context { 68 | paths: Paths, 69 | root: LnkHome, 70 | git_receive_pack: bool, 71 | cert_nonce_seed: Option, 72 | git_receive_hook: PathBuf, 73 | allow_unauthorized_keys: bool, 74 | aliases: Arc>>, 75 | pool: Pool, 76 | } 77 | 78 | impl Context { 79 | fn from(options: &Options) -> anyhow::Result { 80 | let (root, profile, _) = shared::profile(options.root.clone(), options.passphrase.clone())?; 81 | let paths = profile.paths(); 82 | let pool = git::storage::Pool::new( 83 | git::storage::pool::ReadConfig::new(paths.clone()), 84 | STORAGE_POOL_SIZE, 85 | ); 86 | 87 | let git_root = paths.git_dir().canonicalize()?; 88 | let git_receive_hook = git_root.join("hooks").join(POST_RECEIVE_OK_HOOK); 89 | 90 | tracing::debug!("Git root path set to: {:?}", git_root); 91 | 92 | Ok(Context { 93 | paths: paths.clone(), 94 | root, 95 | git_receive_pack: options.git_receive_pack, 96 | git_receive_hook, 97 | cert_nonce_seed: options.cert_nonce_seed.clone(), 98 | allow_unauthorized_keys: options.allow_unauthorized_keys, 99 | aliases: Default::default(), 100 | pool, 101 | }) 102 | } 103 | 104 | /// (Re-)load the authorized keys file. 105 | pub fn load_authorized_keys(&self) -> io::Result> { 106 | let mut authorized_keys = HashSet::new(); 107 | 108 | match File::open(self.paths.git_dir().join(AUTHORIZED_KEYS_FILE)) { 109 | Ok(file) => { 110 | for line in io::BufReader::new(file).lines() { 111 | let key = line?; 112 | if !key.is_empty() { 113 | authorized_keys.insert(key.clone()); 114 | } 115 | } 116 | } 117 | Err(err) if err.kind() == io::ErrorKind::NotFound => { 118 | if !self.allow_unauthorized_keys && self.git_receive_pack { 119 | tracing::warn!("No authorized keys loaded"); 120 | } 121 | } 122 | Err(err) => { 123 | tracing::error!("Authorized keys file could not be loaded: {}", err); 124 | } 125 | } 126 | Ok(authorized_keys.into_iter().collect()) 127 | } 128 | 129 | /// Sets the config receive.advertisePushOptions, which lets the user known they can provide a push option `-o`, 130 | /// to specify unique attributes. This is currently not used, but may be used in the future. 131 | pub fn advertise_push_options(&self) -> Result<(), Error> { 132 | let field = "receive.advertisePushOptions"; 133 | let value = "true"; 134 | 135 | self.set_root_git_config(field, value)?; 136 | 137 | Ok(()) 138 | } 139 | 140 | /// Disables the allowed signers file. 141 | /// We already have methods for authorizing keys, so this just gets in the way. 142 | pub fn disable_signers_file(&self) -> Result<(), Error> { 143 | let field = "gpg.ssh.allowedSignersFile"; 144 | let value = "/dev/null"; 145 | 146 | self.set_root_git_config(field, value)?; 147 | 148 | Ok(()) 149 | } 150 | 151 | /// Disables the automatic git garbage collector. 152 | /// 153 | /// The GC operates across namespaces, which mean it may pack objects from different projects 154 | /// together. This is not only useless, but the process can be very memory intensive. Until 155 | /// a better solution is found, we disable the automatic GC. 156 | pub fn disable_gc(&self) -> Result<(), Error> { 157 | let field = "gc.auto"; 158 | let value = "0"; 159 | 160 | self.set_root_git_config(field, value)?; 161 | 162 | Ok(()) 163 | } 164 | 165 | /// Enables users to submit a signed push: `push --signed` 166 | /// 167 | /// "You should set the certNonceSeed setting to some randomly generated long string that should 168 | /// be kept secret. It is combined with the timestamp to generate a one-time value (“nonce”) 169 | /// that the git client is required to sign and provides both a mechanism to prevent replay 170 | /// attacks and to offer proof that the certificate was generated for that specific server 171 | /// (though for others to verify this, they would need to know the value of the nonce seed)." 172 | pub fn set_cert_nonce_seed(&self) -> Result<(), Error> { 173 | let field = "receive.certNonceSeed"; 174 | let value = self 175 | .cert_nonce_seed 176 | .clone() 177 | .unwrap_or_else(gen_random_string); 178 | 179 | self.set_root_git_config(field, &value)?; 180 | 181 | Ok(()) 182 | } 183 | 184 | /// Sets the SLOP delay for signed push verification. 185 | /// 186 | /// "When a `git push --signed` sent a push certificate with a "nonce" that was issued by a 187 | /// receive-pack serving the same repository within this many seconds, export the "nonce" found 188 | /// in the certificate to GIT_PUSH_CERT_NONCE to the hooks (instead of what the receive-pack 189 | /// asked the sending side to include). This may allow writing checks in pre-receive and 190 | /// post-receive a bit easier. Instead of checking GIT_PUSH_CERT_NONCE_SLOP environment 191 | /// variable that records by how many seconds the nonce is stale to decide if they want to 192 | /// accept the certificate, they only can check GIT_PUSH_CERT_NONCE_STATUS is OK." 193 | pub fn set_cert_nonce_slop(&self) -> Result<(), Error> { 194 | let field = "receive.certNonceSlop"; 195 | let value = 60; // Seconds. 196 | 197 | self.set_root_git_config(field, &value.to_string())?; 198 | 199 | Ok(()) 200 | } 201 | 202 | /// Updates the git config in the monorepo. 203 | pub fn set_root_git_config(&self, field: &str, value: &str) -> Result<(), Error> { 204 | let path = self.paths.git_dir().join("config"); 205 | 206 | tracing::debug!("Searching for git config at: {:?}", path); 207 | 208 | let mut config = git2::Config::open(&path)?; 209 | 210 | config.set_str(field, value)?; 211 | 212 | Ok(()) 213 | } 214 | 215 | /// Populates alias map with unique projects' names and their urns 216 | async fn populate_aliases(&self, map: &mut HashMap) -> Result<(), Error> { 217 | use librad::git::identities::SomeIdentity::Project; 218 | 219 | let storage = self.pool.get().await?; 220 | let identities = identities::any::list(&storage)?; 221 | 222 | for identity in identities.flatten() { 223 | if let Project(project) = identity { 224 | let urn = project.urn(); 225 | let name = project.payload().subject.name.to_string(); 226 | 227 | tracing::info!("alias {:?} for {:?}", name, urn.to_string()); 228 | 229 | if let std::collections::hash_map::Entry::Vacant(e) = map.entry(name.clone()) { 230 | e.insert(urn); 231 | } else { 232 | tracing::warn!("alias {:?} exists, skipping", name); 233 | } 234 | } 235 | } 236 | 237 | Ok(()) 238 | } 239 | 240 | async fn get_meta( 241 | &self, 242 | urn: &Urn, 243 | ) -> Result<(Option, Vec, Option), Error> { 244 | let storage = self.pool.get().await?; 245 | let doc = identities::any::get(&storage, urn)?; 246 | 247 | if let Some(doc) = doc { 248 | let mut peer_ids = Vec::new(); 249 | let mut default_branch = None; 250 | let mut name = None; 251 | 252 | match doc { 253 | SomeIdentity::Person(doc) => { 254 | name = Some(doc.payload().subject.name.to_string()); 255 | peer_ids.extend(doc.delegations().iter().cloned().map(PeerId::from)) 256 | } 257 | SomeIdentity::Project(doc) => { 258 | name = Some(doc.payload().subject.name.to_string()); 259 | default_branch = Some( 260 | doc.subject() 261 | .default_branch 262 | .clone() 263 | .ok_or(Error::NoDefaultBranch)? 264 | .to_string(), 265 | ); 266 | 267 | for delegation in doc.delegations() { 268 | match delegation { 269 | Either::Left(pk) => peer_ids.push(PeerId::from(*pk)), 270 | Either::Right(indirect) => { 271 | peer_ids.extend( 272 | indirect.delegations().iter().cloned().map(PeerId::from), 273 | ); 274 | } 275 | } 276 | } 277 | } 278 | _ => {} 279 | } 280 | Ok((name, peer_ids, default_branch)) 281 | } else { 282 | Ok((None, vec![], None)) 283 | } 284 | } 285 | } 286 | 287 | /// Run the Git Server. 288 | pub async fn run(options: Options) -> anyhow::Result<()> { 289 | let git_version = Command::new("git") 290 | .arg("version") 291 | .output() 292 | .context("'git' command must be available")? 293 | .stdout; 294 | tracing::info!("{}", std::str::from_utf8(&git_version)?.trim()); 295 | 296 | let ctx = Context::from(&options).expect("context creation must not fail"); 297 | { 298 | let mut aliases = ctx.aliases.write().await; 299 | 300 | ctx.populate_aliases(&mut aliases) 301 | .await 302 | .context("populating aliases")?; 303 | } 304 | 305 | if let Err(e) = ctx.set_cert_nonce_seed() { 306 | bail!("Failed to set certificate nonce seed: {:?}", e); 307 | } 308 | if let Err(e) = ctx.set_cert_nonce_slop() { 309 | bail!("Failed to set certificate nonce slop: {:?}", e); 310 | } 311 | if let Err(e) = ctx.advertise_push_options() { 312 | bail!("Failed to set push config: {:?}", e); 313 | } 314 | if let Err(e) = ctx.disable_signers_file() { 315 | bail!("Failed to set signers file config: {:?}", e); 316 | } 317 | if let Err(e) = ctx.disable_gc() { 318 | bail!("Failed to disable gc: {:?}", e); 319 | } 320 | 321 | let app = Router::new() 322 | .route("/:project_id/*request", any(git_handler)) 323 | .layer(Extension(ctx.clone())) 324 | .layer( 325 | TraceLayer::new_for_http() 326 | .make_span_with(|request: &Request| { 327 | tracing::info_span!( 328 | "request", 329 | method = %request.method(), 330 | uri = %request.uri(), 331 | status = tracing::field::Empty, 332 | latency = tracing::field::Empty, 333 | ) 334 | }) 335 | .on_response( 336 | |response: &Response, latency: Duration, span: &Span| { 337 | span.record("status", &tracing::field::debug(response.status())); 338 | span.record("latency", &tracing::field::debug(latency)); 339 | 340 | tracing::info!("Processed"); 341 | }, 342 | ), 343 | ) 344 | .into_make_service_with_connect_info::(); 345 | 346 | if let (Some(cert), Some(key)) = (options.tls_cert, options.tls_key) { 347 | let config = RustlsConfig::from_pem_file(cert, key).await.unwrap(); 348 | 349 | tracing::info!("listening on https://{}", options.listen); 350 | axum_server::bind_rustls(options.listen, config) 351 | .serve(app) 352 | .await?; 353 | } else { 354 | tracing::info!("listening on http://{}", options.listen); 355 | axum::Server::bind(&options.listen).serve(app).await?; 356 | } 357 | 358 | Ok(()) 359 | } 360 | 361 | async fn git_handler( 362 | Extension(ctx): Extension, 363 | AxumPath((project_id, request)): AxumPath<(String, String)>, 364 | method: Method, 365 | headers: HeaderMap, 366 | body: Bytes, 367 | ConnectInfo(remote): ConnectInfo, 368 | query: RawQuery, 369 | ) -> impl IntoResponse { 370 | let peer_id = None; 371 | let query = query.0.unwrap_or_default(); 372 | 373 | let urn = if let Some(name) = project_id.strip_suffix(".git") { 374 | if let Ok(urn) = Urn::try_from_id(name) { 375 | urn 376 | } else { 377 | tracing::debug!("looking for project alias {:?}", name); 378 | 379 | let mut aliases = ctx.aliases.write().await; 380 | if !aliases.contains_key(name) { 381 | // If the alias does not exist, rebuild the cache. 382 | ctx.populate_aliases(&mut aliases).await?; 383 | } 384 | let urn = aliases.get(name).cloned().ok_or(Error::AliasNotFound)?; 385 | tracing::debug!("project alias resolved to {}", urn); 386 | 387 | urn 388 | } 389 | } else { 390 | Urn::try_from_id(project_id).map_err(|_| Error::InvalidId)? 391 | }; 392 | 393 | let (status, headers, body) = git( 394 | ctx, method, headers, body, remote, urn, peer_id, &request, query, 395 | ) 396 | .await?; 397 | 398 | let mut response_headers = HeaderMap::new(); 399 | for (name, vec) in headers.iter() { 400 | for value in vec { 401 | let header: HeaderName = name.try_into()?; 402 | response_headers.insert(header, value.parse()?); 403 | } 404 | } 405 | 406 | Ok::<_, Error>((status, response_headers, body)) 407 | } 408 | 409 | async fn git( 410 | ctx: Context, 411 | method: Method, 412 | headers: HeaderMap, 413 | mut body: impl Buf, 414 | remote: net::SocketAddr, 415 | urn: Urn, 416 | peer_id: Option, 417 | path: &str, 418 | query: String, 419 | ) -> Result<(http::StatusCode, HashMap>, Vec), Error> { 420 | let namespace = urn.encode_id(); 421 | let content_type = 422 | if let Some(Ok(content_type)) = headers.get("Content-Type").map(|h| h.to_str()) { 423 | content_type 424 | } else { 425 | "" 426 | }; 427 | let authorized_keys = match (path, query.as_str()) { 428 | // Eg. `git push` 429 | ("git-receive-pack", _) | (_, "service=git-receive-pack") => { 430 | if !ctx.git_receive_pack { 431 | return Err(Error::ServiceUnavailable("git-receive-pack")); 432 | } 433 | ctx.load_authorized_keys()? 434 | } 435 | _ => vec![], 436 | }; 437 | 438 | let (name, delegates, default_branch) = ctx.get_meta(&urn).await?; 439 | 440 | tracing::debug!("headers: {:?}", headers); 441 | tracing::debug!("namespace: {}", namespace); 442 | tracing::debug!("path: {:?}", path); 443 | tracing::debug!("method: {:?}", method.as_str()); 444 | tracing::debug!("remote: {:?}", remote.to_string()); 445 | tracing::debug!("delegates: {:?}", delegates); 446 | tracing::debug!("authorized keys: {:?}", authorized_keys); 447 | 448 | let mut cmd = Command::new("git"); 449 | 450 | cmd.arg("http-backend"); 451 | 452 | if !authorized_keys.is_empty() { 453 | cmd.env("RADICLE_AUTHORIZED_KEYS", authorized_keys.join(",")); 454 | } 455 | if !delegates.is_empty() { 456 | cmd.env( 457 | "RADICLE_DELEGATES", 458 | delegates 459 | .iter() 460 | .map(|d| d.default_encoding()) 461 | .collect::>() 462 | .join(","), 463 | ); 464 | } 465 | if ctx.allow_unauthorized_keys { 466 | cmd.env("RADICLE_ALLOW_UNAUTHORIZED_KEYS", "true"); 467 | } 468 | if let Some(name) = name { 469 | cmd.env("RADICLE_NAME", name); 470 | } 471 | if let Some(peer_id) = peer_id { 472 | cmd.env("RADICLE_PEER_ID", peer_id.default_encoding()); 473 | } 474 | if let Some(default_branch) = default_branch { 475 | cmd.env("RADICLE_DEFAULT_BRANCH", default_branch); 476 | } 477 | if let LnkHome::Root(root) = &ctx.root { 478 | cmd.env("RADICLE_ROOT", root); 479 | } 480 | 481 | cmd.env("RADICLE_RECEIVE_HOOK", &ctx.git_receive_hook); 482 | cmd.env("REQUEST_METHOD", method.as_str()); 483 | cmd.env("GIT_PROJECT_ROOT", ctx.paths.git_dir().canonicalize()?); 484 | cmd.env("GIT_NAMESPACE", namespace); 485 | cmd.env("PATH_INFO", Path::new("/").join(path)); 486 | cmd.env("CONTENT_TYPE", content_type); 487 | // "The backend process sets GIT_COMMITTER_NAME to $REMOTE_USER and GIT_COMMITTER_EMAIL to 488 | // ${REMOTE_USER}@http.${REMOTE_ADDR}, ensuring that any reflogs created by git-receive-pack 489 | // contain some identifying information of the remote user who performed the push." 490 | cmd.env("REMOTE_USER", remote.ip().to_string()); 491 | cmd.env("REMOTE_ADDR", remote.to_string()); 492 | cmd.env("QUERY_STRING", query); 493 | // "The GIT_HTTP_EXPORT_ALL environmental variable may be passed to git-http-backend to bypass 494 | // the check for the "git-daemon-export-ok" file in each repository before allowing export of 495 | // that repository." 496 | cmd.env("GIT_HTTP_EXPORT_ALL", String::default()); 497 | cmd.stderr(Stdio::piped()) 498 | .stdout(Stdio::piped()) 499 | .stdin(Stdio::piped()); 500 | 501 | // Spawn the git backend. 502 | let mut child = cmd.spawn()?; 503 | 504 | // Whether the request body is compressed. 505 | let gzip = matches!( 506 | headers.get("Content-Encoding").map(|h| h.to_str()), 507 | Some(Ok("gzip")) 508 | ); 509 | 510 | { 511 | // This is safe because we captured the child's stdin. 512 | let mut stdin = child.stdin.take().unwrap(); 513 | 514 | // Copy the request body to git-http-backend's stdin. 515 | if gzip { 516 | let mut decoder = GzDecoder::new(&mut stdin); 517 | let mut reader = body.reader(); 518 | 519 | io::copy(&mut reader, &mut decoder)?; 520 | decoder.finish()?; 521 | } else { 522 | while body.has_remaining() { 523 | let mut chunk = body.chunk(); 524 | let count = chunk.len(); 525 | 526 | io::copy(&mut chunk, &mut stdin)?; 527 | body.advance(count); 528 | } 529 | } 530 | } 531 | 532 | match child.wait_with_output() { 533 | Ok(output) if output.status.success() => { 534 | tracing::info!("git-http-backend: exited successfully for {}", urn); 535 | 536 | let mut reader = std::io::Cursor::new(output.stdout); 537 | let mut headers = HashMap::new(); 538 | 539 | // Parse headers returned by git so that we can use them in the client response. 540 | for line in io::Read::by_ref(&mut reader).lines() { 541 | let line = line?; 542 | 543 | if line.is_empty() || line == "\r" { 544 | break; 545 | } 546 | 547 | let mut parts = line.splitn(2, ':'); 548 | let key = parts.next(); 549 | let value = parts.next(); 550 | 551 | if let (Some(key), Some(value)) = (key, value) { 552 | let value = &value[1..]; 553 | 554 | headers 555 | .entry(key.to_string()) 556 | .or_insert_with(Vec::new) 557 | .push(value.to_string()); 558 | } else { 559 | return Err(Error::Backend); 560 | } 561 | } 562 | 563 | let status = { 564 | tracing::debug!("http-backend: {:?}", &headers); 565 | 566 | let line = headers.remove("Status").unwrap_or_default(); 567 | let line = line.into_iter().next().unwrap_or_default(); 568 | let mut parts = line.split(' '); 569 | 570 | parts 571 | .next() 572 | .and_then(|p| p.parse().ok()) 573 | .unwrap_or(StatusCode::OK) 574 | }; 575 | 576 | let position = reader.position() as usize; 577 | let body = reader.into_inner().split_off(position); 578 | 579 | Ok((status, headers, body)) 580 | } 581 | Ok(output) => { 582 | tracing::error!("git-http-backend: exited with code {}", output.status); 583 | 584 | if let Ok(output) = std::str::from_utf8(&output.stderr) { 585 | tracing::error!("git-http-backend: stderr: {}", output.trim_end()); 586 | } 587 | Err(Error::Backend) 588 | } 589 | Err(err) => { 590 | panic!("failed to wait for git-http-backend: {}", err); 591 | } 592 | } 593 | } 594 | 595 | /// Helper method to generate random string for cert nonce; 596 | fn gen_random_string() -> String { 597 | let rng = fastrand::Rng::new(); 598 | let mut out = String::new(); 599 | 600 | for _ in 0..12 { 601 | out.push(rng.alphanumeric()); 602 | } 603 | out 604 | } 605 | 606 | /// Parse a remote git ref into a peer id and return the remaining input. 607 | /// 608 | /// Eg. `refs/remotes//heads/master` 609 | /// 610 | fn parse_ref(input: &str) -> Result<(PeerId, String), io::Error> { 611 | let suffix = input 612 | .strip_prefix("refs/remotes/") 613 | .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?; 614 | let (remote, rest) = suffix 615 | .split_once('/') 616 | .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?; 617 | let peer_id = PeerId::from_default_encoding(remote) 618 | .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; 619 | 620 | Ok((peer_id, rest.to_owned())) 621 | } 622 | --------------------------------------------------------------------------------