From a8c7dc74c1ec0671355002687473b52276da1705 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Sat, 14 Feb 2026 23:31:21 -0500 Subject: [PATCH] feat: initial version Signed-off-by: Sebastian Krupinski --- .github/workflows/test.yml | 418 ++++++++++++++++ README.md | 459 +++++++++++++++++- action.yml | 88 ++++ scripts/configure.sh | 266 ++++++++++ scripts/install.sh | 967 +++++++++++++++++++++++++++++++++++++ scripts/utils.sh | 289 +++++++++++ 6 files changed, 2486 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml create mode 100644 action.yml create mode 100755 scripts/configure.sh create mode 100755 scripts/install.sh create mode 100755 scripts/utils.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ff89d0d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,418 @@ +name: Test Stalwart Installation Action + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + # Test basic installation without configuration + test-basic-install: + name: Basic Installation (No Config) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y jq curl + + - name: Run basic installation + uses: ./ + # No inputs - should install with defaults + + - name: Wait for service to fully start + run: | + echo "Waiting for Stalwart to fully initialize..." + sleep 15 + + - name: Verify Stalwart service is running + run: | + sleep 10 # Give service time to start + + # Check if systemd is available + if command -v systemctl >/dev/null 2>&1 && systemctl --version >/dev/null 2>&1; then + echo "Checking service status with systemd..." + sudo systemctl status stalwart --no-pager || true + if sudo systemctl is-active --quiet stalwart; then + echo "✓ Stalwart service is running (systemd)" + else + echo "::warning::Stalwart service not active in systemd, checking process..." + fi + else + echo "::warning::systemd not available, checking process directly..." + fi + + # Check if process is running + if pgrep -x stalwart >/dev/null; then + echo "✓ Stalwart process is running" + else + echo "::error::Stalwart process is not running" + ps aux | grep stalwart || true + exit 1 + fi + + - name: Check Stalwart binary + run: | + if [ ! -f /opt/stalwart/bin/stalwart ]; then + echo "::error::Stalwart binary not found" + exit 1 + fi + /opt/stalwart/bin/stalwart --version + + - name: Test web admin accessibility + run: | + # Wait for API to be ready + for i in {1..30}; do + if curl -sf http://localhost:8080/login >/dev/null 2>&1; then + echo "✓ Web admin is accessible" + exit 0 + fi + sleep 2 + done + echo "::error::Web admin not accessible after 60 seconds" + exit 1 + + # Test installation with admin password + test-with-password: + name: Installation with Admin Password + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y jq curl + + - name: Install with admin password + uses: ./ + with: + admin_password: 'TestPassword123!@#' + + - name: Wait for service to fully start + run: | + echo "Waiting for Stalwart to fully initialize..." + sleep 15 + + - name: Verify service is running + run: | + # Check process + if ! pgrep -x stalwart >/dev/null; then + echo "::error::Stalwart process is not running" + echo "Process list:" + ps aux | grep stalwart || true + echo "PID file:" + cat /var/run/stalwart.pid 2>/dev/null || echo "No PID file" + echo "Logs:" + tail -50 /opt/stalwart/logs/*.log 2>/dev/null || echo "No logs" + exit 1 + fi + echo "✓ Stalwart is running" + + - name: Test authentication with new password + run: | + # Wait for API to be ready + echo "Waiting for Stalwart API..." + for i in {1..30}; do + if curl -sf http://localhost:8080/login >/dev/null 2>&1; then + echo "✓ API is ready" + break + fi + sleep 2 + done + + # Test authentication with Basic Auth (the actual method Stalwart uses) + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -u "admin:TestPassword123!@#" \ + http://localhost:8080/api/principal?types=domain&limit=1) + + if [ "$HTTP_CODE" != "200" ]; then + echo "::error::Authentication failed with HTTP $HTTP_CODE" + exit 1 + fi + + echo "✓ Successfully authenticated with new password" + + # Test full configuration with domains and users + test-full-config: + name: Full Configuration (Domains + Users) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y jq curl + + - name: Install with full configuration + uses: ./ + with: + admin_password: 'AdminPass123!@#' + domains: | + [ + { + "name": "test1.local", + "description": "Primary test domain" + }, + { + "name": "test2.local", + "description": "Secondary test domain" + } + ] + users: | + [ + { + "email": "user1@test1.local", + "password": "UserPass123!", + "name": "Test User One", + "quota": 1073741824 + }, + { + "email": "user2@test1.local", + "password": "UserPass456!", + "name": "Test User Two", + "quota": 2147483648 + }, + { + "email": "admin@test2.local", + "password": "AdminUser789!", + "name": "Admin User", + "quota": 5368709120 + } + ] + + - name: Wait for service to fully start + run: | + echo "Waiting for Stalwart to fully initialize..." + sleep 15 + + - name: Verify service is running + run: | + # Check process + if ! pgrep -x stalwart >/dev/null; then + echo "::error::Stalwart process is not running" + echo "Process list:" + ps aux | grep stalwart || true + echo "PID file:" + cat /var/run/stalwart.pid 2>/dev/null || echo "No PID file" + echo "Logs:" + tail -50 /opt/stalwart/logs/*.log 2>/dev/null || true + exit 1 + fi + echo "✓ Stalwart is running" + + - name: Verify admin password was changed + run: | + # Wait for API to be ready + echo "Waiting for Stalwart API..." + for i in {1..30}; do + if curl -sf http://localhost:8080/login >/dev/null 2>&1; then + echo "✓ API is ready" + break + fi + sleep 2 + done + + # Test authentication with Basic Auth + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -u "admin:AdminPass123!@#" \ + http://localhost:8080/api/principal?types=domain&limit=1) + + if [ "$HTTP_CODE" != "200" ]; then + echo "::error::Authentication failed with HTTP $HTTP_CODE" + exit 1 + fi + + echo "✓ Admin authentication successful" + + - name: Verify domains were created + run: | + # List domains via API (using discovered endpoint structure) + DOMAINS=$(curl -sf \ + -u "admin:AdminPass123!@#" \ + "http://localhost:8080/api/principal?types=domain&limit=100") + + echo "Domains response: $DOMAINS" + + # Check if our test domains exist in data.items array + if echo "$DOMAINS" | jq -e '.data.items[] | select(.name == "test1.local")' >/dev/null; then + echo "✓ Domain test1.local found" + else + echo "::warning::Domain test1.local not found" + echo "$DOMAINS" | jq '.data.items[].name' || true + fi + + - name: Verify users were created + run: | + # List accounts via API (using discovered endpoint structure) + ACCOUNTS=$(curl -sf \ + -u "admin:AdminPass123!@#" \ + "http://localhost:8080/api/principal?types=individual&limit=100") + + echo "Accounts response: $ACCOUNTS" + + # Check if users exist in data.items array + if echo "$ACCOUNTS" | jq -e '.data.items[] | select(.emails[] == "user1@test1.local")' >/dev/null; then + echo "✓ User user1@test1.local found" + else + echo "::warning::User user1@test1.local not found" + echo "$ACCOUNTS" | jq '.data.items[].emails[]' || true + fi + + - name: Show logs on failure + if: failure() + run: | + echo "=== Stalwart Service Status ===" + if command -v systemctl >/dev/null 2>&1; then + sudo systemctl status stalwart --no-pager || true + fi + + echo -e "\n=== Process Status ===" + ps aux | grep stalwart || true + + echo -e "\n=== Stalwart Logs ===" + if command -v journalctl >/dev/null 2>&1; then + sudo journalctl -u stalwart -n 100 --no-pager || true + else + sudo tail -n 100 /opt/stalwart/logs/*.log 2>/dev/null || true + fi + + echo -e "\n=== Configuration File ===" + sudo cat /opt/stalwart/etc/config.toml || true + + echo -e "\n=== Network Ports ===" + sudo netstat -tuln | grep -E ':(25|587|465|993|8080)' || true + sudo ss -tuln | grep -E ':(25|587|465|993|8080)' || true + + # Test error handling + test-error-handling: + name: Error Handling Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Test invalid JSON (domains) + id: test_invalid_domains + continue-on-error: true + uses: ./ + with: + admin_password: 'TestPass123' + domains: 'invalid json string' + + - name: Verify invalid JSON was caught + run: | + if [ "${{ steps.test_invalid_domains.outcome }}" = "success" ]; then + echo "::error::Action should have failed with invalid JSON" + exit 1 + fi + echo "✓ Invalid domains JSON was properly rejected" + + - name: Clean up after failed test + if: always() + run: | + # Stop service + if command -v systemctl >/dev/null 2>&1; then + sudo systemctl stop stalwart || true + sudo systemctl disable stalwart || true + fi + + # Kill process if still running + sudo pkill -9 stalwart || true + + # Remove installation + sudo rm -rf /opt/stalwart || true + + # Test on different Ubuntu versions + test-ubuntu-versions: + name: Test on Ubuntu ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-20.04, ubuntu-22.04, ubuntu-24.04] + fail-fast: false + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y jq curl + + - name: Install Stalwart + uses: ./ + with: + admin_password: 'TestPassword123' + + - name: Wait for service to fully start + run: | + echo "Waiting for Stalwart to fully initialize..." + sleep 15 + + - name: Verify installation + run: | + # Check if process is running + if pgrep -x stalwart >/dev/null; then + echo "✓ Stalwart running on ${{ matrix.os }}" + else + echo "::error::Stalwart not running on ${{ matrix.os }}" + ps aux | grep stalwart || true + tail -50 /opt/stalwart/logs/*.log 2>/dev/null || true + exit 1 + fi + + - name: Test API accessibility + run: | + # Wait for API to be ready + echo "Waiting for API on ${{ matrix.os }}..." + for i in {1..30}; do + if curl -sf http://localhost:8080/login >/dev/null 2>&1; then + echo "✓ API accessible on ${{ matrix.os }}" + exit 0 + fi + sleep 2 + done + echo "::error::API not accessible on ${{ matrix.os }}" + tail -50 /opt/stalwart/logs/*.log 2>/dev/null || true + exit 1 + + # Summary job + test-summary: + name: Test Summary + runs-on: ubuntu-latest + needs: [test-basic-install, test-with-password, test-full-config, test-ubuntu-versions] + if: always() + + steps: + - name: Check test results + run: | + echo "## Test Results Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- Basic Install: ${{ needs.test-basic-install.result }}" >> $GITHUB_STEP_SUMMARY + echo "- With Password: ${{ needs.test-with-password.result }}" >> $GITHUB_STEP_SUMMARY + echo "- Full Config: ${{ needs.test-full-config.result }}" >> $GITHUB_STEP_SUMMARY + echo "- Ubuntu Versions: ${{ needs.test-ubuntu-versions.result }}" >> $GITHUB_STEP_SUMMARY + + # Fail if any required test failed + if [ "${{ needs.test-basic-install.result }}" != "success" ] || \ + [ "${{ needs.test-with-password.result }}" != "success" ] || \ + [ "${{ needs.test-full-config.result }}" != "success" ]; then + echo "::error::One or more tests failed" + exit 1 + fi + + echo "✓ All tests passed!" diff --git a/README.md b/README.md index 5782490..2aa53dd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,459 @@ -# action-stalwart-install +# Stalwart Mail Server Installation Action +A GitHub Action to install and configure [Stalwart Mail Server](https://stalw.art/) with optional automated setup for admin password, domains, and users. + +## Features + +- 🚀 **One-step installation** of Stalwart Mail Server +- 🔐 **Automated configuration** via REST API +- 🌐 **Multi-domain support** with JSON array input +- 👥 **Bulk user creation** from JSON configuration +- 🔒 **Secure handling** of passwords using GitHub Secrets +- 📦 **Cross-platform** support (Linux systemd/init.d, macOS) +- ✅ **Production-ready** service setup with auto-start + +## Prerequisites + +- Root/sudo access (action must run as root) +- Required commands: `curl`, `jq`, `tar` +- Linux (Ubuntu, Debian, RHEL, etc.) or macOS +- Network access to download Stalwart binaries + +## Quick Start + +### Basic Installation (No Configuration) + +```yaml +name: Install Stalwart +on: [push] + +jobs: + setup: + runs-on: ubuntu-latest + + steps: + - name: Install Stalwart Mail Server + uses: Nodarx/action-module-install@v1 + # This installs with default settings + # Web admin: http://localhost:8080/login + # Default password: changeme +``` + +### Full Automated Setup + +```yaml +name: Install and Configure Stalwart +on: [push] + +jobs: + setup: + runs-on: ubuntu-latest + + steps: + - name: Install and Configure Stalwart + uses: Nodarx/action-module-install@v1 + with: + # Use GitHub Secrets for sensitive data! + admin_password: ${{ secrets.STALWART_ADMIN_PASSWORD }} + + domains: | + [ + { + "name": "example.com", + "description": "Primary domain" + }, + { + "name": "example.org", + "description": "Secondary domain" + } + ] + + users: | + [ + { + "email": "admin@example.com", + "password": "${{ secrets.ADMIN_USER_PASSWORD }}", + "name": "System Administrator", + "quota": 5368709120 + }, + { + "email": "support@example.com", + "password": "${{ secrets.SUPPORT_USER_PASSWORD }}", + "name": "Support Team", + "quota": 2147483648 + } + ] +``` + +## Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `admin_password` | No | `changeme` | Admin password for Stalwart web interface. **Use GitHub Secrets!** | +| `domains` | No | `""` | JSON array of domains to create. See [Domain Schema](#domain-json-schema) | +| `users` | No | `""` | JSON array of users to create. See [User Schema](#user-json-schema) | + +## JSON Schemas + +### Domain JSON Schema + +```json +[ + { + "name": "example.com", // Required: domain name + "description": "Primary domain" // Optional: description + } +] +``` + +**Fields:** +- `name` (string, **required**): Domain name (e.g., "example.com") +- `description` (string, optional): Human-readable description + +### User JSON Schema + +```json +[ + { + "email": "user@example.com", // Required: email address + "password": "SecurePass123!", // Required: user password + "name": "Full Name", // Optional: display name + "quota": 1073741824 // Optional: storage quota in bytes + } +] +``` + +**Fields:** +- `email` (string, **required**): User email address +- `password` (string, **required**): User password (use GitHub Secrets!) +- `name` (string, optional): Display name (defaults to email if not provided) +- `quota` (integer, optional): Storage quota in bytes (default: 1GB = 1073741824) + +**Common quota values:** +- 1 GB = `1073741824` +- 5 GB = `5368709120` +- 10 GB = `10737418240` +- 50 GB = `53687091200` + +## Usage Examples + +### Example 1: Basic Installation Only + +Install Stalwart without any configuration. You'll configure it manually via web UI. + +```yaml +- uses: Nodarx/action-module-install@v1 +``` + +After installation, access the web admin at `http://your-server:8080/login` with username `admin` and password `changeme`. + +### Example 2: Set Admin Password Only + +```yaml +- uses: Nodarx/action-module-install@v1 + with: + admin_password: ${{ secrets.STALWART_ADMIN_PASSWORD }} +``` + +### Example 3: Create Domains Only + +```yaml +- uses: Nodarx/action-module-install@v1 + with: + admin_password: ${{ secrets.STALWART_ADMIN_PASSWORD }} + domains: | + [ + {"name": "example.com", "description": "Main"}, + {"name": "example.net", "description": "Secondary"} + ] +``` + +### Example 4: Complete Setup with Multiple Users + +```yaml +- uses: Nodarx/action-module-install@v1 + with: + admin_password: ${{ secrets.STALWART_ADMIN_PASSWORD }} + + domains: | + [ + {"name": "mycompany.com"}, + {"name": "mycompany.net"} + ] + + users: | + [ + { + "email": "ceo@mycompany.com", + "password": "${{ secrets.CEO_PASSWORD }}", + "name": "CEO", + "quota": 10737418240 + }, + { + "email": "team@mycompany.com", + "password": "${{ secrets.TEAM_PASSWORD }}", + "name": "Team Mailbox", + "quota": 5368709120 + }, + { + "email": "noreply@mycompany.com", + "password": "${{ secrets.NOREPLY_PASSWORD }}", + "name": "No Reply", + "quota": 1073741824 + } + ] +``` + +### Example 5: Using JSON from Files + +Store your configuration in separate files: + +```yaml +- name: Checkout repository + uses: actions/checkout@v4 + +- name: Install Stalwart + uses: Nodarx/action-module-install@v1 + with: + admin_password: ${{ secrets.STALWART_ADMIN_PASSWORD }} + domains: ${{ readFile('.github/stalwart/domains.json') }} + users: ${{ readFile('.github/stalwart/users.json') }} +``` + +## Security Best Practices + +### 🔒 Always Use GitHub Secrets + +**NEVER** hardcode passwords in your workflow files! + +```yaml +# ❌ WRONG - Password visible in repository +- uses: Nodarx/action-module-install@v1 + with: + admin_password: "MyPassword123" + +# ✅ CORRECT - Password stored in GitHub Secrets +- uses: Nodarx/action-module-install@v1 + with: + admin_password: ${{ secrets.STALWART_ADMIN_PASSWORD }} +``` + +### Setting Up GitHub Secrets + +1. Go to your repository → **Settings** → **Secrets and variables** → **Actions** +2. Click **New repository secret** +3. Add your secrets: + - `STALWART_ADMIN_PASSWORD` + - `USER1_PASSWORD` + - `USER2_PASSWORD` + - etc. + +### Password Requirements + +- Use strong, unique passwords (16+ characters) +- Include uppercase, lowercase, numbers, and symbols +- Never reuse passwords across services +- Rotate passwords regularly + +### Additional Security Tips + +- Restrict Stalwart web admin to localhost or VPN +- Configure firewall rules (ports 25, 465, 587, 993, 8080) +- Enable TLS/SSL for all email protocols +- Regularly update Stalwart to latest version +- Monitor logs for suspicious activity +- Use fail2ban or similar intrusion prevention + +## How It Works + +1. **Prerequisites Check**: Validates root access and required commands (`curl`, `jq`, `tar`) +2. **Installation**: Downloads and installs Stalwart Mail Server binary +3. **Service Setup**: Creates system user and service (systemd/init.d/launchd) +4. **API Wait**: Waits for Stalwart API to become available (up to 60 seconds) +5. **Authentication**: Authenticates with default password (`changeme`) +6. **Password Update**: Changes admin password if provided +7. **Domain Creation**: Creates domains via REST API +8. **User Creation**: Creates users with passwords and quotas via REST API + +## Troubleshooting + +### Action Fails: "Required command 'jq' not found" + +Install `jq` before running the action: + +```yaml +- name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y jq curl + +- name: Install Stalwart + uses: Nodarx/action-module-install@v1 +``` + +### Action Fails: "This action must run as root" + +Use `sudo` in your workflow: + +```yaml +- name: Install Stalwart + run: sudo -E env "PATH=$PATH" ... +``` + +Or use a container that runs as root: + +```yaml +jobs: + setup: + runs-on: ubuntu-latest + container: + image: ubuntu:latest + options: --user root +``` + +### Stalwart API Timeout + +If the API doesn't become available in 60 seconds: +- Check system resources (CPU, memory) +- Review Stalwart logs: `journalctl -u stalwart -n 50` +- Verify port 8080 is not already in use: `netstat -tuln | grep 8080` + +### Domain/User Creation Fails + +- Verify JSON syntax is valid (use a JSON validator) +- Check Stalwart logs for detailed errors +- Ensure domains are created before users +- Verify email addresses match created domains + +### "Failed to authenticate" Error + +- Installation might already be configured +- Try accessing web UI manually: `http://localhost:8080/login` +- Check if admin password was previously changed +- Review configuration script logs + +## Advanced Configuration + +### Custom Installation Path + +The installation path is fixed at `/opt/stalwart` to match Stalwart defaults. If you need a different path, fork this action and modify `STALWART_INSTALL_PATH`. + +### Running in Docker + +```yaml +jobs: + setup: + runs-on: ubuntu-latest + container: + image: ubuntu:22.04 + options: --privileged + + steps: + - name: Install dependencies + run: | + apt-get update + apt-get install -y curl jq sudo systemd + + - name: Install Stalwart + uses: Nodarx/action-module-install@v1 + with: + admin_password: ${{ secrets.STALWART_ADMIN_PASSWORD }} +``` + +### Post-Installation Configuration + +After installation, Stalwart's web admin is available at `http://localhost:8080/login`. You can: +- Configure SMTP, IMAP, POP3 settings +- Set up SSL/TLS certificates +- Configure spam filters and antivirus +- Manage additional domains and users +- View logs and statistics + +## Service Management + +### Check Service Status + +```bash +# Systemd (most Linux distributions) +sudo systemctl status stalwart + +# Init.d (older systems) +sudo service stalwart status + +# macOS +sudo launchctl list | grep stalwart +``` + +### Restart Service + +```bash +# Systemd +sudo systemctl restart stalwart + +# Init.d +sudo service stalwart restart + +# macOS +sudo launchctl stop system/stalwart.mail +sudo launchctl start system/stalwart.mail +``` + +### View Logs + +```bash +# Systemd +sudo journalctl -u stalwart -f + +# Traditional logs +sudo tail -f /opt/stalwart/logs/*.log +``` + +## Uninstallation + +To remove Stalwart: + +```bash +# Stop service +sudo systemctl stop stalwart +sudo systemctl disable stalwart + +# Remove service file +sudo rm /etc/systemd/system/stalwart.service +sudo systemctl daemon-reload + +# Remove installation directory +sudo rm -rf /opt/stalwart + +# Remove system user (optional) +sudo userdel stalwart +``` + +## Contributing + +Contributions are welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test thoroughly +5. Submit a pull request + +## License + +This action is licensed under the AGPL-3.0 License. See [LICENSE](LICENSE) for details. + +Stalwart Mail Server is developed by [Stalwart Labs](https://stalw.art/) and is licensed under AGPL-3.0-only OR LicenseRef-SEL. + +## Support + +- **Stalwart Documentation**: https://stalw.art/docs +- **Issue Tracker**: https://github.com/Nodarx/action-module-install/issues +- **Stalwart Community**: https://github.com/stalwartlabs/stalwart/discussions + +## Acknowledgments + +- Based on the official [Stalwart installation script](https://github.com/stalwartlabs/stalwart) +- Thanks to the Stalwart Labs team for creating an excellent mail server + +--- + +**Note**: This is an unofficial GitHub Action and is not affiliated with or endorsed by Stalwart Labs. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..9d37335 --- /dev/null +++ b/action.yml @@ -0,0 +1,88 @@ +name: 'Install Stalwart Mail Server' +description: 'Installs and configures Stalwart email server with optional admin password, domains, and users' +author: 'Nodarx' + +branding: + icon: 'mail' + color: 'blue' + +inputs: + domains: + description: 'JSON array of domains to create. Example: [{"name":"example.com","description":"Primary domain"}]' + required: false + default: '' + + users: + description: 'JSON array of users to create. Example: [{"email":"user@example.com","password":"pass123","name":"User Name","quota":1073741824}]' + required: false + default: '' + +runs: + using: 'composite' + steps: + - name: Check prerequisites + shell: bash + run: | + echo "::group::Checking prerequisites" + + # Check if running as root + if [ "$(id -u)" -ne 0 ]; then + echo "::error::This action must run as root. Use 'sudo' or run in a container with root privileges." + exit 1 + fi + + # Check for required commands + for cmd in curl jq tar; do + if ! command -v $cmd &> /dev/null; then + echo "::error::Required command '$cmd' not found. Please install it first." + exit 1 + fi + done + + echo "✓ All prerequisites met" + echo "::endgroup::" + + - name: Install Stalwart Mail Server + shell: bash + env: + STALWART_INSTALL_PATH: '/opt/stalwart' + run: | + echo "::group::Installing Stalwart Mail Server" + chmod +x "${{ github.action_path }}/scripts/install.sh" + "${{ github.action_path }}/scripts/install.sh" + echo "::endgroup::" + + - name: Configure Stalwart + shell: bash + if: ${{ inputs.domains != '' || inputs.users != '' }} + env: + STALWART_DOMAINS: ${{ inputs.domains }} + STALWART_USERS: ${{ inputs.users }} + STALWART_INSTALL_PATH: '/opt/stalwart' + run: | + + # Mask user passwords from JSON + if [ -n "$STALWART_USERS" ]; then + echo "$STALWART_USERS" | jq -r '.[].password // empty' | while read -r pass; do + if [ -n "$pass" ]; then + echo "::add-mask::$pass" + fi + done + fi + + echo "::group::Configuring Stalwart" + chmod +x "${{ github.action_path }}/scripts/configure.sh" + "${{ github.action_path }}/scripts/configure.sh" + echo "::endgroup::" + + - name: Display completion message + shell: bash + run: | + HOSTNAME=$(hostname -f 2>/dev/null || echo "localhost") + echo "::notice::🎉 Stalwart Mail Server installation complete!" + echo "::notice::Web admin: http://$HOSTNAME:8080/login" + if [ -n "${{ inputs.admin_password }}" ]; then + echo "::notice::Admin credentials configured via inputs" + else + echo "::notice::Default admin password: changeme (change this immediately!)" + fi diff --git a/scripts/configure.sh b/scripts/configure.sh new file mode 100755 index 0000000..fec22dc --- /dev/null +++ b/scripts/configure.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# +# Stalwart Post-Installation Configuration Script +# Configures admin password, domains, and users via REST API +# + +set -euo pipefail + +# Source utility functions +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/utils.sh" + +# Configuration +readonly STALWART_PATH="${STALWART_INSTALL_PATH:-/opt/stalwart}" +readonly API_URL="http://localhost:8080/api" +readonly MAX_RETRIES=60 +readonly RETRY_DELAY=2 + +# Read the generated password from installation +DEFAULT_ADMIN_PASSWORD="changeme" +if [ -f "${STALWART_PATH}/.init_password" ]; then + DEFAULT_ADMIN_PASSWORD=$(cat "${STALWART_PATH}/.init_password" | tr -d '\n\r') + log_info "Using generated password from installation" + # Clean up the password file for security + rm -f "${STALWART_PATH}/.init_password" +else + log_warning "Password file not found, using default password" +fi +readonly DEFAULT_ADMIN_PASSWORD + +# Environment variables (passed from action.yml) +DOMAINS_JSON="${STALWART_DOMAINS:-}" +USERS_JSON="${STALWART_USERS:-}" + +# Main configuration function +main() { + log_info "Starting Stalwart post-installation configuration" + + # Wait for Stalwart API to be ready + if ! wait_for_stalwart_api; then + log_error "Stalwart API failed to become ready within timeout" + return 1 + fi + + # Set current password (start with generated one) + local current_password="$DEFAULT_ADMIN_PASSWORD" + + # Test authentication with generated password + log_info "Verifying API access with generated password..." + if ! test_auth "$current_password"; then + log_error "Failed to authenticate with generated password" + return 1 + fi + + log_success "API authentication verified" + log_info "📝 Generated admin password: ${current_password}" + log_warning "⚠️ Save this password securely - it won't be shown again!" + + # Create domains if provided + if [ -n "$DOMAINS_JSON" ]; then + log_info "Creating domains..." + if validate_json "$DOMAINS_JSON"; then + create_domains "$current_password" "$DOMAINS_JSON" + else + log_error "Invalid domains JSON format" + return 1 + fi + else + log_info "No domains specified, skipping domain creation" + fi + + # Create users if provided + if [ -n "$USERS_JSON" ]; then + log_info "Creating users..." + if validate_json "$USERS_JSON"; then + create_users "$current_password" "$USERS_JSON" + else + log_error "Invalid users JSON format" + return 1 + fi + else + log_info "No users specified, skipping user creation" + fi + + log_success "Stalwart configuration completed successfully!" + return 0 +} + +# Wait for Stalwart API to be ready +wait_for_stalwart_api() { + log_info "Waiting for Stalwart API to be ready (timeout: ${MAX_RETRIES}s)..." + + local attempt=0 + while [ $attempt -lt $MAX_RETRIES ]; do + # Try to access the API login endpoint + if curl -sf -m 5 "${API_URL%/api}/login" >/dev/null 2>&1; then + log_success "Stalwart API is ready" + return 0 + fi + + attempt=$((attempt + 1)) + if [ $attempt -lt $MAX_RETRIES ]; then + sleep $RETRY_DELAY + fi + done + + return 1 +} + +# Test authentication with Stalwart API using Basic Auth +# Args: $1 = password +# Returns: 0 if auth works, 1 otherwise +test_auth() { + local password="$1" + + local http_code + local response + + # Test by querying domains endpoint (works on fresh and configured systems) + response=$(curl -s -w "\n%{http_code}" \ + -u "admin:${password}" \ + "${API_URL}/principal?types=domain&limit=1") + + http_code=$(echo "$response" | tail -n 1) + + if [ "$http_code" = "200" ]; then + return 0 + else + log_error "Authentication test failed with HTTP $http_code" + response=$(echo "$response" | sed '$d') + log_error "Response: $response" + return 1 + fi +} + +# Create domains from JSON array +# Args: $1 = password, $2 = domains JSON array +create_domains() { + local password="$1" + local domains_json="$2" + + local domain_count + domain_count=$(echo "$domains_json" | jq 'length' 2>/dev/null) + + if [ -z "$domain_count" ] || [ "$domain_count" = "0" ]; then + log_warning "No domains found in JSON array" + return 0 + fi + + log_info "Creating $domain_count domain(s)..." + + local index=0 + local created=0 + local failed=0 + + while [ $index -lt "$domain_count" ]; do + local domain + domain=$(echo "$domains_json" | jq -c ".[$index]" 2>/dev/null) + + if [ -z "$domain" ] || [ "$domain" = "null" ]; then + log_warning "Skipping invalid domain at index $index" + failed=$((failed + 1)) + index=$((index + 1)) + continue + fi + + local domain_name + domain_name=$(echo "$domain" | jq -r '.name // empty' 2>/dev/null) + + if [ -z "$domain_name" ]; then + log_warning "Skipping domain without 'name' field at index $index" + failed=$((failed + 1)) + index=$((index + 1)) + continue + fi + + # Create domain via API + if curl -sf -X POST "${API_URL}/domain" \ + -u "admin:${password}" \ + -H "Content-Type: application/json" \ + -d "$domain" >/dev/null 2>&1; then + log_success "✓ Created domain: $domain_name" + created=$((created + 1)) + else + log_warning "✗ Failed to create domain: $domain_name" + failed=$((failed + 1)) + fi + + index=$((index + 1)) + done + + log_info "Domain creation summary: $created created, $failed failed" + return 0 +} + +# Create users from JSON array +# Args: $1 = password, $2 = users JSON array +create_users() { + local password="$1" + local users_json="$2" + + local user_count + user_count=$(echo "$users_json" | jq 'length' 2>/dev/null) + + if [ -z "$user_count" ] || [ "$user_count" = "0" ]; then + log_warning "No users found in JSON array" + return 0 + fi + + log_info "Creating $user_count user(s)..." + + local index=0 + local created=0 + local failed=0 + + while [ $index -lt "$user_count" ]; do + local user + user=$(echo "$users_json" | jq -c ".[$index]" 2>/dev/null) + + if [ -z "$user" ] || [ "$user" = "null" ]; then + log_warning "Skipping invalid user at index $index" + failed=$((failed + 1)) + index=$((index + 1)) + continue + fi + + local email + email=$(echo "$user" | jq -r '.email // empty' 2>/dev/null) + + if [ -z "$email" ]; then + log_warning "Skipping user without 'email' field at index $index" + failed=$((failed + 1)) + index=$((index + 1)) + continue + fi + + # Build API payload (name, password, description, quota) + local payload + payload=$(echo "$user" | jq '{ + name: .email, + password: .password, + description: (.name // .email), + quota: (.quota // 1073741824) + }' 2>/dev/null) + + # Create user via API + if curl -sf -X POST "${API_URL}/account" \ + -u "admin:${password}" \ + -H "Content-Type: application/json" \ + -d "$payload" >/dev/null 2>&1; then + log_success "✓ Created user: $email" + created=$((created + 1)) + else + log_warning "✗ Failed to create user: $email" + failed=$((failed + 1)) + fi + + index=$((index + 1)) + done + + log_info "User creation summary: $created created, $failed failed" + return 0 +} + +# Execute main function +main "$@" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..0886b48 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,967 @@ +#!/usr/bin/env bash +# shellcheck shell=bash + +# +# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC +# Modified for GitHub Actions integration +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL +# + +# Stalwart install script -- based on the rustup installation script. +# Enhanced with GitHub Actions logging and error handling + +set -euo pipefail + +readonly BASE_URL="https://github.com/stalwartlabs/stalwart/releases/latest/download" + +main() { + downloader --check + need_cmd uname + need_cmd mktemp + need_cmd chmod + need_cmd mkdir + need_cmd rm + need_cmd rmdir + need_cmd tar + + # Make sure we are running as root + if [ "$(id -u)" -ne 0 ] ; then + err "❌ Install failed: This program needs to run as root." + fi + + # Detect OS + local _os="unknown" + local _uname="$(uname)" + _account="stalwart" + if [ "${_uname}" = "Linux" ]; then + _os="linux" + elif [ "${_uname}" = "Darwin" ]; then + _os="macos" + _account="_stalwart" + fi + + # Read arguments or use environment variable + local _dir="${STALWART_INSTALL_PATH:-/opt/stalwart}" + + # Default component setting + local _component="stalwart" + + # Loop through the arguments + for arg in "$@"; do + case "$arg" in + --fdb) + _component="stalwart-foundationdb" + ;; + *) + if [ -n "$arg" ]; then + _dir=$arg + fi + ;; + esac + done + + # Detect platform architecture + get_architecture || return 1 + local _arch="$RETVAL" + assert_nz "$_arch" "arch" + + # Create directories + say "📁 Creating directories..." + ensure mkdir -p "$_dir" "$_dir/bin" "$_dir/etc" "$_dir/logs" + + # Download latest binary + say "⏳ Downloading ${_component} for ${_arch}..." + local _file="${_dir}/bin/stalwart.tar.gz" + local _url="${BASE_URL}/${_component}-${_arch}.tar.gz" + ensure mkdir -p "$_dir" + ensure downloader "$_url" "$_file" "$_arch" + ensure tar zxvf "$_file" -C "$_dir/bin" > /dev/null + if [ "$_component" = "stalwart-foundationdb" ]; then + ignore mv "$_dir/bin/stalwart-foundationdb" "$_dir/bin/stalwart" + fi + ignore chmod +x "$_dir/bin/stalwart" + ignore rm "$_file" + + # Create system account + if ! id -u ${_account} > /dev/null 2>&1; then + say "🖥️ Creating '${_account}' account..." + if [ "${_os}" = "macos" ]; then + local _last_uid="$(dscacheutil -q user | grep uid | awk '{print $2}' | sort -n | tail -n 1)" + local _last_gid="$(dscacheutil -q group | grep gid | awk '{print $2}' | sort -n | tail -n 1)" + local _uid="$((_last_uid+1))" + local _gid="$((_last_gid+1))" + + ensure dscl /Local/Default -create Groups/_stalwart + ensure dscl /Local/Default -create Groups/_stalwart Password \* + ensure dscl /Local/Default -create Groups/_stalwart PrimaryGroupID $_gid + ensure dscl /Local/Default -create Groups/_stalwart RealName "Stalwart service" + ensure dscl /Local/Default -create Groups/_stalwart RecordName _stalwart stalwart + + ensure dscl /Local/Default -create Users/_stalwart + ensure dscl /Local/Default -create Users/_stalwart NFSHomeDirectory /Users/_stalwart + ensure dscl /Local/Default -create Users/_stalwart Password \* + ensure dscl /Local/Default -create Users/_stalwart PrimaryGroupID $_gid + ensure dscl /Local/Default -create Users/_stalwart RealName "Stalwart service" + ensure dscl /Local/Default -create Users/_stalwart RecordName _stalwart stalwart + ensure dscl /Local/Default -create Users/_stalwart UniqueID $_uid + ensure dscl /Local/Default -create Users/_stalwart UserShell /bin/bash + + ensure dscl /Local/Default -delete /Users/_stalwart AuthenticationAuthority + ensure dscl /Local/Default -delete /Users/_stalwart PasswordPolicyOptions + else + ensure useradd ${_account} -s /usr/sbin/nologin -M -r -U + fi + fi + + # Run init and capture the generated password + say "⚙️ Initializing Stalwart configuration..." + local _init_output + _init_output=$($_dir/bin/stalwart --init "$_dir" 2>&1) + echo "$_init_output" + + # Extract the generated password from output + # Output format: "🔑 Your administrator account is 'admin' with password 'XXXXX'." + local _generated_password + _generated_password=$(echo "$_init_output" | sed -n "s/.*with password '\([^']*\)'.*/\1/p") + + # Save the generated password for configure.sh to use + if [ -n "$_generated_password" ]; then + say "📝 Saving generated password for configuration..." + echo "$_generated_password" > "$_dir/.init_password" + chmod 644 "$_dir/.init_password" + else + say "⚠️ Warning: Could not extract generated password" + fi + + # Set permissions + say "🔐 Setting permissions..." + ensure chown -R ${_account}:${_account} "$_dir" + ensure chmod -R 755 "$_dir" + ensure chmod 700 "$_dir/etc/config.toml" + + # Create service file + say "🚀 Starting service..." + if [ "${_os}" = "linux" ]; then + # Check if systemd is actually running (not just installed) + if command -v systemctl >/dev/null 2>&1 && systemctl --version >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + say "Using systemd to manage service..." + create_service_linux_systemd "$_dir" + elif command -v service >/dev/null 2>&1; then + say "Using init.d to manage service..." + create_service_linux_initd "$_dir" + else + say "No service manager detected, starting manually..." + start_service_manually "$_dir" "${_account}" + fi + elif [ "${_os}" = "macos" ]; then + create_service_macos "$_dir" + fi + + # Wait for service to be responsive + say "⏳ Waiting for Stalwart to start..." + local _wait_attempts=0 + local _max_wait=30 + while [ $_wait_attempts -lt $_max_wait ]; do + if curl -sf -m 2 "http://localhost:8080/login" >/dev/null 2>&1; then + say "✓ Stalwart service is responding" + break + fi + _wait_attempts=$((_wait_attempts + 1)) + sleep 1 + done + + # Installation complete + local _host=$(hostname -f 2>/dev/null || echo "localhost") + say "✅ Installation complete! Stalwart service is running." + say "📝 Web admin will be available at: http://$_host:8080/login" + + return 0 +} + +# Functions to create service files +create_service_linux_systemd() { + local _dir="$1" + cat < /etc/systemd/system/stalwart.service +[Unit] +Description=Stalwart +Conflicts=postfix.service sendmail.service exim4.service +ConditionPathExists=__PATH__/etc/config.toml +After=network-online.target + +[Service] +Type=simple +LimitNOFILE=65536 +KillMode=process +KillSignal=SIGINT +Restart=on-failure +RestartSec=5 +ExecStart=__PATH__/bin/stalwart --config=__PATH__/etc/config.toml +SyslogIdentifier=stalwart +User=stalwart +Group=stalwart +AmbientCapabilities=CAP_NET_BIND_SERVICE + +[Install] +WantedBy=multi-user.target +EOF + + if systemctl daemon-reload 2>/dev/null && \ + systemctl enable stalwart.service 2>/dev/null && \ + systemctl restart stalwart.service 2>/dev/null; then + say "✓ Service started with systemd" + else + say "⚠ systemd commands failed, falling back to manual start" + start_service_manually "$_dir" "stalwart" + fi +} + +# Function to start service manually when systemd is not available +start_service_manually() { + local _dir="$1" + local _account="$2" + + say "Starting Stalwart manually in background..." + + # Create a simple wrapper script + cat > /usr/local/bin/stalwart-service </dev/null 2>&1; then + exec runuser -u ${_account} -- ${_dir}/bin/stalwart --config=${_dir}/etc/config.toml +elif command -v su >/dev/null 2>&1; then + exec su -s /bin/bash ${_account} -c "${_dir}/bin/stalwart --config=${_dir}/etc/config.toml" +else + # Fallback: run as current user + exec ${_dir}/bin/stalwart --config=${_dir}/etc/config.toml +fi +EOF + + chmod +x /usr/local/bin/stalwart-service + + # Start in background and redirect output + nohup /usr/local/bin/stalwart-service > ${_dir}/logs/stalwart.out 2>&1 & + local _pid=$! + + # Save PID for later management + echo $_pid > /var/run/stalwart.pid + + # Give it a moment to start + sleep 2 + + # Verify it's running + if kill -0 $_pid 2>/dev/null; then + say "✓ Stalwart started successfully (PID: $_pid)" + else + say "⚠ Warning: Stalwart may not have started successfully" + fi +} + +create_service_linux_initd() { + local _dir="$1" + cat <<"EOF" | sed "s|__PATH__|$_dir|g" > /etc/init.d/stalwart +#!/bin/sh +### BEGIN INIT INFO +# Provides: stalwart +# Required-Start: $network +# Required-Stop: $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Stalwart Server +# Description: Starts and stops the Stalwart Server +# Conflicts: postfix sendmail +### END INIT INFO + +PATH=/sbin:/usr/sbin:/bin:/usr/bin + +. /lib/init/vars.sh +. /lib/lsb/init-functions + +# Service Config +DAEMON=__PATH__/bin/stalwart +DAEMON_ARGS="--config=__PATH__/etc/config.toml" +PIDFILE=/var/run/stalwart.pid +ULIMIT_NOFILE=65536 + +# Exit if the package is not installed +[ -x "$DAEMON" ] || exit 0 + +# Exit if config file doesn't exist +[ -f "__PATH__/etc/config.toml" ] || exit 0 + +# Read configuration variable file if it is present +[ -r /etc/default/stalwart ] && . /etc/default/stalwart + +# Increase file descriptor limit +ulimit -n $ULIMIT_NOFILE + +do_start() +{ + # Return + # 0 if daemon has been started + # 1 if daemon was already running + # 2 if daemon could not be started + start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON --test > /dev/null \ + || return 1 + start-stop-daemon --start --quiet --pidfile $PIDFILE --exec $DAEMON \ + --background --make-pidfile --chuid stalwart:stalwart \ + -- $DAEMON_ARGS \ + || return 2 +} + +do_stop() +{ + # Return + # 0 if daemon has been stopped + # 1 if daemon was already stopped + # 2 if daemon could not be stopped + # other if a failure occurred + start-stop-daemon --stop --quiet --retry=INT/30/KILL/5 --pidfile $PIDFILE --name stalwart + RETVAL="$?" + [ "$RETVAL" = 2 ] && return 2 + # Wait for children to finish too if this is a daemon that forks + # and if the daemon is only ever run from this initscript. + start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec $DAEMON + [ "$?" = 2 ] && return 2 + # Many daemons don't delete their pidfiles when they exit. + rm -f $PIDFILE + return "$RETVAL" +} + +case "$1" in + start) + [ "$VERBOSE" != no ] && log_daemon_msg "Starting Stalwart Server" "stalwart" + do_start + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "$VERBOSE" != no ] && log_daemon_msg "Stopping Stalwart Server" "stalwart" + do_stop + case "$?" in + 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc "$DAEMON" "stalwart" && exit 0 || exit $? + ;; + restart) + log_daemon_msg "Restarting Stalwart Server" "stalwart" + do_stop + case "$?" in + 0|1) + do_start + case "$?" in + 0) log_end_msg 0 ;; + 1) log_end_msg 1 ;; # Old process is still running + *) log_end_msg 1 ;; # Failed to start + esac + ;; + *) + # Failed to stop + log_end_msg 1 + ;; + esac + ;; + *) + echo "Usage: /etc/init.d/stalwart {start|stop|status|restart}" >&2 + exit 3 + ;; +esac + +exit 0 +EOF + chmod +x /etc/init.d/stalwart + + cat < /etc/default/stalwart +# Configuration for Stalwart init script being run during +# the boot sequence + +# Set to 'yes' to enable additional verbosity +#VERBOSE=no +EOF + update-rc.d stalwart defaults + service stalwart start +} + +create_service_macos() { + local _dir="$1" + cat < /Library/LaunchAgents/stalwart.mail.plist + + + + + Label + stalwart.mail + ServiceDescription + Stalwart + ProgramArguments + + __PATH__/bin/stalwart + --config=__PATH__/etc/config.toml + + RunAtLoad + + KeepAlive + + + +EOF + launchctl load /Library/LaunchAgents/stalwart.mail.plist + launchctl enable system/stalwart.mail + launchctl start system/stalwart.mail +} + + +get_architecture() { + local _ostype _cputype _bitness _arch _clibtype + _ostype="$(uname -s)" + _cputype="$(uname -m)" + _clibtype="gnu" + + if [ "$_ostype" = Linux ]; then + if [ "$(uname -o)" = Android ]; then + _ostype=Android + fi + if ldd --version 2>&1 | grep -q 'musl'; then + _clibtype="musl" + fi + fi + + if [ "$_ostype" = Darwin ] && [ "$_cputype" = i386 ]; then + # Darwin `uname -m` lies + if sysctl hw.optional.x86_64 | grep -q ': 1'; then + _cputype=x86_64 + fi + fi + + if [ "$_ostype" = SunOS ]; then + # Both Solaris and illumos presently announce as "SunOS" in "uname -s" + # so use "uname -o" to disambiguate. We use the full path to the + # system uname in case the user has coreutils uname first in PATH, + # which has historically sometimes printed the wrong value here. + if [ "$(/usr/bin/uname -o)" = illumos ]; then + _ostype=illumos + fi + + # illumos systems have multi-arch userlands, and "uname -m" reports the + # machine hardware name; e.g., "i86pc" on both 32- and 64-bit x86 + # systems. Check for the native (widest) instruction set on the + # running kernel: + if [ "$_cputype" = i86pc ]; then + _cputype="$(isainfo -n)" + fi + fi + + case "$_ostype" in + + Android) + _ostype=linux-android + ;; + + Linux) + check_proc + _ostype=unknown-linux-$_clibtype + _bitness=$(get_bitness) + ;; + + FreeBSD) + _ostype=unknown-freebsd + ;; + + NetBSD) + _ostype=unknown-netbsd + ;; + + DragonFly) + _ostype=unknown-dragonfly + ;; + + Darwin) + _ostype=apple-darwin + ;; + + illumos) + _ostype=unknown-illumos + ;; + + MINGW* | MSYS* | CYGWIN* | Windows_NT) + _ostype=pc-windows-gnu + ;; + + *) + err "unrecognized OS type: $_ostype" + ;; + + esac + + case "$_cputype" in + + i386 | i486 | i686 | i786 | x86) + _cputype=i686 + ;; + + xscale | arm) + _cputype=arm + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + fi + ;; + + armv6l) + _cputype=arm + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + + armv7l | armv8l) + _cputype=armv7 + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + + aarch64 | arm64) + _cputype=aarch64 + ;; + + x86_64 | x86-64 | x64 | amd64) + _cputype=x86_64 + ;; + + mips) + _cputype=$(get_endianness mips '' el) + ;; + + mips64) + if [ "$_bitness" -eq 64 ]; then + # only n64 ABI is supported for now + _ostype="${_ostype}abi64" + _cputype=$(get_endianness mips64 '' el) + fi + ;; + + ppc) + _cputype=powerpc + ;; + + ppc64) + _cputype=powerpc64 + ;; + + ppc64le) + _cputype=powerpc64le + ;; + + s390x) + _cputype=s390x + ;; + riscv64) + _cputype=riscv64gc + ;; + *) + err "unknown CPU type: $_cputype" + + esac + + # Detect 64-bit linux with 32-bit userland + if [ "${_ostype}" = unknown-linux-gnu ] && [ "${_bitness}" -eq 32 ]; then + case $_cputype in + x86_64) + if [ -n "${RUSTUP_CPUTYPE:-}" ]; then + _cputype="$RUSTUP_CPUTYPE" + else { + # 32-bit executable for amd64 = x32 + if is_host_amd64_elf; then { + echo "This host is running an x32 userland; as it stands, x32 support is poor," 1>&2 + echo "and there isn't a native toolchain -- you will have to install" 1>&2 + echo "multiarch compatibility with i686 and/or amd64, then select one" 1>&2 + echo "by re-running this script with the RUSTUP_CPUTYPE environment variable" 1>&2 + echo "set to i686 or x86_64, respectively." 1>&2 + echo 1>&2 + echo "You will be able to add an x32 target after installation by running" 1>&2 + echo " rustup target add x86_64-unknown-linux-gnux32" 1>&2 + exit 1 + }; else + _cputype=i686 + fi + }; fi + ;; + mips64) + _cputype=$(get_endianness mips '' el) + ;; + powerpc64) + _cputype=powerpc + ;; + aarch64) + _cputype=armv7 + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + riscv64gc) + err "riscv64 with 32-bit userland unsupported" + ;; + esac + fi + + # Detect armv7 but without the CPU features Rust needs in that build, + # and fall back to arm. + # See https://github.com/rust-lang/rustup.rs/issues/587. + if [ "$_ostype" = "unknown-linux-gnueabihf" ] && [ "$_cputype" = armv7 ]; then + if ensure grep '^Features' /proc/cpuinfo | grep -q -v neon; then + # At least one processor does not have NEON. + _cputype=arm + fi + fi + + _arch="${_cputype}-${_ostype}" + + RETVAL="$_arch" +} + +check_proc() { + # Check for /proc by looking for the /proc/self/exe link + # This is only run on Linux + if ! test -L /proc/self/exe ; then + err "fatal: Unable to find /proc/self/exe. Is /proc mounted? Installation cannot proceed without /proc." + fi +} + +get_bitness() { + need_cmd head + # Architecture detection without dependencies beyond coreutils. + # ELF files start out "\x7fELF", and the following byte is + # 0x01 for 32-bit and + # 0x02 for 64-bit. + # The printf builtin on some shells like dash only supports octal + # escape sequences, so we use those. + local _current_exe_head + _current_exe_head=$(head -c 5 /proc/self/exe ) + if [ "$_current_exe_head" = "$(printf '\177ELF\001')" ]; then + echo 32 + elif [ "$_current_exe_head" = "$(printf '\177ELF\002')" ]; then + echo 64 + else + err "unknown platform bitness" + fi +} + +is_host_amd64_elf() { + need_cmd head + need_cmd tail + # ELF e_machine detection without dependencies beyond coreutils. + # Two-byte field at offset 0x12 indicates the CPU, + # but we're interested in it being 0x3E to indicate amd64, or not that. + local _current_exe_machine + _current_exe_machine=$(head -c 19 /proc/self/exe | tail -c 1) + [ "$_current_exe_machine" = "$(printf '\076')" ] +} + +get_endianness() { + local cputype=$1 + local suffix_eb=$2 + local suffix_el=$3 + + # detect endianness without od/hexdump, like get_bitness() does. + need_cmd head + need_cmd tail + + local _current_exe_endianness + _current_exe_endianness="$(head -c 6 /proc/self/exe | tail -c 1)" + if [ "$_current_exe_endianness" = "$(printf '\001')" ]; then + echo "${cputype}${suffix_el}" + elif [ "$_current_exe_endianness" = "$(printf '\002')" ]; then + echo "${cputype}${suffix_eb}" + else + err "unknown platform endianness" + fi +} + +say() { + printf '%s\n' "$1" +} + +err() { + say "::error::$1" >&2 + exit 1 +} + +need_cmd() { + if ! check_cmd "$1"; then + err "need '$1' (command not found)" + fi +} + +check_cmd() { + command -v "$1" > /dev/null 2>&1 +} + +assert_nz() { + if [ -z "$1" ]; then err "assert_nz $2"; fi +} + +# Run a command that should never fail. If the command fails execution +# will immediately terminate with an error showing the failing +# command. +ensure() { + if ! "$@"; then err "command failed: $*"; fi +} + +# This wraps curl or wget. Try curl first, if not installed, +# use wget instead. +downloader() { + local _dld + local _ciphersuites + local _err + local _status + local _retry + if check_cmd curl; then + _dld=curl + elif check_cmd wget; then + _dld=wget + else + _dld='curl or wget' # to be used in error message of need_cmd + fi + + if [ "$1" = --check ]; then + need_cmd "$_dld" + elif [ "$_dld" = curl ]; then + check_curl_for_retry_support + _retry="$RETVAL" + get_ciphersuites_for_curl + _ciphersuites="$RETVAL" + if [ -n "$_ciphersuites" ]; then + _err=$(curl $_retry --proto '=https' --tlsv1.2 --ciphers "$_ciphersuites" --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + else + echo "Warning: Not enforcing strong cipher suites for TLS, this is potentially less secure" + if ! check_help_for "$3" curl --proto --tlsv1.2; then + echo "Warning: Not enforcing TLS v1.2, this is potentially less secure" + _err=$(curl $_retry --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + else + _err=$(curl $_retry --proto '=https' --tlsv1.2 --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + fi + fi + if [ -n "$_err" ]; then + if echo "$_err" | grep -q 404; then + err "❌ Binary for platform '$3' not found, this platform may be unsupported." + else + echo "$_err" >&2 + fi + fi + return $_status + elif [ "$_dld" = wget ]; then + if [ "$(wget -V 2>&1|head -2|tail -1|cut -f1 -d" ")" = "BusyBox" ]; then + echo "Warning: using the BusyBox version of wget. Not enforcing strong cipher suites for TLS or TLS v1.2, this is potentially less secure" + _err=$(wget "$1" -O "$2" 2>&1) + _status=$? + else + get_ciphersuites_for_wget + _ciphersuites="$RETVAL" + if [ -n "$_ciphersuites" ]; then + _err=$(wget --https-only --secure-protocol=TLSv1_2 --ciphers "$_ciphersuites" "$1" -O "$2" 2>&1) + _status=$? + else + echo "Warning: Not enforcing strong cipher suites for TLS, this is potentially less secure" + if ! check_help_for "$3" wget --https-only --secure-protocol; then + echo "Warning: Not enforcing TLS v1.2, this is potentially less secure" + _err=$(wget "$1" -O "$2" 2>&1) + _status=$? + else + _err=$(wget --https-only --secure-protocol=TLSv1_2 "$1" -O "$2" 2>&1) + _status=$? + fi + fi + fi + if [ -n "$_err" ]; then + if echo "$_err" | grep -q ' 404 Not Found'; then + err "❌ Binary for platform '$3' not found, this platform may be unsupported." + else + echo "$_err" >&2 + fi + fi + return $_status + else + err "Unknown downloader" # should not reach here + fi +} + +# Check if curl supports the --retry flag, then pass it to the curl invocation. +check_curl_for_retry_support() { + local _retry_supported="" + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "curl" "--retry"; then + _retry_supported="--retry 3" + fi + + RETVAL="$_retry_supported" + +} + +check_help_for() { + local _arch + local _cmd + local _arg + _arch="$1" + shift + _cmd="$1" + shift + + local _category + if "$_cmd" --help | grep -q 'For all options use the manual or "--help all".'; then + _category="all" + else + _category="" + fi + + case "$_arch" in + + *darwin*) + if check_cmd sw_vers; then + case $(sw_vers -productVersion) in + 10.*) + # If we're running on macOS, older than 10.13, then we always + # fail to find these options to force fallback + if [ "$(sw_vers -productVersion | cut -d. -f2)" -lt 13 ]; then + # Older than 10.13 + echo "Warning: Detected macOS platform older than 10.13" + return 1 + fi + ;; + 11.*) + # We assume Big Sur will be OK for now + ;; + *) + # Unknown product version, warn and continue + echo "Warning: Detected unknown macOS major version: $(sw_vers -productVersion)" + echo "Warning TLS capabilities detection may fail" + ;; + esac + fi + ;; + + esac + + for _arg in "$@"; do + if ! "$_cmd" --help $_category | grep -q -- "$_arg"; then + return 1 + fi + done + + true # not strictly needed +} + +# Return cipher suite string specified by user, otherwise return strong TLS 1.2-1.3 cipher suites +# if support by local tools is detected. Detection currently supports these curl backends: +# GnuTLS and OpenSSL (possibly also LibreSSL and BoringSSL). Return value can be empty. +get_ciphersuites_for_curl() { + if [ -n "${RUSTUP_TLS_CIPHERSUITES-}" ]; then + # user specified custom cipher suites, assume they know what they're doing + RETVAL="$RUSTUP_TLS_CIPHERSUITES" + return + fi + + local _openssl_syntax="no" + local _gnutls_syntax="no" + local _backend_supported="yes" + if curl -V | grep -q ' OpenSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' LibreSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' BoringSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' GnuTLS/'; then + _gnutls_syntax="yes" + else + _backend_supported="no" + fi + + local _args_supported="no" + if [ "$_backend_supported" = "yes" ]; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "curl" "--tlsv1.2" "--ciphers" "--proto"; then + _args_supported="yes" + fi + fi + + local _cs="" + if [ "$_args_supported" = "yes" ]; then + if [ "$_openssl_syntax" = "yes" ]; then + _cs=$(get_strong_ciphersuites_for "openssl") + elif [ "$_gnutls_syntax" = "yes" ]; then + _cs=$(get_strong_ciphersuites_for "gnutls") + fi + fi + + RETVAL="$_cs" +} + +# Return cipher suite string specified by user, otherwise return strong TLS 1.2-1.3 cipher suites +# if support by local tools is detected. Detection currently supports these wget backends: +# GnuTLS and OpenSSL (possibly also LibreSSL and BoringSSL). Return value can be empty. +get_ciphersuites_for_wget() { + if [ -n "${RUSTUP_TLS_CIPHERSUITES-}" ]; then + # user specified custom cipher suites, assume they know what they're doing + RETVAL="$RUSTUP_TLS_CIPHERSUITES" + return + fi + + local _cs="" + if wget -V | grep -q '\-DHAVE_LIBSSL'; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "wget" "TLSv1_2" "--ciphers" "--https-only" "--secure-protocol"; then + _cs=$(get_strong_ciphersuites_for "openssl") + fi + elif wget -V | grep -q '\-DHAVE_LIBGNUTLS'; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "wget" "TLSv1_2" "--ciphers" "--https-only" "--secure-protocol"; then + _cs=$(get_strong_ciphersuites_for "gnutls") + fi + fi + + RETVAL="$_cs" +} + +# Return strong TLS 1.2-1.3 cipher suites in OpenSSL or GnuTLS syntax. TLS 1.2 +# excludes non-ECDHE and non-AEAD cipher suites. DHE is excluded due to bad +# DH params often found on servers (see RFC 7919). Sequence matches or is +# similar to Firefox 68 ESR with weak cipher suites disabled via about:config. +# $1 must be openssl or gnutls. +get_strong_ciphersuites_for() { + if [ "$1" = "openssl" ]; then + # OpenSSL is forgiving of unknown values, no problems with TLS 1.3 values on versions that don't support it yet. + echo "TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384" + elif [ "$1" = "gnutls" ]; then + # GnuTLS isn't forgiving of unknown values, so this may require a GnuTLS version that supports TLS 1.3 even if wget doesn't. + # Begin with SECURE128 (and higher) then remove/add to build cipher suites. Produces same 9 cipher suites as OpenSSL but in slightly different order. + echo "SECURE128:-VERS-SSL3.0:-VERS-TLS1.0:-VERS-TLS1.1:-VERS-DTLS-ALL:-CIPHER-ALL:-MAC-ALL:-KX-ALL:+AEAD:+ECDHE-ECDSA:+ECDHE-RSA:+AES-128-GCM:+CHACHA20-POLY1305:+AES-256-GCM" + fi +} + +# This is just for indicating that commands' results are being +# intentionally ignored. Usually, because it's being executed +# as part of error handling. +ignore() { + "$@" +} + +main "$@" || exit 1 diff --git a/scripts/utils.sh b/scripts/utils.sh new file mode 100755 index 0000000..b5f055f --- /dev/null +++ b/scripts/utils.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +# +# Utility functions for Stalwart installation and configuration scripts +# + +# Color codes for output (if terminal supports it) +if [ -t 1 ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + NC='\033[0m' # No Color +else + RED='' + GREEN='' + YELLOW='' + BLUE='' + NC='' +fi + +# Logging functions with GitHub Actions workflow commands support + +log_info() { + local message="$1" + echo -e "${BLUE}ℹ${NC} ${message}" +} + +log_success() { + local message="$1" + echo -e "${GREEN}✓${NC} ${message}" +} + +log_warning() { + local message="$1" + echo -e "${YELLOW}⚠${NC} ${message}" + # Also output as GitHub Actions warning + echo "::warning::${message}" +} + +log_error() { + local message="$1" + echo -e "${RED}✗${NC} ${message}" >&2 + # Also output as GitHub Actions error + echo "::error::${message}" >&2 +} + +log_debug() { + local message="$1" + if [ "${DEBUG:-false}" = "true" ]; then + echo -e "${BLUE}[DEBUG]${NC} ${message}" + fi +} + +# Validate JSON string +# Args: $1 = JSON string +# Returns: 0 if valid, 1 if invalid +validate_json() { + local json_string="$1" + + if [ -z "$json_string" ]; then + return 1 + fi + + if ! echo "$json_string" | jq empty >/dev/null 2>&1; then + return 1 + fi + + return 0 +} + +# Check if a command exists +# Args: $1 = command name +# Returns: 0 if exists, 1 if not +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Ensure a command exists, exit with error if not +# Args: $1 = command name +require_command() { + local cmd="$1" + + if ! command_exists "$cmd"; then + log_error "Required command '$cmd' not found. Please install it first." + exit 1 + fi +} + +# Wait for a TCP port to be available +# Args: $1 = host, $2 = port, $3 = timeout (seconds) +# Returns: 0 if available, 1 if timeout +wait_for_port() { + local host="$1" + local port="$2" + local timeout="${3:-30}" + + local elapsed=0 + + while [ $elapsed -lt $timeout ]; do + if timeout 1 bash -c "echo >/dev/tcp/${host}/${port}" 2>/dev/null; then + return 0 + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + + return 1 +} + +# Check if a URL is accessible +# Args: $1 = URL, $2 = timeout (seconds, optional) +# Returns: 0 if accessible, 1 if not +check_url() { + local url="$1" + local timeout="${2:-5}" + + if curl -sf -m "$timeout" "$url" >/dev/null 2>&1; then + return 0 + fi + + return 1 +} + +# Retry a command multiple times +# Args: $1 = max attempts, $2 = delay between attempts, $3+ = command and args +# Returns: 0 if command succeeds, 1 if all attempts fail +retry_command() { + local max_attempts="$1" + local delay="$2" + shift 2 + local cmd=("$@") + + local attempt=1 + + while [ $attempt -le $max_attempts ]; do + if "${cmd[@]}"; then + return 0 + fi + + if [ $attempt -lt $max_attempts ]; then + log_debug "Command failed (attempt $attempt/$max_attempts), retrying in ${delay}s..." + sleep "$delay" + fi + + attempt=$((attempt + 1)) + done + + log_error "Command failed after $max_attempts attempts: ${cmd[*]}" + return 1 +} + +# Mask sensitive data in logs (for GitHub Actions) +# Args: $1 = sensitive string +mask_secret() { + local secret="$1" + + if [ -n "$secret" ]; then + echo "::add-mask::${secret}" + fi +} + +# Create a GitHub Actions group (collapsible section in logs) +# Args: $1 = group name +start_group() { + local group_name="$1" + echo "::group::${group_name}" +} + +# End a GitHub Actions group +end_group() { + echo "::endgroup::" +} + +# Set a GitHub Actions output +# Args: $1 = output name, $2 = output value +set_output() { + local name="$1" + local value="$2" + + if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "${name}=${value}" >> "$GITHUB_OUTPUT" + else + echo "::set-output name=${name}::${value}" + fi +} + +# Generate a random password +# Args: $1 = length (default: 16) +# Returns: random password on stdout +generate_password() { + local length="${1:-16}" + + if command_exists openssl; then + openssl rand -base64 "$length" | tr -d "=+/" | cut -c1-"$length" + elif command_exists pwgen; then + pwgen -s "$length" 1 + else + # Fallback to /dev/urandom + tr -dc 'A-Za-z0-9!@#$%^&*' /dev/null +} + +# Check if a file is writable or can be created +# Args: $1 = file path +# Returns: 0 if writable, 1 if not +is_writable() { + local filepath="$1" + + if [ -e "$filepath" ]; then + [ -w "$filepath" ] + else + local dirpath=$(dirname "$filepath") + [ -w "$dirpath" ] + fi +} + +# Export all functions for use in other scripts +export -f log_info log_success log_warning log_error log_debug +export -f validate_json command_exists require_command +export -f wait_for_port check_url retry_command +export -f mask_secret start_group end_group set_output +export -f generate_password is_root require_root +export -f get_distro has_systemd url_encode json_get is_writable