8 Commits

Author SHA1 Message Date
5299190973 Add ssh skeleton directory
Some checks failed
CI / Backend (Rust) (push) Failing after 11s
CI / Frontend (Vue) (push) Successful in 10m2s
Release / Build Linux (x86_64) (push) Failing after 54s
Release / Build macOS (x86_64) (push) Has been cancelled
Release / Build Windows (x86_64) (push) Has been cancelled
Release / Create Release (push) Has been cancelled
2026-02-27 13:00:02 +00:00
Keith Solomon
f1f106c948 🐞 fix: Even more git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-25 08:05:02 -06:00
Keith Solomon
2a55d5ebec 🐞 fix: More git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 10s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-23 18:25:20 -06:00
Keith Solomon
adefed7c74 🐞 fix: Git fixes
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m57s
2026-02-23 17:04:36 -06:00
Keith Solomon
d39bbd1801 feature: Add remote git support
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m56s
2026-02-23 08:50:06 -06:00
Keith Solomon
0b5816621b 🐞 fix: Update #2 for dbus error
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m54s
2026-02-23 08:22:32 -06:00
Keith Solomon
97508e4f0a 🐞 fix: Update for dbus error
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 9m53s
2026-02-23 06:53:14 -06:00
Keith Solomon
d98181bed9 feature: Add docker support
Some checks failed
CI / Backend (Rust) (push) Failing after 9s
CI / Frontend (Vue) (push) Successful in 10m0s
2026-02-22 22:38:55 -06:00
8 changed files with 528 additions and 33 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.github
.ferrite
backend/target
frontend/node_modules
frontend/dist
data
docs
*.log
*.tmp

1
.gitignore vendored
View File

@@ -37,6 +37,7 @@ data/notes/assets/*
!data/notes/assets/.gitkeep !data/notes/assets/.gitkeep
data/projects/*/ data/projects/*/
!data/projects/.gitkeep !data/projects/.gitkeep
Codex Session.txt
# === Stray root lock file (frontend/package-lock.json is kept for CI) === # === Stray root lock file (frontend/package-lock.json is kept for CI) ===
/package-lock.json /package-lock.json

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
FROM node:20-bookworm-slim AS frontend-builder
WORKDIR /src/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM rust:1.85-bookworm AS backend-builder
WORKDIR /src/backend
RUN apt-get update \
&& apt-get install -y --no-install-recommends pkg-config libdbus-1-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/ ./
RUN cargo build --release
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ripgrep libdbus-1-3 git openssh-client \
&& rm -rf /var/lib/apt/lists/*
COPY --from=backend-builder /src/backend/target/release/ironpad /app/ironpad
COPY --from=frontend-builder /src/frontend/dist /app/static
RUN mkdir -p /app/data
ENV IRONPAD_HOST=0.0.0.0
ENV IRONPAD_PORT=3000
ENV IRONPAD_DISABLE_TRAY=1
ENV IRONPAD_DATA_DIR=/app/data
ENV RUST_LOG=info
EXPOSE 3000
VOLUME ["/app/data"]
CMD ["./ironpad"]

View File

@@ -68,6 +68,53 @@ npm run dev
Open http://localhost:5173 in your browser. Open http://localhost:5173 in your browser.
### Option 3: Run with Docker (Centralized Server)
This runs Ironpad as a single container that serves both API and frontend on port `3000`.
```bash
# Build and start in the background
docker compose up -d --build
# View logs
docker compose logs -f
```
Then open:
- `http://localhost:3000` from the same machine, or
- `http://<your-server-ip>:3000` from another device on your network.
Data persists in `./data` on the host via the compose volume mapping.
To stop:
```bash
docker compose down
```
#### Docker + Private Git Remote Sync
Ironpad can automatically sync the `data/` git repo with a private remote over SSH.
1. Put your SSH key files on the host (example: `./deploy/ssh/id_ed25519` and `./deploy/ssh/id_ed25519.pub`).
2. Uncomment the SSH volume mount and git env vars in `docker-compose.yml`.
3. Set:
- `IRONPAD_GIT_REMOTE_URL` (example: `git@github.com:your-org/ironpad-data.git`)
- `IRONPAD_GIT_SSH_PRIVATE_KEY` (path inside container)
- `IRONPAD_GIT_SSH_KNOWN_HOSTS` (optional; defaults to `/root/.ssh/known_hosts`)
- `IRONPAD_GIT_SYNC_INTERVAL_SECS` (example: `300`)
4. Recreate the stack:
```bash
docker compose up -d --build
```
Sync behavior:
- Every cycle: `fetch -> safe fast-forward if possible -> push`
- If local and remote diverge, auto fast-forward is skipped and a warning is logged.
- If libgit2 SSH auth fails, Ironpad can fall back to `git` CLI (controlled by `IRONPAD_GIT_USE_CLI_FALLBACK`, default `true`).
## Tech Stack ## Tech Stack
![Tech Stack](docs/graphics/tech-stack.png) ![Tech Stack](docs/graphics/tech-stack.png)
@@ -121,7 +168,18 @@ Read about the method:
|---------|---------|-------------| |---------|---------|-------------|
| Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var | | Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var |
| Backend port | 3000 (auto-increments to 3010) | Dynamic port selection | | Backend port | 3000 (auto-increments to 3010) | Dynamic port selection |
| Backend host | `127.0.0.1` | Override with `IRONPAD_HOST` (use `0.0.0.0` for Docker/server access) |
| Fixed port | disabled | Set `IRONPAD_PORT` to force a specific port |
| Disable tray mode | `false` | Set `IRONPAD_DISABLE_TRAY=1` to run headless in production static mode |
| Auto-commit | Every 60 seconds | Git commits when changes exist | | Auto-commit | Every 60 seconds | Git commits when changes exist |
| Git remote URL | not set | `IRONPAD_GIT_REMOTE_URL` creates/updates `origin` |
| Git sync interval | `0` (disabled) | Set `IRONPAD_GIT_SYNC_INTERVAL_SECS` to enable scheduled sync |
| Git SSH private key | not set | `IRONPAD_GIT_SSH_PRIVATE_KEY` path to private key in container |
| Git SSH public key | not set | Optional `IRONPAD_GIT_SSH_PUBLIC_KEY` path |
| Git known_hosts path | `/root/.ssh/known_hosts` | Override with `IRONPAD_GIT_SSH_KNOWN_HOSTS` |
| Git SSH username | `git` | Override with `IRONPAD_GIT_SSH_USERNAME` if needed |
| Git SSH passphrase | not set | Optional `IRONPAD_GIT_SSH_PASSPHRASE` |
| Git CLI fallback | `true` | `IRONPAD_GIT_USE_CLI_FALLBACK` for fetch/push auth fallback |
| Auto-save | 1 second debounce | Frontend saves after typing stops | | Auto-save | 1 second debounce | Frontend saves after typing stops |
## Documentation ## Documentation

View File

@@ -1,7 +1,7 @@
// Hide console window on Windows in release builds (production mode) // Hide console window on Windows in release builds (production mode)
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::net::SocketAddr; use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc; use std::sync::Arc;
use axum::{routing::get, Router}; use axum::{routing::get, Router};
@@ -20,22 +20,58 @@ mod websocket;
/// Find an available port and return the bound listener. /// Find an available port and return the bound listener.
/// Avoids TOCTOU race by keeping the listener alive. /// Avoids TOCTOU race by keeping the listener alive.
async fn find_available_port() -> (TcpListener, u16) { async fn find_available_port() -> (TcpListener, u16) {
let host = std::env::var("IRONPAD_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let bind_ip = match host.parse::<IpAddr>() {
Ok(ip) => ip,
Err(_) => {
warn!("Invalid IRONPAD_HOST '{}', falling back to 127.0.0.1", host);
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))
}
};
if let Ok(port_str) = std::env::var("IRONPAD_PORT") {
match port_str.parse::<u16>() {
Ok(port) => {
let addr = SocketAddr::new(bind_ip, port);
let listener = TcpListener::bind(addr)
.await
.unwrap_or_else(|e| panic!("Failed to bind to {addr}: {e}"));
return (listener, port);
}
Err(_) => {
warn!("Invalid IRONPAD_PORT '{}', falling back to 3000-3010", port_str);
}
}
}
for port in 3000..=3010 { for port in 3000..=3010 {
let addr = SocketAddr::from(([127, 0, 0, 1], port)); let addr = SocketAddr::new(bind_ip, port);
if let Ok(listener) = TcpListener::bind(addr).await { if let Ok(listener) = TcpListener::bind(addr).await {
return (listener, port); return (listener, port);
} }
} }
panic!("No available ports in range 30003010"); panic!("No available ports in range 3000-3010");
}
fn env_flag(name: &str) -> bool {
std::env::var(name)
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
.unwrap_or(false)
} }
fn main() { fn main() {
tracing_subscriber::fmt().init(); tracing_subscriber::fmt().init();
config::init_data_dir(); config::init_data_dir();
if config::is_production() { let production = config::is_production();
let disable_tray = env_flag("IRONPAD_DISABLE_TRAY");
if production && !disable_tray {
run_with_tray(); run_with_tray();
} else { } else {
if production && disable_tray {
info!("Production static mode detected; running headless (IRONPAD_DISABLE_TRAY=1)");
}
// Development mode: normal tokio runtime, no tray // Development mode: normal tokio runtime, no tray
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(run_server(None)); rt.block_on(run_server(None));
@@ -66,8 +102,14 @@ async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
warn!("Git init skipped: {}", e); warn!("Git init skipped: {}", e);
} }
// Configure git remote from env (if provided)
if let Err(e) = services::git::configure_remote_from_env() {
warn!("Git remote setup skipped: {}", e);
}
// Start auto-commit background task (tries to commit every 60s) // Start auto-commit background task (tries to commit every 60s)
services::git::start_auto_commit(); services::git::start_auto_commit();
services::git::start_auto_sync();
// CORS layer (permissive for local-only app) // CORS layer (permissive for local-only app)
let cors = CorsLayer::permissive(); let cors = CorsLayer::permissive();
@@ -130,7 +172,11 @@ async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
} }
// Start server // Start server
info!("Ironpad running on http://localhost:{port}"); let bound_addr = listener
.local_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| format!("127.0.0.1:{port}"));
info!("Ironpad running on http://{bound_addr}");
axum::serve(listener, app).await.expect("Server failed"); axum::serve(listener, app).await.expect("Server failed");
} }

View File

@@ -1,3 +1,5 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration; use std::time::Duration;
use chrono::Utc; use chrono::Utc;
@@ -96,6 +98,171 @@ pub struct RemoteInfo {
/// The background task simply tries to commit every interval; /// The background task simply tries to commit every interval;
/// commit_all() already handles "no changes" gracefully. /// commit_all() already handles "no changes" gracefully.
#[derive(Debug, Clone)]
struct GitAuthConfig {
username: String,
private_key: Option<PathBuf>,
public_key: Option<PathBuf>,
passphrase: Option<String>,
}
fn git_auth_config() -> GitAuthConfig {
let username = std::env::var("IRONPAD_GIT_SSH_USERNAME").unwrap_or_else(|_| "git".to_string());
let private_key = std::env::var("IRONPAD_GIT_SSH_PRIVATE_KEY")
.ok()
.map(PathBuf::from)
.filter(|p| p.exists());
let public_key = std::env::var("IRONPAD_GIT_SSH_PUBLIC_KEY")
.ok()
.map(PathBuf::from)
.filter(|p| p.exists());
let passphrase = std::env::var("IRONPAD_GIT_SSH_PASSPHRASE")
.ok()
.and_then(|s| {
let trimmed = s.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
});
GitAuthConfig {
username,
private_key,
public_key,
passphrase,
}
}
fn remote_callbacks() -> git2::RemoteCallbacks<'static> {
let auth = git_auth_config();
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(move |_url, username_from_url, _allowed_types| {
// Always prefer configured username (env-driven).
// Some remotes embed a username in URL; that can cause auth mismatches.
let username = auth.username.as_str();
if let Some(url_user) = username_from_url {
if url_user != username {
tracing::warn!(
"Remote URL username '{}' differs from IRONPAD_GIT_SSH_USERNAME '{}'; using env value",
url_user,
username
);
}
}
if let Some(private_key) = auth.private_key.as_deref() {
let public_key: Option<&Path> = auth.public_key.as_deref();
let passphrase = auth.passphrase.as_deref();
// First try with configured public key path (if provided),
// then retry without public key file to avoid mismatch issues.
if let Some(pub_key_path) = public_key {
match git2::Cred::ssh_key(username, Some(pub_key_path), private_key, passphrase) {
Ok(cred) => return Ok(cred),
Err(e) => {
tracing::warn!(
"SSH key auth with explicit public key failed for user '{}', private '{}', public '{}': {}",
username,
private_key.display(),
pub_key_path.display(),
e
);
}
}
}
match git2::Cred::ssh_key(username, None, private_key, passphrase) {
Ok(cred) => return Ok(cred),
Err(e) => {
tracing::warn!(
"SSH key auth from private key failed for user '{}', key '{}': {}",
username,
private_key.display(),
e
);
}
}
} else {
tracing::warn!(
"IRONPAD_GIT_SSH_PRIVATE_KEY not set or file missing; falling back to SSH agent"
);
}
git2::Cred::ssh_key_from_agent(username)
});
callbacks
}
fn use_git_cli_fallback() -> bool {
std::env::var("IRONPAD_GIT_USE_CLI_FALLBACK")
.map(|v| !matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off"))
.unwrap_or(true)
}
fn known_hosts_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("IRONPAD_GIT_SSH_KNOWN_HOSTS") {
let p = PathBuf::from(path);
if p.exists() {
return Some(p);
}
}
let default = PathBuf::from("/root/.ssh/known_hosts");
if default.exists() {
Some(default)
} else {
None
}
}
fn git_ssh_command(auth: &GitAuthConfig) -> Option<String> {
let private_key = auth.private_key.as_ref()?;
let mut cmd = format!(
"ssh -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes",
private_key.display()
);
if let Some(known_hosts) = known_hosts_path() {
cmd.push_str(&format!(" -o UserKnownHostsFile={}", known_hosts.display()));
}
Some(cmd)
}
fn run_git_cli(args: &[&str]) -> Result<(), String> {
let auth = git_auth_config();
let data_path = config::data_dir();
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(data_path)
.env("GIT_TERMINAL_PROMPT", "0");
if let Some(ssh_cmd) = git_ssh_command(&auth) {
cmd.env("GIT_SSH_COMMAND", ssh_cmd);
}
let output = cmd
.output()
.map_err(|e| format!("Failed to run git CLI: {}", e))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let msg = if !stderr.is_empty() { stderr } else { stdout };
Err(format!(
"git {} failed (exit {}): {}",
args.join(" "),
output.status.code().unwrap_or(-1),
msg
))
}
}
/// Get repository status /// Get repository status
pub fn get_status() -> Result<RepoStatus, String> { pub fn get_status() -> Result<RepoStatus, String> {
let data_path = config::data_dir(); let data_path = config::data_dir();
@@ -317,28 +484,34 @@ pub fn push_to_remote() -> Result<(), String> {
return Err("No remote URL configured".to_string()); return Err("No remote URL configured".to_string());
} }
// Create callbacks for authentication
let mut callbacks = git2::RemoteCallbacks::new();
// Try to use credential helper from git config
callbacks.credentials(|_url, username_from_url, _allowed_types| {
// Try SSH agent first
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
});
// Set up push options // Set up push options
let mut push_options = git2::PushOptions::new(); let mut push_options = git2::PushOptions::new();
push_options.remote_callbacks(callbacks); push_options.remote_callbacks(remote_callbacks());
// Push the current branch // Push the current branch
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name); let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
remote match remote.push(&[&refspec], Some(&mut push_options)) {
.push(&[&refspec], Some(&mut push_options)) Ok(_) => {
.map_err(|e| format!("Push failed: {}. Make sure SSH keys are configured.", e))?;
tracing::info!("Successfully pushed to origin/{}", branch_name); tracing::info!("Successfully pushed to origin/{}", branch_name);
Ok(()) Ok(())
} }
Err(e) => {
let err_text = e.to_string();
if !use_git_cli_fallback() {
return Err(format!(
"Push failed: {}. Make sure SSH keys are configured.",
err_text
));
}
tracing::warn!("libgit2 push failed, trying git CLI fallback: {}", err_text);
run_git_cli(&["push", "origin", branch_name]).map_err(|cli_err| {
format!("Push failed: {} (libgit2) / {} (git CLI)", err_text, cli_err)
})?;
tracing::info!("Successfully pushed to origin/{} (git CLI fallback)", branch_name);
Ok(())
}
}
}
/// Check if remote is configured /// Check if remote is configured
pub fn has_remote() -> bool { pub fn has_remote() -> bool {
@@ -351,6 +524,30 @@ pub fn has_remote() -> bool {
false false
} }
/// Configure `origin` from IRONPAD_GIT_REMOTE_URL.
/// If `origin` exists, updates its URL. Otherwise creates it.
pub fn configure_remote_from_env() -> Result<(), String> {
let remote_url = match std::env::var("IRONPAD_GIT_REMOTE_URL") {
Ok(url) if !url.trim().is_empty() => url,
_ => return Ok(()),
};
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
if repo.find_remote("origin").is_ok() {
repo.remote_set_url("origin", &remote_url)
.map_err(|e| format!("Failed to update origin URL: {}", e))?;
tracing::info!("Updated git remote origin from IRONPAD_GIT_REMOTE_URL");
} else {
repo.remote("origin", &remote_url)
.map_err(|e| format!("Failed to create origin remote: {}", e))?;
tracing::info!("Configured git remote origin from IRONPAD_GIT_REMOTE_URL");
}
Ok(())
}
/// Start auto-commit background task. /// Start auto-commit background task.
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully. /// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
pub fn start_auto_commit() { pub fn start_auto_commit() {
@@ -374,6 +571,111 @@ pub fn start_auto_commit() {
}); });
} }
fn fast_forward_to_upstream(repo: &Repository, branch_name: &str) -> Result<(), String> {
let local_branch = repo
.find_branch(branch_name, git2::BranchType::Local)
.map_err(|e| format!("Local branch not found: {}", e))?;
let upstream_branch = match local_branch.upstream() {
Ok(upstream) => upstream,
Err(_) => return Ok(()),
};
let local_oid = match local_branch.get().target() {
Some(oid) => oid,
None => return Ok(()),
};
let upstream_oid = match upstream_branch.get().target() {
Some(oid) => oid,
None => return Ok(()),
};
let (ahead, behind) = repo
.graph_ahead_behind(local_oid, upstream_oid)
.map_err(|e| e.to_string())?;
if behind == 0 {
return Ok(());
}
if ahead > 0 {
tracing::warn!(
"Remote is ahead by {} and local is ahead by {}; skipping auto fast-forward",
behind,
ahead
);
return Ok(());
}
let refname = format!("refs/heads/{}", branch_name);
repo.reference(&refname, upstream_oid, true, "ironpad auto fast-forward")
.map_err(|e| format!("Failed to move local branch ref: {}", e))?;
repo.set_head(&refname)
.map_err(|e| format!("Failed to set HEAD: {}", e))?;
let mut checkout = git2::build::CheckoutBuilder::new();
checkout.force();
repo.checkout_head(Some(&mut checkout))
.map_err(|e| format!("Failed to checkout fast-forwarded HEAD: {}", e))?;
tracing::info!("Fast-forwarded local branch {} by {} commit(s)", branch_name, behind);
Ok(())
}
fn sync_once() -> Result<(), String> {
configure_remote_from_env()?;
if !has_remote() {
return Ok(());
}
fetch_from_remote()?;
// Try to fast-forward local branch to upstream when safe (no divergence).
let data_path = config::data_dir();
let repo = Repository::open(data_path).map_err(|e| format!("Not a git repository: {}", e))?;
if let Ok(head) = repo.head() {
if let Some(branch_name) = head.shorthand() {
let _ = fast_forward_to_upstream(&repo, branch_name);
}
}
// Push local commits (if any) after fetching.
if let Err(e) = push_to_remote() {
if !(e.contains("non-fast-forward") || e.contains("rejected")) {
return Err(e);
}
tracing::warn!("Auto-sync push skipped: {}", e);
}
Ok(())
}
/// Start periodic remote sync.
/// Controlled via env vars:
/// - IRONPAD_GIT_SYNC_INTERVAL_SECS: sync interval in seconds (default 0 = disabled)
/// - IRONPAD_GIT_REMOTE_URL: optional remote URL used to create/update `origin`
pub fn start_auto_sync() {
let interval_secs = std::env::var("IRONPAD_GIT_SYNC_INTERVAL_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if interval_secs == 0 {
tracing::info!("Git auto-sync disabled (set IRONPAD_GIT_SYNC_INTERVAL_SECS > 0 to enable)");
return;
}
tokio::spawn(async move {
let mut ticker = interval(Duration::from_secs(interval_secs));
loop {
ticker.tick().await;
if let Err(e) = sync_once() {
tracing::warn!("Git auto-sync failed: {}", e);
}
}
});
}
/// Get commit history (most recent first) /// Get commit history (most recent first)
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> { pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
let data_path = config::data_dir(); let data_path = config::data_dir();
@@ -630,18 +932,20 @@ pub fn fetch_from_remote() -> Result<(), String> {
.find_remote("origin") .find_remote("origin")
.map_err(|e| format!("Remote 'origin' not found: {}", e))?; .map_err(|e| format!("Remote 'origin' not found: {}", e))?;
// Create callbacks for authentication
let mut callbacks = git2::RemoteCallbacks::new();
callbacks.credentials(|_url, username_from_url, _allowed_types| {
git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git"))
});
let mut fetch_options = git2::FetchOptions::new(); let mut fetch_options = git2::FetchOptions::new();
fetch_options.remote_callbacks(callbacks); fetch_options.remote_callbacks(remote_callbacks());
remote match remote.fetch(&[] as &[&str], Some(&mut fetch_options), None) {
.fetch(&[] as &[&str], Some(&mut fetch_options), None) Ok(_) => Ok(()),
.map_err(|e| format!("Fetch failed: {}", e))?; Err(e) => {
let err_text = e.to_string();
Ok(()) if !use_git_cli_fallback() {
return Err(format!("Fetch failed: {}", err_text));
}
tracing::warn!("libgit2 fetch failed, trying git CLI fallback: {}", err_text);
run_git_cli(&["fetch", "origin", "--prune"]).map_err(|cli_err| {
format!("Fetch failed: {} (libgit2) / {} (git CLI)", err_text, cli_err)
})
}
}
} }

0
deploy/ssh/.gitkeep Normal file
View File

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
ironpad:
container_name: ironpad
restart: unless-stopped
build:
context: .
dockerfile: Dockerfile
environment:
IRONPAD_HOST: "0.0.0.0"
IRONPAD_PORT: "3000"
IRONPAD_DISABLE_TRAY: "1"
IRONPAD_DATA_DIR: "/app/data"
RUST_LOG: "info"
# Git sync (optional)
# IRONPAD_GIT_REMOTE_URL: "git@github.com:your-org/your-private-repo.git"
# IRONPAD_GIT_SSH_USERNAME: "git"
# IRONPAD_GIT_SSH_PRIVATE_KEY: "/run/secrets/ironpad_ssh/id_ed25519"
# IRONPAD_GIT_SSH_PUBLIC_KEY: "/run/secrets/ironpad_ssh/id_ed25519.pub"
# IRONPAD_GIT_SSH_KNOWN_HOSTS: "/run/secrets/ironpad_ssh/known_hosts"
# IRONPAD_GIT_SSH_PASSPHRASE: ""
# IRONPAD_GIT_SYNC_INTERVAL_SECS: "300"
# IRONPAD_GIT_USE_CLI_FALLBACK: "true"
ports:
- "3000:3000"
volumes:
- ./data:/app/data
# Mount SSH key material read-only for private remote auth
# - ./deploy/ssh:/run/secrets/ironpad_ssh:ro