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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.13.6
1.13.8
20 changes: 20 additions & 0 deletions ansible/playbooks/sync_instance_configs_and_restart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@
delegate_to: localhost
become: false

- name: Ensure user-hooks directory exists on host
ansible.builtin.file:
path: "{{ qlds_dir }}/user-hooks"
state: directory
owner: ql
group: ql
mode: '0755'

- name: Sync user LD_PRELOAD hooks from UI server to instance user-hooks dir
ansible.builtin.synchronize:
src: "../../configs/{{ host_name }}/{{ qlds_id }}/user-hooks/"
dest: "{{ qlds_dir }}/user-hooks/"
delete: yes
rsync_path: "sudo -u ql rsync"
rsync_opts:
- "--include=*.so"
- "--exclude=*"
delegate_to: localhost
become: false

- name: Backfill common minqlx-plugins to instance directory
ansible.builtin.command:
argv:
Expand Down
35 changes: 11 additions & 24 deletions docs/api_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,6 @@ Example success response:
| `/instances/<id>/config` | GET | Get instance config files |
| `/instances/<id>/config` | PUT | Update config and apply (triggers Ansible sync) |
| `/instances/<id>/hooks` | GET | List available LD_PRELOAD hook shared objects |
| `/instances/<id>/hooks` | PUT | Replace enabled LD_PRELOAD hook list and queue apply |
| `/instances/<id>/hooks/files` | POST | Upload a new hook `.so` file |
| `/instances/<id>/hooks/files/<filename>` | GET/PUT/PATCH/DELETE | Download, replace, rename, or delete a hook file |
| `/instances/<id>/hooks/files/<filename>/description` | PATCH | Set a hook file description |
Expand Down Expand Up @@ -423,37 +422,25 @@ BinaryMetadata description, and active system hooks.
}
```

```
PUT /api/instances/<id>/hooks
```

Replaces the ordered LD_PRELOAD hook list. Files in `scripts/` that are absent
from `enabled` are disabled.
Hook enable/order changes are saved through the shared config endpoint:

```json
{
"enabled": ["highfps_hook.so", "timer_hook.so"]
}
```http
PUT /api/instances/<id>/config
```

Validation requires each filename to be a unique `.so` basename with no path
separators, no `..`, not reserved for a system hook, present under `user-hooks/`
(or `scripts/` for legacy instances), and an ELF file. Validation failures
return `400`; held instance locks return `409`.

Success returns `202 Accepted` and queues `apply_instance_hooks`.

```json
{
"data": {
"task_id": "rq-job-id"
}
"configs": { "server.cfg": "...", "mappool.txt": "...", "access.txt": "...", "workshop.txt": "..." },
"enabled_hooks": ["highfps_hook.so", "timer_hook.so"],
"restart": true
}
```

Running instances restart after the unit is re-rendered with
`Environment=LD_PRELOAD=...`; stopped instances update the unit and remain
stopped.
`enabled_hooks` replaces the ordered LD_PRELOAD hook list after filtering to
`.so` basenames that exist in the instance `user-hooks/` directory. Running
instances with changed hooks are forced to restart even if a client submits
`"restart": false`; stopped instances with hook-only changes are applied with
`restart=false` and remain stopped.

### Hook File CRUD

Expand Down
106 changes: 46 additions & 60 deletions docs/technical.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ The Flask application follows the application factory pattern, which provides se
- `index_routes.py`: Handles the main index page.
* `host_routes.py`: Handles CRUD operations for Hosts, protected by `@jwt_required()`.
* `instance_routes.py`: Handles CRUD operations for QLInstances, protected by `@jwt_required()`.
* `instance_hooks_routes.py`: Lists and updates per-instance LD_PRELOAD hook selections from uploaded `.so` files.
* `instance_hooks_routes.py`: Lists and manages per-instance LD_PRELOAD hook files from uploaded `.so` files. Hook selection writes use `PUT /instances/<id>/config` rather than a separate user-facing apply route.
* `preset_api_routes.py`: Handles CRUD operations for ConfigPresets, protected by `@jwt_required()`. Preset writes accept generic config and factory maps, plugin draft IDs, checked plugin lists, and checked factory lists. Mutating operations (rename, content update, delete) are rejected with `403` for any preset where `is_builtin = True`.
* `server_status_routes.py`: Handles live status retrieval (`GET /api/server-status`) and workshop preview lookup (`GET /api/server-status/workshop-preview/<workshop_id>`).
* `settings_routes.py`: Handles application settings management (API keys, rate limit config).
Expand Down Expand Up @@ -238,7 +238,8 @@ The project uses pytest for testing, with fixtures defined in `tests/conftest.py
- **Playbook Structure:**
- **`ansible/playbooks/setup_host.yml`:** Performs the initial one-time setup on a newly provisioned host. Installs prerequisites (including `iptables-persistent`, and `redis-server` only when the host runtime needs its own Redis), configures the firewall using a template (`ansible/templates/iptables.rules.j2`) that defines both filter and NAT rules which are applied atomically via `iptables-restore` and persisted, creates the `ql` user, installs base SteamCMD/QLDS/minqlx to shared locations (`/home/ql/qlds-base`, `/home/ql/minqlx-shared`), and syncs common assets (`/home/ql/assets/common`).
- **`ansible/playbooks/add_qlds_instance.yml`:** Adds a new QLDS instance to a pre-configured host. Creates the instance directory (`/home/ql/qlds-{id}`), copies shared resources (QLDS base, minqlx, common assets) into the instance directory, syncs instance-specific configuration files from the UI server (`configs/<host>/<id>/`), installs instance-specific Python dependencies, and manages the systemd service.
- **`ansible/playbooks/update_instance_hooks.yml`:** Re-renders a QLDS instance systemd unit with the current LD_PRELOAD value and daemon-reloads systemd. It restarts the service only when the accepted hook update came from a non-stopped instance.
- **`ansible/playbooks/sync_instance_configs_and_restart.yml`:** Applies saved configuration changes to an existing instance. It syncs configs, factories, plugin drafts, and `user-hooks/` to the host, re-renders the service unit with the current `LD_PRELOAD`, and restarts only when requested/required.
- **`ansible/playbooks/update_instance_hooks.yml`:** System-hook maintenance path used by backend tasks to re-render a QLDS instance systemd unit with the current LD_PRELOAD value and daemon-reload systemd when a system hook changes outside the user Save Configuration flow.
- **`ansible/playbooks/manage_qlds_service.yml`:** Manages the `qlds@<id>.service` systemd service (start, stop, restart, delete service file).
- **`ansible/playbooks/get_qlds_logs.yml`:** Retrieves logs for a specific instance service.
- **`ansible/playbooks/setup_qlfilter.yml`:** Installs QLFilter (eBPF/XDP packet filter) on a target host.
Expand All @@ -265,27 +266,37 @@ The project uses pytest for testing, with fixtures defined in `tests/conftest.py

### LD_PRELOAD Hooks

Per-instance LD_PRELOAD hooks are stored on `QLInstance.ld_preload_hooks` as a
comma-separated list of uploaded `.so` filenames. `_build_ld_preload_paths()` in
`ui/task_logic/ansible_instance_mgmt.py` converts that list into a colon-joined
path string, prepending any active system hooks. The system hook registry is
empty today; `force_rate.so` is reserved so a future built-in hook cannot be
shadowed by a user upload.
Per-instance user hook selections are stored on `QLInstance.ld_preload_hooks` as
a comma-separated list of uploaded `.so` filenames. In the user-facing save flow,
the Hooks tab sends those selections as `enabled_hooks` through
`PUT /api/instances/<id>/config`; the normal **Save Configuration** path validates the
list, drops entries whose binaries are not present in the instance `user-hooks/`
directory, persists the filtered order, and queues the config sync/restart task.
The dedicated user-facing `PUT /instances/<id>/hooks` route/client apply path has
been removed; hook file CRUD routes remain for upload, download, replace, rename,
delete, and description edits.

`_build_ld_preload_paths()` in `ui/task_logic/ansible_instance_mgmt.py` converts
the stored user hook list into a colon-joined path string, prepending any active
system hooks. The system-hook task path remains available for backend-managed
system hooks such as `force_rate.so` when their predicates change outside the
user Save Configuration flow.

The `qlds@.service.j2` template emits `Environment=LD_PRELOAD=...` only when
the computed value is non-empty. Existing deploy, restart, config apply, and
LAN-rate reconfigure flows pass the same `ld_preload_paths` extra-var so later
unit re-renders preserve hook state.

The `apply_instance_hooks` RQ task delegates to
`ui/task_logic/ansible_instance_hooks.py`. It re-validates enabled hooks with a
pre-flight existence and ELF-magic check, preserves CPU affinity while
re-rendering the unit, and leaves stopped instances stopped by passing
`restart_service=false` to `update_instance_hooks.yml`.
- **Terraform Run Logging:**
* Detailed stdout and stderr from Terraform CLI executions (triggered by tasks in `ui/task_logic/terraform_provision.py` and `ui/task_logic/terraform_destroy.py`) are no longer stored directly in the `Host.logs` database field.
* Instead, these verbose logs are saved to individual files within the `logs/terraform_runs/` directory (e.g., `logs/terraform_runs/host_<host_id>_<task_name>_<command>_<job_id>_<timestamp>.log`). This is managed by the `save_terraform_run_log` function in `ui/task_logic/file_logger.py`.
* The `Host.logs` database field now stores concise, timestamped status messages, including a reference to the path of the detailed log file for each Terraform command executed.
the computed value is non-empty. Deploy, restart, LAN-rate reconfigure, and Save
Configuration flows pass the same `ld_preload_paths` extra-var so later unit
re-renders preserve hook state. `sync_instance_configs_and_restart.yml` now syncs
`user-hooks/` to the game host as part of Save Configuration before templating the
unit; running instances with hook changes force a restart, while stopped instances
are templated and left stopped.

### Terraform Run Logging

Detailed stdout and stderr from Terraform CLI executions (triggered by tasks in `ui/task_logic/terraform_provision.py` and `ui/task_logic/terraform_destroy.py`) are no longer stored directly in the `Host.logs` database field.

Instead, these verbose logs are saved to individual files within the `logs/terraform_runs/` directory (e.g., `logs/terraform_runs/host_<host_id>_<task_name>_<command>_<job_id>_<timestamp>.log`). This is managed by the `save_terraform_run_log` function in `ui/task_logic/file_logger.py`.

The `Host.logs` database field now stores concise, timestamped status messages, including a reference to the path of the detailed log file for each Terraform command executed.

### QLDS CPU Affinity

Expand Down Expand Up @@ -352,18 +363,23 @@ re-run later.

## 99k LAN Rate Mode

The 99k LAN Rate Mode is a per-instance configurable feature that enables high-bandwidth LAN server functionality using NAT-based iptables rules. This allows servers to bypass the default 25k rate limit for clients connecting over the internet.
The 99k LAN Rate Mode is a per-instance option that lets QLDS offer LAN-rate
settings to internet clients. On hosts migrated to the hook mechanism
(`Host.lan_rate_uses_hook = true`), QLSM enables this by activating the reserved
system hook `force_rate.so` through the same LD_PRELOAD/system-hook pipeline used
for hook maintenance. On legacy hosts, QLSM falls back to the older NAT-based
iptables path.

### Overview

When enabled, LAN rate mode:
1. Configures the QLDS server with LAN-specific settings (`sv_serverType 1`, `sv_lanForceRate 1`)
2. Sets up NAT iptables rules to redirect external traffic through localhost
3. Enables the `route_localnet` kernel parameter to allow routing to 127.0.0.1
2. On migrated hosts, enables the `force_rate.so` system hook when templating the service unit
3. On legacy hosts, applies NAT/`route_localnet` rules so external clients appear as LAN clients

When disabled (default for internet servers):
1. Configures the QLDS server for internet mode (`sv_serverType 2`, `sv_lanForceRate 0`)
2. No NAT rules are applied - traffic goes directly to the server
2. Removes the migrated-host system-hook predicate or legacy NAT behavior from the active service config

### Server Arguments

Expand All @@ -377,43 +393,13 @@ When disabled (default for internet servers):
+set sv_serverType 2 +set sv_lanForceRate 0
```

### Network Configuration

The LAN rate mode uses iptables NAT rules to make external clients appear as localhost connections:

**NAT Rules (per port with LAN rate enabled):**
```bash
# PREROUTING: Redirect incoming packets to localhost
iptables -t nat -A PREROUTING -p udp --dport <port> -j DNAT --to-destination 127.0.0.1

# POSTROUTING: Source NAT for responses
iptables -t nat -A POSTROUTING -p udp -d 127.0.0.1 --dport <port> -j SNAT --to-source 127.0.0.1

# INPUT (required for NAT to work properly)
iptables -t nat -A INPUT -d 127.0.0.1 -j SNAT --to-source 127.0.0.1
```

**Kernel Parameter:**
```bash
sysctl -w net.ipv4.conf.all.route_localnet=1
```

### Implementation Details

- **Database:** `lan_rate_enabled` boolean field on `QLInstance` model (default: `false`)
- **API Endpoint:** `PUT /api/instances/<id>/lan-rate` to toggle LAN rate on existing instances
- **Ansible Playbooks:**
- `add_qlds_instance.yml`: Conditionally sets up route_localnet when deploying with LAN rate enabled
- `update_instance_lan_rate.yml`: Toggles LAN rate on existing instances (updates systemd service, adds/removes NAT rules, restarts service)
- **Task Logic:** `reconfigure_instance_lan_rate_logic()` in `ui/task_logic/ansible_instance_mgmt.py`

### Host-Wide Settings

The `route_localnet` sysctl setting is host-wide. Once enabled for any instance with LAN rate, it remains enabled. This is safe because:
- Enabling it when not needed is harmless
- Disabling it when any instance still needs it would break those instances

The NAT iptables rules are per-instance (per-port) and are added/removed individually.
- **Database:** `lan_rate_enabled` boolean field on `QLInstance` model (default: `false`); `Host.lan_rate_uses_hook` selects migrated hook-based handling versus the legacy NAT path.
- **API Endpoint:** `PUT /api/instances/<id>/lan-rate` toggles LAN rate on existing instances.
- **Migrated host path:** `reconfigure_instance_lan_rate_logic()` delegates to `apply_instance_hooks_logic(..., restart_service=True)`, which re-renders the unit with current LD_PRELOAD paths and restarts the instance.
- **Legacy host path:** `update_instance_lan_rate.yml` updates systemd arguments, reconciles per-instance NAT rules, and restarts the service.
- **Host setup/migration:** setup and migration tasks install/sync `force_rate.so` and mark hosts as hook-capable when the system-hook path is available.

### Frontend

Expand Down
24 changes: 21 additions & 3 deletions docs/user/features/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,38 @@ QLSM validates that uploaded files are ELF binaries (checks the magic bytes). No

After uploading, the hook appears in the list in disabled state.

Uploads are stored immediately on the QLSM server. The new binary is copied to the game host the next time you click **Save Configuration**.

### Enable Or Disable A Hook

Toggle the switch on the hook row. The change takes effect on the next instance restart (or immediately if "Restart after saving" is enabled).
Toggle the switch on the hook row, then click **Save Configuration**. Hook selections are saved with the rest of the instance configuration.

### Reorder Hooks

Drag hook rows to change the load order. Hooks are passed to `LD_PRELOAD` in top-to-bottom order. System hooks always load before user hooks regardless of position.

Reordering is also saved through **Save Configuration**.

### Delete A Hook

Click the delete (trash) icon on the hook row and confirm in the modal. Deleting a hook removes it from the host on the next sync.
Click the delete (trash) icon on the hook row and confirm in the modal. Deleting a hook removes it from the game host on the next **Save Configuration** sync.

Deletes happen immediately on the QLSM server. The removed binary and any resulting `LD_PRELOAD` changes are reflected on the game host the next time you click **Save Configuration**.

## Saving Hook Changes

Hook enable/disable and reorder changes use the same **Save Configuration** button as config, plugin, factory, and basic instance settings.

- **Running instances:** hook changes require the QLDS process to restart so `LD_PRELOAD` can be rebuilt. QLSM forces the restart toggle on and disables it for that save; clicking **Save Configuration** syncs files, templates the systemd unit, and restarts the instance automatically.
- **Stopped instances:** clicking **Save Configuration** syncs files and templates the systemd unit, but the instance stays stopped. Start it manually when you are ready to run with the new hook set.
- **File uploads/deletes:** upload and delete operations happen immediately on the QLSM server, but hook binaries are copied to or removed from the game host only on the next **Save Configuration**.

## Missing Hooks

If a hook file exists in the database but its binary is missing from the host filesystem, QLSM shows a warning row with the filename highlighted. Click the remove button on that row to delete the stale entry. This can happen if host files were deleted out-of-band.

If a missing hook remains selected, QLSM drops it on the next **Save Configuration**. Stale `LD_PRELOAD` entries can block the server from starting, so missing binaries are not preserved indefinitely.

## Load Order

When an instance starts, QLSM builds the `LD_PRELOAD` value in this order:
Expand All @@ -57,7 +73,9 @@ When an instance starts, QLSM builds the `LD_PRELOAD` value in this order:

## Hooks In Presets

Saving a preset from an instance also records which user hooks were enabled (and their load order). Loading that preset onto another instance and saving replaces the target instance's enabled hooks to match — see [Presets And Default Config](../presets/overview.md).
The **Load Preset** and **Save Preset** buttons are available on the Hooks tab, the same as every other tab in the editor.

Saving a preset from an instance also records which user hooks were enabled (and their load order) — capturing the current selection shown in the Hooks tab, including changes you haven't saved yet. Loading that preset onto another instance and saving replaces the target instance's enabled hooks to match — see [Presets And Default Config](../presets/overview.md).

## Related Pages

Expand Down
2 changes: 2 additions & 0 deletions docs/user/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ QLSM uses `v<major>.<minor>.<patch>` tags. Every merged pull request is listed a

| Version | Date | PR | Changes |
| --- | --- | --- | --- |
| `v1.13.8` | 2026-07-07 | — | Add Load Preset and Save Preset buttons to the Hooks tab; Save Preset captures the current hook selection. |
| `v1.13.7` | 2026-07-05 | — | Hooks now save through Save Configuration; removed the separate Apply & Restart button. |
| `v1.13.6` | 2026-07-03 | [#136](https://github.com/dngrtech/qlsm/pull/136) | Fix presets losing their LD_PRELOAD hook files when loaded onto an instance, and make presets remember which hooks were enabled. |
| `v1.13.5` | 2026-07-03 | [#135](https://github.com/dngrtech/qlsm/pull/135) | Fix preset ZIP import rejecting `.so` plugin scripts, and surface `.so` scripts correctly in the preset API instead of silently dropping them. |
| `v1.13.4` | 2026-07-02 | — | Bug fixes and improvements. |
Expand Down
2 changes: 1 addition & 1 deletion docs/user/version.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"latest": "1.13.6",
"latest": "1.13.8",
"releaseNotesUrl": "https://dngrtech.github.io/qlsm/releases/"
}
Loading
Loading