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
78 changes: 69 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
A server manager for FiveM and RedM, written in Rust.

Observer starts and supervises FXServer, installs its builds and serves a web panel for your staff,
from one executable that keeps its state in SQLite. It never shows anything to players, but for two
messages you write, the one a banned player reads and the one a kicked player reads: it raises
documented server events, and your own resources decide what to do with them.
from one executable that keeps its state in SQLite. It never shows anything to players, but for what
you write yourself: the refusal messages for bans, for maintenance and for the allowlist, the kick
message, and the name your server takes in the server list during maintenance. It raises documented
server events, and your own resources decide what to do with them.

> **Status: proof of concept.** Settings, the panel and the integration contract may still change
> between versions.
Expand Down Expand Up @@ -36,6 +37,10 @@ documented server events, and your own resources decide what to do with them.
- **Kicks, announcements and messages.** The staff kicks a connected player, or everyone, with a reason
and your kick message. An announcement to the server and a message to one player are server events,
which your resources show.
- **Allowlist.** Open the server to approved players only: a refused player reads your message with
the ID of their request, which the staff approves or denies in the panel.
- **Maintenance mode.** One switch closes the server to everyone but the players you list, with your
message, through restarts, until someone ends it.
- **Accounts and audit.** An owner and sub-users with fine-grained permissions, and an audit log of every
action.
- **Updates.** A newer release shows in the panel with its notes; the owner installs it in one click,
Expand Down Expand Up @@ -1046,12 +1051,11 @@ on a connecting player is checked against the new end and reads the new reason.

The bridge holds each connecting player on `playerConnecting` while Observer checks them, which takes
milliseconds. A banned player is refused with the **refusal message** of the settings' Bans and kicks
category: it can name the reason, when the ban ends and the ban ID to quote in an appeal. With the kick
message, it is all the text Observer ever shows players. If Observer has not answered within 5
seconds, the player gets in, and Observer drops them once they joined if a ban holds them. A player
banned while playing is dropped at once. By default one hardware token in common is enough to refuse a
player; the settings can ask for more, or for none. Addresses are never matched: players behind one
address would be refused together.
category: it can name the reason, when the ban ends and the ban ID to quote in an appeal. If Observer
has not answered within 5 seconds, the player gets in, and Observer drops them once they joined if a
ban holds them. A player banned while playing is dropped at once. By default one hardware token in
common is enough to refuse a player; the settings can ask for more, or for none. Addresses are never
matched: players behind one address would be refused together.

Your resources hear of both through `observer:playerRefused` and `observer:playerBanned`, in the
contract. With **Send txAdmin's events too**, each ban made while the server runs also raises
Expand All @@ -1077,6 +1081,62 @@ Sending announcements and messaging players are two permissions.
Your resources hear of a kick through `observer:playerKicked`, raised while the player is still
connected. Every kick, announcement and message is in the audit log, with its text.

## Allowlist

With **Approved players only** on, in the settings' Allowlist category, the server lets in only the
players the staff approved. It goes by the licence alone, and bans come first: an approved player who
is banned stays out.

A player who is not approved is refused with the allowlist's **refusal message**, which you write: it
names the ID of their request, such as `7KQ-2XP`, for them to give your staff. The request shows on the
Allowlist page with the player's name, their licence and how often they tried. The staff approves it,
and the player gets in from their next connection, or denies it. A player who tries again keeps their
request and its ID; a denied one who comes back makes a new request, under a new ID. **Approve a
licence** lets a player in ahead of their first visit, with a note that says who it is. Taking an
approval back keeps the player out again.

Reading the page takes **View players**; answering requests, approving a licence and taking an approval
back take **Manage the allowlist**. The switch and the message are settings, changed with the password
asked again, like the bans' message. Every answer, approval and change is in the audit log.

What it does not do: a player who shows no licence, as on a server with `sv_lan`, cannot be approved,
and makes no request. Turning the allowlist on, or taking an approval back, drops no one: the players
connected stay until they leave. If Observer has not answered a connection within 5 seconds, the
player gets in. Observer keeps the 2,000 requests tried last. Your resources hear of each refusal
through `observer:playerRefused`, with `cause = 'allowlist'`, in the contract.

## Maintenance mode

**Maintenance**, beside Restart and Stop above every page, closes the server to players while you
work on it. The panel shows first what a refused player will read and how many players still get in,
then asks. From then on the server refuses every connection with your **maintenance message**, and a
banner on every page says since when and by whom, with **End maintenance** to open the server again.
It holds through restarts of FXServer and of Observer.

The settings' Maintenance mode category holds the message and **Who still gets in**: the players
maintenance lets through, by licence. Type a name to pick a player Observer knows, or paste a licence,
with a note that says who it is. A panel account has no licence, so list the player each member of
your staff plays as. A player on the list gets in whether the allowlist has them or not; a ban still
refuses them.

**Name in the server list**, in the same category, is the name the server takes in FiveM's server
list while maintenance is on; leave it empty and the server keeps its own. Observer's bridge sets
`sv_hostname` to it, and `sv_projectName` when your cfg gives one, then gives both back when
maintenance ends. FXServer sends its name to the list with each heartbeat, every three minutes or
15 seconds after a player connects or leaves; when the list shows it is up to Cfx.re. A restart of
FXServer during maintenance reads the names of your cfg again, and the bridge sets the maintenance
name once the server is online.

Turning maintenance on or off takes **Start and stop the server**. The message, the list and the
name are settings, changed with the password asked again. Each switch and each change is in the audit
log.
Your resources hear `observer:maintenance` at each switch, and once more at each start of FXServer
while it is on; each refusal raises `observer:playerRefused` with `cause = 'maintenance'`.

What it does not do: turning maintenance on drops no one, the players connected stay until they
leave. If Observer has not answered a connection within 5 seconds, the player gets in. A player who
shows no licence cannot be on the list.

## Live map

The Live map page shows where the connected players stand, as the server sees them, so that the staff
Expand Down
2 changes: 1 addition & 1 deletion crates/fake-fxserver/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ fn dynamic(server: &Server) -> String {
joined => joined as u64,
},
"gametype": "",
"hostname": server.convar("sv_hostname").unwrap_or_else(|| "FXServer, but unconfigured".to_owned()),
"hostname": server.hostname(),
"iv": "0",
"mapname": "",
"sv_maxclients": server.convar("sv_maxclients").unwrap_or_else(|| "48".to_owned()),
Expand Down
48 changes: 48 additions & 0 deletions crates/fake-fxserver/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ impl Server {
.insert(name.to_ascii_lowercase(), value.to_owned());
}

/// `sv_hostname`, which FXServer declares with a value of its own.
pub fn hostname(&self) -> String {
self.convar("sv_hostname")
.unwrap_or_else(|| "FXServer, but unconfigured".to_owned())
}

fn number(&self, name: &str) -> Option<u64> {
self.convar(name)?.parse().ok()
}
Expand Down Expand Up @@ -1288,6 +1294,7 @@ impl Fake {
};
console::script(&bridge, &format!("txAdmin:events:{event} {}", payload["event"]));
}
Some("serverName") => self.server_name(payload["name"].as_str()),
Some(_) => {}
None => console::script(
&bridge,
Expand Down Expand Up @@ -1336,6 +1343,47 @@ impl Fake {
}
}

/// What the real bridge does with the server's maintenance name: the convars the server list
/// reads take it, what they held kept aside, and get it back when no name comes.
fn server_name(&self, name: Option<&str>) {
const KEPT: &str = "observer_name_kept";
const NAMES: [(&str, &str); 2] = [
("sv_hostname", "observer_name_hostname"),
("sv_projectName", "observer_name_project"),
];
let held = |convar: &str| match convar {
"sv_hostname" => self.server.hostname(),
other => self.server.convar(other).unwrap_or_default(),
};
let mut kept = self.server.convar(KEPT).unwrap_or_default();
if let Some(name) = name.filter(|name| !name.is_empty()) {
if kept.is_empty() {
let mut renamed = Vec::new();
for (convar, aside) in NAMES {
let held = held(convar);
if !held.is_empty() {
self.server.set(aside, &held);
renamed.push(convar);
}
}
kept = renamed.join(" ");
self.server.set(KEPT, &kept);
}
for (convar, _) in NAMES {
if kept.contains(convar) {
self.server.set(convar, name);
}
}
} else {
for (convar, aside) in NAMES {
if kept.contains(convar) {
self.server.set(convar, &held(aside));
}
}
self.server.set(KEPT, "");
}
}

/// Observer's answer for a player held at `playerConnecting`: in, or refused with a message.
fn verdict(&mut self, bridge: &str, payload: &serde_json::Value) {
let Some(index) = self
Expand Down
Loading
Loading