feat: git push deploy (roadmap step 2)
Full self-contained git push flow — no GitHub required:
git remote add hiy ssh://hiy@myserver/myapp
git push hiy main
What was added:
- Bare git repo per app (HIY_DATA_DIR/repos/<app-id>.git)
Initialised automatically on app create; removed on app delete.
post-receive hook is written into each repo and calls the internal
API to queue a build using the same pipeline as webhook deploys.
- SSH key management
New ssh_keys DB table. Admin UI (/admin/users) now shows SSH keys
per user with add/remove. New API routes:
GET/POST /api/users/:id/ssh-keys
DELETE /api/ssh-keys/:key_id
On every change, HIY rewrites HIY_SSH_AUTHORIZED_KEYS with
command= restricted entries pointing at hiy-git-shell.
- scripts/git-shell
SSH command= override installed at HIY_GIT_SHELL (default
/usr/local/bin/hiy-git-shell). Validates the push via
GET /internal/git/auth, then exec's git-receive-pack on the
correct bare repo.
- Internal API routes (authenticated by shared internal_token)
GET /internal/git/auth -- git-shell permission check
POST /internal/git/:app_id/push -- post-receive build trigger
- Builder: git-push deploys use file:// path to the local bare repo
instead of the app's remote repo_url.
- internal_token persists across restarts in HIY_DATA_DIR/internal-token.
New env vars:
HIY_SSH_AUTHORIZED_KEYS path to the authorized_keys file to manage
HIY_GIT_SHELL path to the git-shell script on the host
Both webhook and git-push deploys feed the same build queue.
https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
This commit is contained in:
parent
1671aaf8e8
commit
cb0795617f
10 changed files with 517 additions and 2 deletions
67
scripts/git-shell
Executable file
67
scripts/git-shell
Executable file
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env bash
|
||||
# HIY git-shell — SSH authorized_keys command= override
|
||||
#
|
||||
# Install at: /usr/local/bin/hiy-git-shell (or set HIY_GIT_SHELL in .env)
|
||||
# Each authorized_keys entry is written by HIY in this form:
|
||||
#
|
||||
# command="/usr/local/bin/hiy-git-shell <user-id> <api-url> <token> <repos-dir>",
|
||||
# no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty <public-key>
|
||||
#
|
||||
# OpenSSH sets SSH_ORIGINAL_COMMAND to what the developer actually ran, e.g.:
|
||||
# git-receive-pack '/myapp'
|
||||
# This script validates the push and exec's git-receive-pack on the bare repo.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
USER_ID="${1:-}"
|
||||
API_URL="${2:-http://localhost:3000}"
|
||||
TOKEN="${3:-}"
|
||||
REPOS_DIR="${4:-/data/repos}"
|
||||
|
||||
if [ -z "$USER_ID" ] || [ -z "$TOKEN" ]; then
|
||||
echo "hiy: internal configuration error — contact your administrator." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ORIG="${SSH_ORIGINAL_COMMAND:-}"
|
||||
|
||||
# Only git-receive-pack (push) is supported; reject everything else.
|
||||
if [[ "$ORIG" != git-receive-pack* ]]; then
|
||||
echo "hiy: only 'git push' is supported on this host." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse the app name from: git-receive-pack '/myapp' or git-receive-pack 'myapp'
|
||||
APP_NAME=$(echo "$ORIG" | sed "s/git-receive-pack '\\///;s/git-receive-pack '//;s/'.*//")
|
||||
APP_NAME="${APP_NAME##/}" # strip leading slash if still present
|
||||
APP_NAME="${APP_NAME%/}" # strip trailing slash
|
||||
|
||||
if [ -z "$APP_NAME" ]; then
|
||||
echo "hiy: could not parse app name from: $ORIG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ask HIY whether this user may push to this app.
|
||||
RESPONSE=$(curl -sf \
|
||||
-H "X-Hiy-Token: $TOKEN" \
|
||||
"${API_URL}/internal/git/auth?user_id=${USER_ID}&app=${APP_NAME}" \
|
||||
2>/dev/null) || {
|
||||
echo "hiy: push denied (cannot reach server or access denied for '${APP_NAME}')." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
APP_ID=$(echo "$RESPONSE" | python3 -c \
|
||||
"import sys, json; print(json.load(sys.stdin)['app_id'])" 2>/dev/null)
|
||||
|
||||
if [ -z "$APP_ID" ]; then
|
||||
echo "hiy: push denied for '${APP_NAME}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_PATH="${REPOS_DIR}/${APP_ID}.git"
|
||||
if [ ! -d "$REPO_PATH" ]; then
|
||||
echo "hiy: repository not found for app '${APP_NAME}' (expected ${REPO_PATH})." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec git-receive-pack "$REPO_PATH"
|
||||
|
|
@ -98,6 +98,13 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
|
|||
let build_script = std::env::var("HIY_BUILD_SCRIPT")
|
||||
.unwrap_or_else(|_| "./builder/build.sh".into());
|
||||
|
||||
// For git-push deploys, use the local bare repo instead of the remote URL.
|
||||
let repo_url = if deploy.triggered_by == "git-push" {
|
||||
format!("file://{}/repos/{}.git", state.data_dir, app.id)
|
||||
} else {
|
||||
app.repo_url.clone()
|
||||
};
|
||||
|
||||
let build_dir = format!("{}/builds/{}", state.data_dir, app.id);
|
||||
|
||||
// Log diagnostics before spawning so even a spawn failure leaves a breadcrumb.
|
||||
|
|
@ -125,7 +132,7 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
|
|||
.arg(&build_script)
|
||||
.env("APP_ID", &app.id)
|
||||
.env("APP_NAME", &app.name)
|
||||
.env("REPO_URL", &app.repo_url)
|
||||
.env("REPO_URL", &repo_url)
|
||||
.env("BRANCH", &app.branch)
|
||||
.env("PORT", app.port.to_string())
|
||||
.env("ENV_FILE", &env_file)
|
||||
|
|
|
|||
|
|
@ -77,5 +77,17 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
|
|||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@ pub struct AppState {
|
|||
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]
|
||||
|
|
@ -97,6 +104,21 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
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,
|
||||
|
|
@ -106,6 +128,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
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.
|
||||
|
|
@ -139,6 +164,9 @@ async fn main() -> anyhow::Result<()> {
|
|||
.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))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
|
||||
|
||||
// ── Public routes ─────────────────────────────────────────────────────────
|
||||
|
|
@ -149,7 +177,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
// 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));
|
||||
.route("/webhook/:app_id", post(routes::webhooks::github))
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -74,3 +74,18 @@ pub struct UpdateUser {
|
|||
pub password: Option<String>,
|
||||
pub is_admin: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct SshKey {
|
||||
pub id: String,
|
||||
pub user_id: String,
|
||||
pub label: String,
|
||||
pub public_key: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateSshKey {
|
||||
pub label: String,
|
||||
pub public_key: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use axum::{
|
|||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
|
|
@ -48,6 +49,11 @@ pub async fn create(
|
|||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
})?;
|
||||
|
||||
// Initialise a bare git repo for git-push deploys.
|
||||
if let Err(e) = init_bare_repo(&s, &id).await {
|
||||
tracing::warn!("Failed to init bare repo for {}: {}", id, e);
|
||||
}
|
||||
|
||||
fetch_app(&s, &id).await.map(|a| (StatusCode::CREATED, Json(a)))
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +106,13 @@ pub async fn delete(
|
|||
if res.rows_affected() == 0 {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// Clean up the bare repo (best-effort).
|
||||
let repo_path = format!("{}/repos/{}.git", s.data_dir, id);
|
||||
if let Err(e) = std::fs::remove_dir_all(&repo_path) {
|
||||
tracing::warn!("Failed to remove bare repo {}: {}", repo_path, e);
|
||||
}
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
|
@ -147,3 +160,52 @@ async fn fetch_app(s: &AppState, id: &str) -> Result<App, StatusCode> {
|
|||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.ok_or(StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
/// Initialise a bare git repo and write the post-receive hook for git-push deploys.
|
||||
async fn init_bare_repo(s: &AppState, app_id: &str) -> anyhow::Result<()> {
|
||||
let repos_dir = format!("{}/repos", s.data_dir);
|
||||
std::fs::create_dir_all(&repos_dir)?;
|
||||
|
||||
let repo_path = format!("{}/{}.git", repos_dir, app_id);
|
||||
|
||||
let out = tokio::process::Command::new("git")
|
||||
.args(["init", "--bare", &repo_path])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git init --bare failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// Write the post-receive hook that calls HIY's internal push endpoint.
|
||||
let hook_path = format!("{}/hooks/post-receive", repo_path);
|
||||
let hook = format!(
|
||||
r#"#!/usr/bin/env bash
|
||||
# HIY post-receive hook — queues a build after git push.
|
||||
set -euo pipefail
|
||||
HIY_API_URL="http://localhost:3000"
|
||||
HIY_INTERNAL_TOKEN="{token}"
|
||||
APP_ID="{app_id}"
|
||||
while read OLD_SHA NEW_SHA REF; do
|
||||
BRANCH="${{REF##refs/heads/}}"
|
||||
curl -sf -X POST \
|
||||
-H "X-Hiy-Token: $HIY_INTERNAL_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{{\"sha\":\"$NEW_SHA\",\"branch\":\"$BRANCH\"}}" \
|
||||
"$HIY_API_URL/internal/git/$APP_ID/push" || true
|
||||
done
|
||||
echo "[hiy] Build queued. Watch progress in the dashboard."
|
||||
"#,
|
||||
token = s.internal_token,
|
||||
app_id = app_id,
|
||||
);
|
||||
|
||||
std::fs::write(&hook_path, hook)?;
|
||||
std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?;
|
||||
|
||||
tracing::info!("Initialised bare repo at {}", repo_path);
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
115
server/src/routes/git.rs
Normal file
115
server/src/routes/git.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/// Internal routes called by hiy-git-shell and post-receive hooks.
|
||||
/// Authenticated by the shared `internal_token` (X-Hiy-Token header).
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{builder, AppState};
|
||||
|
||||
// ── Token check ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn check_token(state: &AppState, headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get("x-hiy-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|t| t == state.internal_token)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ── GET /internal/git/auth — can this user push to this app? ─────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthParams {
|
||||
pub user_id: String,
|
||||
pub app: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AuthResponse {
|
||||
pub app_id: String,
|
||||
}
|
||||
|
||||
pub async fn auth_check(
|
||||
State(s): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<AuthParams>,
|
||||
) -> impl IntoResponse {
|
||||
if !check_token(&s, &headers) {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
// Resolve app by name (the app ID is the lowercased name slug).
|
||||
let app_id: Option<String> =
|
||||
sqlx::query_scalar("SELECT id FROM apps WHERE id = ? OR name = ?")
|
||||
.bind(¶ms.app)
|
||||
.bind(¶ms.app)
|
||||
.fetch_optional(&s.db)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
let app_id = match app_id {
|
||||
Some(id) => id,
|
||||
None => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
|
||||
// Admins can push to any app.
|
||||
let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?")
|
||||
.bind(¶ms.user_id)
|
||||
.fetch_optional(&s.db)
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
.unwrap_or(0);
|
||||
|
||||
if is_admin != 0 {
|
||||
return Json(AuthResponse { app_id }).into_response();
|
||||
}
|
||||
|
||||
// Non-admin: check user_apps grant.
|
||||
let granted: Option<i64> =
|
||||
sqlx::query_scalar("SELECT 1 FROM user_apps WHERE user_id = ? AND app_id = ?")
|
||||
.bind(¶ms.user_id)
|
||||
.bind(&app_id)
|
||||
.fetch_optional(&s.db)
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
|
||||
if granted.is_some() {
|
||||
Json(AuthResponse { app_id }).into_response()
|
||||
} else {
|
||||
StatusCode::FORBIDDEN.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /internal/git/:app_id/push — post-receive hook triggers a build ─────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PushBody {
|
||||
pub sha: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn push_trigger(
|
||||
State(s): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(app_id): Path<String>,
|
||||
body: Option<Json<PushBody>>,
|
||||
) -> impl IntoResponse {
|
||||
if !check_token(&s, &headers) {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
|
||||
let sha = body.and_then(|b| b.sha.clone());
|
||||
|
||||
match builder::enqueue_deploy(&s, &app_id, "git-push", sha).await {
|
||||
Ok(deploy) => (StatusCode::OK, Json(json!({"deploy_id": deploy.id}))).into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("git push trigger for {}: {}", app_id, e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
pub mod apps;
|
||||
pub mod deploys;
|
||||
pub mod envvars;
|
||||
pub mod git;
|
||||
pub mod ssh_keys;
|
||||
pub mod ui;
|
||||
pub mod users;
|
||||
pub mod webhooks;
|
||||
|
|
|
|||
154
server/src/routes/ssh_keys.rs
Normal file
154
server/src/routes/ssh_keys.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{models::{CreateSshKey, SshKey}, AppState};
|
||||
|
||||
// ── List SSH keys for a user ──────────────────────────────────────────────────
|
||||
|
||||
pub async fn list(
|
||||
State(s): State<AppState>,
|
||||
Path(user_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let keys: Vec<SshKey> =
|
||||
sqlx::query_as("SELECT * FROM ssh_keys WHERE user_id = ? ORDER BY created_at")
|
||||
.bind(&user_id)
|
||||
.fetch_all(&s.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
Json(keys)
|
||||
}
|
||||
|
||||
// ── Add SSH key ───────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn add(
|
||||
State(s): State<AppState>,
|
||||
Path(user_id): Path<String>,
|
||||
Json(body): Json<CreateSshKey>,
|
||||
) -> impl IntoResponse {
|
||||
let public_key = body.public_key.trim().to_string();
|
||||
let label = body.label.trim().to_string();
|
||||
|
||||
if label.is_empty() || public_key.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "label and public_key required"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Basic sanity check: must start with a known key type.
|
||||
let valid_prefix = ["ssh-rsa", "ssh-ed25519", "ssh-ecdsa", "ecdsa-sha2-", "sk-"]
|
||||
.iter()
|
||||
.any(|p| public_key.starts_with(p));
|
||||
if !valid_prefix {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "public_key does not look like an SSH public key"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Reject keys that would break the authorized_keys command= line.
|
||||
if public_key.contains('\n') || public_key.contains('"') {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "public_key contains invalid characters"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let res = sqlx::query(
|
||||
"INSERT INTO ssh_keys (id, user_id, label, public_key, created_at) VALUES (?,?,?,?,?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&user_id)
|
||||
.bind(&label)
|
||||
.bind(&public_key)
|
||||
.bind(&now)
|
||||
.execute(&s.db)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
if let Err(e) = regenerate_authorized_keys(&s).await {
|
||||
tracing::warn!("authorized_keys regen failed: {}", e);
|
||||
}
|
||||
(StatusCode::CREATED, Json(json!({"id": id}))).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("add ssh key: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "db error"}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remove SSH key ────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn remove(
|
||||
State(s): State<AppState>,
|
||||
Path(key_id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
sqlx::query("DELETE FROM ssh_keys WHERE id = ?")
|
||||
.bind(&key_id)
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.ok();
|
||||
if let Err(e) = regenerate_authorized_keys(&s).await {
|
||||
tracing::warn!("authorized_keys regen failed: {}", e);
|
||||
}
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
// ── Regenerate authorized_keys ────────────────────────────────────────────────
|
||||
|
||||
/// Rewrites the authorized_keys file from all SSH keys in the database.
|
||||
/// Each entry uses a `command=` override so pushes are routed through hiy-git-shell.
|
||||
/// No-ops silently if HIY_SSH_AUTHORIZED_KEYS is not configured.
|
||||
pub async fn regenerate_authorized_keys(s: &AppState) -> anyhow::Result<()> {
|
||||
if s.ssh_authorized_keys_file.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let keys: Vec<SshKey> = sqlx::query_as("SELECT * FROM ssh_keys ORDER BY user_id, created_at")
|
||||
.fetch_all(&s.db)
|
||||
.await?;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for key in &keys {
|
||||
// Embed user_id, api url (always localhost:3000), token, and repos dir into the
|
||||
// command= so hiy-git-shell needs no separate config file.
|
||||
let api_url = format!("http://localhost:3000");
|
||||
let repos_dir = format!("{}/repos", s.data_dir);
|
||||
let cmd = format!(
|
||||
"command=\"{shell} {uid} {api} {token} {repos}\",\
|
||||
no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty {pk}",
|
||||
shell = s.git_shell_path,
|
||||
uid = key.user_id,
|
||||
api = api_url,
|
||||
token = s.internal_token,
|
||||
repos = repos_dir,
|
||||
pk = key.public_key,
|
||||
);
|
||||
lines.push(cmd);
|
||||
}
|
||||
|
||||
let content = lines.join("\n") + if lines.is_empty() { "" } else { "\n" };
|
||||
|
||||
// Create parent directory if needed.
|
||||
if let Some(parent) = std::path::Path::new(&s.ssh_authorized_keys_file).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&s.ssh_authorized_keys_file, content)?;
|
||||
tracing::info!("Wrote {} SSH key(s) to {}", lines.len(), s.ssh_authorized_keys_file);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -688,7 +688,20 @@ pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
|
|||
<button onclick="grantApp('${{u.id}}')" style="margin-left:4px">Grant</button>
|
||||
</span>` : ''}}
|
||||
</div>
|
||||
<div id="ssh-keys-${{u.id}}" style="margin-top:12px">
|
||||
<span class="muted" style="font-size:0.78rem">SSH keys:</span>
|
||||
<span class="muted" style="font-size:0.82rem"> loading…</span>
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px;gap:6px">
|
||||
<input type="text" id="key-label-${{u.id}}" placeholder="label (e.g. laptop)"
|
||||
style="max-width:140px;font-size:0.82rem;padding:4px 8px">
|
||||
<input type="text" id="key-value-${{u.id}}" placeholder="ssh-ed25519 AAAA…"
|
||||
style="flex:1;font-size:0.82rem;padding:4px 8px">
|
||||
<button onclick="addSshKey('${{u.id}}')" style="font-size:0.82rem">Add key</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
// Load SSH keys for each user asynchronously.
|
||||
for (const u of users) loadSshKeys(u.id);
|
||||
}}
|
||||
|
||||
async function addUser() {{
|
||||
|
|
@ -751,6 +764,43 @@ pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
|
|||
load();
|
||||
}}
|
||||
|
||||
async function loadSshKeys(userId) {{
|
||||
const res = await fetch('/api/users/' + userId + '/ssh-keys');
|
||||
const keys = await res.json();
|
||||
const el = document.getElementById('ssh-keys-' + userId);
|
||||
if (!el) return;
|
||||
if (!keys.length) {{
|
||||
el.innerHTML = '<span class="muted" style="font-size:0.78rem">SSH keys:</span> <span class="muted" style="font-size:0.82rem">none</span>';
|
||||
}} else {{
|
||||
el.innerHTML = '<span class="muted" style="font-size:0.78rem">SSH keys:</span> ' +
|
||||
keys.map(k => `
|
||||
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
|
||||
${{k.label}}
|
||||
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px"
|
||||
onclick="removeSshKey('${{k.id}}','${{userId}}')">✕</a>
|
||||
</span>`).join('');
|
||||
}}
|
||||
}}
|
||||
|
||||
async function addSshKey(userId) {{
|
||||
const label = document.getElementById('key-label-' + userId).value.trim();
|
||||
const publicKey = document.getElementById('key-value-' + userId).value.trim();
|
||||
if (!label || !publicKey) return;
|
||||
await fetch('/api/users/' + userId + '/ssh-keys', {{
|
||||
method: 'POST',
|
||||
headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify({{label, public_key: publicKey}})
|
||||
}});
|
||||
document.getElementById('key-label-' + userId).value = '';
|
||||
document.getElementById('key-value-' + userId).value = '';
|
||||
loadSshKeys(userId);
|
||||
}}
|
||||
|
||||
async function removeSshKey(keyId, userId) {{
|
||||
await fetch('/api/ssh-keys/' + keyId, {{method: 'DELETE'}});
|
||||
loadSshKeys(userId);
|
||||
}}
|
||||
|
||||
load();
|
||||
</script>"#
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue