Replaces SSH as the primary git push path — no key generation needed. # Admin UI: Users → Generate key (shown once) git remote add hiy http://hiy:API_KEY@myserver/git/myapp git push hiy main What was added: - api_keys DB table (id, user_id, label, key_hash/SHA-256, created_at) Keys are stored as SHA-256 hashes; the plaintext is shown once on creation and never stored. - routes/api_keys.rs GET/POST /api/users/:id/api-keys — list / generate DELETE /api/api-keys/:key_id — revoke - HTTP Smart Protocol endpoints (public, auth via Basic + API key) GET /git/:app/info/refs — ref advertisement POST /git/:app/git-receive-pack — receive pack, runs post-receive hook Authentication: HTTP Basic where the password is the API key. git prompts once and caches via the OS credential store. post-receive hook fires as normal and queues the build. - Admin UI: API keys section per user with generate/revoke and a one-time reveal box showing the ready-to-use git remote command. SSH path (git-shell + authorized_keys) is still functional for users who prefer it; both paths feed the same build queue. https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
202 lines
8.3 KiB
Rust
202 lines
8.3 KiB
Rust
use axum::{
|
|
middleware,
|
|
routing::{delete, get, post, put},
|
|
Router,
|
|
};
|
|
use std::sync::Arc;
|
|
use tokio::sync::Mutex;
|
|
use std::collections::VecDeque;
|
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|
|
|
mod auth;
|
|
mod builder;
|
|
mod db;
|
|
mod models;
|
|
mod routes;
|
|
|
|
pub use db::DbPool;
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppState {
|
|
pub db: DbPool,
|
|
/// Queue of deploy IDs waiting to be processed.
|
|
pub build_queue: Arc<Mutex<VecDeque<String>>>,
|
|
pub data_dir: String,
|
|
/// token → user_id (in-memory; cleared on restart).
|
|
pub sessions: auth::Sessions,
|
|
/// True when at least one user exists in the database.
|
|
pub auth_enabled: bool,
|
|
/// Bootstrap credentials (used when users table is empty).
|
|
pub admin_user: Option<String>,
|
|
pub admin_pass: Option<String>,
|
|
/// e.g. "yourdomain.com" or "localhost" — used for cookie Domain + redirect URLs.
|
|
pub domain_suffix: String,
|
|
/// Shared secret used by the git-shell and post-receive hook to call internal API routes.
|
|
pub internal_token: String,
|
|
/// Path to the SSH authorized_keys file managed by HIY (e.g. /home/hiy/.ssh/authorized_keys).
|
|
/// Empty string means SSH key management is disabled.
|
|
pub ssh_authorized_keys_file: String,
|
|
/// Path to the hiy-git-shell binary on the host (embedded in authorized_keys command= lines).
|
|
pub git_shell_path: String,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
dotenvy::dotenv().ok();
|
|
|
|
tracing_subscriber::registry()
|
|
.with(
|
|
tracing_subscriber::EnvFilter::new(
|
|
std::env::var("RUST_LOG")
|
|
.unwrap_or_else(|_| "hiy_server=debug,tower_http=debug".into()),
|
|
),
|
|
)
|
|
.with(tracing_subscriber::fmt::layer())
|
|
.init();
|
|
|
|
let data_dir = std::env::var("HIY_DATA_DIR").unwrap_or_else(|_| "./data".into());
|
|
std::fs::create_dir_all(&data_dir)?;
|
|
|
|
let db = db::connect(&data_dir).await?;
|
|
db::migrate(&db).await?;
|
|
|
|
let admin_user = std::env::var("HIY_ADMIN_USER").ok().filter(|s| !s.is_empty());
|
|
let admin_pass = std::env::var("HIY_ADMIN_PASS").ok().filter(|s| !s.is_empty());
|
|
let domain_suffix =
|
|
std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into());
|
|
|
|
// Count DB users.
|
|
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
|
.fetch_one(&db)
|
|
.await
|
|
.unwrap_or(0);
|
|
|
|
// Bootstrap: create initial admin from env vars if no users exist yet.
|
|
if user_count == 0 {
|
|
if let (Some(u), Some(p)) = (&admin_user, &admin_pass) {
|
|
let hash = bcrypt::hash(p, bcrypt::DEFAULT_COST)?;
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
sqlx::query(
|
|
"INSERT INTO users (id, username, password_hash, is_admin, created_at) \
|
|
VALUES (?,?,?,1,?)",
|
|
)
|
|
.bind(&id)
|
|
.bind(u.as_str())
|
|
.bind(&hash)
|
|
.bind(&now)
|
|
.execute(&db)
|
|
.await?;
|
|
tracing::info!("Bootstrap: created admin user '{}'", u);
|
|
} else {
|
|
tracing::warn!(
|
|
"No users in database and HIY_ADMIN_USER/HIY_ADMIN_PASS not set — \
|
|
dashboard is unprotected! Set both in .env to create the first admin."
|
|
);
|
|
}
|
|
}
|
|
|
|
let auth_enabled: bool = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
|
.fetch_one(&db)
|
|
.await
|
|
.unwrap_or(0i64)
|
|
> 0;
|
|
|
|
let build_queue = Arc::new(Mutex::new(VecDeque::<String>::new()));
|
|
|
|
// Internal token: load from file (persists across restarts) or generate fresh.
|
|
let token_file = format!("{}/internal-token", data_dir);
|
|
let internal_token = if let Ok(t) = std::fs::read_to_string(&token_file) {
|
|
t.trim().to_string()
|
|
} else {
|
|
let t = uuid::Uuid::new_v4().to_string();
|
|
let _ = std::fs::write(&token_file, &t);
|
|
t
|
|
};
|
|
|
|
let ssh_authorized_keys_file = std::env::var("HIY_SSH_AUTHORIZED_KEYS")
|
|
.unwrap_or_default();
|
|
let git_shell_path = std::env::var("HIY_GIT_SHELL")
|
|
.unwrap_or_else(|_| "/usr/local/bin/hiy-git-shell".into());
|
|
|
|
let state = AppState {
|
|
db,
|
|
build_queue,
|
|
data_dir,
|
|
sessions: auth::new_sessions(),
|
|
auth_enabled,
|
|
admin_user,
|
|
admin_pass,
|
|
domain_suffix,
|
|
internal_token,
|
|
ssh_authorized_keys_file,
|
|
git_shell_path,
|
|
};
|
|
|
|
// Single background worker — sequential builds to avoid saturating the Pi.
|
|
let worker_state = state.clone();
|
|
tokio::spawn(async move {
|
|
builder::build_worker(worker_state).await;
|
|
});
|
|
|
|
// ── Protected routes (admin login required) ───────────────────────────────
|
|
let protected = Router::new()
|
|
.route("/", get(routes::ui::index))
|
|
.route("/apps/:id", get(routes::ui::app_detail))
|
|
.route("/admin/users", get(routes::ui::users_page))
|
|
.route("/api/status", get(routes::ui::status_json))
|
|
// Apps API
|
|
.route("/api/apps", get(routes::apps::list).post(routes::apps::create))
|
|
.route("/api/apps/:id", get(routes::apps::get_one)
|
|
.put(routes::apps::update)
|
|
.delete(routes::apps::delete))
|
|
.route("/api/apps/:id/stop", post(routes::apps::stop))
|
|
.route("/api/apps/:id/restart", post(routes::apps::restart))
|
|
// Deploys API
|
|
.route("/api/apps/:id/deploy", post(routes::deploys::trigger))
|
|
.route("/api/apps/:id/deploys", get(routes::deploys::list))
|
|
.route("/api/deploys/:id", get(routes::deploys::get_one))
|
|
.route("/api/deploys/:id/logs", get(routes::deploys::logs_sse))
|
|
// Env vars API
|
|
.route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set))
|
|
.route("/api/apps/:id/env/:key", delete(routes::envvars::remove))
|
|
// Users API (admin only — already behind admin middleware)
|
|
.route("/api/users", get(routes::users::list).post(routes::users::create))
|
|
.route("/api/users/:id", put(routes::users::update).delete(routes::users::delete))
|
|
.route("/api/users/:id/apps/:app_id", post(routes::users::grant_app).delete(routes::users::revoke_app))
|
|
// SSH key management (admin only)
|
|
.route("/api/users/:id/ssh-keys", get(routes::ssh_keys::list).post(routes::ssh_keys::add))
|
|
.route("/api/ssh-keys/:key_id", delete(routes::ssh_keys::remove))
|
|
// API key management (admin only)
|
|
.route("/api/users/:id/api-keys", get(routes::api_keys::list).post(routes::api_keys::create))
|
|
.route("/api/api-keys/:key_id", delete(routes::api_keys::revoke))
|
|
.route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
|
|
|
|
// ── Public routes ─────────────────────────────────────────────────────────
|
|
let public = Router::new()
|
|
.route("/login", get(auth::login_page).post(auth::handle_login))
|
|
.route("/logout", get(auth::handle_logout))
|
|
.route("/denied", get(auth::denied_page))
|
|
// Called by Caddy forward_auth for every deployed-app request.
|
|
.route("/auth/verify", get(auth::verify))
|
|
// GitHub webhooks use HMAC-SHA256 — no session needed.
|
|
.route("/webhook/:app_id", post(routes::webhooks::github))
|
|
// HTTP Smart Protocol for git push — authenticated by API key.
|
|
.route("/git/:app/info/refs", get(routes::git::http_info_refs))
|
|
.route("/git/:app/git-receive-pack", post(routes::git::http_receive_pack))
|
|
// Internal routes: called by hiy-git-shell and post-receive hooks.
|
|
.route("/internal/git/auth", get(routes::git::auth_check))
|
|
.route("/internal/git/:app_id/push", post(routes::git::push_trigger));
|
|
|
|
let app = Router::new()
|
|
.merge(protected)
|
|
.merge(public)
|
|
.with_state(state);
|
|
|
|
let addr = std::env::var("HIY_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into());
|
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
|
tracing::info!("Listening on http://{}", addr);
|
|
axum::serve(listener, app).await?;
|
|
|
|
Ok(())
|
|
}
|