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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Lint

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

- name: Install ShellCheck
run: sudo apt-get update && sudo apt-get install -y shellcheck

- name: Check syntax
run: bash -n srv-ctl.sh lib/os-utils.sh lib/storage.sh

- name: Run ShellCheck
run: |
cp tests/fixtures/config.local.test config.local
shellcheck -x srv-ctl.sh lib/os-utils.sh lib/storage.sh
54 changes: 54 additions & 0 deletions .github/workflows/test-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: E2E Tests (VM)

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
vm:
name: ${{ matrix.os }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
# Only test OSes with cryptsetup >=2.4.0 (BitLocker support): Debian 12+, Ubuntu 22.04+
os: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04]
steps:
- uses: actions/checkout@v4

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Install dependencies
run: sudo apt-get update && sudo apt-get install -y qemu-system-x86 qemu-utils cloud-image-utils

- name: Cache VM images
uses: actions/cache@v4
with:
path: ~/.cache/vm-images
key: vm-image-${{ matrix.os }}

- name: Download cloud image
run: |
mkdir -p ~/.cache/vm-images
./tests/vm/download-image.sh ${{ matrix.os }}

- name: Run E2E tests (${{ matrix.os }})
timeout-minutes: 15
run: ./tests/vm/run-e2e-tests.sh ${{ matrix.os }}

- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-results-${{ matrix.os }}
path: tests/vm/results/

- name: Cleanup
if: always()
run: ./tests/vm/cleanup.sh
54 changes: 54 additions & 0 deletions .github/workflows/test-integration-vm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Integration Tests

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
vm:
name: ${{ matrix.os }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

The VM tests workflow references "debian-13" on line 16, which corresponds to an unreleased version. While the download-image.sh script has a URL for it, this should be documented as experimental/testing. Consider adding a comment to clarify this is a testing/daily build and may be unstable, or exclude it from the standard test matrix and only run it manually.

Suggested change
fail-fast: false
fail-fast: false
# Note: debian-13 is an unreleased/experimental image (e.g., daily build) and may be unstable.

Copilot uses AI. Check for mistakes.
matrix:
# Only test OSes with cryptsetup >=2.4.0 (BitLocker support): Debian 12+, Ubuntu 22.04+
os: [debian-12, debian-13, ubuntu-22.04, ubuntu-24.04]
steps:
- uses: actions/checkout@v4

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Install dependencies
run: sudo apt-get update && sudo apt-get install -y qemu-system-x86 qemu-utils cloud-image-utils

- name: Cache VM images
uses: actions/cache@v4
with:
path: ~/.cache/vm-images
key: vm-image-${{ matrix.os }}

- name: Download cloud image
run: |
mkdir -p ~/.cache/vm-images
./tests/vm/download-image.sh ${{ matrix.os }}

- name: Run integration tests (${{ matrix.os }})
timeout-minutes: 15
run: ./tests/vm/run-tests.sh ${{ matrix.os }}

- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: results-${{ matrix.os }}
path: tests/vm/results/

- name: Cleanup
if: always()
run: ./tests/vm/cleanup.sh
23 changes: 23 additions & 0 deletions .github/workflows/test-unit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Unit Tests

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

jobs:
unit:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4

- name: Install bats
run: |
sudo apt-get update
sudo apt-get install -y git curl
git clone --branch v1.13.0 --depth 1 https://github.com/bats-core/bats-core.git /tmp/bats-core
sudo /tmp/bats-core/install.sh /usr/local

- name: Run unit tests
run: bats tests/unit/*.bats
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
config.local
config.local.*

# Test artifacts
tests/vm/results/
tests/vm/work/
63 changes: 54 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ Small utility to manage home server services dependent on encrypted storage.
## Requirements

- **cryptsetup**: Version 2.4.0+ (supports both LUKS and BitLocker encryption)
- Note: cryptsetup >=2.4.0 is only available in Debian 12+ and Ubuntu 22.04+.
- BitLocker support and all E2E/integration tests require these or newer OS versions.
- **lvm2**: Required only if using LVM volumes
- **Root privileges**: Script must be run as root
- **GNU coreutils**: Required for version comparison (`sort -V`)
- **systemd**: Required for service management
- **Root privileges**: Script must be run as root for start/stop/unlock operations

## Configuration

Expand All @@ -35,7 +39,6 @@ readonly ST_USER_2="bob"
# Service names (constructed automatically)
readonly ST_SERVICE_1="syncthing@${ST_USER_1}.service"
readonly ST_SERVICE_2="syncthing@${ST_USER_2}.service"
readonly DOCKER_SERVICE="docker.service"
```

### Storage Device Configuration
Expand All @@ -47,28 +50,39 @@ Each storage device supports:
- **LVM Support**: Optional logical volume management
- **Encryption Type**: Either `luks` or `bitlocker`
- **Key Files**: Optional for automated unlocking
- **Ownership**: Optional user/group for mount point
- **Mount Options**: Additional filesystem mount options

Example for BitLocker device:
Example for a BitLocker device (all fields shown):

```bash
readonly STORAGE_2A_MOUNT="storage2a"
readonly STORAGE_2A_MAPPER="storage2a-data"
readonly STORAGE_2A_UUID="your-device-uuid"
readonly STORAGE_2A_KEY_FILE="/path/to/recovery.key"
readonly STORAGE_2A_ENCRYPTION_TYPE="bitlocker"
readonly STORAGE_2A_MOUNT="storage2a" # Mount point under /mnt/
readonly STORAGE_2A_MAPPER="storage2a-data" # Device mapper name
readonly STORAGE_2A_LVM_NAME="none" # LVM volume name ("none" to disable)
readonly STORAGE_2A_LVM_GROUP="vg-srv" # LVM group (used if LVM enabled)
readonly STORAGE_2A_UUID="your-device-uuid" # Device UUID (find with: sudo blkid)
readonly STORAGE_2A_KEY_FILE="/path/to/key" # Key file path ("none" for interactive)
readonly STORAGE_2A_ENCRYPTION_TYPE="bitlocker" # "luks" or "bitlocker"
readonly STORAGE_2A_OWNER_USER="sync_srv" # Mount ownership user ("none" to skip)
readonly STORAGE_2A_OWNER_GROUP="sync_srv" # Mount ownership group ("none" to skip)
readonly STORAGE_2A_MOUNT_OPTIONS="defaults" # Additional mount options
```

> **Note**: See `config.local.template` for the complete list of all configurable devices and their default values.

## Usage

```bash
sudo ./srv-ctl.sh start # Start all services and mount devices
sudo ./srv-ctl.sh stop # Stop all services and unmount devices
sudo ./srv-ctl.sh unlock-only # Only unlock and mount devices
sudo ./srv-ctl.sh stop-services-only # Only stop services
./srv-ctl.sh validate-config # Validate configuration without making changes
./srv-ctl.sh validate-config # Validate configuration (no root required)
./srv-ctl.sh help # Show help message
```

**Note**: The `validate-config` command does not require root privileges unless key files have restricted permissions.

## Migration from Old Format

If you have an existing `config.local` from an earlier version, you'll need to update it to the new format. The main changes:
Expand All @@ -87,3 +101,34 @@ If you have an existing `config.local` from an earlier version, you'll need to u
- Enhanced validation and error handling

Use `./srv-ctl.sh validate-config` to check your configuration after updating.

## Development & Testing

The project includes comprehensive tests with VM-based testing:

```bash
# Run local tests (no root required)
./tests/run-tests.sh

# Run full VM tests (CI only, multi-OS)
./tests/vm/run-vm-tests.sh ubuntu-22.04
```

See [`tests/README.md`](tests/README.md) for detailed testing documentation.

## Project Structure

```
srv-ctl/
├── srv-ctl.sh # Main script
├── lib/
│ ├── os-utils.sh # OS-level utilities
│ └── storage.sh # Storage operations
├── config.local.template # Configuration template
└── tests/ # Test suite
```

## License

See repository for license information.

25 changes: 21 additions & 4 deletions config.local.template
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ readonly CRYPTSETUP_MIN_VERSION="2.4.0"
readonly ST_USER_1="none" # Set to username to enable (e.g., "alice")
readonly ST_USER_2="none" # Set to username to enable (e.g., "bob")

# Service names (automatically constructed)
readonly ST_SERVICE_1="${ST_USER_1:+syncthing@${ST_USER_1}.service}"
readonly ST_SERVICE_2="${ST_USER_2:+syncthing@${ST_USER_2}.service}"
# Service names (automatically constructed from user names)
readonly ST_SERVICE_1=$([ "$ST_USER_1" != "none" ] && echo "syncthing@${ST_USER_1}.service" || echo "none")
readonly ST_SERVICE_2=$([ "$ST_USER_2" != "none" ] && echo "syncthing@${ST_USER_2}.service" || echo "none")
Comment on lines +13 to +14

Copilot AI Dec 26, 2025

Copy link

Choose a reason for hiding this comment

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

The service name construction using command substitution with square bracket tests creates unnecessary complexity. If ST_USER_1 is "none", this will still construct "syncthing@none.service" string before the test. Consider using a simpler conditional assignment or moving this logic into a function for better maintainability.

Suggested change
readonly ST_SERVICE_1=$([ "$ST_USER_1" != "none" ] && echo "syncthing@${ST_USER_1}.service" || echo "none")
readonly ST_SERVICE_2=$([ "$ST_USER_2" != "none" ] && echo "syncthing@${ST_USER_2}.service" || echo "none")
if [ "$ST_USER_1" != "none" ]; then
readonly ST_SERVICE_1="syncthing@${ST_USER_1}.service"
else
readonly ST_SERVICE_1="none"
fi
if [ "$ST_USER_2" != "none" ]; then
readonly ST_SERVICE_2="syncthing@${ST_USER_2}.service"
else
readonly ST_SERVICE_2="none"
fi

Copilot uses AI. Check for mistakes.
readonly DOCKER_SERVICE="none" # Set to "docker.service" to enable

# -----------------------------------------------------------------------------
Expand All @@ -25,6 +25,9 @@ readonly PRIMARY_DATA_LVM_GROUP="vg-srv" # LVM group name (used if LVM volume i
readonly PRIMARY_DATA_UUID="none" # Set to device UUID to enable (find with: sudo blkid)
readonly PRIMARY_DATA_KEY_FILE="none" # Set to key file path for automated unlock
readonly PRIMARY_DATA_ENCRYPTION_TYPE="luks" # Options: "luks" or "bitlocker"
readonly PRIMARY_DATA_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv")
readonly PRIMARY_DATA_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv")
readonly PRIMARY_DATA_MOUNT_OPTIONS="defaults" # Additional mount options (umask, etc.)

# -----------------------------------------------------------------------------
# Storage devices for Syncthing service 1
Expand All @@ -37,6 +40,9 @@ readonly STORAGE_1A_LVM_GROUP="vg-srv" # LVM group name (used if LVM volume is
readonly STORAGE_1A_UUID="none" # Set to device UUID to enable (find with: sudo blkid)
readonly STORAGE_1A_KEY_FILE="none" # Set to key file path for automated unlock
readonly STORAGE_1A_ENCRYPTION_TYPE="luks" # Options: "luks" or "bitlocker"
readonly STORAGE_1A_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv")
readonly STORAGE_1A_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv")
readonly STORAGE_1A_MOUNT_OPTIONS="defaults" # Additional mount options (umask, etc.)

readonly STORAGE_1B_MOUNT="storage1b" # Mount point under /mnt/
readonly STORAGE_1B_MAPPER="storage1b-data" # Device mapper name
Expand All @@ -45,6 +51,9 @@ readonly STORAGE_1B_LVM_GROUP="vg-srv" # LVM group name (used if LVM volume is
readonly STORAGE_1B_UUID="none" # Set to device UUID to enable (find with: sudo blkid)
readonly STORAGE_1B_KEY_FILE="none" # Set to key file path for automated unlock
readonly STORAGE_1B_ENCRYPTION_TYPE="luks" # Options: "luks" or "bitlocker"
readonly STORAGE_1B_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv")
readonly STORAGE_1B_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv")
readonly STORAGE_1B_MOUNT_OPTIONS="defaults" # Additional mount options (umask, etc.)

# -----------------------------------------------------------------------------
# Storage devices for Syncthing service 2
Expand All @@ -57,6 +66,9 @@ readonly STORAGE_2A_LVM_GROUP="vg-srv" # LVM group name (used if LVM volume is
readonly STORAGE_2A_UUID="none" # Set to device UUID to enable (find with: sudo blkid)
readonly STORAGE_2A_KEY_FILE="none" # Set to key file path for automated unlock
readonly STORAGE_2A_ENCRYPTION_TYPE="luks" # Options: "luks" or "bitlocker"
readonly STORAGE_2A_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv")
readonly STORAGE_2A_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv")
readonly STORAGE_2A_MOUNT_OPTIONS="defaults" # Additional mount options (umask, etc.)

readonly STORAGE_2B_MOUNT="storage2b" # Mount point under /mnt/
readonly STORAGE_2B_MAPPER="storage2b-data" # Device mapper name
Expand All @@ -65,6 +77,9 @@ readonly STORAGE_2B_LVM_GROUP="vg-srv" # LVM group name (used if LVM volume is
readonly STORAGE_2B_UUID="none" # Set to device UUID to enable (find with: sudo blkid)
readonly STORAGE_2B_KEY_FILE="none" # Set to key file path for automated unlock
readonly STORAGE_2B_ENCRYPTION_TYPE="luks" # Options: "luks" or "bitlocker"
readonly STORAGE_2B_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv")
readonly STORAGE_2B_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv")
readonly STORAGE_2B_MOUNT_OPTIONS="defaults" # Additional mount options (umask, etc.)

# -----------------------------------------------------------------------------
# Network share configuration
Expand All @@ -74,4 +89,6 @@ readonly NETWORK_SHARE_ADDRESS="none" # Set to share path to enable (e.g., "//s
readonly NETWORK_SHARE_MOUNT="none" # Set to mount name (e.g., "network")
readonly NETWORK_SHARE_PROTOCOL="none" # Set to protocol: "cifs", "nfs", etc.
readonly NETWORK_SHARE_CREDENTIALS="none" # Set to credentials file path
readonly NETWORK_SHARE_OPTIONS="uid=1000,gid=1000,iocharset=utf8" # Mount options
readonly NETWORK_SHARE_OWNER_USER="none" # Set to username for mount ownership (e.g., "sync_srv")
readonly NETWORK_SHARE_OWNER_GROUP="none" # Set to group name for mount ownership (e.g., "sync_srv")
readonly NETWORK_SHARE_OPTIONS="iocharset=utf8" # Additional mount options (vers=3.0, etc.)
Loading
Loading