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
226 changes: 48 additions & 178 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,208 +1,78 @@
# server-cli
# fly — the FlyWP server CLI

Easy CLI tool for servers managed by FlyWP.
`fly` manages the sites on a server that [FlyWP](https://flywp.com) provisions. It also runs the
FlyWP monitoring agent.

Conforms to the FlyWP monitoring agent contract v0.5.0.

## Installation

### Prerequisites

- [Docker](https://www.docker.com/get-started)
- [Docker Compose](https://docs.docker.com/compose/install/)

### Quick Install

You can easily install the `fly` CLI tool using the following command. This will download and run the `install.sh` script, which will automatically detect your operating system and architecture, download the latest release, and install it to `/usr/local/bin`:
## Install

```bash
curl -fsSL https://raw.githubusercontent.com/flywp/server-cli/main/install.sh | sudo bash
```

<details>

<summary>Manual Installation</summary>

### Manual Installation

If you prefer to manually download and install the binary, follow these steps:

1. Download the precompiled binaries from the [Releases](https://github.com/flywp/server-cli/releases) page. Choose the version suitable for your operating system and architecture.

1. Download the [latest tarball]((https://github.com/flywp/server-cli/releases)) for your platform:

```bash
wget https://github.com/flywp/server-cli/releases/download/v0.1.0/fly-linux-amd64.tar.gz
```

2. Extract the tarball:
```bash
tar -xzf fly-linux-amd64.tar.gz
```

3. Move the binary to a directory in your PATH:
```bash
sudo mv fly-linux-amd64 /usr/local/bin/fly
```

4. Verify the installation:
```bash
fly version
```

</details>

## Usage

### Base Docker Compose

FlyWP has a base Docker Compose configuration for running MySQL, Redis, Ofelia, and Nginx Proxy that are shared for all sites hosted on the server. The base Docker Compose must be started before a site can be created.

```bash
fly base start # starts the base services (mysql, redis, nginx-proxy)
fly base stop # stops the base services
fly base restart # restarts the base services
```

### Site Operations

You can run the following commands from anywhere inside a site folder or by specifying the domain name.

```bash
fly start --domain example.com # starts the website
fly stop --domain example.com # stops the website
fly restart --domain example.com # restarts the website
fly --domain example.com wp <command> # execute WP-CLI commands
fly logs --domain example.com # view logs from all containers or a single one
fly restart <container> --domain example.com # restart a container
fly --domain example.com exec [container] <command> # execute commands inside a container. Default: the PHP container
```

Or run the commands from within the site directory without specifying the domain:

```bash
fly start # starts the website
fly stop # stops the website
fly restart # restarts the website
fly wp <command> # execute WP-CLI commands
fly logs [container] # view logs from all containers or a single one
fly logs -f [container] # follow the logs (--tail N shows the last N lines)
fly restart <container> # restart a container
fly exec [container] <command> # execute commands inside a container. Default: the PHP container
```

### WP-CLI

**wp-cli**: To access `wp-cli`, use the following command from anywhere in the website folder or specify the domain name. The CLI will find the appropriate WordPress folder to execute the `wp` command.

```bash
fly --domain example.com wp plugin list --format=json
```

All arguments after the WP-CLI command (or after the command for `fly exec`) go to that command unchanged, flags included. Put `--domain` before the command. To pass a flag as the first argument, put `--` before it, for example `fly wp -- --info`.
The script installs the latest release for your architecture (Linux amd64 or arm64) to
`/usr/local/bin/fly`. It checks the download against `checksums.txt` first.

### Monitoring agent
To update later, run `sudo fly update`.

`fly agent run` is the FlyWP monitoring agent. It runs all the time under systemd (`fly-agent.service`, as the server user, not root), and FlyWP installs it. Each minute it measures CPU, load, memory, swap, disk and network traffic, the pressure (PSI) and the disk activity. It reads the server each 10 seconds, so each minute also has its peaks. It also measures the CPU, the memory and the disk use of each site: each Docker Compose project in the home folder of the server user. It measures the disk use at most one time each hour, at the lowest I/O priority. It sends the values and the server status (restart needed, waiting updates, OS, kernel, uptime, CPU count, Docker state and version) to FlyWP. It keeps unsent data on disk for up to 24 hours. FlyWP can update and restart the agent through it, without SSH. The agent does not need Docker. When Docker runs, the agent reads its socket with two requests only: `GET /version` and `GET /containers/json`.

It reads `FLY_AGENT_URL` (https), `FLY_AGENT_TOKEN` and `FLY_AGENT_SERVER_ID` from `/etc/fly/agent.env`, and keeps its state in `STATE_DIRECTORY` (`/var/lib/fly-agent`).

The agent also updates itself. Once a day, at a time set by the server id, it checks the latest release on GitHub. It installs the release only when:

- the release is newer than the running version (the agent never downgrades)
- the release has a valid signature from the FlyWP release key (see [Releasing](#releasing))
- the signature is more than 24 hours old

To turn this off on one server, add `FLY_AGENT_AUTO_UPDATE=off` to `/etc/fly/agent.env` and restart the agent. Updates that FlyWP sends still work.

```bash
systemctl status fly-agent # is the agent running?
journalctl -u fly-agent -f # the agent log
```

### Global Commands

A few helper commands to debug the server installation and start/stop all sites.

```bash
fly status # shows the status of the system
fly sites start # starts all sites
fly sites stop # stops all sites
fly sites restart # stops and starts all sites
```

## Development

Go 1.27 or later is required (`go.mod` selects the toolchain). The Makefile holds the common tasks:

```bash
make build # builds bin/fly with the version from git
make test # go test ./... -race
make lint # golangci-lint (pinned version, built with the module's Go)
make vuln # govulncheck
make check # fmt-check, vet, lint, test and vuln (CI runs the same)
make release # static linux/amd64 and linux/arm64 archives + checksums.txt in build/
make help # lists all targets
```

`make release VERSION=v0.2.0` stamps a specific version. The build date is the date of the commit, so the same commit and Go version give the same binary on any computer. The release archives must keep the names `fly-linux-<arch>.tar.gz` with the binary `fly-linux-<arch>` inside: installed CLIs look for these names when they run `fly update`.

CI runs `make check` and `make release` on every pull request and on every push to `develop` and `main`.

### Releasing

`main` is the release branch. To publish a release, tag a commit on `main` and push the tag:
<details>
<summary>Install by hand</summary>

```bash
git tag -a v0.2.0 -m "v0.2.0"
git push origin v0.2.0
arch=amd64 # or arm64
base=https://github.com/flywp/server-cli/releases/latest/download
curl -fsSLO "$base/fly-linux-$arch.tar.gz" && curl -fsSLO "$base/checksums.txt"
sha256sum -c --ignore-missing checksums.txt
tar -xzf "fly-linux-$arch.tar.gz" && sudo install -m 0755 "fly-linux-$arch" /usr/local/bin/fly
fly version
```

The Release workflow checks that the tag is on `main`, runs `make check`, builds the archives with `make release`, and creates the GitHub release with both archives and `checksums.txt`. A tag with a pre-release suffix, such as `v0.2.0-rc.1`, becomes a pre-release, so installed CLIs do not update to it.
</details>

`install.sh` and `fly update` install only a release that has `checksums.txt`. Releases before v0.2.0 have none, so push the tag right after the merge into `main`: until the release is published, `install.sh` from `main` stops.
## Use

After the workflow publishes the release, sign it on your own computer:
The site commands need Docker and Docker Compose. Run them inside a site folder, or name the site
with `--domain`.

```bash
make sign-release VERSION=v0.2.0 KEY=<private key file> # KEY=- reads the key from stdin
fly base start|stop|restart # the shared services: MySQL, Redis, Ofelia, Nginx Proxy
fly start|stop|restart # the site
fly restart <container> # one container of the site
fly wp <command> # WP-CLI, for example: fly wp plugin list
fly exec [container] <command> # a command in a container (default: PHP)
fly logs [-f] [container] # the logs of the site, or of one container

fly sites start|stop|restart # all sites
fly status # the state of the server and its services
fly update # install the latest release
fly version
```

The signature says "this release is the code of my tag", so the command signs only what it can build again:

1. It shows the commit of your **local** tag and asks you to type the tag. Review that commit first: the signature is the approval.
2. It downloads the archives and `checksums.txt`, and checks the archives against the sums.
3. It builds the release again from your local tag, with the Go version of the CI build, and compares the binaries byte for byte. A binary holds its commit, so this also proves that CI built your tag.
4. It signs `checksums.txt`, checks the signature with the keys of the tag, and uploads `checksums.txt.sig`.
Put `--domain` before the command: `fly --domain example.com wp plugin list`. Everything after
the command goes to it unchanged. To pass a flag as the first argument, put `--` before it:
`fly wp -- --info`.

If GitHub serves a swapped archive, or the tag on GitHub moved, step 2 or 3 stops before the signature. Agents install a release by themselves only when it has a valid signature, and only 24 hours after it was signed. Each server then installs it at its own time of day. To stop a bad release in those 24 hours, mark it as a pre-release on GitHub.
## Monitoring agent

The signing key is kept outside GitHub, so that a push to GitHub alone cannot reach every server. `make release-key KEY=<file>` makes a key and prints its public key line for `internal/release/keys.go`. `COMMENT=` names the key, in the key file and next to its line in `keys.go` (the default is "server-cli release key for flywp"). Keep the private key in a password manager, with a backup. Never commit it, and never put it in a GitHub secret. If the key is lost or leaked, ship a binary with a new key through a FlyWP update: that path does not use the signature.

### Dev pre-releases

To test a branch on real servers before it merges, publish a dev pre-release of its current commit:
FlyWP installs `fly agent run` as the systemd service `fly-agent`. Each minute, it sends the health
of the server and of each site to FlyWP. It runs as the server user, not as root, and it updates
itself only to signed releases.

```bash
make dev-version # prints the tag, for example v0.2.0-dev.1a2b3c4
make dev-release # tags the commit and pushes the tag; CI publishes the pre-release
systemctl status fly-agent
journalctl -u fly-agent -f
```

The version is the next minor version after the latest release, plus the short commit hash (`DEV_BASE=v0.1.2` overrides the first part). Pre-release tags can come from any branch. `make dev-release` refuses uncommitted changes, commits that are not pushed, and commits whose release workflow would publish the tag as a full release.

`fly update` and `install.sh` only install the latest full release, so install a dev pre-release on a test server by hand:
See [docs/monitoring-agent.md](docs/monitoring-agent.md).

```bash
tag=v0.2.0-dev.1a2b3c4 arch=amd64 # arch: amd64 or arm64 (uname -m: x86_64 or aarch64)
base=https://github.com/flywp/server-cli/releases/download/$tag
curl -fsSLO "$base/fly-linux-$arch.tar.gz" && curl -fsSLO "$base/checksums.txt"
sha256sum -c --ignore-missing checksums.txt
tar -xzf "fly-linux-$arch.tar.gz" && sudo install -m 0755 "fly-linux-$arch" /usr/local/bin/fly
fly version
```
## Documentation

To build the same version locally without publishing it, run `make release VERSION=$(make -s dev-version)`.
| | |
|---|---|
| [Monitoring agent](docs/monitoring-agent.md) | What it measures, its settings and files, updates, security |
| [Development](docs/development.md) | Build, tests, code layout |
| [Releasing](docs/releasing.md) | Release, signing, dev pre-releases, rollback |
| [Design decisions](docs/decisions.md) | Why the agent works the way it does |

## License

This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
MIT. See [LICENSE](LICENSE).
90 changes: 90 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Design decisions

Each decision of the monitoring agent, with its reason. Change one only when new evidence
contradicts the reason.

## Shape

- **One binary.** The agent is `fly agent run`, part of `fly`, not a separate program. One release,
one install, one update path.
- **One loop.** One goroutine wakes every 10 seconds, reads the server, and at the tick of each
minute makes the sample and, every few minutes, sends. Only the disk walk of the sites runs apart.
The work is small, and one loop is easy to reason about after a crash.
- **Plain JSON files for state**, written to a temporary file, synced and renamed. No database: the
queues are small, and a crash must never leave half a file. Event ids are ULIDs, made when the
event is queued, so a resend keeps its id and FlyWP can drop duplicates.
- **HTTPS only**, except for a loopback host, which tests and local development need. Redirects are
refused: a redirect could turn a POST into a GET, or send the token over plain http.

## Measurements

- **Peaks, not only averages.** A minute average hides a 10-second spike: 10 seconds at 100% CPU and
50 seconds idle average to about 17%. So the agent reads every 10 seconds and sends the peak
window of the minute next to the minute value. The minute values stay as they were, because alerts
read them: a sustained value is an incident, a 10-second peak is not.
- **No peak of load or disk space.** The kernel already smooths the load over a minute, and disk
space changes over minutes, not seconds.
- **Pressure (PSI)**, from the `total=` counters, not the kernel's `avg10`/`avg60` (moving averages
that do not match the windows of a sample). "How full" is not "under pressure": memory that makes
tasks wait is a problem at any percent.
- **Disk activity, not a busy percent.** An SSD or a cloud disk serves many requests at once, so
"100% busy" does not mean full. The agent sends bytes and operations, and I/O pressure shows the
waiting. Only whole hardware disks count: partitions, loop, device-mapper and RAID devices would
count the same I/O twice.
- **Network:** only interfaces with a hardware device (and no `master`), else the default-route
interface. Virtual interfaces would count the same traffic twice.
- **Short windows are dropped.** A reading less than 5 seconds from its neighbour is left out, and a
first minute shorter than 10 seconds after a fresh start sends no sample. A window of a few milliseconds, or the load of
the start itself, would show as a false spike.
- **`null`, not 0,** when a value is unknown: after a reboot, a counter that went back, or a missing
kernel feature. A 0 would draw a false dip.
- **Waiting updates** come from `apt-check`, once an hour and not in the send budget. A failed count
keeps the last one.

## Sites

- **A site is a Docker Compose project** whose folder is directly in the home folder of the server
user. That is where FlyWP puts sites, and the Compose label gives the folder without guessing.
- **CPU and memory from cgroup v2**, not `docker stats`: the agent reads files and makes no extra
Docker requests. A container counts for CPU only when both ticks saw the same container **and the
same cgroup**: `docker restart` keeps the container id but starts a new cgroup whose CPU time
begins at 0.
- **Only two Docker requests** (`GET /version`, `GET /containers/json`), with a 5-second timeout.
The socket is powerful; the agent uses the least of it.
- **Disk use once an hour, in the background, at idle I/O priority**, with a 5-minute limit for each
folder. A walk of a large site must never delay a sample or slow the sites.

## Sending

- **Keep data until FlyWP accepts it**, up to 24 hours of samples. A 400 drops the batch (it will
never be accepted); a 401, 429, 5xx or network error keeps it.
- **Values are checked before they are queued.** One bad value makes FlyWP refuse the whole batch,
so the agent clamps each value to the range of the contract first.
- **Samples and events have separate backoffs.** A failing event must not stop the metrics.

## Updates

- **The agent never downgrades.** An update to an older release fails with the reason. A bad
release is fixed with a newer one. This keeps agents that updated themselves from being moved back.
- **Two update paths, and both stay:**
- FlyWP's `agent.update` with a pinned sha256. It is fast, and it is the recovery path: it does
not read the signature.
- The daily automatic update, which needs a signature. Without it, a person who controls the GitHub
repository alone could put code on every server.
- **Offline ed25519 signatures.** The maintainer signs `checksums.txt` on their own computer after
rebuilding the release from their local tag. The key is never on GitHub or in CI. The public keys
are compiled in.
- **A 24-hour wait after the signature**, then each server at its own time of day. A bad release can
be stopped in that time by marking it as a pre-release.
- **A signature does not expire.**
- **Why the command path must stay:** if the key leaks, the release that removes it would itself
need a signature, and only the leaked key could make one. The pinned-sha256 path is the way out.
- **`fly update` stays.** It restarts the agent, and it keeps the owner of the binary, so the agent
can still replace it.

## Security

- **The agent never runs as root.** It runs as the server user under systemd.
- **Root never writes into the server user's folder.** When `install.sh` or `fly update` replaces
the agent's binary, the file is written by the server user or changed through the open file, never
by a path that the server user could swap for a link.
Loading
Loading