Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/release-appimage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ jobs:
curl \
file \
libayatana-appindicator3-dev \
libfuse2 \
librsvg2-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
Expand Down
64 changes: 59 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ The goal is to provide a small native dashboard for the common tasks usually han
- Configurable project root path.
- Project folder opening through the Tauri opener plugin.
- Per-project preview server using `php -S 127.0.0.1:<port> -t <projectRoot>`.
- First-run distro presets for Fedora, Arch, Ubuntu, Debian, and Windows/XAMPP.
- First-run XAMPP compatibility setup for an htdocs-style root and project folder.
- Built-in phpMyAdmin shortcut, defaulting to `http://localhost/phpmyadmin`.
- Theme selection with light, dark, terminal-style, block-style, and shadcn variants.
- Linux AppImage and Windows installer release workflow through GitHub Actions.

Expand All @@ -27,6 +30,7 @@ Runtime requirements:
- `pkexec` for privileged service actions.
- `journalctl` for service log retrieval.
- `php` on `PATH` for the project preview server.
- A local phpMyAdmin installation if the phpMyAdmin shortcut is used.

Development requirements:

Expand All @@ -43,6 +47,7 @@ sudo apt-get install -y \
curl \
file \
libayatana-appindicator3-dev \
libfuse2 \
librsvg2-dev \
libssl-dev \
libwebkit2gtk-4.1-dev \
Expand All @@ -51,6 +56,12 @@ sudo apt-get install -y \
wget
```

On Fedora, install the equivalent build packages through `dnf`. AppImage bundling also needs the FUSE runtime libraries:

```bash
sudo dnf install fuse fuse-libs
```

## Development

Install dependencies:
Expand Down Expand Up @@ -138,14 +149,22 @@ tauri build --bundles nsis
Create a draft release by pushing a version tag:

```bash
git tag v0.1.0
git push origin v0.1.0
git tag v0.3.0
git push origin v0.3.0
```

The Linux workflow also supports manual runs with a tag input. The Windows workflow is manual-only, so run it from GitHub Actions with the same tag if you want both artifacts attached to the same draft release.

The Windows artifact is useful for installer packaging checks, but StackPilot's current service controls are Linux-specific. Windows service support would require separate backend commands.

For local AppImage builds on newer Fedora releases, use:

```bash
pnpm run appimage
```

This script sets `NO_STRIP=1` before running Tauri. Fedora 43 system libraries can contain newer ELF sections such as `.relr.dyn`, and the `strip` binary bundled inside `linuxdeploy` may fail to process them.

## Architecture

Frontend:
Expand All @@ -168,9 +187,44 @@ Configuration:

- Default project root: `/var/www/html`.
- Saved project root key: `stackpilot.projectRoot`.
- Saved project folder key: `stackpilot.projectName`.
- Saved phpMyAdmin URL key: `stackpilot.phpMyAdminUrl`.
- Saved distro preset key: `stackpilot.distroPreset`.
- Saved service unit map key: `stackpilot.serviceUnits`.
- Saved Windows mode key: `stackpilot.windowsMode`.
- Saved XAMPP mode key: `stackpilot.xamppMode`.
- First-run setup key: `stackpilot.setupComplete`.
- Saved theme key: `stackpilot.theme`.
- Tauri bundle configuration: `src-tauri/tauri.conf.json`.

## Distro Presets

The first-run setup starts with an environment preset screen. Linux users can choose Fedora, Arch, Ubuntu, or Debian. Windows users can enable the Windows/XAMPP checkbox.

Presets set the initial project root, phpMyAdmin URL, XAMPP mode, and service unit names:

- Fedora: `httpd`, `mariadb`, `php-fpm`, root `/var/www/html`.
- Arch: `httpd`, `mariadb`, `php-fpm`, root `/srv/http`.
- Ubuntu: `apache2`, `mariadb`, `php8.3-fpm`, root `/var/www/html`.
- Debian: `apache2`, `mariadb`, `php8.2-fpm`, root `/var/www/html`.
- Windows/XAMPP: root `C:\xampp\htdocs`, systemd controls disabled.

The service unit names remain editable in Settings because PHP-FPM unit names vary by installed PHP version.

## XAMPP Compatibility Mode

On first launch, StackPilot asks for the local folder that should behave like `htdocs`, a project folder name, and a phpMyAdmin URL.

When XAMPP compatibility mode is enabled:

- The project root is treated as the htdocs equivalent.
- Open Project Site serves `<htdocs>/<project-name>` with PHP's built-in server.
- Open Project Root opens the htdocs equivalent folder.
- StackPilot still shows the Apache-style route `http://localhost/<project-name>/` for reference.
- The phpMyAdmin button opens the configured phpMyAdmin URL.

This avoids Apache 404s when Fedora's `httpd` document root is still `/var/www/html` and the configured htdocs-equivalent folder lives elsewhere.

## Project Preview Server

The project preview server does not rewrite Apache configuration. It starts a user-space PHP server for the selected project root:
Expand All @@ -185,11 +239,11 @@ Use Apache service controls for the system stack. Use Open Project Site for the

## Known Limits

- Service names are currently hard-coded to Fedora-style `httpd`, `mariadb`, and `php-fpm`.
- Windows mode disables systemd service controls. Use XAMPP Control Panel or Windows services for stack-level actions on Windows.
- Service management requires a desktop session where `pkexec` can prompt for authorization.
- The browser-only Vite preview cannot access Tauri commands.
- The project preview server uses PHP's built-in server and is intended for local development, not production hosting.
- Windows release artifacts can be built, but service control behavior is currently Linux-only.
- Linux service unit names are preset-based and editable. Invalid unit names are rejected before running `systemctl`.

## Repository Notes

Expand All @@ -199,4 +253,4 @@ Keep release-related changes synchronized between:
- `src-tauri/tauri.conf.json`
- `.github/workflows/release-appimage.yml`

When changing service names, add them in both the frontend service definitions and Rust backend service list.
When adding a new distro preset, update `STACK_PRESETS` in `src/App.tsx`. The Rust backend validates service unit names at runtime instead of keeping a distro-specific service list.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
{
"name": "stackpilot",
"private": true,
"version": "0.1.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri",
"appimage": "tauri build --bundles appimage"
"appimage": "NO_STRIP=1 tauri build --bundles appimage"
},
"dependencies": {
"@tauri-apps/api": "^2.10.1",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "stackpilot"
version = "0.1.0"
version = "0.3.0"
description = "A desktop control panel for a local LAMP stack"
authors = ["you"]
edition = "2021"
Expand Down
Binary file modified src-tauri/icons/icon.ico
Binary file not shown.
92 changes: 51 additions & 41 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::fs;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
Expand All @@ -10,12 +10,6 @@ use std::sync::Mutex;
use std::thread;
use std::time::Duration;

#[derive(Clone, Copy)]
struct Service {
name: &'static str,
label: &'static str,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ServiceStatus {
Expand All @@ -27,6 +21,13 @@ struct ServiceStatus {
message: String,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ServiceRequest {
name: String,
label: String,
}

#[derive(Serialize)]
struct CommandResult {
success: bool,
Expand Down Expand Up @@ -60,29 +61,19 @@ struct ProjectServerStatus {

type ProjectServerState = Mutex<Option<ProjectServer>>;

const SERVICES: [Service; 3] = [
Service {
name: "httpd",
label: "Apache HTTP Server",
},
Service {
name: "mariadb",
label: "MariaDB",
},
Service {
name: "php-fpm",
label: "PHP-FPM",
},
];

const ACTIONS: [&str; 3] = ["start", "stop", "restart"];

fn resolve_service(name: &str) -> Result<Service, String> {
SERVICES
.iter()
.copied()
.find(|service| service.name == name)
.ok_or_else(|| format!("Unsupported service: {name}"))
fn validate_service_name(name: &str) -> Result<(), String> {
let is_valid = !name.trim().is_empty()
&& name
.chars()
.all(|character| character.is_ascii_alphanumeric() || "_.@:-".contains(character));

is_valid.then_some(()).ok_or_else(|| {
format!(
"Invalid service unit name: {name}. Use only letters, numbers, dots, dashes, underscores, colons, and @."
)
})
}

fn validate_action(action: &str) -> Result<(), String> {
Expand Down Expand Up @@ -214,13 +205,24 @@ fn spawn_project_server(root: &Path, port: u16) -> Result<ProjectServer, String>
}
}

fn read_systemctl_field(service: Service, field: &str) -> Result<CommandResult, String> {
run_command("systemctl", &[field, service.name])
fn read_systemctl_field(service_name: &str, field: &str) -> Result<CommandResult, String> {
run_command("systemctl", &[field, service_name])
}

fn service_status(service: Service) -> ServiceStatus {
let active_result = read_systemctl_field(service, "is-active");
let enabled_result = read_systemctl_field(service, "is-enabled");
fn service_status(service: &ServiceRequest) -> ServiceStatus {
if let Err(error) = validate_service_name(&service.name) {
return ServiceStatus {
name: service.name.clone(),
label: service.label.clone(),
active_state: "unknown".to_string(),
enabled_state: None,
ok: false,
message: error,
};
}

let active_result = read_systemctl_field(&service.name, "is-active");
let enabled_result = read_systemctl_field(&service.name, "is-enabled");

let active_state = match &active_result {
Ok(result) if !result.stdout.is_empty() => result.stdout.clone(),
Expand All @@ -242,8 +244,8 @@ fn service_status(service: Service) -> ServiceStatus {
};

ServiceStatus {
name: service.name.to_string(),
label: service.label.to_string(),
name: service.name.clone(),
label: service.label.clone(),
ok: active_state == "active",
active_state,
enabled_state,
Expand All @@ -252,28 +254,36 @@ fn service_status(service: Service) -> ServiceStatus {
}

#[tauri::command]
fn get_service_statuses() -> Vec<ServiceStatus> {
SERVICES.iter().copied().map(service_status).collect()
fn get_service_statuses(services: Vec<ServiceRequest>) -> Result<Vec<ServiceStatus>, String> {
if services.is_empty() {
return Err("At least one service must be configured.".to_string());
}
Comment on lines 256 to +260
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 | Confidence: Medium

The Tauri command get_service_statuses changed from accepting no arguments to requiring a Vec<ServiceRequest>. The frontend caller in src/App.tsx (loadStatuses) has been updated to pass serviceDefinitions. However, the PR does not include any updates to existing integration tests (if any), and the related_context shows no test files. Any future external consumer (e.g., a CLI tool or another frontend) using this command will fail to compile. This is a breaking API change. Since the PR is owned and merged by the same author, the risk is lower, but it introduces a coupling that may break automated workflows or CI that invoke the command directly. Additionally, the old resolve_service fallback is gone; the new approach validates the service name on each call, which is more flexible but requires callers to supply the list consistently.


if services.len() > 8 {
return Err("Too many services configured.".to_string());
}

Ok(services.iter().map(service_status).collect())
}

#[tauri::command]
fn run_service_action(service: String, action: String) -> Result<CommandResult, String> {
let service = resolve_service(&service)?;
validate_service_name(&service)?;
validate_action(&action)?;

run_command("pkexec", &["systemctl", &action, service.name])
run_command("pkexec", &["systemctl", &action, service.as_str()])
}

#[tauri::command]
fn get_service_logs(service: String, lines: Option<u16>) -> Result<CommandResult, String> {
let service = resolve_service(&service)?;
validate_service_name(&service)?;
let line_count = lines.unwrap_or(80).clamp(20, 500).to_string();

run_command(
"journalctl",
&[
"-u",
service.name,
service.as_str(),
"-n",
&line_count,
"--no-pager",
Expand Down
28 changes: 3 additions & 25 deletions src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,12 @@
},
"productName": "StackPilot",
"mainBinaryName": "StackPilot",
"version": "0.1.0",
"version": "0.3.0",
"identifier": "com.stackpilot.app",
"plugins": {},
"app": {
"security": {
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost",
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' asset: http://asset.localhost blob: data:",
"font-src": "'self' data:",
"object-src": "'none'",
"base-uri": "'none'",
"form-action": "'none'",
"frame-ancestors": "'none'",
"worker-src": "'none'",
"media-src": "'none'"
},
"devCsp": {
"default-src": "'self' customprotocol: asset: http://localhost:1420",
"connect-src": "ipc: http://ipc.localhost http://localhost:1420 ws://localhost:1420",
"script-src": "'self' 'unsafe-eval' http://localhost:1420",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' asset: http://asset.localhost blob: data:",
"font-src": "'self' data:"
}
"csp": null
},
Comment on lines 26 to 28
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: High

The entire csp and devCsp objects have been replaced with "csp": null, effectively disabling Content Security Policy for both production and development. Without CSP, the Tauri webview loses defense-in-depth against script injection, data exfiltration via inline styles, and open-redirect attacks (e.g., if an attacker-controlled phpMyAdminUrl is loaded). The app allows user-configurable URLs and local file paths, which could be used to load malicious HTML/JS. CSP removal is a deliberate regression in the application's security posture.

Suggested change
"security": {
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost",
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' asset: http://asset.localhost blob: data:",
"font-src": "'self' data:",
"object-src": "'none'",
"base-uri": "'none'",
"form-action": "'none'",
"frame-ancestors": "'none'",
"worker-src": "'none'",
"media-src": "'none'"
},
"devCsp": {
"default-src": "'self' customprotocol: asset: http://localhost:1420",
"connect-src": "ipc: http://ipc.localhost http://localhost:1420 ws://localhost:1420",
"script-src": "'self' 'unsafe-eval' http://localhost:1420",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' asset: http://asset.localhost blob: data:",
"font-src": "'self' data:"
}
"csp": null
},
"security": {
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost",
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' asset: http://asset.localhost blob: data:",
"font-src": "'self' data:",
"object-src": "'none'",
"base-uri": "'none'",
"form-action": "'none'",
"frame-ancestors": "'none'",
"worker-src": "'none'",
"media-src": "'none'"
},
"devCsp": {
"default-src": "'self' customprotocol: asset: http://localhost:1420",
"connect-src": "ipc: http://ipc.localhost http://localhost:1420 ws://localhost:1420",
"script-src": "'self' 'unsafe-eval' http://localhost:1420",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' asset: http://asset.localhost blob: data:",
"font-src": "'self' data:"
}
}

"windows": [
{
Expand All @@ -55,6 +34,5 @@
"useHttpsScheme": true
}
]

}
}
}
Loading