Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
*.flatpak
|
||||
Dockerfile
|
||||
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
*.flatpak
|
||||
Generated
+2303
File diff suppressed because it is too large
Load Diff
+19
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "flatpak-repo"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.104"
|
||||
axum = { version = "0.8.9", features = ["http2"] }
|
||||
clap = { version = "4.6.5", features = ["derive", "env"] }
|
||||
futures = "0.3.33"
|
||||
http = "1.5.0"
|
||||
http-body-util = "0.1.4"
|
||||
reqwest = "0.13.4"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
tempfile = "3.27.0"
|
||||
tokio = { version = "1.53.1", features = ["full"] }
|
||||
tower-http = { version = "0.7.0", features = ["compression-gzip", "fs", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
##################
|
||||
### BASE STAGE ###
|
||||
##################
|
||||
FROM rust:1.97.1 AS base
|
||||
|
||||
ARG TARGETARCH # https://docs.docker.com/build/building/multi-platform/
|
||||
RUN if [ "$TARGETARCH" = "" ]; then echo "x86_64-unknown-linux-musl" | tee /rust-target; fi
|
||||
RUN if [ "$TARGETARCH" = "amd64" ]; then echo "x86_64-unknown-linux-musl" | tee /rust-target; fi
|
||||
RUN if [ "$TARGETARCH" = "arm64" ]; then echo "aarch64-unknown-linux-musl" | tee /rust-target; fi
|
||||
|
||||
# Install build dependencies
|
||||
RUN rustup target add $(cat /rust-target)
|
||||
|
||||
# required by "ring"
|
||||
RUN apt-get update && apt-get install -y musl-tools
|
||||
|
||||
RUN mkdir /out
|
||||
WORKDIR /app
|
||||
|
||||
###################
|
||||
### BUILD STAGE ###
|
||||
###################
|
||||
FROM base AS build
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cargo build --release --locked --target $(cat /rust-target) -p flatpak-repo
|
||||
RUN mv /app/target/$(cat /rust-target)/release/flatpak-repo /out/flatpak-repo
|
||||
RUN strip /out/flatpak-repo
|
||||
|
||||
########################
|
||||
### PRODUCTION STAGE ###
|
||||
########################
|
||||
FROM archlinux:latest
|
||||
|
||||
RUN pacman -Syu --noconfirm && pacman -S --noconfirm flatpak ostree
|
||||
|
||||
ENV FR_BIND="0.0.0.0:3000"
|
||||
EXPOSE 3000/tcp
|
||||
|
||||
ENV FR_REPO_PATH="/flatpak"
|
||||
|
||||
ENV RUST_LOG="info"
|
||||
|
||||
WORKDIR /
|
||||
|
||||
# Copy application binary
|
||||
COPY --from=build /out/flatpak-repo /bin/
|
||||
|
||||
CMD ["/bin/flatpak-repo"]
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::Request,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use http::header;
|
||||
|
||||
pub async fn flatpak_headers_middleware(req: Request<Body>, next: Next) -> Response<Body> {
|
||||
let path = req.uri().path().to_string();
|
||||
let mut response = next.run(req).await;
|
||||
|
||||
// Add content-type for Flatpak metadata files.
|
||||
if path.ends_with(".flatpakrepo") || path.ends_with(".flatpakref") || path == "/config" {
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
"application/octet-stream".parse().unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
// Add cache-control based on OSTree path semantics.
|
||||
let cache_control = if path.starts_with("/objects/")
|
||||
|| path.starts_with("/deltas/")
|
||||
|| path.starts_with("/extensions/")
|
||||
{
|
||||
// Content-addressed and immutable.
|
||||
"public, immutable, max-age=31536000"
|
||||
} else if path.starts_with("/refs/") {
|
||||
// Refs may change on update.
|
||||
"public, must-revalidate, max-age=60"
|
||||
} else {
|
||||
return response;
|
||||
};
|
||||
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, cache_control.parse().unwrap());
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn serve_flatpakrepo(repo_path: PathBuf) -> impl IntoResponse {
|
||||
let path = repo_path.join("example.flatpakrepo");
|
||||
match tokio::fs::read_to_string(&path).await {
|
||||
Ok(contents) => (
|
||||
[(header::CONTENT_TYPE, "application/octet-stream")],
|
||||
contents,
|
||||
),
|
||||
Err(_) => (
|
||||
[(header::CONTENT_TYPE, "text/plain")],
|
||||
"No .flatpakrepo file found".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
use std::{future::ready, net::SocketAddr, path::PathBuf, sync::Arc};
|
||||
|
||||
use anyhow::{Context, bail};
|
||||
use axum::{
|
||||
Router,
|
||||
http::StatusCode,
|
||||
middleware::{self},
|
||||
routing::{get, post},
|
||||
};
|
||||
use clap::Parser;
|
||||
use tokio::sync::Mutex;
|
||||
use tower_http::{compression::CompressionLayer, services::ServeDir, trace::TraceLayer};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
mod flatpak;
|
||||
mod upload;
|
||||
mod util;
|
||||
|
||||
#[derive(Default)]
|
||||
struct SrvState {
|
||||
repo: Mutex<PathBuf>,
|
||||
}
|
||||
|
||||
type SharedState = Arc<SrvState>;
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Opt {
|
||||
/// IP and port to bind to.
|
||||
#[clap(long, env = "FR_BIND", default_value = "127.0.0.1:3000")]
|
||||
bind: SocketAddr,
|
||||
|
||||
/// Path to the ostree repository directory
|
||||
#[clap(long, env = "FR_REPO_PATH")]
|
||||
repo: PathBuf,
|
||||
|
||||
#[clap(long, env = "RUST_LOG", default_value = "debug")]
|
||||
log_level: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let opt = Opt::parse();
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::new(&opt.log_level))
|
||||
.init();
|
||||
|
||||
// TODO: Create an empty archive-z2 repository
|
||||
// ostree init --repo=~/my-apps --mode=archive-z2
|
||||
|
||||
// Sanity check that opt.repo looks like a valid ostree dir
|
||||
let repo = opt.repo;
|
||||
let repo_looks_valid =
|
||||
repo.is_dir() && repo.join("objects").is_dir() && repo.join("refs").is_dir();
|
||||
if !repo_looks_valid {
|
||||
bail!("{repo:?} must be a valid ostree directory.")
|
||||
}
|
||||
|
||||
let serve_dir = ServeDir::new(&repo);
|
||||
|
||||
let state = SrvState {
|
||||
repo: Mutex::new(repo.clone()),
|
||||
};
|
||||
let state: SharedState = Arc::new(state);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/flatpak-bundle/upload", post(upload::flatpak_bundle))
|
||||
// Explicitly deny access to internal OSTree directories.
|
||||
.route("/state/{*path}", get(|| ready(StatusCode::NOT_FOUND)))
|
||||
.route("/tmp/{*path}", get(|| ready(StatusCode::NOT_FOUND)))
|
||||
// Optional: serve a .flatpakrepo descriptor at a known endpoint.
|
||||
.route(
|
||||
"/repo.flatpakrepo",
|
||||
get(move || flatpak::serve_flatpakrepo(repo.clone())),
|
||||
)
|
||||
// Everything else is served from the repo directory.
|
||||
.fallback_service(serve_dir)
|
||||
// Add Flatpak-specific cache headers and content types.
|
||||
.layer(middleware::from_fn(flatpak::flatpak_headers_middleware))
|
||||
// Gzip compression for refs and small files.
|
||||
.layer(CompressionLayer::new())
|
||||
// Request logging.
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&opt.bind)
|
||||
.await
|
||||
.context("Failed to bind tcp listener")?;
|
||||
|
||||
tracing::info!("Listening on http://{}", opt.bind);
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use anyhow::Context;
|
||||
use axum::{body::Body, extract::State, http::StatusCode};
|
||||
use futures::{StreamExt, future::ready, stream};
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::{io::AsyncWriteExt, process::Command, task::block_in_place};
|
||||
|
||||
use crate::{SharedState, util::e};
|
||||
|
||||
pub async fn flatpak_bundle(
|
||||
State(state): State<SharedState>,
|
||||
body: Body,
|
||||
) -> Result<(), StatusCode> {
|
||||
let mut stream = body.into_data_stream();
|
||||
|
||||
// Get first chunks
|
||||
let Some(Ok(first_chunk)) = stream.next().await else {
|
||||
tracing::warn!("Unexpected EOF");
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
};
|
||||
|
||||
// Sanity check that it looks like a flatpak
|
||||
// Technically not correct to require that the first chunk is 7 bytes or more, but who cares.
|
||||
if !first_chunk.starts_with(b"flatpak") {
|
||||
tracing::warn!("Not a valid flatpak bundle. Missing 'flatpak' magic string.");
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Create a tmpfile
|
||||
let (tmp_path, mut tmp_file) = block_in_place(|| {
|
||||
let mut file = NamedTempFile::new()?;
|
||||
file.disable_cleanup(true);
|
||||
let path = file.path().to_path_buf();
|
||||
let file = tokio::fs::File::from_std(file.into_file());
|
||||
std::io::Result::Ok((path, file))
|
||||
})
|
||||
.context("Failed to create tmpfile")
|
||||
.map_err(e(StatusCode::BAD_REQUEST))?;
|
||||
|
||||
// Write bundle to tmpfile
|
||||
tracing::info!("Downloading flatpak to {tmp_path:?}");
|
||||
let mut stream = stream::once(ready(Ok(first_chunk))).chain(stream);
|
||||
loop {
|
||||
let Some(chunk) = stream.next().await else {
|
||||
break;
|
||||
};
|
||||
|
||||
let chunk = chunk
|
||||
.context("Stream error")
|
||||
.map_err(e(StatusCode::BAD_REQUEST))?;
|
||||
|
||||
tmp_file
|
||||
.write_all(&chunk)
|
||||
.await
|
||||
.context("Tmpfile write error")
|
||||
.map_err(e(StatusCode::INTERNAL_SERVER_ERROR))?;
|
||||
}
|
||||
|
||||
let repo = state.repo.lock().await;
|
||||
|
||||
// Import bundle into repository
|
||||
tracing::info!("Importing {tmp_path:?} into repo {:?}", &*repo);
|
||||
let output = Command::new("flatpak")
|
||||
.arg("build-import-bundle")
|
||||
.args([&*repo, &tmp_path])
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute flatpak build-import-bundle")
|
||||
.map_err(e(StatusCode::INTERNAL_SERVER_ERROR))?;
|
||||
|
||||
if !output.status.success() {
|
||||
tracing::error!("flatpak build-import-bundle failed");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
for line in stdout.lines() {
|
||||
tracing::error!("[stdout] {line}");
|
||||
}
|
||||
for line in stderr.lines() {
|
||||
tracing::error!("[stderr] {line}");
|
||||
}
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
drop(tmp_file);
|
||||
if let Err(e) = tokio::fs::remove_file(&tmp_path).await {
|
||||
tracing::warn!("Failed to remove tmpfile at {tmp_path:?}: {e}");
|
||||
}
|
||||
|
||||
tracing::info!("Success");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
use std::fmt::Display;
|
||||
|
||||
use http::StatusCode;
|
||||
|
||||
#[track_caller]
|
||||
pub fn e<E: Display>(code: StatusCode) -> impl FnOnce(E) -> StatusCode {
|
||||
move |e| {
|
||||
tracing::error!("{e}");
|
||||
code
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user