feat: initial version #1

Merged
Sebastian merged 1 commits from chore/initial-version into main 2026-02-15 14:24:13 +00:00
6 changed files with 2530 additions and 1 deletions
Showing only changes of commit cc3fffbda1 - Show all commits

400
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,400 @@
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 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
id: install
uses: ./
with:
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 service is accessible
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"
exit 0
fi
sleep 2
done
echo "::error::API not accessible after 60 seconds"
exit 1
- name: Verify domains and users were created
run: |
echo "=== Reading Admin Password ==="
if [ -f /tmp/stalwart_admin_password ]; then
ADMIN_PASSWORD=$(cat /tmp/stalwart_admin_password)
echo "✓ Admin password retrieved"
else
echo "::error::Admin password file not found"
exit 1
fi
echo ""
echo "=== Verifying Domains ==="
DOMAINS_RESPONSE=$(curl -s -u "admin:$ADMIN_PASSWORD" \
"http://localhost:8080/api/principal?types=domain&limit=100")
DOMAIN_COUNT=$(echo "$DOMAINS_RESPONSE" | jq '.data.total // 0')
echo "Total domains found: $DOMAIN_COUNT"
# List domains
echo "$DOMAINS_RESPONSE" | jq -r '.data.items[] | " - \(.name): \(.description // "No description")"'
# Verify specific domains exist
if echo "$DOMAINS_RESPONSE" | jq -e '.data.items[] | select(.name == "test1.local")' >/dev/null; then
echo "✓ Domain test1.local exists"
else
echo "::error::Domain test1.local not found"
exit 1
fi
if echo "$DOMAINS_RESPONSE" | jq -e '.data.items[] | select(.name == "test2.local")' >/dev/null; then
echo "✓ Domain test2.local exists"
else
echo "::error::Domain test2.local not found"
exit 1
fi
echo ""
echo "=== Verifying Users ==="
USERS_RESPONSE=$(curl -s -u "admin:$ADMIN_PASSWORD" \
"http://localhost:8080/api/principal?types=individual&limit=100")
USER_COUNT=$(echo "$USERS_RESPONSE" | jq '.data.total // 0')
echo "Total users found: $USER_COUNT"
# List users
echo "$USERS_RESPONSE" | jq -r '.data.items[] | " - \(.name) (\(.emails[0])): roles=\(.roles)"'
# Verify we have at least the 3 users we created
if [ "$USER_COUNT" -lt 3 ]; then
echo "::error::Expected at least 3 users, found $USER_COUNT"
exit 1
fi
# Verify specific users exist and have correct roles
for user_email in "user1@test1.local" "user2@test1.local" "admin@test2.local"; do
USER_DATA=$(echo "$USERS_RESPONSE" | jq --arg email "$user_email" '.data.items[] | select(.emails[] == $email)')
if [ -z "$USER_DATA" ]; then
echo "::error::User $user_email not found"
exit 1
fi
# Check if user has "user" role
HAS_USER_ROLE=$(echo "$USER_DATA" | jq '.roles | contains(["user"])')
if [ "$HAS_USER_ROLE" = "true" ]; then
echo "✓ User $user_email exists with 'user' role"
else
echo "::error::User $user_email exists but missing 'user' role"
exit 1
fi
done
echo ""
echo "✓ All domains and users verified successfully"
- name: Verify unauthenticated JMAP access
run: |
echo "Testing unauthenticated JMAP endpoint..."
# Call without authentication (follow redirects with -L)
HTTP_CODE=$(curl -s -L \
-o /tmp/jmap_response_no_auth.json \
-w "%{http_code}" \
"http://localhost:8080/.well-known/jmap")
echo "HTTP Status Code: $HTTP_CODE"
echo "JMAP Response:"
cat /tmp/jmap_response_no_auth.json
echo ""
# Check if request succeeded
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::JMAP endpoint returned HTTP $HTTP_CODE for unauthenticated request"
cat /tmp/jmap_response_no_auth.json || true
exit 1
fi
# Verify username is empty (no authentication)
USERNAME=$(cat /tmp/jmap_response_no_auth.json | jq -r '.username // empty')
if [ -z "$USERNAME" ]; then
echo "✓ Unauthenticated access returns empty username"
else
echo "::warning::Expected empty username for unauthenticated request, got '$USERNAME'"
fi
- name: Verify created user can authenticate
run: |
echo "Testing user authentication via JMAP endpoint..."
# Test user1@test1.local authentication (follow redirects with -L)
HTTP_CODE=$(curl -s -L \
-o /tmp/jmap_response_auth.json \
-w "%{http_code}" \
-u "user1@test1.local:UserPass123!" \
"http://localhost:8080/.well-known/jmap")
echo "HTTP Status Code: $HTTP_CODE"
echo "JMAP Response:"
cat /tmp/jmap_response_auth.json
echo ""
# Check if request succeeded
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::JMAP endpoint returned HTTP $HTTP_CODE"
exit 1
fi
# Verify username field contains our test user
USERNAME=$(cat /tmp/jmap_response_auth.json | jq -r '.username // empty')
if [ "$USERNAME" = "user1@test1.local" ]; then
echo "✓ User authentication successful: $USERNAME"
else
echo "::error::Expected username 'user1@test1.local', got '$USERNAME'"
exit 1
fi
# Verify accounts object is not empty (means user is authenticated)
ACCOUNTS=$(cat /tmp/jmap_response_auth.json | jq '.accounts // {}')
if [ "$ACCOUNTS" != "{}" ]; then
echo "✓ User has active accounts"
else
echo "::error::User accounts are empty (authentication may have failed)"
exit 1
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:
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
# Summary job
test-summary:
name: Test Summary
runs-on: ubuntu-latest
needs: [test-basic-install, test-full-config]
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 "- Full Config: ${{ needs.test-full-config.result }}" >> $GITHUB_STEP_SUMMARY
# Fail if any required test failed
if [ "${{ needs.test-basic-install.result }}" != "success" ] || \
[ "${{ needs.test-full-config.result }}" != "success" ]; then
echo "::error::One or more tests failed"
exit 1
fi
echo "✓ All tests passed!"

459
README.md
View File

@@ -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.

83
action.yml Normal file
View File

@@ -0,0 +1,83 @@
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"

333
scripts/configure.sh Executable file
View File

@@ -0,0 +1,333 @@
#!/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!"
# Save admin password to temp file for testing/debugging (remove in production)
echo "$current_password" > /tmp/stalwart_admin_password
chmod 600 /tmp/stalwart_admin_password
# 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
# Build API payload with required structure
local payload
payload=$(echo "$domain" | jq '{
type: "domain",
quota: (.quota // 0),
name: .name,
description: (.description // ""),
secrets: [],
emails: [],
urls: [],
memberOf: [],
roles: [],
lists: [],
members: [],
enabledPermissions: [],
disabledPermissions: [],
externalMembers: []
}' 2>/dev/null)
# Create domain via API
if curl -sf -X POST "${API_URL}/principal" \
-u "admin:${password}" \
-H "Content-Type: application/json" \
-d "$payload" >/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
# Hash the password with SHA-512 (API requires hashed passwords in secrets array)
local hashed_password=""
local raw_password
raw_password=$(echo "$user" | jq -r '.password // empty' 2>/dev/null)
if [ -n "$raw_password" ]; then
if command -v mkpasswd >/dev/null 2>&1; then
hashed_password=$(mkpasswd -m sha-512 "$raw_password")
elif command -v openssl >/dev/null 2>&1; then
hashed_password=$(openssl passwd -6 "$raw_password")
else
log_warning "Cannot hash password for $email - no mkpasswd or openssl found"
fi
fi
# Build API payload with required structure
local payload
if [ -n "$hashed_password" ]; then
payload=$(echo "$user" | jq --arg email "$email" --arg pwd "$hashed_password" '{
type: "individual",
quota: (.quota // 0),
name: $email,
description: (.name // ""),
secrets: [$pwd],
emails: [$email],
urls: [],
memberOf: [],
roles: ["user"],
lists: [],
members: [],
enabledPermissions: [],
disabledPermissions: [],
externalMembers: []
}' 2>/dev/null)
else
payload=$(echo "$user" | jq --arg email "$email" '{
type: "individual",
quota: (.quota // 0),
name: $email,
description: (.name // ""),
secrets: [],
emails: [$email],
urls: [],
memberOf: [],
roles: ["user"],
lists: [],
members: [],
enabledPermissions: [],
disabledPermissions: [],
externalMembers: []
}' 2>/dev/null)
fi
# Create user via API
if curl -sf -X POST "${API_URL}/principal" \
-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 "$@"

967
scripts/install.sh Executable file
View File

@@ -0,0 +1,967 @@
#!/usr/bin/env bash
# shellcheck shell=bash
#
# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
# 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 <<EOF | sed "s|__PATH__|$_dir|g" > /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 <<EOF
#!/bin/bash
set -e
# Increase file descriptor limit
ulimit -n 65536
# Change to service user and run stalwart
if command -v runuser >/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 <<EOF > /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 <<EOF | sed "s|__PATH__|$_dir|g" > /Library/LaunchAgents/stalwart.mail.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>stalwart.mail</string>
<key>ServiceDescription</key>
<string>Stalwart</string>
<key>ProgramArguments</key>
<array>
<string>__PATH__/bin/stalwart</string>
<string>--config=__PATH__/etc/config.toml</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>
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

289
scripts/utils.sh Executable file
View File

@@ -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/urandom | head -c "$length"
fi
}
# Check if running as root
# Returns: 0 if root, 1 if not
is_root() {
[ "$(id -u)" -eq 0 ]
}
# Ensure script is running as root
require_root() {
if ! is_root; then
log_error "This script must be run as root"
exit 1
fi
}
# Get the Linux distribution name
get_distro() {
if [ -f /etc/os-release ]; then
. /etc/os-release
echo "$ID"
elif [ -f /etc/lsb-release ]; then
. /etc/lsb-release
echo "$DISTRIB_ID" | tr '[:upper:]' '[:lower:]'
else
echo "unknown"
fi
}
# Check if systemd is available
has_systemd() {
command_exists systemctl && [ -d /run/systemd/system ]
}
# URL encode a string
# Args: $1 = string to encode
url_encode() {
local string="$1"
local strlen=${#string}
local encoded=""
local pos c o
for (( pos=0; pos<strlen; pos++ )); do
c=${string:$pos:1}
case "$c" in
[-_.~a-zA-Z0-9] )
o="${c}"
;;
* )
printf -v o '%%%02x' "'$c"
;;
esac
encoded+="${o}"
done
echo "${encoded}"
}
# Parse JSON value from a JSON string
# Args: $1 = JSON string, $2 = key path (e.g., ".data.token")
# Returns: value on stdout
json_get() {
local json="$1"
local key="$2"
echo "$json" | jq -r "$key // empty" 2>/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