Metadata-Version: 2.4
Name: fexec
Version: 0.1.39
Summary: Secure Execution Gateway - API-first service for executing pre-defined system commands
Author-email: Jan Nitecki <jnitecki@tbss-ltd.com>
License-Expression: LicenseRef-Proprietary
Project-URL: Homepage, https://github.com/jnitecki/fexec
Project-URL: Repository, https://github.com/jnitecki/fexec
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Development Status :: 5 - Production/Stable
Classifier: Programming Language :: Cython
Requires-Python: >=3.7
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Flask>=2.0.0
Requires-Dist: PyJWT>=2.0.0
Requires-Dist: PyYAML>=6.0
Requires-Dist: docker>=5.0.0
Requires-Dist: python-dotenv>=0.19.0
Requires-Dist: cryptography>=41.0.0
Requires-Dist: watchdog>=4.0.0
Dynamic: classifier
Dynamic: license-file

# FExec - Secure Execution Gateway

FExec is an HTTP API service that executes pre-defined system commands or Docker container tasks on demand. It acts as a security boundary between untrusted callers and the host execution environment: only explicitly configured command aliases can be triggered, and every parameter is validated before use.

## Objectives

Containers that provide functionality used only occasionally still consume memory and CPU while they sit idle, waiting to be needed. If usage follows a regular schedule, a cron job can start the container when needed and stop it afterwards. If usage is purely interactive, a person can start it by hand. But when the functionality is needed on demand — triggered by an event such as a step in an automated workflow — neither option fits: nothing is watching for the event, so the container either runs continuously to be ready (wasting resources) or isn't there when the event happens.

The obvious workaround, for local automation, is to give the calling process access to the Docker socket so it can start containers itself. That solves the on-demand problem, but at a cost: anything with socket access can run arbitrary containers with no restriction on image, command, or resources — an unauthenticated, unaudited path to full control of the Docker daemon.

FExec exists to close that gap. It replaces direct Docker socket access with a narrow, authenticated HTTP API: callers can only trigger commands or containers an operator has explicitly pre-defined as aliases, with every parameter validated before use. Functionality that's needed only occasionally can be started exactly when it's needed — without leaving it running all the time, and without handing callers unrestricted control of the host.

## Background

FExec grew out of a concrete need: running browser-based web automation (via Playwright) as part of a larger workflow. Playwright containers are memory-heavy, and running them permanently — especially alongside other, similar occasional-use workloads competing for the same memory — wasn't a good trade-off.

The automation itself was orchestrated by an internal n8n instance, but the intent was to expose the ability to trigger it more broadly, including to callers outside the local network, while tightly restricting which containers could run and what they were allowed to do. That requirement put potentially dangerous operations — starting arbitrary containers or processes — behind three layers of protection: authentication of every call to FExec (calls to the Docker socket itself are never authenticated), a strict, operator-defined whitelist of what can be run at all (no arbitrary commands, ever), and granular authorization that determines, per caller, which of those pre-defined aliases they're actually allowed to invoke.

## Execution Modes and Types

Every action FExec's API can perform is defined as an alias — a named, operator-authored configuration that combines what to run, how input and output are mapped, how it's logged, and other execution details. Each alias is configured along two independent dimensions: **type** (what runs) and **mode** (how its lifecycle and invocation are handled).

Each alias is configured as one of two execution types:

- **Container** — FExec starts a Docker container, runs it to completion, and returns the result. Input and output are exchanged with the containerized process through whichever combination of environment variables, extra command-line arguments, streams, and files the alias maps to the API's parameters.
- **Process** — FExec starts a normal host process instead of a container, using the same range of input/output mapping options (environment variables, command-line arguments, streams, files).

Both types share the same authentication, authorization, and parameter validation — the only difference is what actually runs: a container or a host process.

Every alias also declares a `mode`, mandatory, which currently must be `inline` — the only mode supported today. In Inline mode (for both Container and Process types), the API call blocks until the container/process runs to completion, and its output/exit code is returned inline with the response.

## How It Works

Clients call FExec over HTTP, authenticating with a JWT or API key. FExec looks up the requested alias, validates all parameters against the alias's regex rules, executes the command (on the host or in a Docker container), and returns the result. No arbitrary commands can be run — only aliases defined by the operator.

## Installation

```bash
pip install .
```

Or build a wheel first:

```bash
python -m build
pip install dist/fexec-*.whl
```

## Quick Start

1. Create `/etc/fexec/fexec.conf` (or use defaults — the file is optional; a warning is printed if it is absent):

```ini
ListenPort = 8080
LogLevel   = info
AppLog     = /var/log/fexec/app.log info json
AuditLog   = /var/log/fexec/audit.log
```

2. Add at least one authentication file to `/etc/fexec/auth.d/` and at least one alias to `/etc/fexec/alias.d/`. See [CONFIGURATION.md](CONFIGURATION.md) for format details.

3. Start the service:

```bash
fexecd
# or override specific settings via CLI flags:
fexecd --config /path/to/fexec.conf --listen-port 9000
# or via environment variables:
FEXEC_LISTEN_PORT=9000 FEXEC_LOG_LEVEL=info fexecd
# run as a background daemon (detach from terminal):
fexecd --daemon
# print usage and all accepted flags, then exit:
fexecd --help
# print version string and exit:
fexecd --version
```

## API

### Authentication

Every request must include one of:

| Header | Value |
|--------|-------|
| `Authorization` | `Bearer <JWT>` |
| `X-API-Key` | `<key>` |

### Endpoint

```
POST /v1/{alias_name}
```

### Request Body

Parameters can be sent in any of the following formats:

**JSON** (`Content-Type: application/json`):
```json
{ "param1": "value1", "param2": "value2" }
```

**Form-encoded** (`Content-Type: application/x-www-form-urlencoded`):
```
param1=value1&param2=value2
```

**Multipart form** (`Content-Type: multipart/form-data`):
```
-F param1=value1 -F param2=value2
```

A request with no body (absent or zero Content-Length) is always treated as having no parameters, regardless of the Content-Type header. A request with a non-zero body and an unrecognised Content-Type is rejected with `400`.

### Response Codes

| Code | Meaning |
|------|---------|
| `200` | Success — body contains the alias output. Also returned when the command exited non-zero but the alias's `exitCodeMap` explicitly maps that exit code to 200 |
| `400` | Bad request — missing/invalid/extra parameters |
| `401` | Missing or invalid credentials |
| `403` | Credentials valid but alias not permitted for this user |
| `404` | Alias does not exist |
| `408` | Execution timed out and the process/container was killed by FExec |
| `422` | Command completed but exited with a non-zero code (default; overridable per alias via `exitCodeMap` — see [Exit Code Status Mapping](CONFIGURATION.md#exit-code-status-mapping)) |
| `500` | Command executed but failed internally (infrastructure-level failure, e.g. Docker unreachable) |
| `503` | Service is shutting down |

`200` from FExec means both FExec *and* the invoked process/container
succeeded — not merely that FExec was able to run it — except when
`exitCodeMap` has explicitly reassigned that exit code's status.

### Example

```bash
curl -X POST https://localhost:443/v1/generate-report \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <jwt>" \
  -d '{"type": "pdf", "date": "2024-01-15"}'
```

## Command Line Reference

Run `fexecd --help` (or `fexecd -h`) to see the full usage summary and exit without starting the service.

| Flag | Metavar | Config key | Environment variable |
|------|---------|------------|----------------------|
| `--help`, `-h` | — | — | — |
| `--version` | — | — | — |
| `--config` | `PATH` | — | — |
| `--daemon` | — | — | — |
| `--pid-file` | `PATH` | `PidFile` | `FEXEC_PID_FILE` |
| `--auth-dir` | `DIR` | `AuthDir` | `FEXEC_AUTH_DIR` |
| `--alias-dir` | `DIR` | `AliasDir` | `FEXEC_ALIAS_DIR` |
| `--shared-dir` | `DIR` | `SharedDir` | `FEXEC_SHARED_DIR` |
| `--external-dir` | `DIR` | `ExternalDir` | `FEXEC_EXTERNAL_DIR` |
| `--listen-address` | `ADDR` | `ListenAddress` | `FEXEC_LISTEN_ADDRESS` |
| `--listen-port` | `PORT` | `ListenPort` | `FEXEC_LISTEN_PORT` |
| `--ssl-enabled` | `true\|false\|1\|0` | `SslEnabled` | `FEXEC_SSL_ENABLED` |
| `--ssl-cert` | `PATH` | `SslCert` | `FEXEC_SSL_CERT` |
| `--ssl-key` | `PATH` | `SslKey` | `FEXEC_SSL_KEY` |
| `--log-level` | `LEVEL` | `LogLevel` | `FEXEC_LOG_LEVEL` |
| `--log-dir` | `DIR` | `LogDir` | `FEXEC_LOG_DIR` |
| `--audit-log` | `PATH` | `AuditLog` | `FEXEC_AUDIT_LOG` |
| `--app-log` | `SPEC` | `AppLog` | `FEXEC_APP_LOG` |
| `--auth-reload` | `SECONDS` | `AuthReload` | `FEXEC_AUTH_RELOAD` |
| `--alias-reload` | `SECONDS` | `AliasReload` | `FEXEC_ALIAS_RELOAD` |
| `--container` | `0\|1` | `Container` | `FEXEC_CONTAINER` |
| `--max-timeout` | `SECONDS` | `MaxTimeout` | `FEXEC_MAX_TIMEOUT` |
| `--refresh-image` | `always\|on-start\|no` | `RefreshImage` | `FEXEC_REFRESH_IMAGE` |
| `--prefetch-image` | `on-start\|on-config-change\|no` | `PrefetchImage` | `FEXEC_PREFETCH_IMAGE` |

**Precedence (highest first):** CLI flag → environment variable → `fexec.conf` → built-in default.

## Configuration

FExec is configured through four layers (highest precedence first):

1. **CLI flags** — e.g. `--listen-port 9000`
2. **Environment variables** — `FEXEC_<PARAM>` e.g. `FEXEC_LISTEN_PORT=9000`
3. **`/etc/fexec/fexec.conf`** — main service settings (port, SSL, log file, directories, reload intervals)
4. **`/etc/fexec/auth.d/*.yaml`** — users with API keys and permitted aliases; JWT public keys
5. **`/etc/fexec/alias.d/*.yaml`** — command aliases (what to run, parameters, output format)

All configuration is loaded at startup and validated. Any error causes FExec to refuse to start and print all errors to the console and log. FExec also refuses to start if no aliases or no authentication (users or JWT keys) are configured, or if the SSL certificate is expired or mismatched with its key. At runtime, auth and alias configuration is reloaded automatically when files change (configurable interval) and on `SIGHUP`. The main `fexec.conf` requires a restart to re-apply.

See [CONFIGURATION.md](CONFIGURATION.md) for a complete reference including all parameters, formats, and examples.

## Process Management

| Signal | Effect |
|--------|--------|
| `SIGHUP` | Reload auth and alias configuration (non-disruptive) |
| `SIGTERM` | Graceful shutdown — stops accepting new requests, waits for in-flight ones to finish |
| `SIGINT` (Ctrl-C) | Same graceful shutdown as SIGTERM |
| `SIGKILL` | Immediate termination |

A PID file is written to `/run/fexec.pid` (configurable via `PidFile`).

## Security Model

- **Whitelist execution**: only pre-configured aliases run; no arbitrary commands
- **Per-parameter validation**: every input validated against a regex defined in the alias
- **Container isolation**: Docker aliases run with `--network none`, `--read-only`, `--cap-drop ALL`
- **No state leakage**: per-execution temp directories are deleted after each run
- **Opaque errors**: internal failures return `500` without stack traces; details go to the log
- **Audit log**: every call is recorded with user identity, alias name, parameters, and exit status

## Logs

FExec writes two independent log streams:

| Stream | Default path | Content |
|--------|-------------|---------|
| **AuditLog** | `/var/log/fexec/audit.log` | One nginx-style line per request: timestamp, source IP, user, alias, duration, HTTP status, ApplicationCode, exit code, PID (process aliases) or container/image ID (container aliases), correlation ID |
| **AppLog** | `/var/log/fexec/app.log` | Application diagnostic messages (startup, errors, warnings). JSON format by default. Level-filtered — default level is `warn` (set `LogLevel = info` to see startup messages) |

Both targets are configurable in `fexec.conf` and accept `/dev/stdout` or `/dev/stderr` as the path. A relative path for either is resolved against `LogDir` (default `/var/log/fexec`). When running interactively (stdout is a TTY) and neither log targets stdout/stderr, AppLog entries are also echoed to stdout in plain-text format at the configured `LogLevel`.

Every failed-request entry in AppLog includes the same correlation ID as the corresponding AuditLog line, allowing the two to be cross-referenced.

## Volume Preserve

Container-type aliases can mark a volume with `preserve: flatten` or `preserve: yes` (in its YAML definition) to have that volume's content copied to `LogDir` after the container finishes, surviving the run directory's cleanup independent of the alias's `output`. See [CONFIGURATION.md](CONFIGURATION.md#volume-preserve) for the full semantics (applicability, naming, and collision handling).

## Stream Preserve

Any alias (process or container) can set `preserveStreams: true`, or list individual `stdout`/`stderr` entries, to have that execution's stdout/stderr copied to `LogDir` after it finishes, independent of the alias's `output`. See [CONFIGURATION.md](CONFIGURATION.md#stream-preserve) for the full semantics.

## Exit Code Status Mapping

By default a zero exit code returns HTTP 200 and any other exit code returns HTTP 422 — the response body is always built from the alias's `output` config either way. Any alias can override this via `exitCodeMap`, mapping specific exit codes or ranges to different HTTP statuses (including back to 200). See [CONFIGURATION.md](CONFIGURATION.md#exit-code-status-mapping) for the full semantics.
