Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5299190973 | |||
|
|
f1f106c948 | ||
|
|
2a55d5ebec | ||
|
|
adefed7c74 | ||
|
|
d39bbd1801 | ||
|
|
0b5816621b | ||
|
|
97508e4f0a | ||
|
|
d98181bed9 | ||
|
|
10ead43260 |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
.git
|
||||
.github
|
||||
.ferrite
|
||||
|
||||
backend/target
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
|
||||
data
|
||||
docs
|
||||
|
||||
*.log
|
||||
*.tmp
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,6 +37,7 @@ data/notes/assets/*
|
||||
!data/notes/assets/.gitkeep
|
||||
data/projects/*/
|
||||
!data/projects/.gitkeep
|
||||
Codex Session.txt
|
||||
|
||||
# === Stray root lock file (frontend/package-lock.json is kept for CI) ===
|
||||
/package-lock.json
|
||||
|
||||
41
Dockerfile
Normal file
41
Dockerfile
Normal 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"]
|
||||
58
README.md
58
README.md
@@ -68,6 +68,53 @@ npm run dev
|
||||
|
||||
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
|
||||
|
||||

|
||||
@@ -121,7 +168,18 @@ Read about the method:
|
||||
|---------|---------|-------------|
|
||||
| Data directory | `data/` next to executable | Override with `IRONPAD_DATA_DIR` env var |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
## Documentation
|
||||
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
/target
|
||||
/release
|
||||
161
backend/Cargo.lock
generated
161
backend/Cargo.lock
generated
@@ -20,6 +20,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansi_term"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.101"
|
||||
@@ -32,6 +41,17 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -188,6 +208,21 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "2.34.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c"
|
||||
dependencies = [
|
||||
"ansi_term",
|
||||
"atty",
|
||||
"bitflags 1.3.2",
|
||||
"strsim",
|
||||
"textwrap",
|
||||
"unicode-width",
|
||||
"vec_map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cocoa"
|
||||
version = "0.25.0"
|
||||
@@ -318,6 +353,37 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libdbus-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-codegen"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a49da9fdfbe872d4841d56605dc42efa5e6ca3291299b87f44e1cde91a28617c"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"dbus",
|
||||
"xml-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-tree"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f456e698ae8e54575e19ddb1f9b7bce2298568524f215496b248eb9498b4f508"
|
||||
dependencies = [
|
||||
"dbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -665,6 +731,15 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -1014,6 +1089,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ksni"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4934310bdd016e55725482b8d35ac0c16fd058c1b955d8959aa2d953b918c85b"
|
||||
dependencies = [
|
||||
"dbus",
|
||||
"dbus-codegen",
|
||||
"dbus-tree",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@@ -1032,6 +1119,15 @@ version = "0.2.182"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libgit2-sys"
|
||||
version = "0.17.0+1.8.1"
|
||||
@@ -1713,6 +1809,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.116"
|
||||
@@ -1750,6 +1852,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -2025,6 +2136,7 @@ checksum = "59d4bd406170690dc30eabb3badc67a085beaf9b2c3b1923afcc9c26a2191353"
|
||||
dependencies = [
|
||||
"cocoa",
|
||||
"core-graphics",
|
||||
"ksni",
|
||||
"libc",
|
||||
"objc",
|
||||
"objc-foundation",
|
||||
@@ -2074,6 +2186,12 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
@@ -2133,6 +2251,12 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -2278,6 +2402,22 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -2287,6 +2427,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -2373,6 +2519,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
@@ -2744,6 +2899,12 @@ version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Hide console window on Windows in release builds (production mode)
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
@@ -20,22 +20,58 @@ mod websocket;
|
||||
/// Find an available port and return the bound listener.
|
||||
/// Avoids TOCTOU race by keeping the listener alive.
|
||||
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 {
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], port));
|
||||
let addr = SocketAddr::new(bind_ip, port);
|
||||
if let Ok(listener) = TcpListener::bind(addr).await {
|
||||
return (listener, port);
|
||||
}
|
||||
}
|
||||
panic!("No available ports in range 3000–3010");
|
||||
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() {
|
||||
tracing_subscriber::fmt().init();
|
||||
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();
|
||||
} else {
|
||||
if production && disable_tray {
|
||||
info!("Production static mode detected; running headless (IRONPAD_DISABLE_TRAY=1)");
|
||||
}
|
||||
// Development mode: normal tokio runtime, no tray
|
||||
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
|
||||
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);
|
||||
}
|
||||
|
||||
// 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)
|
||||
services::git::start_auto_commit();
|
||||
services::git::start_auto_sync();
|
||||
|
||||
// CORS layer (permissive for local-only app)
|
||||
let cors = CorsLayer::permissive();
|
||||
@@ -130,7 +172,11 @@ async fn run_server(port_tx: Option<std::sync::mpsc::Sender<u16>>) {
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
@@ -96,6 +98,171 @@ pub struct RemoteInfo {
|
||||
/// The background task simply tries to commit every interval;
|
||||
/// 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
|
||||
pub fn get_status() -> Result<RepoStatus, String> {
|
||||
let data_path = config::data_dir();
|
||||
@@ -317,27 +484,33 @@ pub fn push_to_remote() -> Result<(), 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
|
||||
let mut push_options = git2::PushOptions::new();
|
||||
push_options.remote_callbacks(callbacks);
|
||||
push_options.remote_callbacks(remote_callbacks());
|
||||
|
||||
// Push the current branch
|
||||
let refspec = format!("refs/heads/{}:refs/heads/{}", branch_name, branch_name);
|
||||
remote
|
||||
.push(&[&refspec], Some(&mut push_options))
|
||||
.map_err(|e| format!("Push failed: {}. Make sure SSH keys are configured.", e))?;
|
||||
|
||||
tracing::info!("Successfully pushed to origin/{}", branch_name);
|
||||
Ok(())
|
||||
match remote.push(&[&refspec], Some(&mut push_options)) {
|
||||
Ok(_) => {
|
||||
tracing::info!("Successfully pushed to origin/{}", branch_name);
|
||||
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
|
||||
@@ -351,6 +524,30 @@ pub fn has_remote() -> bool {
|
||||
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.
|
||||
/// Tries to commit every 60 seconds; commit_all() already handles "no changes" gracefully.
|
||||
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)
|
||||
pub fn get_log(limit: Option<usize>) -> Result<Vec<CommitDetail>, String> {
|
||||
let data_path = config::data_dir();
|
||||
@@ -630,18 +932,20 @@ pub fn fetch_from_remote() -> Result<(), String> {
|
||||
.find_remote("origin")
|
||||
.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();
|
||||
fetch_options.remote_callbacks(callbacks);
|
||||
fetch_options.remote_callbacks(remote_callbacks());
|
||||
|
||||
remote
|
||||
.fetch(&[] as &[&str], Some(&mut fetch_options), None)
|
||||
.map_err(|e| format!("Fetch failed: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
match remote.fetch(&[] as &[&str], Some(&mut fetch_options), None) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
let err_text = e.to_string();
|
||||
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
0
deploy/ssh/.gitkeep
Normal file
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal 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
|
||||
Reference in New Issue
Block a user