Compare commits
9 Commits
10ead43260
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7ff37a8ec | ||
| 5299190973 | |||
|
|
f1f106c948 | ||
|
|
2a55d5ebec | ||
|
|
adefed7c74 | ||
|
|
d39bbd1801 | ||
|
|
0b5816621b | ||
|
|
97508e4f0a | ||
|
|
d98181bed9 |
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
|
||||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -1,10 +1,11 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
branches: [main]
|
# push:
|
||||||
pull_request:
|
# branches: [main]
|
||||||
branches: [main]
|
# pull_request:
|
||||||
|
# branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
138
.github/workflows/release.yml
vendored
138
.github/workflows/release.yml
vendored
@@ -6,127 +6,45 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
docker:
|
||||||
name: Build ${{ matrix.name }}
|
name: Build and Push Docker Image
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: Linux (x86_64)
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
os: ubuntu-latest
|
|
||||||
archive: tar.gz
|
|
||||||
asset_name: ironpad-linux-x86_64
|
|
||||||
- name: macOS (x86_64)
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
os: macos-latest
|
|
||||||
archive: tar.gz
|
|
||||||
asset_name: ironpad-macos-x86_64
|
|
||||||
- name: Windows (x86_64)
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
os: windows-latest
|
|
||||||
archive: zip
|
|
||||||
asset_name: ironpad-windows-x86_64
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Set image name
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Install system dependencies (Linux)
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y cmake libdbus-1-dev pkg-config
|
|
||||||
|
|
||||||
- name: Install Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- name: Build frontend
|
|
||||||
working-directory: frontend
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Copy frontend to static
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/ironpad" >> "$GITHUB_ENV"
|
||||||
mkdir -p backend/static
|
|
||||||
cp -r frontend/dist/* backend/static/
|
|
||||||
|
|
||||||
- name: Build backend (release)
|
- name: Set up Docker Buildx
|
||||||
working-directory: backend
|
uses: docker/setup-buildx-action@v3
|
||||||
run: cargo build --release --target ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Package (Unix)
|
- name: Log in to GHCR
|
||||||
if: matrix.archive == 'tar.gz'
|
uses: docker/login-action@v3
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
RELEASE_DIR="${{ matrix.asset_name }}-${{ github.ref_name }}"
|
|
||||||
mkdir -p "$RELEASE_DIR"
|
|
||||||
cp "backend/target/${{ matrix.target }}/release/ironpad" "$RELEASE_DIR/"
|
|
||||||
cp -r backend/static "$RELEASE_DIR/static"
|
|
||||||
cp README.md LICENSE "$RELEASE_DIR/"
|
|
||||||
tar czf "$RELEASE_DIR.tar.gz" "$RELEASE_DIR"
|
|
||||||
echo "ASSET=$RELEASE_DIR.tar.gz" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Package (Windows)
|
|
||||||
if: matrix.archive == 'zip'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
RELEASE_DIR="${{ matrix.asset_name }}-${{ github.ref_name }}"
|
|
||||||
mkdir -p "$RELEASE_DIR"
|
|
||||||
cp "backend/target/${{ matrix.target }}/release/ironpad.exe" "$RELEASE_DIR/"
|
|
||||||
cp -r backend/static "$RELEASE_DIR/static"
|
|
||||||
cp README.md LICENSE "$RELEASE_DIR/"
|
|
||||||
7z a "$RELEASE_DIR.zip" "$RELEASE_DIR"
|
|
||||||
echo "ASSET=$RELEASE_DIR.zip" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset_name }}
|
registry: ghcr.io
|
||||||
path: ${{ env.ASSET }}
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
release:
|
- name: Docker metadata
|
||||||
name: Create Release
|
id: meta
|
||||||
needs: build
|
uses: docker/metadata-action@v5
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
images: ${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=tag
|
||||||
|
type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Build and push image
|
||||||
uses: softprops/action-gh-release@v2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
name: Ironpad ${{ github.ref_name }}
|
context: .
|
||||||
body: |
|
file: ./Dockerfile
|
||||||
## Downloads
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
| Platform | File |
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|----------|------|
|
|
||||||
| Windows (x86_64) | `ironpad-windows-x86_64-${{ github.ref_name }}.zip` |
|
|
||||||
| macOS (x86_64) | `ironpad-macos-x86_64-${{ github.ref_name }}.tar.gz` |
|
|
||||||
| Linux (x86_64) | `ironpad-linux-x86_64-${{ github.ref_name }}.tar.gz` |
|
|
||||||
|
|
||||||
**Linux:** Extract the tar.gz and run `./ironpad`. Works on any distro -- it's a standalone binary with no dependencies.
|
|
||||||
|
|
||||||
**macOS:** Extract and run. You may need to allow it in System Settings > Privacy & Security on first launch.
|
|
||||||
|
|
||||||
**Windows:** Extract the zip and run `ironpad.exe`.
|
|
||||||
|
|
||||||
---
|
|
||||||
files: artifacts/**/*
|
|
||||||
generate_release_notes: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
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.
|
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
|
||||||
|
|
||||||

|

|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 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() {
|
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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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