Compare commits

...

11 Commits

25 changed files with 687 additions and 195 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
/target
.env
.vscode
test.db
/registry
config.toml

1
.gitignore vendored
View File

@ -1,6 +1,5 @@
/target
.env
.vscode
test.db
/registry
config.toml

90
Cargo.lock generated
View File

@ -37,6 +37,12 @@ version = "1.0.70"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7de8ce5e0f9f8d88245311066a578d72b7af3e7088f32783804676302df237e4"
[[package]]
name = "arc-swap"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6"
[[package]]
name = "argmap"
version = "1.1.2"
@ -113,9 +119,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "axum"
version = "0.6.16"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "113713495a32dd0ab52baf5c10044725aa3aec00b31beda84218e469029b72a3"
checksum = "f8175979259124331c1d7bf6586ee7e0da434155e4b2d48ec2c8386281d8df39"
dependencies = [
"async-trait",
"axum-core",
@ -184,6 +190,26 @@ dependencies = [
"syn 2.0.15",
]
[[package]]
name = "axum-server"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "447f28c85900215cc1bea282f32d4a2f22d55c5a300afdfbc661c8d6a632e063"
dependencies = [
"arc-swap",
"bytes",
"futures-util",
"http",
"http-body",
"hyper",
"pin-project-lite",
"rustls 0.21.5",
"rustls-pemfile",
"tokio",
"tokio-rustls 0.24.1",
"tower-service",
]
[[package]]
name = "base64"
version = "0.13.1"
@ -742,6 +768,25 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
[[package]]
name = "h2"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97ec8491ebaf99c8eaa73058b045fe58073cd6be7f596ac993ced0b0a0c01049"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@ -849,6 +894,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
@ -1256,6 +1302,7 @@ dependencies = [
"axum",
"axum-auth",
"axum-macros",
"axum-server",
"bcrypt",
"bitflags 2.2.1",
"bytes",
@ -1267,6 +1314,7 @@ dependencies = [
"hmac",
"jws",
"jwt",
"lazy_static",
"ldap3",
"pin-project-lite",
"qstring",
@ -1572,6 +1620,18 @@ dependencies = [
"webpki",
]
[[package]]
name = "rustls"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79ea77c539259495ce8ca47f53e66ae0330a8819f67e23ac96ca02f50e7b7d36"
dependencies = [
"log",
"ring",
"rustls-webpki",
"sct",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.2"
@ -1581,6 +1641,16 @@ dependencies = [
"base64 0.21.0",
]
[[package]]
name = "rustls-webpki"
version = "0.101.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15f36a6828982f422756984e47912a7a51dcbc2a197aa791158f8ca61cd8204e"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.12"
@ -1847,7 +1917,7 @@ dependencies = [
"once_cell",
"paste",
"percent-encoding",
"rustls",
"rustls 0.20.8",
"rustls-pemfile",
"sha2 0.10.6",
"smallvec",
@ -1887,7 +1957,7 @@ checksum = "804d3f245f894e61b1e6263c84b23ca675d96753b5abfd5cc8597d86806e8024"
dependencies = [
"once_cell",
"tokio",
"tokio-rustls",
"tokio-rustls 0.23.4",
]
[[package]]
@ -2083,11 +2153,21 @@ version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59"
dependencies = [
"rustls",
"rustls 0.20.8",
"tokio",
"webpki",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls 0.21.5",
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.12"

View File

@ -32,7 +32,8 @@ sha256 = "1.1.2"
pin-project-lite = "0.2.9"
anyhow = "1.0.70"
async-stream = "0.3.5"
axum = "0.6.16"
axum = "0.6.18"
axum-server = { version = "0.5.1", features = [ "tls-rustls" ] }
axum-macros = "0.3.7"
tower-http = { version = "0.4.0", features = [ "trace", "normalize-path" ] }
@ -52,3 +53,4 @@ rand = "0.8.5"
bcrypt = "0.14.0"
bitflags = "2.2.1"
ldap3 = "0.11.1"
lazy_static = "1.4.0"

47
Dockerfile Normal file
View File

@ -0,0 +1,47 @@
FROM rust:alpine3.17 as builder
# update packages
RUN apk update
RUN apk add build-base openssl-dev ca-certificates
# create root application folder
WORKDIR /app
COPY ./ /app/src
# Install rust toolchains
RUN rustup toolchain install stable
RUN rustup default stable
WORKDIR /app/src
# Build dependencies only. Separate these for caches
RUN cargo install cargo-build-deps
RUN cargo build-deps --release
# Build the release executable.
RUN cargo build --release
# Runner stage. I tried using distroless (gcr.io/distroless/static-debian11), but the image was only ~3MBs smaller than
# alpine. I chose to use alpine since a user can easily be added to the image.
FROM alpine:3.17
ARG UNAME=orca-registry
ARG UID=1000
ARG GID=1000
# Add user and copy the executable from the build stage.
RUN adduser --disabled-password --gecos "" $UNAME -s -G $GID -u $UID
COPY --from=builder --chown=$UID:$GID /app/src/target/release/orca-registry /app/orca-registry
RUN mkdir /data && \
chown -R $UID:$GID /data && \
chown -R $UID:$GID /app
USER $UNAME
WORKDIR /app/
EXPOSE 3000
ENTRYPOINT [ "/app/orca-registry" ]

View File

@ -1,4 +1,44 @@
# Docker registry
Docker registry written in Rust.
# Orca registry
Orca is a pure-rust implementation of a Docker Registry.
This follows the [docker registry spec](https://docs.docker.com/registry/spec/api/).
Note: Orca is still in early development ([status](#status)).
## Features
* Low resource consumption
* Easy to deploy
* Single application and executable
## Status
The project is still in early development, use at your own risk. Although the registry does work, and you can push and pull images from it, there is no simple way to modify user permissions and to add users to the registry. Currently, the only way to add a user and, modify their permissions, is to edit the sqlite database.
### Adding users
These instructions are assuming the user is stored in the database, if you use LDAP auth, users are created automatically and you don't need all this.
> Note: These instructions are subject to change or quickly become outdated without notes in the instructions.
1. Open the sqlite database in an editor.
2. Create a bcrypt password hash for the new user:
```shell
$ htpasswd -nB
```
3. Insert the new user's email, password hash into the `user_logins` table. The salt is not used, so you can put whatever there
> WARNING: Ensure that the username is all lowercase!!!
```sql
INSERT INTO user_logins (email, password_hash, password_salt) VALUES ("example@email.com", "some password", "random salt")
```
4. Insert the new user into another table, `users` so the registry knows the source of the user
> WARNING: Ensure that the username is all lowercase!!!
```sql
INSERT INTO users (username, email, login_source) VALUES ("example", "example@email.com", 0)
```
a `login_source` of `0` means database
1. Give the user registry permissions
> WARNING: Ensure that the username is all lowercase!!!
```sql
INSERT INTO user_registry_permissions (email, user_type) VALUES ("example@email.com", 1)
```
a `user_type` of `1` means admin, they have permission for all image repositories.

View File

@ -2,16 +2,27 @@ listen_address = "127.0.0.1"
listen_port = "3000"
url = "http://localhost:3000/"
[ldap]
connection_url = "ldap://localhost:389"
bind_dn = "cn=admin,dc=planetexpress,dc=com"
bind_password = "GoodNewsEveryone"
user_base_dn = "ou=people,dc=planetexpress,dc=com"
group_base_dn = "ou=people,dc=planetexpress,dc=com"
# error, warn, info, debug, trace
log_level = "debug"
user_search_filter = "(&(objectClass=person)(mail=%s))"
group_search_filter = "(&(objectclass=groupOfNames)(member=%d))"
[storage]
driver = "filesystem"
path = "/app/blobs"
admin_filter = "(memberOf=cn=admin_staff,ou=people,dc=planetexpress,dc=com)"
[database]
type = "sqlite"
path = "/app/orca.db"
#[ldap]
#connection_url = "ldap://localhost:389"
#bind_dn = "cn=admin,dc=planetexpress,dc=com"
#bind_password = "GoodNewsEveryone"
#user_base_dn = "ou=people,dc=planetexpress,dc=com"
#group_base_dn = "ou=people,dc=planetexpress,dc=com"
#
#user_search_filter = "(&(objectClass=person)(mail=%s))"
#group_search_filter = "(&(objectclass=groupOfNames)(member=%d))"
#
#admin_filter = "(memberOf=cn=admin_staff,ou=people,dc=planetexpress,dc=com)"
#login_attribute = "mail"
#display_name_attribute = "displayName"

7
dev-sql/create_user.sql Normal file
View File

@ -0,0 +1,7 @@
--- Creates a regular user with the password 'test'
INSERT OR IGNORE INTO users (username, email, login_source) VALUES ('test', 'test@example.com', 0);
INSERT OR IGNORE INTO user_logins (email, password_hash, password_salt) VALUES ('test@example.com', '$2y$05$k3gn.RxGxh59NhtyyiWPeeQ2J9kqVaImiL3GPuBjMsiJ51Bn3js.K', 'x5ECk0jUmOSfBWxW52wsyO');
INSERT OR IGNORE INTO user_registry_permissions (email, user_type) VALUES ('test@example.com', 0);
-- example of giving this user pull access to a repository
--INSERT OR IGNORE INTO user_repo_permissions (email, repository_name, repository_permissions) VALUES ('test@example.com', 'admin/alpine', 1);

View File

@ -2,9 +2,19 @@
- [x] Simple auth
- [x] ldap auth
- [ ] permission stuff
- [ ] Only allow users to create repositories if its the same name as their username, or if they're an admin
- [ ] Only allow users to pull from their own repositories
- [ ] simple way to define users and their permissions through a "users.toml"
- [x] Only allow users to create repositories if its the same name as their username, or if they're an admin
- [x] Only allow users to pull from their own repositories
- [ ] token expiry
- [ ] postgresql
- [ ] prometheus metrics
- [ ] simple webui for managing the registry
- [x] streaming layer bytes into providers
- [x] streaming layer bytes from providers
- [x] streaming layer bytes from providers
- [ ] better client error messages
- [ ] fix repository list
- [ ] its not responding with anything
- [ ] make sure private repositories dont show up
- [x] fix pulling from public repositories when not logged in
- [ ] database table for orca related info (version, etc.)
- [ ] only execute sql schemas if this table is missing or not updated

View File

@ -15,7 +15,7 @@ use rand::Rng;
use crate::{dto::{scope::Scope, user::TokenInfo}, app_state::AppState};
use crate::database::Database;
use crate::auth::unauthenticated_response;
use crate::auth::auth_challenge_response;
#[derive(Deserialize, Debug)]
pub struct TokenAuthRequest {
@ -179,7 +179,8 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? {
debug!("Authentication failed, incorrect password!");
return Ok(unauthenticated_response(&state.config));
// TODO: Dont unwrap, find a way to return multiple scopes
return Ok(auth_challenge_response(&state.config, Some(auth.scope.first().unwrap().clone())));
}
drop(auth_driver);
@ -187,7 +188,11 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
let now = SystemTime::now();
let token = create_jwt_token(account)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|_| {
error!("Failed to create jwt token!");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let token_str = token.token;
debug!("Created jwt token");
@ -208,7 +213,11 @@ pub async fn auth_basic_get(basic_auth: Option<AuthBasic>, state: State<Arc<AppS
let database = &state.database;
database.store_user_token(token_str.clone(), account.clone(), token.expiry, token.created_at).await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
.map_err(|_| {
error!("Failed to store user token in database!");
StatusCode::INTERNAL_SERVER_ERROR
})?;
drop(database);
return Ok((

View File

@ -1,6 +1,5 @@
use std::sync::Arc;
use axum::Extension;
use axum::body::StreamBody;
use axum::extract::{State, Path};
use axum::http::{StatusCode, header, HeaderName};
@ -8,19 +7,9 @@ use axum::response::{IntoResponse, Response};
use tokio_util::io::ReaderStream;
use crate::app_state::AppState;
use crate::auth::unauthenticated_response;
use crate::dto::RepositoryVisibility;
use crate::dto::user::{Permission, UserAuth};
use crate::error::AppError;
pub async fn digest_exists_head(Path((name, layer_digest)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Result<Response, AppError> {
// Check if the user has permission to pull, or that the repository is public
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PULL, Some(RepositoryVisibility::Public)).await? {
return Ok(unauthenticated_response(&state.config));
}
drop(auth_driver);
pub async fn digest_exists_head(Path((_name, layer_digest)): Path<(String, String)>, state: State<Arc<AppState>>) -> Result<Response, AppError> {
let storage = state.storage.lock().await;
if storage.has_digest(&layer_digest).await? {
@ -38,14 +27,7 @@ pub async fn digest_exists_head(Path((name, layer_digest)): Path<(String, String
Ok(StatusCode::NOT_FOUND.into_response())
}
pub async fn pull_digest_get(Path((name, layer_digest)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Result<Response, AppError> {
// Check if the user has permission to pull, or that the repository is public
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PULL, Some(RepositoryVisibility::Public)).await? {
return Ok(unauthenticated_response(&state.config));
}
drop(auth_driver);
pub async fn pull_digest_get(Path((_name, layer_digest)): Path<(String, String)>, state: State<Arc<AppState>>) -> Result<Response, AppError> {
let storage = state.storage.lock().await;
if let Some(len) = storage.digest_length(&layer_digest).await? {

View File

@ -36,7 +36,7 @@ pub async fn list_repositories(Query(params): Query<ListRepositoriesParams>, sta
let last_repo = repos.last().and_then(|s| Some(s.clone()));
// Construct the link header
let url = &state.config.get_url();
let url = &state.config.url();
let mut url = format!("<{}/v2/_catalog?n={}", url, limit);
if let Some(last_repo) = last_repo {
url += &format!("&limit={}", last_repo);

View File

@ -1,13 +1,12 @@
use std::sync::Arc;
use axum::Extension;
use axum::extract::{Path, State};
use axum::response::{Response, IntoResponse};
use axum::http::{StatusCode, HeaderName, header};
use tracing::log::warn;
use tracing::{debug, info};
use crate::auth::unauthenticated_response;
use crate::auth::access_denied_response;
use crate::app_state::AppState;
use crate::database::Database;
use crate::dto::RepositoryVisibility;
@ -16,13 +15,7 @@ use crate::dto::manifest::Manifest;
use crate::dto::user::{UserAuth, Permission};
use crate::error::AppError;
pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>, body: String) -> Result<Response, AppError> {
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? {
return Ok(unauthenticated_response(&state.config));
}
drop(auth_driver);
pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, auth: UserAuth, body: String) -> Result<Response, AppError> {
// Calculate the sha256 digest for the manifest.
let calculated_hash = sha256::digest(body.clone());
let calculated_digest = format!("sha256:{}", calculated_hash);
@ -30,7 +23,7 @@ pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>
let database = &state.database;
// Create the image repository and save the image manifest. This repository will be private by default
database.save_repository(&name, RepositoryVisibility::Private, None).await?;
database.save_repository(&name, RepositoryVisibility::Private, Some(auth.user.email), None).await?;
database.save_manifest(&name, &calculated_digest, &body).await?;
// If the reference is not a digest, then it must be a tag name.
@ -64,13 +57,19 @@ pub async fn upload_manifest_put(Path((name, reference)): Path<(String, String)>
}
}
pub async fn pull_manifest_get(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Result<Response, AppError> {
pub async fn pull_manifest_get(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, auth: Option<UserAuth>) -> Result<Response, AppError> {
// Check if the user has permission to pull, or that the repository is public
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PULL, Some(RepositoryVisibility::Public)).await? {
return Ok(unauthenticated_response(&state.config));
if let Some(auth) = auth {
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PULL, Some(RepositoryVisibility::Public)).await? {
return Ok(access_denied_response(&state.config));
}
} else {
let database = &state.database;
if database.get_repository_visibility(&name).await? != Some(RepositoryVisibility::Public) {
return Ok(access_denied_response(&state.config));
}
}
drop(auth_driver);
let database = &state.database;
let digest = match Digest::is_digest(&reference) {
@ -107,13 +106,20 @@ pub async fn pull_manifest_get(Path((name, reference)): Path<(String, String)>,
).into_response())
}
pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Result<Response, AppError> {
pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, auth: Option<UserAuth>) -> Result<Response, AppError> {
// Check if the user has permission to pull, or that the repository is public
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PULL, Some(RepositoryVisibility::Public)).await? {
return Ok(unauthenticated_response(&state.config));
if let Some(auth) = auth {
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PULL, Some(RepositoryVisibility::Public)).await? {
return Ok(access_denied_response(&state.config));
}
drop(auth_driver);
} else {
let database = &state.database;
if database.get_repository_visibility(&name).await? != Some(RepositoryVisibility::Public) {
return Ok(access_denied_response(&state.config));
}
}
drop(auth_driver);
// Get the digest from the reference path.
let database = &state.database;
@ -148,10 +154,10 @@ pub async fn manifest_exists_head(Path((name, reference)): Path<(String, String)
).into_response())
}
pub async fn delete_manifest(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Result<Response, AppError> {
pub async fn delete_manifest(Path((name, reference)): Path<(String, String)>, state: State<Arc<AppState>>, auth: UserAuth) -> Result<Response, AppError> {
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? {
return Ok(unauthenticated_response(&state.config));
return Ok(access_denied_response(&state.config));
}
drop(auth_driver);

View File

@ -1,6 +1,5 @@
use std::sync::Arc;
use axum::Extension;
use axum::extract::State;
use axum::response::{IntoResponse, Response};
use axum::http::{StatusCode, HeaderName};
@ -14,13 +13,13 @@ pub mod tags;
pub mod catalog;
pub mod auth;
use crate::dto::user::UserAuth;
/// https://docs.docker.com/registry/spec/api/#api-version-check
/// full endpoint: `/v2/`
pub async fn version_check(Extension(_auth): Extension<UserAuth>, _state: State<Arc<AppState>>) -> Response {
pub async fn version_check(_state: State<Arc<AppState>>) -> Response {
(
StatusCode::OK,
[( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0" )]
StatusCode::UNAUTHORIZED,
[
( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0" ),
]
).into_response()
}

View File

@ -37,7 +37,7 @@ pub async fn list_tags(Path((name, )): Path<(String, )>, Query(params): Query<Li
let last_tag = tags.last();
// Construct the link header
let url = &state.config.get_url();
let url = &state.config.url();
let mut url = format!("<{}/v2/{}/tags/list?n={}", url, name, limit);
if let Some(last_tag) = last_tag {
url += &format!("&limit={}", last_tag.name);

View File

@ -2,7 +2,6 @@ use std::collections::HashMap;
use std::io::ErrorKind;
use std::sync::Arc;
use axum::Extension;
use axum::http::{StatusCode, header, HeaderName};
use axum::extract::{Path, BodyStream, State, Query};
use axum::response::{IntoResponse, Response};
@ -12,39 +11,26 @@ use futures::StreamExt;
use tracing::{debug, warn};
use crate::app_state::AppState;
use crate::auth::unauthenticated_response;
use crate::byte_stream::ByteStream;
use crate::dto::user::{UserAuth, Permission};
use crate::error::AppError;
/// Starting an upload
pub async fn start_upload_post(Path((name, )): Path<(String, )>, Extension(auth): Extension<UserAuth>, state: State<Arc<AppState>>) -> Result<Response, AppError> {
let mut auth_driver = state.auth_checker.lock().await;
if auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? {
debug!("Upload requested");
let uuid = uuid::Uuid::new_v4();
pub async fn start_upload_post(Path((name, )): Path<(String, )>) -> Result<Response, AppError> {
debug!("Upload requested");
let uuid = uuid::Uuid::new_v4();
debug!("Requesting upload of image {}, generated uuid: {}", name, uuid);
debug!("Requesting upload of image {}, generated uuid: {}", name, uuid);
let location = format!("/v2/{}/blobs/uploads/{}", name, uuid.to_string());
debug!("Constructed upload url: {}", location);
let location = format!("/v2/{}/blobs/uploads/{}", name, uuid.to_string());
debug!("Constructed upload url: {}", location);
return Ok((
StatusCode::ACCEPTED,
[ (header::LOCATION, location) ]
).into_response());
}
Ok(unauthenticated_response(&state.config))
return Ok((
StatusCode::ACCEPTED,
[ (header::LOCATION, location) ]
).into_response());
}
pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, String)>, Extension(auth): Extension<UserAuth>, state: State<Arc<AppState>>, mut body: BodyStream) -> Result<Response, AppError> {
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? {
return Ok(unauthenticated_response(&state.config));
}
drop(auth_driver);
pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String, String)>, state: State<Arc<AppState>>, mut body: BodyStream) -> Result<Response, AppError> {
let storage = state.storage.lock().await;
let current_size = storage.digest_length(&layer_uuid).await?;
@ -85,7 +71,7 @@ pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String,
(0, written_size)
};
let full_uri = format!("{}/v2/{}/blobs/uploads/{}", state.config.get_url(), name, layer_uuid);
let full_uri = format!("{}/v2/{}/blobs/uploads/{}", state.config.url(), name, layer_uuid);
Ok((
StatusCode::ACCEPTED,
[
@ -97,13 +83,7 @@ pub async fn chunked_upload_layer_patch(Path((name, layer_uuid)): Path<(String,
).into_response())
}
pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, String)>, Query(query): Query<HashMap<String, String>>, Extension(auth): Extension<UserAuth>, state: State<Arc<AppState>>, body: Bytes) -> Result<Response, AppError> {
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? {
return Ok(unauthenticated_response(&state.config));
}
drop(auth_driver);
pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, String)>, Query(query): Query<HashMap<String, String>>, state: State<Arc<AppState>>, body: Bytes) -> Result<Response, AppError> {
let digest = query.get("digest").unwrap();
let storage = state.storage.lock().await;
@ -126,13 +106,7 @@ pub async fn finish_chunked_upload_put(Path((name, layer_uuid)): Path<(String, S
).into_response())
}
pub async fn cancel_upload_delete(Path((name, layer_uuid)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Result<Response, AppError> {
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? {
return Ok(unauthenticated_response(&state.config));
}
drop(auth_driver);
pub async fn cancel_upload_delete(Path((_name, layer_uuid)): Path<(String, String)>, state: State<Arc<AppState>>) -> Result<Response, AppError> {
let storage = state.storage.lock().await;
storage.delete_digest(&layer_uuid).await?;
@ -140,13 +114,7 @@ pub async fn cancel_upload_delete(Path((name, layer_uuid)): Path<(String, String
Ok(StatusCode::OK.into_response())
}
pub async fn check_upload_status_get(Path((name, layer_uuid)): Path<(String, String)>, state: State<Arc<AppState>>, Extension(auth): Extension<UserAuth>) -> Result<Response, AppError> {
let mut auth_driver = state.auth_checker.lock().await;
if !auth_driver.user_has_permission(auth.user.username, name.clone(), Permission::PUSH, None).await? {
return Ok(unauthenticated_response(&state.config));
}
drop(auth_driver);
pub async fn check_upload_status_get(Path((name, layer_uuid)): Path<(String, String)>, state: State<Arc<AppState>>) -> Result<Response, AppError> {
let storage = state.storage.lock().await;
let ending = storage.digest_length(&layer_uuid).await?.unwrap_or(0);

View File

@ -3,7 +3,7 @@ use ldap3::{LdapConnAsync, Ldap, Scope, SearchEntry};
use sqlx::{Pool, Sqlite};
use tracing::{debug, warn};
use crate::{config::LdapConnectionConfig, dto::{user::{Permission, LoginSource, RegistryUserType}, RepositoryVisibility}, database::Database};
use crate::{config::LdapConnectionConfig, dto::{user::{Permission, LoginSource, RegistryUserType, self}, RepositoryVisibility}, database::Database};
use super::AuthDriver;
@ -60,6 +60,7 @@ impl AuthDriver for LdapAuthDriver {
Ok(true)
} else {
debug!("LDAP is falling back to database");
// fall back to database auth since this user might be local
self.database.user_has_permission(email, repository, permission, required_visibility).await
}

View File

@ -2,12 +2,11 @@ pub mod ldap_driver;
use std::{ops::Deref, sync::Arc};
use axum::{extract::State, http::{StatusCode, HeaderMap, header, HeaderName, Request}, middleware::Next, response::{Response, IntoResponse}};
use axum::{extract::State, http::{StatusCode, HeaderMap, header, HeaderName, Request, Method}, middleware::Next, response::{Response, IntoResponse}};
use sqlx::{Pool, Sqlite};
use tracing::debug;
use tracing::{debug, warn, error};
use crate::{app_state::AppState, dto::{user::{Permission, RegistryUserType}, RepositoryVisibility}, config::Config};
use crate::{app_state::AppState, dto::{user::{Permission, RegistryUserType, UserAuth}, RepositoryVisibility, scope::{Scope, ScopeType, Action}}, config::Config};
use crate::database::Database;
use async_trait::async_trait;
@ -24,19 +23,36 @@ pub trait AuthDriver: Send + Sync {
async fn verify_user_login(&mut self, email: String, password: String) -> anyhow::Result<bool>;
}
// Implement AuthDriver for anything the implements Database
#[async_trait]
impl AuthDriver for Pool<Sqlite> {
impl<T> AuthDriver for T
where
T: Database + Send + Sync
{
async fn user_has_permission(&mut self, email: String, repository: String, permission: Permission, required_visibility: Option<RepositoryVisibility>) -> anyhow::Result<bool> {
let allowed_to = {
match self.get_user_registry_type(email.clone()).await? {
Some(RegistryUserType::Admin) => true,
_ => {
check_user_permissions(self, email, repository, permission, required_visibility).await?
match self.get_repository_owner(&repository).await? {
Some(owner) if owner == email => return Ok(true),
Some(_other_owner) => {
match self.get_user_registry_type(email.clone()).await? {
Some(RegistryUserType::Admin) => return Ok(true),
_ => {
return Ok(check_user_permissions(self, email, repository, permission, required_visibility).await?);
}
}
}
};
Ok(allowed_to)
},
None => {
// If the repository does not exist, see if its the per-user repositories and autocreate it.
if let Some(user) = self.get_user(email.clone()).await? {
let username = user.username.to_lowercase();
if repository.starts_with(&username) {
self.save_repository(&repository, RepositoryVisibility::Private, Some(email), None).await?;
return Ok(true);
}
}
},
}
Ok(false)
}
async fn verify_user_login(&mut self, email: String, password: String) -> anyhow::Result<bool> {
@ -80,7 +96,7 @@ impl Deref for AuthToken {
type Rejection = (StatusCode, HeaderMap);
pub async fn require_auth<B>(State(state): State<Arc<AppState>>, mut request: Request<B>, next: Next<B>) -> Result<Response, Rejection> {
let bearer = format!("Bearer realm=\"{}/auth\"", state.config.get_url());
let bearer = format!("Bearer realm=\"{}/auth\"", state.config.url());
let mut failure_headers = HeaderMap::new();
failure_headers.append(header::WWW_AUTHENTICATE, bearer.parse().unwrap());
failure_headers.append(HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".parse().unwrap());
@ -108,7 +124,7 @@ pub async fn require_auth<B>(State(state): State<Arc<AppState>>, mut request: Re
Ok(next.run(request).await)
} else {
let bearer = format!("Bearer realm=\"{}/auth\"", state.config.get_url());
let bearer = format!("Bearer realm=\"{}/auth\"", state.config.url());
Ok((
StatusCode::UNAUTHORIZED,
[
@ -119,14 +135,100 @@ pub async fn require_auth<B>(State(state): State<Arc<AppState>>, mut request: Re
}
}
/// Creates a response with an Unauthorized (401) status code.
/// The www-authenticate header is set to notify the client of where to authorize with.
#[inline(always)]
pub fn unauthenticated_response(config: &Config) -> Response {
let bearer = format!("Bearer realm=\"{}/auth\"", config.get_url());
pub fn auth_challenge_response(config: &Config, scope: Option<Scope>) -> Response {
let bearer = match scope {
Some(scope) => format!("Bearer realm=\"{}/auth\",scope=\"{}\"", config.url(), scope),
None => format!("Bearer realm=\"{}/auth\"", config.url())
};
debug!("responding with www-authenticate header of: \"{}\"", bearer);
(
StatusCode::UNAUTHORIZED,
[
( header::WWW_AUTHENTICATE, bearer ),
( header::CONTENT_TYPE, "application/json".to_string() ),
( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() )
],
//"{\"errors\":[{\"code\":\"UNAUTHORIZED\",\"message\":\"access to the requested resource is not authorized\",\"detail\":[{\"Type\":\"repository\",\"Name\":\"samalba/my-app\",\"Action\":\"pull\"},{\"Type\":\"repository\",\"Name\":\"samalba/my-app\",\"Action\":\"push\"}]}]}"
).into_response()
}
/// Creates a response with a Forbidden (403) status code.
/// No other headers are set.
#[inline(always)]
pub fn access_denied_response(_config: &Config) -> Response {
(
StatusCode::FORBIDDEN,
[
( HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".to_string() )
]
).into_response()
}
pub async fn check_auth<B>(State(state): State<Arc<AppState>>, auth: Option<UserAuth>, request: Request<B>, next: Next<B>) -> Result<Response, Rejection> {
let config = &state.config;
// note: url is relative to /v2
let url = request.uri().to_string();
if url == "/" && auth.is_none() {
debug!("Responding to /v2/ with an auth challenge");
return Ok(auth_challenge_response(config, None));
}
let url_split: Vec<&str> = url.split("/").skip(1).collect();
let target_name = url_split[0].replace("%2F", "/");
let target_type = url_split[1];
// check if the request is targeting something inside an image repository
if target_type == "blobs" || target_type == "uploads" || target_type == "manifests" {
let scope_actions: &[Action] = match request.method().clone() {
Method::GET | Method::HEAD => &[Action::Pull],
Method::POST | Method::PATCH | Method::PUT => &[Action::Pull, Action::Push],
_ => &[],
};
let scope = Scope::new(ScopeType::Repository, target_name.clone(), scope_actions);
// respond with an auth challenge if there is no auth header.
//if !headers.contains_key(header::AUTHORIZATION) && auth.is_none() {
if auth.is_none() {
debug!("User is not authenticated, sending challenge");
return Ok(auth_challenge_response(config, Some(scope)));
}
let auth = auth.unwrap();
let mut auth_checker = state.auth_checker.lock().await;
// Check permission for each action
for action in scope_actions {
// action to permission
let permission = match action {
Action::Pull => Permission::PULL,
Action::Push => Permission::PUSH,
_ => Permission::NONE,
};
// get optional required visibility from action
let vis = match action {
Action::Pull => Some(RepositoryVisibility::Public),
_ => None,
};
match auth_checker.user_has_permission(auth.user.email.clone(), target_name.clone(), permission, vis).await {
Ok(false) => return Ok(auth_challenge_response(config, Some(scope))),
Ok(true) => { },
Err(e) => {
error!("Error when checking user permissions! {}", e);
return Err((StatusCode::INTERNAL_SERVER_ERROR, HeaderMap::new()));
},
}
}
} else {
warn!("Unhandled auth check for '{target_type}'!!"); // TODO
}
Ok(next.run(request).await)
}

View File

@ -1,6 +1,8 @@
use anyhow::anyhow;
use figment::{Figment, providers::{Env, Toml, Format}};
use figment_cliarg_provider::FigmentCliArgsProvider;
use serde::Deserialize;
use serde::{Deserialize, Deserializer};
use tracing::Level;
use std::env;
@ -29,12 +31,46 @@ fn default_display_name_attribute() -> String {
"displayName".to_string()
}
#[derive(Deserialize, Clone)]
pub struct FilesystemDriverConfig {
pub path: String,
}
#[derive(Deserialize, Clone)]
#[serde(tag = "driver", rename_all = "snake_case")]
pub enum StorageConfig {
Filesystem(FilesystemDriverConfig),
}
#[derive(Deserialize, Clone)]
pub struct SqliteDbConfig {
pub path: String,
}
#[derive(Deserialize, Clone)]
pub struct TlsConfig {
pub enable: bool,
pub key: String,
pub cert: String,
}
#[derive(Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DatabaseConfig {
Sqlite(SqliteDbConfig),
}
#[derive(Deserialize, Clone)]
pub struct Config {
pub listen_address: String,
pub listen_port: String,
pub url: Option<String>,
url: Option<String>,
#[serde(deserialize_with = "serialize_log_level", default = "default_log_level")]
pub log_level: Level,
pub ldap: Option<LdapConnectionConfig>,
pub database: DatabaseConfig,
pub storage: StorageConfig,
pub tls: Option<TlsConfig>,
}
#[allow(dead_code)]
@ -72,10 +108,36 @@ impl Config {
Ok(config)
}
pub fn get_url(&self) -> String {
pub fn url(&self) -> String {
match &self.url {
Some(u) => u.clone(),
None => format!("http://{}:{}", self.listen_address, self.listen_port)
}
}
}
}
fn default_log_level() -> Level {
Level::INFO
}
fn serialize_log_level<'de, D>(deserializer: D) -> Result<Level, D::Error>
where D: Deserializer<'de> {
let s = String::deserialize(deserializer)?.to_lowercase();
let s = s.as_str();
match s {
"error" => Ok(Level::ERROR),
"warn" => Ok(Level::WARN),
"info" => Ok(Level::INFO),
"debug" => Ok(Level::DEBUG),
"trace" => Ok(Level::TRACE),
_ => Err(serde::de::Error::custom(format!("Unknown log level: '{}'", s))),
}
}
/* fn<'de, D> serialize_log_level(D) -> Result<Level, D::Error>
where D: Deserializer<'de>
{
} */
//fn serialize_log_level() -> Level

View File

@ -1,6 +1,6 @@
use async_trait::async_trait;
use sqlx::{Sqlite, Pool};
use tracing::debug;
use tracing::{debug, warn};
use chrono::{DateTime, Utc, NaiveDateTime, TimeZone};
@ -42,8 +42,9 @@ pub trait Database {
async fn has_repository(&self, repository: &str) -> anyhow::Result<bool>;
async fn get_repository_visibility(&self, repository: &str) -> anyhow::Result<Option<RepositoryVisibility>>;
async fn get_repository_owner(&self, repository: &str) -> anyhow::Result<Option<String>>;
/// Create a repository
async fn save_repository(&self, repository: &str, visibility: RepositoryVisibility, owning_project: Option<String>) -> anyhow::Result<()>;
async fn save_repository(&self, repository: &str, visibility: RepositoryVisibility, owner_email: Option<String>, owning_project: Option<String>) -> anyhow::Result<()>;
/// List all repositories.
/// If limit is not specified, a default limit of 1000 will be returned.
async fn list_repositories(&self, limit: Option<u32>, last_repo: Option<String>) -> anyhow::Result<Vec<String>>;
@ -52,6 +53,7 @@ pub trait Database {
/// User stuff
async fn does_user_exist(&self, email: String) -> anyhow::Result<bool>;
async fn create_user(&self, email: String, username: String, login_source: LoginSource) -> anyhow::Result<User>;
async fn get_user(&self, email: String) -> anyhow::Result<Option<User>>;
async fn add_user_auth(&self, email: String, password_hash: String, password_salt: String) -> anyhow::Result<()>;
async fn set_user_registry_type(&self, email: String, user_type: RegistryUserType) -> anyhow::Result<()>;
async fn verify_user_login(&self, email: String, password: String) -> anyhow::Result<bool>;
@ -254,7 +256,7 @@ impl Database for Pool<Sqlite> {
}
async fn get_repository_visibility(&self, repository: &str) -> anyhow::Result<Option<RepositoryVisibility>> {
let row: (u32, ) = match sqlx::query_as("SELECT visibility FROM repositories WHERE 'name' = ?")
let row: (u32, ) = match sqlx::query_as("SELECT visibility FROM repositories WHERE name = ?")
.bind(repository)
.fetch_one(self).await {
Ok(row) => row,
@ -271,29 +273,42 @@ impl Database for Pool<Sqlite> {
Ok(Some(RepositoryVisibility::try_from(row.0)?))
}
async fn save_repository(&self, repository: &str, visibility: RepositoryVisibility, owning_project: Option<String>) -> anyhow::Result<()> {
async fn get_repository_owner(&self, repository: &str) -> anyhow::Result<Option<String>> {
let row: (String, ) = match sqlx::query_as("SELECT owner_email FROM repositories WHERE name = ?")
.bind(repository)
.fetch_one(self).await {
Ok(row) => row,
Err(e) => match e {
sqlx::Error::RowNotFound => {
return Ok(None)
},
_ => {
debug!("here's the error: {:?}", e);
return Err(anyhow::Error::new(e));
}
}
};
Ok(Some(row.0))
}
async fn save_repository(&self, repository: &str, visibility: RepositoryVisibility, owner_email: Option<String>, owning_project: Option<String>) -> anyhow::Result<()> {
// ensure that the repository was not already created
if self.has_repository(repository).await? {
debug!("repo exists");
debug!("Skipping creation of repository since it already exists");
return Ok(());
}
debug!("repo does not exist");
match owning_project {
Some(owner) => {
sqlx::query("INSERT INTO repositories (name, visibility, owning_project) VALUES (?, ?, ?)")
.bind(repository)
.bind(visibility as u32)
.bind(owner)
.execute(self).await?;
},
None => {
sqlx::query("INSERT INTO repositories (name, visibility) VALUES (?, ?)")
.bind(repository)
.bind(visibility as u32)
.execute(self).await?;
}
}
// unwrap None values to empty for inserting into database
let owner_email = owner_email.unwrap_or(String::new());
let owning_project = owning_project.unwrap_or(String::new());
sqlx::query("INSERT INTO repositories (name, visibility, owner_email, owning_project) VALUES (?, ?, ?, ?)")
.bind(repository)
.bind(visibility as u32)
.bind(owner_email)
.bind(owning_project)
.execute(self).await?;
Ok(())
}
@ -353,6 +368,25 @@ impl Database for Pool<Sqlite> {
Ok(User::new(username, email, login_source))
}
async fn get_user(&self, email: String) -> anyhow::Result<Option<User>> {
let email = email.to_lowercase();
let row: (String, u32) = match sqlx::query_as("SELECT username, login_source FROM users WHERE email = ?")
.bind(email.clone())
.fetch_one(self).await {
Ok(row) => row,
Err(e) => match e {
sqlx::Error::RowNotFound => {
return Ok(None)
},
_ => {
return Err(anyhow::Error::new(e));
}
}
};
Ok(Some(User::new(row.0, email, LoginSource::try_from(row.1)?)))
}
async fn add_user_auth(&self, email: String, password_hash: String, password_salt: String) -> anyhow::Result<()> {
let email = email.to_lowercase();
sqlx::query("INSERT INTO user_logins (email, password_hash, password_salt) VALUES (?, ?, ?)")
@ -376,9 +410,20 @@ impl Database for Pool<Sqlite> {
async fn verify_user_login(&self, email: String, password: String) -> anyhow::Result<bool> {
let email = email.to_lowercase();
let row: (String, ) = sqlx::query_as("SELECT password_hash FROM users WHERE email = ?")
let row: (String,) = match sqlx::query_as("SELECT password_hash FROM user_logins WHERE email = ?")
.bind(email)
.fetch_one(self).await?;
.fetch_one(self).await {
Ok(row) => row,
Err(e) => match e {
sqlx::Error::RowNotFound => {
return Ok(false)
},
_ => {
return Err(anyhow::Error::new(e));
}
}
};
Ok(bcrypt::verify(password, &row.0)?)
}
@ -405,6 +450,8 @@ impl Database for Pool<Sqlite> {
async fn get_user_repo_permissions(&self, email: String, repository: String) -> anyhow::Result<Option<RepositoryPermissions>> {
let email = email.to_lowercase();
debug!("email: {email}, repo: {repository}");
let row: (u32, ) = match sqlx::query_as("SELECT repository_permissions FROM user_repo_permissions WHERE email = ? AND repository_name = ?")
.bind(email.clone())
@ -423,13 +470,17 @@ impl Database for Pool<Sqlite> {
let vis = match self.get_repository_visibility(&repository).await? {
Some(v) => v,
None => return Ok(None),
None => {
warn!("Failure to find visibility for repository '{}'", repository);
return Ok(None)
},
};
// Also get the user type for the registry, if its admin return admin repository permissions
let utype = match self.get_user_registry_usertype(email).await? {
Some(t) => t,
None => return Ok(None),
// assume a regular user is their type is not found
None => RegistryUserType::Regular,
};
if utype == RegistryUserType::Admin {
@ -499,7 +550,7 @@ impl Database for Pool<Sqlite> {
let (expiry, created_at) = (Utc.timestamp_millis_opt(expiry).single(), Utc.timestamp_millis_opt(created_at).single());
if let (Some(expiry), Some(created_at)) = (expiry, created_at) {
let user = User::new(email, user_row.0, LoginSource::try_from(user_row.1)?);
let user = User::new(user_row.0, email, LoginSource::try_from(user_row.1)?);
let token = TokenInfo::new(token, expiry, created_at);
let auth = UserAuth::new(user, token);

View File

@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS projects (
CREATE TABLE IF NOT EXISTS repositories (
name TEXT NOT NULL UNIQUE PRIMARY KEY,
owning_project TEXT,
owner_email TEXT,
-- 0 = private, 1 = public
visibility INTEGER NOT NULL
);
@ -70,6 +71,7 @@ CREATE TABLE IF NOT EXISTS user_tokens (
created_at BIGINT NOT NULL
);
-- create admin user
-- create admin user (password is 'admin')
INSERT OR IGNORE INTO users (username, email, login_source) VALUES ('admin', 'admin@example.com', 0);
INSERT OR IGNORE INTO user_logins (email, password_hash, password_salt) VALUES ('admin@example.com', '$2b$12$x5ECk0jUmOSfBWxW52wsyOmFxNZkwc2J9FH225if4eBnQYUvYLYYq', 'x5ECk0jUmOSfBWxW52wsyO');
INSERT OR IGNORE INTO user_logins (email, password_hash, password_salt) VALUES ('admin@example.com', '$2y$05$v9ND7dQKvfkOtY4XpnKVaOpvV0F5RDnW1Ec.nfkZ0vmEjLX5D5S8e', 'x5ECk0jUmOSfBWxW52wsyO');
INSERT OR IGNORE INTO user_registry_permissions (email, user_type) VALUES ('admin@example.com', 1);

View File

@ -3,7 +3,7 @@ use serde::{Deserialize, de::Visitor};
use std::fmt;
#[derive(Default, Debug)]
#[derive(Default, Debug, Clone)]
pub enum ScopeType {
#[default]
Unknown,
@ -19,7 +19,7 @@ impl fmt::Display for ScopeType {
}
}
#[derive(Default, Debug)]
#[derive(Default, Debug, Clone)]
pub enum Action {
#[default]
None,
@ -37,13 +37,23 @@ impl fmt::Display for Action {
}
}
#[derive(Default, Debug)]
#[derive(Default, Debug, Clone)]
pub struct Scope {
scope_type: ScopeType,
path: String,
actions: Vec<Action>,
}
impl Scope {
pub fn new(scope_type: ScopeType, path: String, actions: &[Action]) -> Self {
Self {
scope_type,
path,
actions: actions.to_vec(),
}
}
}
impl fmt::Display for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let actions = self.actions

View File

@ -1,7 +1,12 @@
use std::collections::HashMap;
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use axum::{http::{StatusCode, header, HeaderName, HeaderMap, request::Parts}, extract::FromRequestParts};
use bitflags::bitflags;
use chrono::{DateTime, Utc};
use tracing::debug;
use crate::{app_state::AppState, database::Database};
use super::RepositoryVisibility;
@ -72,9 +77,58 @@ impl UserAuth {
}
}
#[async_trait]
impl FromRequestParts<Arc<AppState>> for UserAuth {
type Rejection = (StatusCode, HeaderMap);
async fn from_request_parts(parts: &mut Parts, state: &Arc<AppState>) -> Result<Self, Self::Rejection> {
let bearer = format!("Bearer realm=\"{}/auth\"", state.config.url());
let mut failure_headers = HeaderMap::new();
failure_headers.append(header::WWW_AUTHENTICATE, bearer.parse().unwrap());
failure_headers.append(HeaderName::from_static("docker-distribution-api-version"), "registry/2.0".parse().unwrap());
debug!("starting UserAuth request parts");
let auth = String::from(
parts.headers
.get(header::AUTHORIZATION)
.ok_or((StatusCode::UNAUTHORIZED, failure_headers.clone()))?
.to_str()
.map_err(|_| (StatusCode::UNAUTHORIZED, failure_headers.clone()))?
);
debug!("got auth header");
let token = match auth.split_once(' ') {
Some((auth, token)) if auth == "Bearer" => token,
// This line would allow empty tokens
//_ if auth == "Bearer" => Ok(AuthToken(None)),
_ => return Err( (StatusCode::UNAUTHORIZED, failure_headers) ),
};
debug!("got token");
// If the token is not valid, return an unauthorized response
let database = &state.database;
if let Ok(Some(user)) = database.verify_user_token(token.to_string()).await {
debug!("Authenticated user through request extractor: {}", user.user.username);
Ok(user)
} else {
debug!("Failure to verify user token, responding with auth realm");
Err((
StatusCode::UNAUTHORIZED,
failure_headers
))
}
}
}
bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Permission: u32 {
const NONE = 0b0000;
const PULL = 0b0001;
const PUSH = 0b0010;
const EDIT = 0b0111;

View File

@ -9,6 +9,7 @@ mod auth;
mod error;
use std::net::SocketAddr;
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
@ -18,12 +19,16 @@ use axum::middleware::Next;
use axum::response::Response;
use axum::{Router, routing};
use axum::ServiceExt;
use axum_server::tls_rustls::RustlsConfig;
use lazy_static::lazy_static;
use regex::Regex;
use tokio::fs::File;
use tower_layer::Layer;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::sqlite::{SqlitePoolOptions, SqliteConnectOptions, SqliteJournalMode};
use tokio::sync::Mutex;
use tower_http::normalize_path::NormalizePathLayer;
use tracing::{debug, Level};
use tracing::{debug, info};
use app_state::AppState;
use database::Database;
@ -31,15 +36,18 @@ use database::Database;
use crate::storage::StorageDriver;
use crate::storage::filesystem::FilesystemDriver;
use crate::config::Config;
use crate::config::{Config, DatabaseConfig, StorageConfig};
use tower_http::trace::TraceLayer;
lazy_static! {
static ref REGISTRY_URL_REGEX: Regex = regex::Regex::new(r"/v2/([\w\-_./]+)/(blobs|tags|manifests)").unwrap();
}
/// Encode the 'name' path parameter in the url
async fn change_request_paths<B>(mut request: Request<B>, next: Next<B>) -> Result<Response, StatusCode> {
// Attempt to find the name using regex in the url
let regex = regex::Regex::new(r"/v2/([\w/]+)/(blobs|tags|manifests)")
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let regex = &REGISTRY_URL_REGEX;
let captures = match regex.captures(request.uri().path()) {
Some(captures) => captures,
None => return Ok(next.run(request).await),
@ -61,19 +69,34 @@ async fn change_request_paths<B>(mut request: Request<B>, next: Next<B>) -> Resu
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_max_level(Level::DEBUG)
.init();
let config = Config::new()
.expect("Failure to parse config!");
tracing_subscriber::fmt()
.with_max_level(config.log_level)
.init();
let sqlite_config = match &config.database {
DatabaseConfig::Sqlite(sqlite) => sqlite,
};
// Create a database file if it doesn't exist already
if !Path::new(&sqlite_config.path).exists() {
File::create(&sqlite_config.path).await?;
}
let connection_options = SqliteConnectOptions::from_str(&format!("sqlite://{}", &sqlite_config.path))?
.journal_mode(SqliteJournalMode::Wal);
let pool = SqlitePoolOptions::new()
.max_connections(15)
.connect("test.db").await?;
.connect_with(connection_options).await?;
pool.create_schema().await?;
let storage_driver: Mutex<Box<dyn StorageDriver>> = Mutex::new(Box::new(FilesystemDriver::new("registry/blobs")));
let storage_driver: Mutex<Box<dyn StorageDriver>> = match &config.storage {
StorageConfig::Filesystem(fs) => {
Mutex::new(Box::new(FilesystemDriver::new(&fs.path)))
}
};
// figure out the auth driver depending on whats specified in the config,
// the fallback is a database auth driver.
@ -89,10 +112,11 @@ async fn main() -> anyhow::Result<()> {
let app_addr = SocketAddr::from_str(&format!("{}:{}", config.listen_address, config.listen_port))?;
let tls_config = config.tls.clone();
let state = Arc::new(AppState::new(pool, storage_driver, config, auth_driver));
let auth_middleware = axum::middleware::from_fn_with_state(state.clone(), auth::require_auth);
//let auth_middleware = axum::middleware::from_fn_with_state(state.clone(), auth::require_auth);
let auth_middleware = axum::middleware::from_fn_with_state(state.clone(), auth::check_auth);
let path_middleware = axum::middleware::from_fn(change_request_paths);
let app = Router::new()
@ -126,10 +150,23 @@ async fn main() -> anyhow::Result<()> {
let layered_app = NormalizePathLayer::trim_trailing_slash().layer(path_middleware.layer(app));
debug!("Starting http server, listening on {}", app_addr);
axum::Server::bind(&app_addr)
.serve(layered_app.into_make_service())
.await?;
match tls_config {
Some(tls) if tls.enable => {
info!("Starting https server, listening on {}", app_addr);
let config = RustlsConfig::from_pem_file(&tls.cert, &tls.key).await?;
axum_server::bind_rustls(app_addr, config)
.serve(layered_app.into_make_service())
.await?;
},
_ => {
info!("Starting http server, listening on {}", app_addr);
axum::Server::bind(&app_addr)
.serve(layered_app.into_make_service())
.await?;
}
}
Ok(())
}

View File

@ -25,6 +25,11 @@ impl FilesystemDriver {
fn get_digest_path(&self, digest: &str) -> String {
format!("{}/{}", self.storage_path, digest)
}
fn ensure_storage_path(&self) -> std::io::Result<()>
{
std::fs::create_dir_all(&self.storage_path)
}
}
#[async_trait]
@ -40,6 +45,8 @@ impl StorageDriver for FilesystemDriver {
}
async fn save_digest_stream(&self, digest: &str, mut stream: ByteStream, append: bool) -> anyhow::Result<usize> {
self.ensure_storage_path()?;
let path = self.get_digest_path(digest);
let mut file = fs::OpenOptions::new()
.write(true)