Compare commits

59 Commits

Author SHA1 Message Date
oysteikt 71d2b72c34 module.nix: filter nulls from config
Build and test / check (push) Successful in 1m9s
Build and test / test (push) Successful in 2m12s
Build and test / build (push) Successful in 2m37s
Build and test / docs (push) Successful in 3m25s
2026-06-24 13:37:18 +09:00
oysteikt a6a1bd2324 fingerd/local_email: write tests
Build and test / check (push) Successful in 1m8s
Build and test / build (push) Successful in 1m38s
Build and test / test (push) Successful in 1m54s
Build and test / docs (push) Successful in 4m46s
2026-06-24 13:27:40 +09:00
oysteikt 41d00321d5 ruptime: fix --all flag behaviour, move filtering to server
Build and test / check (push) Successful in 1m6s
Build and test / build (push) Successful in 1m38s
Build and test / test (push) Successful in 3m10s
Build and test / docs (push) Successful in 3m41s
2026-06-24 13:09:35 +09:00
oysteikt d1e1e30ee6 fixup! {rwhod,fingerd}: add ignore-user lists
Build and test / check (push) Successful in 1m26s
Build and test / build (push) Successful in 2m6s
Build and test / test (push) Successful in 2m9s
Build and test / docs (push) Successful in 4m41s
2026-06-24 12:57:29 +09:00
oysteikt 1bfc330000 {finger,ruptime,rwho}: render nicer varlink errors for users 2026-06-24 12:55:25 +09:00
oysteikt 21945c73ab {rwhod,fingerd}: disable varlink endpoints according to config 2026-06-24 12:55:24 +09:00
oysteikt 4af04d7dd6 {rwhod,fingerd}: add ignore-user lists 2026-06-24 12:55:24 +09:00
oysteikt b8b4d8dcc0 rwhod: warn incoming packets with weird timestamp orders 2026-06-24 12:53:45 +09:00
oysteikt de863408e4 rwhod: apply time correction to packets 2026-06-24 12:53:45 +09:00
oysteikt 736a962257 rwhod: filter idle users and down hosts serverside 2026-06-23 17:09:17 +09:00
oysteikt e594419aed proto/rwhod: ensure packets get truncated on conversion
Build and test / build (push) Successful in 1m36s
Build and test / check (push) Successful in 2m22s
Build and test / test (push) Successful in 2m22s
Build and test / docs (push) Successful in 4m34s
2026-06-23 01:40:16 +09:00
oysteikt 8e5f2849ff nix/module: allow access to /dev for fingerd
Build and test / check (push) Successful in 1m3s
Build and test / test (push) Successful in 1m54s
Build and test / build (push) Successful in 2m1s
Build and test / docs (push) Successful in 3m56s
2026-05-13 00:39:27 +09:00
oysteikt a7c678ce22 fingerd: mark X ttys as non-writable and non-idle 2026-05-13 00:39:11 +09:00
oysteikt 32625d4141 finger: format results 2026-05-13 00:28:51 +09:00
oysteikt fa466c00dd proto/finger: improve classic formatting of user sessions 2026-05-13 00:28:10 +09:00
oysteikt 9b37c6b3c8 proto/finger: skip failing tests
Build and test / build (push) Successful in 1m34s
Build and test / check (push) Successful in 1m38s
Build and test / test (push) Successful in 2m12s
Build and test / docs (push) Successful in 3m26s
2026-05-11 10:16:10 +09:00
oysteikt ad2a22e8bf proto/finger: rewrite user session idle time parser 2026-05-11 10:13:13 +09:00
oysteikt 61fef28133 proto/finger: partially fix parsing of user sessions
Build and test / build (push) Successful in 2m21s
Build and test / check (push) Successful in 1m24s
Build and test / test (push) Failing after 2m28s
Build and test / docs (push) Successful in 4m49s
2026-05-01 02:39:58 +09:00
oysteikt 11b97cb1b9 proto/finger: add classic formatter, split up parser 2026-04-30 22:00:20 +09:00
oysteikt 16b2bc5c27 proto/finger: parse timezones
Build and test / build (push) Successful in 1m34s
Build and test / check (push) Successful in 1m40s
Build and test / test (push) Successful in 1m52s
Build and test / docs (push) Successful in 3m49s
2026-04-29 08:27:31 +09:00
oysteikt ebad14aa02 proto/finger: parse pgp key, project and plan
Build and test / check (push) Successful in 1m6s
Build and test / build (push) Successful in 1m51s
Build and test / test (push) Successful in 1m51s
Build and test / docs (push) Successful in 3m43s
2026-04-29 08:09:21 +09:00
oysteikt be07298867 fingerd: add raw user response variant
Build and test / check (push) Successful in 1m34s
Build and test / build (push) Successful in 1m41s
Build and test / test (push) Successful in 2m4s
Build and test / docs (push) Successful in 3m19s
2026-04-29 06:22:20 +09:00
oysteikt 02279a9225 finger: add (but don't implement) --raw flag
Build and test / check (push) Successful in 1m3s
Build and test / build (push) Successful in 1m32s
Build and test / test (push) Successful in 2m3s
Build and test / docs (push) Successful in 3m28s
2026-04-29 06:12:14 +09:00
oysteikt ff38ea3a35 fingerd: demote local email parsing logs to trace level
Build and test / check (push) Successful in 1m4s
Build and test / build (push) Successful in 1m31s
Build and test / test (push) Successful in 1m52s
Build and test / docs (push) Successful in 4m6s
2026-04-29 06:03:42 +09:00
oysteikt a3aae2b28b bin/roowhod: configure loglevel via config file, use journald protocol 2026-04-29 06:03:11 +09:00
oysteikt 448ce6e500 server/rwhod: split into several files
Build and test / check (push) Successful in 1m3s
Build and test / build (push) Successful in 1m41s
Build and test / test (push) Successful in 1m50s
Build and test / docs (push) Successful in 3m20s
2026-04-29 05:22:01 +09:00
oysteikt f85d315aaa nix/vm: enable trace logging 2026-04-29 05:22:01 +09:00
oysteikt a0d5034e85 module.nix: grant CAP_DAC_READ_SEARCH to daemon 2026-04-29 05:22:00 +09:00
oysteikt b9b5fa5735 fingerd: add basic mailbox parsing functionality 2026-04-29 05:22:00 +09:00
oysteikt a1dea1b600 proto/finger: parse mail status
Build and test / check (push) Successful in 58s
Build and test / build (push) Successful in 1m39s
Build and test / test (push) Successful in 2m48s
Build and test / docs (push) Successful in 3m20s
2026-04-29 03:55:00 +09:00
oysteikt e741dfd3c1 fingerd: don't nest utmp entry requests
Build and test / check (push) Successful in 1m33s
Build and test / build (push) Successful in 1m42s
Build and test / test (push) Successful in 1m48s
Build and test / docs (push) Successful in 3m32s
2026-04-28 21:08:28 +09:00
oysteikt 3873c3a995 roowhod: add timeout to all actions
Build and test / check (push) Successful in 1m3s
Build and test / build (push) Successful in 1m41s
Build and test / test (push) Successful in 1m51s
Build and test / docs (push) Successful in 3m17s
2026-04-27 17:00:58 +09:00
oysteikt e5f1615f75 finger: add basic implementation for fetching utmp users
Build and test / check (push) Successful in 1m0s
Build and test / build (push) Successful in 1m38s
Build and test / test (push) Successful in 2m2s
Build and test / docs (push) Successful in 3m45s
2026-04-27 16:06:50 +09:00
oysteikt 9c10b395d3 module.nix: add logLevel option 2026-04-27 16:05:01 +09:00
oysteikt 023aefff20 finger: debug logging serverside
Build and test / check (push) Successful in 1m24s
Build and test / build (push) Successful in 1m31s
Build and test / test (push) Successful in 1m56s
Build and test / docs (push) Successful in 3m35s
2026-04-23 20:53:24 +09:00
oysteikt 9cd1d0fe83 finger: dedup results 2026-04-23 20:53:24 +09:00
oysteikt 21e8574825 finger: fix smart defaults for both -s/-l and -H/-o arg pairs 2026-04-23 19:04:35 +09:00
oysteikt 27634288c0 treewide: cargo fmt 2026-04-23 19:04:34 +09:00
oysteikt 5f8fc7944b finger: provide server with more arguments
Build and test / check (push) Failing after 45s
Build and test / build (push) Successful in 1m40s
Build and test / test (push) Successful in 2m6s
Build and test / docs (push) Successful in 3m58s
2026-04-23 16:07:53 +09:00
oysteikt 8920362d49 Fix --help description for all binaries 2026-04-23 15:37:48 +09:00
oysteikt 92f77bfaff finger: implement basic fuzzy search mechanism 2026-04-23 15:34:23 +09:00
oysteikt 106a955ad1 finger: smarter -s/-l defaults
Build and test / check (push) Failing after 1m19s
Build and test / build (push) Successful in 1m41s
Build and test / test (push) Successful in 2m51s
Build and test / docs (push) Successful in 3m57s
2026-04-23 14:13:25 +09:00
oysteikt f1eced9e23 flake.lock, Cargo.{toml,lock}: bump deps 2026-04-23 14:12:15 +09:00
oysteikt 9361dcf941 fingerd: read homedir files, read gecos fields
Build and test / check (push) Successful in 1m12s
Build and test / build (push) Successful in 1m49s
Build and test / test (push) Successful in 2m59s
Build and test / docs (push) Successful in 3m19s
2026-02-13 01:14:19 +09:00
oysteikt 97e359004b fingerd: respect ~/.nofinger
Build and test / check (push) Successful in 1m6s
Build and test / test (push) Successful in 2m9s
Build and test / build (push) Successful in 2m21s
Build and test / docs (push) Successful in 3m34s
2026-02-12 13:20:26 +09:00
oysteikt f9e60b0f03 nix: build with crane
Build and test / check (push) Successful in 1m10s
Build and test / build (push) Successful in 1m35s
Build and test / test (push) Successful in 2m47s
Build and test / docs (push) Successful in 3m1s
2026-02-12 11:41:08 +09:00
oysteikt ba7a7f2b26 roowhod: notify systemd on startup
Build and test / check (push) Successful in 1m12s
Build and test / build (push) Successful in 1m37s
Build and test / test (push) Successful in 1m56s
Build and test / docs (push) Successful in 5m33s
2026-02-12 11:19:08 +09:00
oysteikt 2c646af236 fingerd: use structs from proto, fix clippy lints
Build and test / build (push) Successful in 1m37s
Build and test / check (push) Successful in 1m50s
Build and test / test (push) Successful in 2m10s
Build and test / docs (push) Successful in 3m10s
2026-02-12 11:05:05 +09:00
oysteikt 23d2611bff proto/finger: add more fields and tests, parse office details
Build and test / check (push) Failing after 1m9s
Build and test / test (push) Successful in 1m55s
Build and test / build (push) Successful in 2m4s
Build and test / docs (push) Successful in 3m11s
2026-02-12 10:49:10 +09:00
oysteikt 9c6a0dec2f fingerd: move parsing logic to proto, add support for more fields 2026-02-12 10:23:11 +09:00
oysteikt 8697809974 Add some tests for running basic server functionality
Build and test / check (push) Failing after 1m9s
Build and test / build (push) Successful in 1m34s
Build and test / test (push) Successful in 1m58s
Build and test / docs (push) Successful in 3m28s
2026-02-09 11:12:06 +09:00
oysteikt a5e5235c56 fingerd: fix a lot of parsing issues
Build and test / check (push) Failing after 1m31s
Build and test / test (push) Successful in 2m17s
Build and test / build (push) Successful in 2m28s
Build and test / docs (push) Successful in 4m18s
2026-02-09 10:53:58 +09:00
oysteikt 6ca9e0ced1 A bunch of work on finger
Build and test / check (push) Failing after 42s
Build and test / build (push) Failing after 1m38s
Build and test / test (push) Failing after 1m40s
Build and test / docs (push) Failing after 2m56s
2026-02-08 22:03:29 +09:00
oysteikt def4eec2d5 module.nix: add some systemd hardening
Build and test / check (push) Successful in 1m9s
Build and test / test (push) Successful in 1m56s
Build and test / build (push) Successful in 2m11s
Build and test / docs (push) Successful in 4m29s
2026-01-31 13:43:15 +09:00
oysteikt 178c7314a4 server/varlink_api: prefix all types with Varlink
Build and test / build (push) Successful in 1m42s
Build and test / check (push) Successful in 1m44s
Build and test / test (push) Successful in 1m53s
Build and test / docs (push) Successful in 2m58s
2026-01-31 13:10:24 +09:00
oysteikt de296f20d9 server/varlink_api: register finger api 2026-01-31 13:07:40 +09:00
oysteikt 5dc3327980 proto/finger: test serialization roundtrip
Build and test / check (push) Successful in 1m6s
Build and test / build (push) Successful in 1m42s
Build and test / test (push) Successful in 1m53s
Build and test / docs (push) Successful in 4m57s
2026-01-31 12:50:39 +09:00
oysteikt 23b163e828 flake.lock: bump, Cargo.{toml,lock}: update inputs
Build and test / check (push) Successful in 1m16s
Build and test / build (push) Successful in 1m59s
Build and test / test (push) Successful in 2m0s
Build and test / docs (push) Successful in 4m14s
2026-01-25 16:14:26 +09:00
oysteikt 7985f182c6 proto/finger: add some basic types and de/serializers 2026-01-25 16:05:56 +09:00
31 changed files with 4028 additions and 691 deletions
Generated
+287 -192
View File
File diff suppressed because it is too large Load Diff
+28 -18
View File
@@ -16,23 +16,29 @@ autobins = false
autolib = false
[dependencies]
anyhow = "1.0.100"
bytes = "1.11.0"
chrono = { version = "0.4.42", features = ["serde"] }
clap = { version = "4.5.53", features = ["derive"] }
futures-util = "0.3.31"
nix = { version = "0.30.1", features = ["hostname", "net"] }
anyhow = "1.0.102"
bytes = "1.11.1"
chrono = { version = "0.4.44", features = ["serde"] }
clap = { version = "4.6.1", features = ["derive"] }
futures-util = "0.3.32"
nix = { version = "0.31.2", features = ["hostname", "net", "fs", "user"] }
serde = { version = "1.0.228", features = ["derive"] }
tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
toml = "0.9.10"
tokio = { version = "1.52.1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
toml = "1.1.2"
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
# onc-rpc = "0.3.2"
sd-notify = { version = "0.4.5", optional = true }
serde_json = "1.0.148"
uucore = { version = "0.5.0", features = ["utmpx"] }
zlink = { version = "0.2.0", features = ["introspection"] }
clap_complete = "4.5.65"
sd-notify = { version = "0.5.0", optional = true }
serde_json = "1.0.149"
uucore = { version = "0.8.0", features = ["utmpx"] }
zlink = { version = "0.4.1", features = ["introspection"] }
clap_complete = "4.6.2"
itertools = "0.14.0"
tokio-util = "0.7.18"
caps = "0.5.6"
users = { version = "0.11.0", default-features = false }
tracing-journald = "0.3.2"
chrono-tz = "0.10.4"
[features]
default = ["systemd"]
@@ -47,10 +53,10 @@ name = "roowhod"
bench = false
path = "src/bin/roowhod.rs"
# [[bin]]
# name = "finger"
# bench = false
# path = "src/bin/finger.rs"
[[bin]]
name = "finger"
bench = false
path = "src/bin/finger.rs"
# [[bin]]
# name = "rup"
@@ -82,3 +88,7 @@ inherits = "release"
strip = true
lto = true
codegen-units = 1
[dev-dependencies]
indoc = "2.0.7"
tempfile = "3.27.0"
Generated
+22 -6
View File
@@ -1,12 +1,27 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1776635034,
"narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=",
"owner": "ipetkov",
"repo": "crane",
"rev": "dc7496d8ea6e526b1254b55d09b966e94673750f",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1767116409,
"narHash": "sha256-5vKw92l1GyTnjoLzEagJy5V5mDFck72LiQWZSOnSicw=",
"lastModified": 1776548001,
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "cad22e7d996aea55ecab064e84834289143e44a0",
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
"type": "github"
},
"original": {
@@ -18,6 +33,7 @@
},
"root": {
"inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
@@ -29,11 +45,11 @@
]
},
"locked": {
"lastModified": 1767322002,
"narHash": "sha256-yHKXXw2OWfIFsyTjduB4EyFwR0SYYF0hK8xI9z4NIn0=",
"lastModified": 1776914043,
"narHash": "sha256-qug5r56yW1qOsjSI99l3Jm15JNT9CvS2otkXNRNtrPI=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "03c6e38661c02a27ca006a284813afdc461e9f7e",
"rev": "2d35c4358d7de3a0e606a6e8b27925d981c01cc3",
"type": "github"
},
"original": {
+24 -6
View File
@@ -4,9 +4,11 @@
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
crane.url = "github:ipetkov/crane";
};
outputs = { self, nixpkgs, rust-overlay}:
outputs = { self, nixpkgs, rust-overlay, crane }:
let
inherit (nixpkgs) lib;
@@ -71,6 +73,9 @@
roowho2 = final: prev: {
inherit (self.packages.${prev.stdenv.hostPlatform.system}) roowho2;
};
roowho2-crane = final: prev: {
roowho2 = self.packages.${prev.stdenv.hostPlatform.system}.roowho2-crane;
};
};
nixosModules.default = ./nix/module.nix;
@@ -79,12 +84,13 @@
let
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
cargoLock = ./Cargo.lock;
craneLib = (crane.mkLib pkgs).overrideToolchain(p: p.rust-bin.nightly.latest.default);
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./src
./Cargo.toml
./Cargo.lock
(craneLib.fileset.commonCargoSources ./.)
# ./assets
];
};
@@ -93,13 +99,25 @@
cargo = pkgs.rust-bin.nightly.latest.cargo;
};
in {
default = self.packages.${system}.roowho2;
default = self.packages.${system}.roowho2-crane;
roowho2 = pkgs.callPackage ./nix/package.nix { inherit cargoToml cargoLock src rustPlatform; };
roowho2 = pkgs.callPackage ./nix/package.nix {
inherit cargoToml cargoLock src rustPlatform;
};
roowho2-crane = pkgs.callPackage ./nix/package.nix {
useCrane = true;
inherit cargoToml cargoLock src craneLib;
};
filteredSource = pkgs.runCommandLocal "filtered-source" { } ''
ln -s ${src} $out
'';
});
checks = forAllSystems (system: pkgs: _: {
# NOTE: the non-crane build runs tests during checkPhase
inherit (self.packages.${system}) roowho2;
});
};
}
+116 -2
View File
@@ -11,11 +11,52 @@ in {
type = lib.types.submodule {
freeformType = format.type;
options = {
log_level = lib.mkOption {
type = lib.types.enum [ "info" "debug" "trace" ];
default = "info";
description = "Log level for the roowho2 daemon.";
};
rwhod = {
enable = lib.mkEnableOption "the rwhod service" // {
default = true;
};
ignore_list_path = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = lib.literalExpression ''
pkgs.writeText "rwhod-ignore-list" '''
# Ignore the following users from rwhod
user:user1
user:user2
uid:1001
'''
'';
description = "Path to the ignore list for users that should be hidden from rwhod.";
};
# TODO: allow configuring socket config
};
fingerd = {
enable = lib.mkEnableOption "the fingerd service" // {
default = true;
};
ignore_list_path = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = lib.literalExpression ''
pkgs.writeText "fingerd-ignore-list" '''
# Ignore the following users from rwhod
user:user1
user:user2
uid:1001
'''
'';
description = "Path to the ignore list for users that should be hidden from fingerd.";
};
# TODO: allow configuring socket config
};
};
@@ -49,11 +90,84 @@ in {
systemd.services.roowho2 = {
serviceConfig = {
ExecStart = "${lib.getExe' cfg.package "roowhod"} --config ${format.generate "roowho2-config.toml" cfg.settings}";
Type = "notify";
ExecStart = let
configFile = format.generate "roowho2-config.toml" (lib.filterAttrsRecursive (n: v: v != null) cfg.settings);
in "${lib.getExe' cfg.package "roowhod"} --config ${configFile}";
Restart = "on-failure";
DynamicUser = true;
# TODO: hardening
# NOTE: roowho2 might at some point need to read from home directories
# to get user settings, so let's keep these disabled for now.
# PrivateUsers = true;
# ProtectHome = true;
# NOTE: We need this capability to be able to read inside the home directories of users without
# them needing to open their homedirs to the rest of the system.
AmbientCapabilities = [ "CAP_DAC_READ_SEARCH" ];
CapabilityBoundingSet = [ "CAP_DAC_READ_SEARCH" ];
DeviceAllow = "";
DevicePolicy = "closed";
LockPersonality = true;
MemoryDenyWriteExecute = true;
NoNewPrivileges = true;
PrivateDevices = true;
# NOTE: all ipc traffic is served through the socket activation fds or provided by systemd
PrivateIPC = true;
PrivateMounts = true;
PrivateTmp = true;
ProcSubset = "pid";
ProtectClock = true;
ProtectControlGroups = "strict";
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
RemoveIPC = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
"AF_NETLINK"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SocketBindDeny = "any";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
"~@resources"
];
RuntimeDirectory = "roowho2/root-mnt";
RuntimeDirectoryMode = "0700";
RootDirectory = "/run/roowho2/root-mnt";
BindReadOnlyPaths = lib.filter (x: x != null) ([
builtins.storeDir
"/etc"
# NOTE: need logind socket for utmp entries
"/run/systemd"
"/home"
# NOTE: finger might need access to mail directories
"-/var/spool"
"-/var/mail"
# NOTE: finger needs access to stat tty devices
"/dev"
] ++ lib.optionals cfg.settings.rwhod.enable [
cfg.settings.rwhod.ignore_list_path
] ++ lib.optionals cfg.settings.fingerd.enable [
cfg.settings.fingerd.ignore_list_path
]);
UMask = "0077";
};
};
+56 -12
View File
@@ -10,22 +10,62 @@
, cargoToml
, cargoLock
, src
, useCrane ? false
, craneLib ? null
}:
rustPlatform.buildRustPackage {
pname = "roowho2";
let
mainProgram = "roowhod";
buildFunction = if useCrane then craneLib.buildPackage else rustPlatform.buildRustPackage;
pnameCraneSuffix = lib.optionalString useCrane "-crane";
pname = "${cargoToml.package.name}${pnameCraneSuffix}";
rustPlatformArgs = {
buildType = "releaselto";
buildFeatures = lib.optionals stdenv.hostPlatform.isLinux [
"systemd"
];
cargoLock.lockFile = cargoLock;
doCheck = true;
useNextest = true;
nativeCheckInputs = [
versionCheckHook
];
cargoCheckFeatures = lib.optionals stdenv.hostPlatform.isLinux [
"systemd"
];
};
craneArgs = {
cargoLock = cargoLock;
cargoExtraArgs = lib.escapeShellArgs [ "--features" (lib.concatStringsSep "," (
lib.optionals stdenv.hostPlatform.isLinux [
"systemd"
]
)) ];
cargoArtifacts = craneLib.buildDepsOnly {
inherit pname;
inherit (cargoToml.package) version;
src = lib.fileset.toSource {
root = ../.;
fileset = lib.fileset.unions [
(craneLib.fileset.cargoTomlAndLock ../.)
];
};
cargoLock = cargoLock;
};
};
in
buildFunction ({
inherit pname;
inherit (cargoToml.package) version;
inherit src;
cargoLock.lockFile = cargoLock;
buildType = "releaselto";
RUSTFLAGS = "-Zhigher-ranked-assumptions";
buildFeatures = lib.optionals stdenv.hostPlatform.isLinux [
"systemd"
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
systemdLibs
];
@@ -41,12 +81,16 @@ rustPlatform.buildRustPackage {
installShellCompletion "--${shell}" --cmd "${command}" "$TMP/${command}.${shell}"
'') {
shell = [ "bash" "zsh" "fish" ];
command = [ "rwho" "ruptime" ];
command = [ "rwho" "ruptime" "finger" ];
};
in lib.concatStringsSep "\n" installShellCompletions;
meta = with lib; {
license = licenses.mit;
license = licenses.bsdOriginalUC;
platforms = platforms.linux ++ platforms.darwin;
inherit mainProgram;
};
}
//
(if useCrane then craneArgs else rustPlatformArgs)
)
+3 -1
View File
@@ -4,7 +4,7 @@ let
pkgs = import nixpkgs {
inherit system;
overlays = [
self.overlays.default
self.overlays.roowho2-crane
];
};
in
@@ -34,6 +34,7 @@ nixpkgs.lib.nixosSystem {
Try running any of:
rwho
ruptime
finger "alice"
To log into other containers, use:
machinectl shell c1
@@ -47,6 +48,7 @@ nixpkgs.lib.nixosSystem {
services.roowho2 = {
enable = true;
settings.log_level = "trace";
};
programs.vim = {
+125 -12
View File
@@ -1,4 +1,13 @@
use clap::Parser;
use anyhow::Context;
use clap::{CommandFactory, Parser, builder::ArgPredicate};
use clap_complete::{Shell, generate};
use roowho2_lib::{
proto::finger_protocol::FingerResponseUserEntry,
server::{
fingerd::{FingerRequestInfo, FingerRequestNetworking},
varlink_api::{VarlinkFingerClientError, VarlinkFingerClientProxy},
},
};
/// User information lookup program
///
@@ -19,11 +28,7 @@ use clap::Parser;
/// If standard output is a socket, finger will emit a carriage return (^M) before every linefeed (^J).
/// This is for processing remote finger requests when invoked by the daemon.
#[derive(Debug, Parser)]
#[command(
author = "Programvareverkstedet <projects@pvv.ntnu.no>",
about,
version,
)]
#[command(author = "Programvareverkstedet <projects@pvv.ntnu.no>", version)]
pub struct Args {
/// Forces finger to use IPv4 addresses only.
#[arg(long, short = '4', conflicts_with = "ipv6")]
@@ -51,17 +56,25 @@ pub struct Args {
/// When used in conjunction with the -s option, the name of the remote host
/// is displayed instead of the office location and office phone.
#[arg(long, short, requires = "short", conflicts_with = "office")]
#[arg(long, short = 'H', requires = "short", conflicts_with = "office")]
host: bool,
/// When used in conjunction with the -s option, the office location and
/// office phone information is displayed instead of the name of the remote host.
#[arg(long, short, requires = "short", conflicts_with = "host")]
// TODO: this is default true, should be false when host is true
#[arg(
long,
short,
requires = "short",
conflicts_with = "host",
default_value = "true",
default_value_if("host", ArgPredicate::IsPresent, "false")
)]
office: bool,
/// This option restricts the gecos output to only the users' real name.
/// It also has the side-effect of restricting the output of the remote host
/// when used in conjunction with the -h option.
/// when used in conjunction with the -H option.
#[arg(long, short, requires = "short")]
gecos: bool,
@@ -106,9 +119,109 @@ pub struct Args {
/// Output in JSON format
#[arg(long, short)]
json: bool,
/// When fingering remote users, don't try to parse the content before displaying it,
/// but instead just print the bytes as they are received from the remote.
///
/// Note that this option makes it impossible to represent remote users as JSON.
#[arg(long, short, conflicts_with = "json")]
raw: bool,
/// Generate shell completion scripts for the specified shell
/// and print them to stdout.
#[arg(long, value_enum, hide = true)]
completions: Option<Shell>,
users: Option<Vec<String>>,
}
fn main() {
let _args = Args::parse();
unimplemented!()
fn determine_request_info(args: &Args) -> FingerRequestInfo {
let is_long = if args.long {
true
} else if args.short {
false
} else {
args.users.is_some()
};
if is_long {
FingerRequestInfo::Long {
prevent_files: args.prevent_files,
}
} else {
debug_assert!(
!args.host || !args.office,
"Host and office options cannot both be enabled for short output format"
);
if args.host {
FingerRequestInfo::ShortHost {
restrict_gecos: args.gecos,
}
} else {
FingerRequestInfo::ShortOffice {
restrict_gecos: args.gecos,
}
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
if let Some(shell) = args.completions {
generate(shell, &mut Args::command(), "rwho", &mut std::io::stdout());
return Ok(());
}
let mut conn = zlink::unix::connect("/run/roowho2/roowho2.varlink")
.await
.expect("Failed to connect to fingerd server");
let request_info = determine_request_info(&args);
let request_networking = match (args.ipv4, args.ipv6) {
(true, false) => FingerRequestNetworking::IPv4Only,
(false, true) => FingerRequestNetworking::IPv6Only,
_ => FingerRequestNetworking::Any,
};
let reply = conn
.finger(
args.users,
!args.no_name_match,
request_info,
request_networking,
args.no_acct,
args.raw,
)
.await
.context("Failed to send finger request")?
.map_err(|e| match e {
VarlinkFingerClientError::Disabled => {
anyhow::anyhow!("The fingerd service is disabled on the server")
}
VarlinkFingerClientError::TimedOut => {
anyhow::anyhow!("The fingerd service timed out while processing the request")
}
VarlinkFingerClientError::InvalidRequest => {
anyhow::anyhow!("The fingerd service could not process the request, please check the logs or report the error to your system administrators")
}
})?;
if args.json {
println!("{}", serde_json::to_string_pretty(&reply).unwrap());
} else {
for user in reply {
match user {
FingerResponseUserEntry::Structured(structured) => {
println!("{}", structured.classic_format());
}
FingerResponseUserEntry::Raw(raw) => {
println!("{}", raw);
}
}
}
}
Ok(())
}
+58 -15
View File
@@ -8,10 +8,13 @@ use std::{
use anyhow::Context;
use clap::Parser;
use tokio::{net::UdpSocket, sync::RwLock};
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
use tokio_util::sync::CancellationToken;
use tracing::level_filters::LevelFilter;
use tracing_subscriber::layer::SubscriberExt;
use roowho2_lib::server::{
config::DEFAULT_CONFIG_PATH,
config::{DEFAULT_CONFIG_PATH, LogLevel},
ignore_list::IgnoreList,
rwhod::{RwhodStatusStore, rwhod_packet_receiver_task, rwhod_packet_sender_task},
varlink_api::varlink_client_server_task,
};
@@ -37,11 +40,6 @@ struct Args {
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.init();
let config = toml::from_str::<roowho2_lib::server::config::Config>(
&std::fs::read_to_string(&args.config_path).context(format!(
"Failed to read configuration file {:?}",
@@ -49,21 +47,46 @@ async fn main() -> anyhow::Result<()> {
))?,
)?;
let fd_map: HashMap<String, OwnedFd> = HashMap::from_iter(
sd_notify::listen_fds_with_names(false)?.map(|(fd_num, name)| {
let log_filter = match config.log_level.unwrap_or(LogLevel::Info) {
LogLevel::Info => LevelFilter::INFO,
LogLevel::Debug => LevelFilter::DEBUG,
LogLevel::Trace => LevelFilter::TRACE,
};
let subscriber = tracing_subscriber::registry()
.with(log_filter)
.with(tracing_journald::layer()?);
tracing::subscriber::set_global_default(subscriber)
.context("Failed to set global default tracing subscriber")?;
let rwhod_ignore_list = IgnoreList::load_optional(config.rwhod.ignore_list_path.as_deref())?;
let finger_ignore_list = IgnoreList::load_optional(config.fingerd.ignore_list_path.as_deref())?;
let fd_map: HashMap<String, OwnedFd> =
HashMap::from_iter(sd_notify::listen_fds_with_names()?.map(|(fd_num, name)| {
(
name.clone(),
// SAFETY: please don't mess around with file descriptors in random places
// around the codebase lol
unsafe { std::os::fd::OwnedFd::from_raw_fd(fd_num) },
)
}),
);
}));
let mut join_set = tokio::task::JoinSet::new();
let whod_status_store = Arc::new(RwLock::new(HashMap::new()));
let client_server_token = CancellationToken::new();
let client_server_token_ = client_server_token.clone();
tokio::spawn(async move {
client_server_token_.cancelled().await;
tracing::info!("RWHOD client-server is now accepting connections");
#[cfg(feature = "systemd")]
sd_notify::notify(&[sd_notify::NotifyState::Ready]).ok();
Ok::<(), anyhow::Error>(())
});
if config.rwhod.enable {
tracing::info!("Starting RWHOD server");
@@ -77,7 +100,11 @@ async fn main() -> anyhow::Result<()> {
})
.context("RWHOD server is enabled, but socket fd not provided by systemd")??;
join_set.spawn(rwhod_server(socket, whod_status_store.clone()));
join_set.spawn(rwhod_server(
socket,
whod_status_store.clone(),
rwhod_ignore_list.clone(),
));
} else {
tracing::debug!("RWHOD server is disabled in configuration");
}
@@ -89,6 +116,10 @@ async fn main() -> anyhow::Result<()> {
.try_clone()
.context("Failed to clone RWHOD client-server socket fd")?,
whod_status_store.clone(),
config.rwhod.enable,
config.fingerd.enable,
finger_ignore_list,
client_server_token,
));
join_set.spawn(ctrl_c_handler());
@@ -107,12 +138,12 @@ async fn ctrl_c_handler() -> anyhow::Result<()> {
async fn rwhod_server(
socket: UdpSocket,
whod_status_store: RwhodStatusStore,
ignore_list: Option<IgnoreList>,
) -> anyhow::Result<()> {
let socket = Arc::new(socket);
let interfaces = roowho2_lib::server::rwhod::determine_relevant_interfaces()?;
let sender_task = rwhod_packet_sender_task(socket.clone(), interfaces);
let sender_task = rwhod_packet_sender_task(socket.clone(), interfaces, ignore_list);
let receiver_task = rwhod_packet_receiver_task(socket.clone(), whod_status_store);
tokio::select! {
@@ -126,13 +157,25 @@ async fn rwhod_server(
async fn client_server(
socket_fd: OwnedFd,
whod_status_store: RwhodStatusStore,
rwhod_enabled: bool,
fingerd_enabled: bool,
finger_ignore_list: Option<IgnoreList>,
startup_token: CancellationToken,
) -> anyhow::Result<()> {
// SAFETY: see above
let std_socket =
unsafe { std::os::unix::net::UnixListener::from_raw_fd(socket_fd.as_raw_fd()) };
std_socket.set_nonblocking(true)?;
let zlink_listener = zlink::unix::Listener::try_from(OwnedFd::from(std_socket))?;
let client_server_task = varlink_client_server_task(zlink_listener, whod_status_store);
let client_server_task = varlink_client_server_task(
zlink_listener,
whod_status_store,
rwhod_enabled,
fingerd_enabled,
finger_ignore_list,
);
startup_token.cancel();
client_server_task.await?;
+1 -5
View File
@@ -6,11 +6,7 @@ use clap::Parser;
/// The output shows the current time of day, how long the system has been up, and the load averages.
/// The load average numbers give the number of jobs in the run queue averaged over 1, 5 and 15 minutes.
#[derive(Debug, Parser)]
#[command(
author = "Programvareverkstedet <projects@pvv.ntnu.no>",
about,
version,
)]
#[command(author = "Programvareverkstedet <projects@pvv.ntnu.no>", version)]
pub struct Args {
/// The hosts to query.
hosts: Vec<String>,
+20 -19
View File
@@ -3,7 +3,10 @@ use chrono::{Duration, Utc};
use clap::{CommandFactory, Parser};
use clap_complete::{Shell, generate};
use roowho2_lib::{proto::WhodStatusUpdate, server::varlink_api::RwhodClientProxy};
use roowho2_lib::{
proto::WhodStatusUpdate,
server::varlink_api::{VarlinkRwhodClientError, VarlinkRwhodClientProxy},
};
/// Show host status of local machines.
///
@@ -12,11 +15,7 @@ use roowho2_lib::{proto::WhodStatusUpdate, server::varlink_api::RwhodClientProxy
///
/// Machines for which no status report has been received for 11 minutes are shown as being down.
#[derive(Debug, Parser)]
#[command(
author = "Programvareverkstedet <projects@pvv.ntnu.no>",
about,
version
)]
#[command(author = "Programvareverkstedet <projects@pvv.ntnu.no>", version)]
pub struct Args {
/// Users idle an hour or more are not counted unless the `-a` flag is given.
#[arg(long, short)]
@@ -71,10 +70,20 @@ async fn main() -> anyhow::Result<()> {
.expect("Failed to connect to rwhod server");
let mut reply = conn
.ruptime()
.ruptime(args.all)
.await
.context("Failed to send rwho request")?
.map_err(|e| anyhow::anyhow!("Server returned an error for rwho request: {:?}", e))?;
.map_err(|e| match e {
VarlinkRwhodClientError::Disabled => {
anyhow::anyhow!("The rwhod service is disabled on the server")
}
VarlinkRwhodClientError::TimedOut => {
anyhow::anyhow!("The rwhod service timed out while processing the request")
}
VarlinkRwhodClientError::InvalidRequest => {
anyhow::anyhow!("The rwhod service could not process the request, please check the logs or report the error to your system administrators")
}
})?;
sort_entries(&mut reply, args.load, args.time, args.users, args.reverse);
@@ -87,7 +96,7 @@ async fn main() -> anyhow::Result<()> {
// }
} else {
for entry in &reply {
let line = old_format_machine_entry(args.all, entry);
let line = old_format_machine_entry(entry);
println!("{}", line);
}
}
@@ -129,7 +138,7 @@ fn sort_entries(
});
}
fn old_format_machine_entry(all: bool, entry: &WhodStatusUpdate) -> String {
fn old_format_machine_entry(entry: &WhodStatusUpdate) -> String {
let time_since_last_ping = Utc::now() - entry.sendtime;
let is_up = time_since_last_ping <= Duration::minutes(11);
@@ -146,15 +155,7 @@ fn old_format_machine_entry(all: bool, entry: &WhodStatusUpdate) -> String {
format!(" {:2}:{:02}", hours, minutes)
};
let user_count = if all {
entry.users.len()
} else {
entry
.users
.iter()
.filter(|user| user.idle_time < Duration::hours(1))
.count()
};
let user_count = entry.users.len();
format!(
"{:<12.12} {} {}, {:4} user{} load {:>4.2}, {:>4.2}, {:>4.2}",
+1 -5
View File
@@ -8,11 +8,7 @@ use clap::Parser;
/// the host-name with the names of the users currently logged on is printed on each line.
/// The `rusers` command will wait for one minute to catch late responders.
#[derive(Debug, Parser)]
#[command(
author = "Programvareverkstedet <projects@pvv.ntnu.no>",
about,
version,
)]
#[command(author = "Programvareverkstedet <projects@pvv.ntnu.no>", version)]
pub struct Args {
/// Print all machines responding even if no one is currently logged in
#[arg(long, short)]
+1 -5
View File
@@ -5,11 +5,7 @@ use clap::Parser;
/// The `rwall` command sends a message to the users logged into the specified host.
/// The message to be sent can be typed in and terminated with EOF or it can be in a file
#[derive(Debug, Parser)]
#[command(
author = "Programvareverkstedet <projects@pvv.ntnu.no>",
about,
version,
)]
#[command(author = "Programvareverkstedet <projects@pvv.ntnu.no>", version)]
pub struct Args {
/// The host to send the message to
host: String,
+16 -11
View File
@@ -1,7 +1,10 @@
use anyhow::Context;
use clap::{CommandFactory, Parser};
use clap_complete::{Shell, generate};
use roowho2_lib::{proto::WhodUserEntry, server::varlink_api::RwhodClientProxy};
use roowho2_lib::{
proto::WhodUserEntry,
server::varlink_api::{VarlinkRwhodClientError, VarlinkRwhodClientProxy},
};
/// Check who is logged in on local machines.
///
@@ -13,11 +16,7 @@ use roowho2_lib::{proto::WhodUserEntry, server::varlink_api::RwhodClientProxy};
/// If a user hasn't typed to the system for an hour or more,
/// then the user will be omitted from the output of `rwho` unless the `-a` flag is given.
#[derive(Debug, Parser)]
#[command(
author = "Programvareverkstedet <projects@pvv.ntnu.no>",
about,
version
)]
#[command(author = "Programvareverkstedet <projects@pvv.ntnu.no>", version)]
pub struct Args {
/// Print all machines responding even if no one is currently logged in
#[arg(long, short)]
@@ -54,11 +53,17 @@ async fn main() -> anyhow::Result<()> {
.rwho(args.all)
.await
.context("Failed to send rwho request")?
.map_err(|e| anyhow::anyhow!("Server returned an error for rwho request: {:?}", e))?;
if !args.all {
reply.retain(|(_, user)| user.idle_time.num_minutes() <= 11);
}
.map_err(|e| match e {
VarlinkRwhodClientError::Disabled => {
anyhow::anyhow!("The rwhod service is disabled on the server")
}
VarlinkRwhodClientError::TimedOut => {
anyhow::anyhow!("The rwhod service timed out while processing the request")
}
VarlinkRwhodClientError::InvalidRequest => {
anyhow::anyhow!("The rwhod service could not process the request, please check the logs or report the error to your system administrators")
}
})?;
reply.sort_by(|(host, user), (host2, user2)| {
user.user_id
+4
View File
@@ -1,2 +1,6 @@
#![feature(iter_map_windows)]
#![feature(gethostname)]
#![feature(trim_prefix_suffix)]
pub mod proto;
pub mod server;
+301 -42
View File
@@ -1,55 +1,314 @@
mod classic_formatter;
mod parser;
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use nix::libc::uid_t;
use chrono::{DateTime, TimeDelta, Utc};
use serde::{Deserialize, Serialize};
use crate::proto::finger_protocol::{
classic_formatter::classic_format_finger_response_structured_user_entry,
parser::try_parse_structured_user_entry_from_raw_finger_response,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FingerPerson {
/// User id
uid: uid_t,
/// User' home directory
dir: PathBuf,
/// Home phone no.
homephone: String,
/// Login name
pub struct FingerRequest {
long: bool,
name: String,
/// Office name
office: String,
/// Office phone no.
officephone: String,
/// Full name
realname: String,
/// User's shell
shell: String,
/// Last time mail was read
mailread: DateTime<Utc>,
/// Last time mail was received
mailrecv: DateTime<Utc>,
/// List of where the user is or has been
where_: Vec<FingerWhere>,
}
impl FingerRequest {
pub fn new(long: bool, name: String) -> Self {
Self { long, name }
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut result = Vec::new();
if self.long {
result.extend(b"/W ");
}
result.extend(self.name.as_bytes());
result.extend(b"\r\n");
result
}
pub fn from_bytes(bytes: &[u8]) -> Self {
let (long, name) = if &bytes[..3] == b"/W " {
(true, &bytes[3..])
} else {
(false, bytes)
};
let name = match name.strip_suffix(b"\r\n") {
Some(new_name) => new_name,
None => name,
};
Self::new(long, String::from_utf8_lossy(name).to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct RawFingerResponse(String);
impl RawFingerResponse {
pub fn new(content: String) -> Self {
Self(content)
}
pub fn get_inner(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn from_bytes(bytes: &[u8]) -> Self {
if bytes.is_empty() {
return Self(String::new());
}
fn normalize(c: u8) -> u8 {
if c == (b'\r' | 0x80) || c == (b'\n' | 0x80) {
c & 0x7f
} else {
c
}
}
let normalized: Vec<u8> = bytes
.iter()
.copied()
.map(normalize)
.chain(std::iter::once(normalize(*bytes.last().unwrap())))
.map_windows(|[a, b]| {
if *a == b'\r' && *b == b'\n' {
None
} else {
Some(*a)
}
})
.flatten()
.collect();
let result = String::from_utf8_lossy(&normalized).to_string();
Self(result)
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.0.len() + 2);
for &b in self.0.as_bytes() {
if b == b'\n' {
out.extend_from_slice(b"\r\n");
} else {
out.push(b);
}
}
if !self.0.ends_with('\n') {
out.extend_from_slice(b"\r\n");
}
out
}
}
impl From<String> for RawFingerResponse {
fn from(s: String) -> Self {
Self::new(s)
}
}
impl From<&str> for RawFingerResponse {
fn from(s: &str) -> Self {
Self::new(s.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FingerWhere {
/// Type/status of request
status: FingerWhereStatus,
/// Tty is writable
writable: bool,
/// Time of (last) login
loginat: DateTime<Utc>,
/// how long idle (if logged in)
idletime: DateTime<Utc>,
// TODO: are technically limited by UT_LINESIZE and UT_HOSTSIZE
/// Tty line
tty: String,
/// Remote hostname
host: String,
pub enum FingerResponseUserEntry {
Structured(Box<FingerResponseStructuredUserEntry>),
Raw(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[repr(C)]
pub enum FingerWhereStatus {
LastLog,
LoggedIn,
pub struct FingerResponseStructuredUserEntry {
/// The unix username of this user, as noted in passwd
pub username: String,
/// The full name of this user, as noted in passwd
pub full_name: String,
/// The path to the home directory of this user, as noted in passwd
pub home_dir: PathBuf,
/// The path to the shell of this user, as noted in passwd
pub shell: PathBuf,
/// Office location, if available
pub office: Option<String>,
/// Office phone number, if available
pub office_phone: Option<String>,
/// Home phone number, if available
pub home_phone: Option<String>,
/// Whether the user has never logged in to this host
pub never_logged_in: bool,
/// A list of user sessions, sourced from utmp entries
pub sessions: Vec<FingerResponseUserSession>,
/// Contents of ~/.forward, if it exists
pub forward_status: Option<String>,
/// Whether the user has new or unread mail
pub mail_status: Option<MailStatus>,
/// Contents of ~/.pgpkey, if it exists
pub pgp_key: Option<String>,
/// Contents of ~/.project, if it exists
pub project: Option<String>,
/// Contents of ~/.plan, if it exists
pub plan: Option<String>,
}
impl FingerResponseStructuredUserEntry {
#[allow(clippy::too_many_arguments)]
pub fn new(
username: String,
full_name: String,
home_dir: PathBuf,
shell: PathBuf,
office: Option<String>,
office_phone: Option<String>,
home_phone: Option<String>,
never_logged_in: bool,
sessions: Vec<FingerResponseUserSession>,
forward_status: Option<String>,
mail_status: Option<MailStatus>,
pgp_key: Option<String>,
project: Option<String>,
plan: Option<String>,
) -> Self {
debug_assert!(
!never_logged_in || sessions.is_empty(),
"User cannot be marked as never logged in while having active sessions"
);
Self {
username,
full_name,
home_dir,
shell,
office,
office_phone,
home_phone,
never_logged_in,
sessions,
forward_status,
mail_status,
pgp_key,
project,
plan,
}
}
/// Try parsing a [FingerResponseUserEntry] from the text format used by bsd-finger.
pub fn try_from_raw_finger_response(
response: &RawFingerResponse,
username: String,
) -> anyhow::Result<Self> {
try_parse_structured_user_entry_from_raw_finger_response(response, username)
}
pub fn classic_format(&self) -> String {
classic_format_finger_response_structured_user_entry(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MailStatus {
NoMail,
NewMailReceived {
received_time: DateTime<Utc>,
unread_since: DateTime<Utc>,
},
MailLastRead(DateTime<Utc>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FingerResponseUserSession {
/// The tty on which this session exists
pub tty: String,
/// When the user logged in and created this session
pub login_time: DateTime<Utc>,
/// The hostname or address of the machine from which the user is logged in, if available
pub host: Option<String>,
/// The amount of time since the use last interacted with the tty
pub idle_time: Option<TimeDelta>,
/// Whether this tty is writable, and thus can receive messages via `mesg(1)`
pub messages_on: bool,
}
impl FingerResponseUserSession {
pub fn new(
tty: String,
login_time: DateTime<Utc>,
host: Option<String>,
idle_time: Option<TimeDelta>,
messages_on: bool,
) -> Self {
Self {
tty,
login_time,
host,
idle_time,
messages_on,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_finger_raw_serialization_roundrip() {
let request = FingerRequest::new(true, "alice".to_string());
let bytes = request.to_bytes();
let deserialized = FingerRequest::from_bytes(&bytes);
assert_eq!(request, deserialized);
let request2 = FingerRequest::new(false, "bob".to_string());
let bytes2 = request2.to_bytes();
let deserialized2 = FingerRequest::from_bytes(&bytes2);
assert_eq!(request2, deserialized2);
let response = RawFingerResponse::new("Hello, World!\nThis is a test.\n".to_string());
let response_bytes = response.to_bytes();
let deserialized_response = RawFingerResponse::from_bytes(&response_bytes);
assert_eq!(response, deserialized_response);
let response2 = RawFingerResponse::new("Single line response\n".to_string());
let response_bytes2 = response2.to_bytes();
let deserialized_response2 = RawFingerResponse::from_bytes(&response_bytes2);
assert_eq!(response2, deserialized_response2);
}
}
@@ -0,0 +1,246 @@
use chrono::{Duration, TimeDelta};
use crate::proto::finger_protocol::{
FingerResponseStructuredUserEntry, FingerResponseUserSession, MailStatus,
};
pub fn classic_format_finger_response_structured_user_entry(
entry: &FingerResponseStructuredUserEntry,
) -> String {
let mut result = String::new();
result += &format!(
"Login: {:<16}\t\t\tName: {}\n",
entry.username, entry.full_name
);
result += &format!(
"Directory: {:<24}\tShell: {}\n",
entry.home_dir.display(),
entry.shell.display()
);
if let Some(office) = &entry.office {
result += &format!("Office: {}\n", office);
}
if let Some(office_phone) = &entry.office_phone {
result += &format!("Office Phone: {}\n", office_phone);
}
if let Some(home_phone) = &entry.home_phone {
result += &format!("Home Phone: {}\n", home_phone);
}
if entry.never_logged_in {
result += "Never logged in.\n";
} else {
let max_tty_len = entry
.sessions
.iter()
.map(|s| s.tty.len())
.max()
.unwrap_or(0);
for session in &entry.sessions {
result += &format_session_for_finger(session, max_tty_len);
result += "\n";
}
}
if let Some(forward) = &entry.forward_status {
result += &format!("Mail forwarded to {}\n", forward);
}
if let Some(mail_status) = &entry.mail_status {
match mail_status {
MailStatus::NoMail => result += "No mail.\n",
MailStatus::NewMailReceived {
received_time,
unread_since,
} => {
result += &format!(
"New mail received {}\nUnread since {}\n",
received_time.format("%a %b %e %H:%M (%Z)"),
unread_since.format("%a %b %e %H:%M (%Z)")
);
}
MailStatus::MailLastRead(last_read) => {
result += &format!(
"Mail last read {}\n",
last_read.format("%a %b %e %H:%M (%Z)")
);
}
}
}
if let Some(pgp_key) = &entry.pgp_key {
result += &format!("PGP key:\n{}\n", pgp_key);
}
if let Some(project) = &entry.project {
result += &format!("Project:\n{}\n", project);
}
if let Some(plan) = &entry.plan {
result += &format!("Plan:\n{}\n", plan);
} else {
result += "No Plan.\n";
}
result.trim().to_string()
}
fn format_session_for_finger(session: &FingerResponseUserSession, max_tty_len: usize) -> String {
let mut result = String::new();
result += &format!(
"On since {} on {} from {}",
session.login_time.format("%a %b %e %H:%M (%Z)"),
session.tty,
session.host.as_deref().unwrap_or("unknown")
);
if let Some(idle_time) = session.idle_time {
result += &format!(
"\n{:width$}{} idle",
"",
format_idle_time_for_finger(idle_time),
width = max_tty_len - session.tty.len() + 3,
);
}
if !session.messages_on {
result += "\n (messages off)";
}
result
}
fn format_idle_time_for_finger(idle_time: TimeDelta) -> String {
debug_assert!(
idle_time.num_seconds() >= 0,
"Idle time should never be negative"
);
let mut result = String::new();
let days = idle_time.num_days();
let hours = (idle_time - Duration::days(days)).num_hours();
let minutes = (idle_time - Duration::days(days) - Duration::hours(hours)).num_minutes();
let seconds =
(idle_time - Duration::days(days) - Duration::hours(hours) - Duration::minutes(minutes))
.num_seconds();
if days > 0 {
result += &format!("{} day{} ", days, if days == 1 { "" } else { "s" });
}
if hours > 0 {
result += &format!("{} hour{} ", hours, if hours == 1 { "" } else { "s" });
}
if minutes > 0 && days == 0 {
result += &format!("{} minute{} ", minutes, if minutes == 1 { "" } else { "s" });
}
if seconds > 0 && days == 0 && hours == 0 {
result += &format!("{} second{} ", seconds, if seconds == 1 { "" } else { "s" });
}
result.trim().to_string()
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use indoc::indoc;
use crate::proto::finger_protocol::FingerResponseUserSession;
use super::*;
#[test]
fn test_format_session_for_finger() {
let values = [
(
FingerResponseUserSession {
tty: "pts/14".to_string(),
login_time: Utc.with_ymd_and_hms(2026, 5, 12, 15, 39, 0).unwrap(),
host: Some(":pts/21:S.5".to_string()),
idle_time: Some(Duration::days(3) + Duration::hours(18)),
messages_on: true,
},
indoc! {
"
On since Tue May 12 15:39 (UTC) on pts/14 from :pts/21:S.5
3 days 18 hours idle
"
}
.trim()
.to_string(),
),
(
FingerResponseUserSession {
tty: "pts/16".to_string(),
login_time: Utc.with_ymd_and_hms(2026, 5, 12, 15, 39, 0).unwrap(),
host: Some(":pts/21:S.6".to_string()),
idle_time: Some(Duration::hours(18) + Duration::minutes(47)),
messages_on: true,
},
indoc! {
"
On since Tue May 12 15:39 (UTC) on pts/16 from :pts/21:S.6
18 hours 47 minutes idle
"
}
.trim()
.to_string(),
),
(
FingerResponseUserSession {
tty: "pts/21".to_string(),
login_time: Utc.with_ymd_and_hms(2026, 5, 12, 15, 39, 0).unwrap(),
host: Some("212.102.29.193".to_string()),
idle_time: Some(Duration::minutes(24) + Duration::seconds(22)),
messages_on: false,
},
indoc! {
"
On since Tue May 12 15:39 (UTC) on pts/21 from 212.102.29.193
24 minutes 22 seconds idle
(messages off)
"
}
.trim()
.to_string(),
),
];
for (session, expected) in values {
assert_eq!(
format_session_for_finger(&session, "pts/10".len()),
expected
);
}
}
#[test]
fn test_format_session_for_finger_long_tty_string() {
let session = FingerResponseUserSession {
tty: "pts/1".to_string(),
login_time: Utc.with_ymd_and_hms(2026, 5, 12, 15, 39, 0).unwrap(),
host: Some(":pts/21:S.5".to_string()),
idle_time: Some(Duration::hours(1) + Duration::minutes(30)),
messages_on: true,
};
let expected = indoc! {
"
On since Tue May 12 15:39 (UTC) on pts/1 from :pts/21:S.5
1 hour 30 minutes idle
"
}
.trim()
.to_string();
assert_eq!(
format_session_for_finger(&session, "pts/123".len()),
expected
);
}
}
+965
View File
@@ -0,0 +1,965 @@
use std::path::PathBuf;
use anyhow::Context;
use chrono::{
DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeZone, Timelike, Utc, Weekday,
};
use itertools::Itertools;
use crate::proto::finger_protocol::{
FingerResponseStructuredUserEntry, FingerResponseUserSession, MailStatus, RawFingerResponse,
};
/// Parse the time serialization format commonly used by bsd-finger
fn parse_bsd_finger_time(time: &str) -> anyhow::Result<DateTime<Utc>> {
let time_parts: Vec<_> = time.split_ascii_whitespace().collect();
let time_ = &time_parts[..time_parts.len() - 1].join(" ");
let timezone = time_parts[time_parts.len() - 1]
.trim_start_matches('(')
.trim_end_matches(')');
let tz: chrono_tz::Tz = timezone
.parse()
.context(format!("Failed to parse timezone in login time: {}", time))?;
let mut parts = time_.split_whitespace();
let weekday = match parts.next() {
Some("Mon") => Weekday::Mon,
Some("Tue") => Weekday::Tue,
Some("Wed") => Weekday::Wed,
Some("Thu") => Weekday::Thu,
Some("Fri") => Weekday::Fri,
Some("Sat") => Weekday::Sat,
Some("Sun") => Weekday::Sun,
_ => anyhow::bail!("Invalid weekday in login time: {}", time_),
};
let month = match parts.next() {
Some("Jan") => 1,
Some("Feb") => 2,
Some("Mar") => 3,
Some("Apr") => 4,
Some("May") => 5,
Some("Jun") => 6,
Some("Jul") => 7,
Some("Aug") => 8,
Some("Sep") => 9,
Some("Oct") => 10,
Some("Nov") => 11,
Some("Dec") => 12,
_ => anyhow::bail!("Invalid month in login time: {}", time_),
};
let day: u32 = parts
.next()
.and_then(|d| d.parse().ok())
.ok_or_else(|| anyhow::anyhow!("Invalid day in login time: {}", time_))?;
let time_part = parts
.next()
.ok_or_else(|| anyhow::anyhow!("Missing time in login time: {}", time_))?;
let clock = NaiveTime::parse_from_str(time_part, "%H:%M").map_err(|e| {
anyhow::anyhow!(
"Failed to parse time component in login time: {}: {}",
time_,
e
)
})?;
let now = Utc::now();
const MAX_YEARS_BACK: i32 = 10;
let mut year = None;
for offset in 0..=MAX_YEARS_BACK {
let year_ = now.year() - offset;
let date = match NaiveDate::from_ymd_opt(year_, month, day) {
Some(d) => d,
None => continue,
};
if date.weekday() != weekday {
continue;
}
year = Some(year_);
}
if year.is_none() {
return Err(anyhow::anyhow!(
"Could not find a valid year for login time {} within {} years",
time_,
MAX_YEARS_BACK
));
}
tz.with_ymd_and_hms(year.unwrap(), month, day, clock.hour(), clock.minute(), 0)
.single()
.ok_or_else(|| {
anyhow::anyhow!(
"Failed to convert login time to timezone-aware datetime: {}",
time_
)
})
.map(|dt| dt.with_timezone(&Utc))
}
pub fn try_parse_structured_user_entry_from_raw_finger_response(
response: &RawFingerResponse,
username: String,
) -> anyhow::Result<FingerResponseStructuredUserEntry> {
let content = response.get_inner();
let lines: Vec<&str> = content.lines().collect();
if lines.len() < 2 {
return Err(anyhow::anyhow!(
"Unexpected finger response format for user {}",
username
));
}
let first_line = lines[0];
let second_line = lines[1];
let full_name = parse_full_name(first_line, &username)?;
let home_dir = parse_home_dir(second_line, &username)?;
let shell = parse_shell(second_line, &username)?;
let mut current_index = 2;
let (office, office_phone, home_phone) = parse_gecos_fields(&lines, &mut current_index)?;
let never_logged_in = lines[current_index].trim() == "Never logged in.";
let user_sessions = if never_logged_in {
current_index += 1;
vec![]
} else {
parse_user_sessions(&lines, &mut current_index)
};
let forward_status = parse_forward_status(&lines, &mut current_index);
let mail_status = parse_mail_status(&lines, &mut current_index)?;
let pgp_key = parse_pgp_key(&lines, &mut current_index);
let project = parse_project(&lines, &mut current_index);
let plan = parse_plan(&lines, &mut current_index);
debug_assert!(
current_index == lines.len(),
"Not all lines in finger response were parsed for user {}. Unparsed lines: {:?}",
username,
&lines[current_index..]
);
Ok(FingerResponseStructuredUserEntry::new(
username,
full_name,
home_dir,
shell,
office,
office_phone,
home_phone,
never_logged_in,
user_sessions,
forward_status,
mail_status,
pgp_key,
project,
plan,
))
}
fn parse_full_name(first_line: &str, username: &str) -> anyhow::Result<String> {
Ok(first_line
.split("Name:")
.nth(1)
.ok_or_else(|| {
anyhow::anyhow!(
"Failed to parse full name from finger response for user {}",
username
)
})?
.trim()
.to_string())
}
fn parse_home_dir(second_line: &str, username: &str) -> anyhow::Result<PathBuf> {
second_line
.split("Directory:")
.nth(1)
.and_then(|s| s.split("Shell:").next())
.map(|s| s.trim())
.map(PathBuf::from)
.ok_or_else(|| {
anyhow::anyhow!(
"Failed to parse home directory from finger response for user {}",
username
)
})
}
fn parse_shell(second_line: &str, username: &str) -> anyhow::Result<PathBuf> {
second_line
.split("Shell:")
.nth(1)
.map(|s| s.trim())
.map(PathBuf::from)
.ok_or_else(|| {
anyhow::anyhow!(
"Failed to parse shell from finger response for user {}",
username
)
})
}
fn parse_gecos_fields(
lines: &[&str],
current_index: &mut usize,
) -> anyhow::Result<(Option<String>, Option<String>, Option<String>)> {
let mut office: Option<String> = None;
let mut office_phone: Option<String> = None;
let mut home_phone: Option<String> = None;
// TODO: handle case where office details contains comma, use last comma as separator
if let Some(line) = lines.get(*current_index)
&& line.trim().starts_with("Office:")
{
let office_line = line.trim().trim_start_matches("Office:").trim();
if let Some((office_loc, phone)) = office_line.split_once(',') {
office = Some(office_loc.trim().to_string());
office_phone = Some(phone.trim().to_string());
} else {
office = Some(office_line.to_string());
}
*current_index += 1;
}
if let Some(line) = lines.get(*current_index)
&& line.trim().starts_with("Office Phone:")
{
let phone = line.trim().trim_start_matches("Office Phone:").trim();
office_phone = Some(phone.to_string());
*current_index += 1;
}
if let Some(line) = lines.get(*current_index)
&& line.trim().starts_with("Home Phone:")
{
let phone = line.trim().trim_start_matches("Home Phone:").trim();
home_phone = Some(phone.to_string());
*current_index += 1;
}
Ok((office, office_phone, home_phone))
}
fn parse_user_sessions(
lines: &[&str],
current_index: &mut usize,
) -> Vec<FingerResponseUserSession> {
let mut sessions = Vec::new();
while let Some(line) = lines.get(*current_index)
&& line.starts_with("On since")
{
let line_to_parse = if line.contains("from")
&& let Some(next_line) = lines.get(*current_index + 1)
&& next_line
.trim_suffix(" (messages off)")
.strip_suffix("idle")
.is_some()
{
*current_index += 2;
line.to_string() + "\n" + next_line
} else {
*current_index += 1;
line.to_string()
};
match parse_user_session(&line_to_parse) {
Ok(session) => {
sessions.push(session);
}
Err(err) => {
tracing::warn!("Failed to parse user session from line: {}\n{}", line, err);
}
}
}
sessions
}
/// Try parsing a [FingerResponseUserSession] from the text format used by bsd-finger.
pub fn parse_user_session(line: &str) -> anyhow::Result<FingerResponseUserSession> {
let parts: Vec<&str> = line.split_whitespace().collect();
debug_assert!(parts[0] == "On");
debug_assert!(parts[1] == "since");
let login_time_str = parts
.iter()
.take_while(|&&s| s != "on")
.skip(2)
.cloned()
.join(" ");
let login_time = parse_bsd_finger_time(&login_time_str)?;
let (tty_loc, tty_str) = parts
.iter()
.enumerate()
.skip(2)
.skip_while(|&(_, &s)| s != "on")
.nth(1)
.ok_or_else(|| anyhow::anyhow!("Failed to find tty in finger session line: {line}"))?;
let tty = tty_str.trim_end_matches(',').to_string();
let (host_loc, host) = match parts
.iter()
.enumerate()
.skip(tty_loc)
.skip_while(|&(_, &s)| s != "from")
.nth(1)
{
Some((host_loc, host)) => (host_loc, Some(host.to_string())),
None => (tty_loc, None),
};
let idle_str = parts
.iter()
.skip(host_loc + 1)
.take_while(|&&x| x != "idle")
.join(" ")
.trim_end_matches("(messages off)")
.to_string();
let idle_time = if idle_str.is_empty() {
None
} else {
Some(parse_user_session_idle_time(&idle_str)?)
};
let messages_on = !line.ends_with("(messages off)");
Ok(FingerResponseUserSession::new(
tty,
login_time,
host,
idle_time,
messages_on,
))
}
/// Parse the idle time from the text string generated by bsd-finger
fn parse_user_session_idle_time(str: &str) -> anyhow::Result<Duration> {
let mut total_duration = Duration::zero();
let parts: Vec<&str> = str.split_whitespace().collect();
let mut i = 0;
while i < parts.len() {
let value_str = parts[i];
let unit_str = parts
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("Missing time unit in idle time string: {}", str))?;
let value: i64 = value_str
.parse()
.map_err(|e| anyhow::anyhow!("Failed to parse value from idle time {}: {}", str, e))?;
match *unit_str {
"day" | "days" => total_duration += Duration::days(value),
"hour" | "hours" => total_duration += Duration::hours(value),
"minute" | "minutes" => total_duration += Duration::minutes(value),
"second" | "seconds" => total_duration += Duration::seconds(value),
_ => {
return Err(anyhow::anyhow!(
"Unknown time unit '{}' in idle time string: {}",
unit_str,
str
));
}
}
i += 2;
}
Ok(total_duration)
}
fn parse_forward_status(lines: &[&str], current_index: &mut usize) -> Option<String> {
let next_line = lines.get(*current_index);
// TODO: handle multi-line case
if let Some(line) = next_line
&& line.trim().starts_with("Mail forwarded to ")
{
*current_index += 1;
Some(line.trim().trim_prefix("Mail forwarded to ").to_string())
} else {
None
}
}
fn parse_mail_status(
lines: &[&str],
current_index: &mut usize,
) -> anyhow::Result<Option<MailStatus>> {
let next_line = lines.get(*current_index);
if let Some(line) = next_line
&& line.trim().starts_with("New mail received")
{
let received_time_line =
parse_bsd_finger_time(line.trim().trim_start_matches("New mail received "))?;
let unread_since_line = parse_bsd_finger_time(
lines
.get(*current_index + 1)
.ok_or_else(|| anyhow::anyhow!("Missing unread since line in mail status"))?
.trim()
.trim_start_matches("Unread since "),
)?;
*current_index += 2;
Ok(Some(MailStatus::NewMailReceived {
received_time: received_time_line,
unread_since: unread_since_line,
}))
} else if let Some(line) = next_line
&& (line.trim().starts_with("Mail last read"))
{
*current_index += 1;
let datetime = parse_bsd_finger_time(line.trim().trim_prefix("Mail last read "))?;
Ok(Some(MailStatus::MailLastRead(datetime)))
} else if let Some(line) = next_line
&& line.trim() == "No mail."
{
*current_index += 1;
Ok(Some(MailStatus::NoMail))
} else {
tracing::warn!("Failed to parse mail status from line: {:?}", next_line);
Ok(None)
}
}
fn parse_pgp_key(lines: &[&str], current_index: &mut usize) -> Option<String> {
let next_line = lines.get(*current_index);
if let Some(line) = next_line
&& line.trim().starts_with("PGP key:")
{
*current_index += 1;
let mut pgp_lines = Vec::new();
while let Some(line) = lines.get(*current_index) {
let trimmed = line.trim();
if trimmed.starts_with("Project:") || trimmed.starts_with("Plan:") {
break;
}
pgp_lines.push(trimmed);
*current_index += 1;
}
Some(pgp_lines.join("\n"))
} else {
None
}
}
fn parse_project(lines: &[&str], current_index: &mut usize) -> Option<String> {
let next_line = lines.get(*current_index);
if let Some(line) = next_line
&& line.trim().starts_with("Project:")
{
if line.trim().trim_start_matches("Project:").trim().is_empty() {
*current_index += 1;
let mut project_lines = Vec::new();
while let Some(line) = lines.get(*current_index) {
let trimmed = line.trim();
if trimmed.starts_with("Plan:") {
break;
}
project_lines.push(trimmed);
*current_index += 1;
}
Some(project_lines.join("\n"))
} else {
*current_index += 1;
Some(
line.trim()
.trim_start_matches("Project:")
.trim()
.to_string(),
)
}
} else {
None
}
}
fn parse_plan(lines: &[&str], current_index: &mut usize) -> Option<String> {
let next_line = lines.get(*current_index);
if let Some(line) = next_line
&& line.trim().starts_with("Plan:")
{
if line.trim().trim_start_matches("Plan:").trim().is_empty() {
*current_index += 1;
let mut plan_lines = Vec::new();
while let Some(line) = lines.get(*current_index) {
plan_lines.push(line.trim());
*current_index += 1;
}
Some(plan_lines.join("\n"))
} else {
*current_index += 1;
Some(line.trim().trim_start_matches("Plan:").trim().to_string())
}
} else if let Some(line) = next_line
&& line.trim() == "No Plan."
{
*current_index += 1;
None
} else {
None
}
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Timelike};
use super::*;
#[test]
fn test_parse_bsd_finger_time() {
let cases = vec![
"Mon Mar 1 10:00 (UTC)",
"Tue Feb 28 23:59 (UTC)",
"Wed Dec 31 00:00 (UTC)",
"Wed Dec 31 00:00 (GMT)",
"Wed Dec 31 00:00 (Asia/Tokyo)",
];
for input in cases {
let datetime = parse_bsd_finger_time(input);
assert!(
datetime.is_ok(),
"Failed to parse datetime for input: {}",
input
);
}
}
#[test]
fn test_parse_user_session_idle_time() {
let cases = vec![
("1 second", 1),
("3 minutes 2 seconds", 3 * 60 + 2),
(
"1 day 5 hours 30 minutes",
1 * 24 * 60 * 60 + 5 * 60 * 60 + 30 * 60,
),
("1 day 1 second", 1 * 24 * 60 * 60 + 1),
];
for (input, expected_seconds) in cases {
let duration = parse_user_session_idle_time(input).unwrap();
assert_eq!(
duration.num_seconds(),
expected_seconds,
"Failed on input: {}",
input
);
}
}
#[test]
fn test_finger_user_session_parsing() {
let line = "On since Mon Mar 1 10:00 (UTC) on pts/0 from host.example.com";
let session = parse_user_session(line).unwrap();
assert_eq!(session.tty, "pts/0");
assert_eq!(session.host, Some("host.example.com".into()));
assert_eq!(session.login_time.weekday(), Weekday::Mon);
assert_eq!(session.login_time.hour(), 10);
assert_eq!(session.idle_time, None);
assert!(session.messages_on);
let line_off =
"On since Mon Mar 1 10:00 (UTC) on pts/1 from another.host.com (messages off)";
let session_off = parse_user_session(line_off).unwrap();
assert_eq!(session_off.tty, "pts/1");
assert_eq!(session_off.host, Some("another.host.com".into()));
assert_eq!(session_off.login_time.weekday(), Weekday::Mon);
assert_eq!(session_off.login_time.hour(), 10);
assert_eq!(session_off.idle_time, None);
assert!(!session_off.messages_on);
let line_idle = "On since Mon Mar 1 10:00 (UTC) on pts/2 1 day 5 hours 30 minutes idle";
let session_idle = parse_user_session(line_idle).unwrap();
assert_eq!(session_idle.tty, "pts/2");
assert_eq!(session_idle.host, None);
assert_eq!(session_idle.login_time.weekday(), Weekday::Mon);
assert_eq!(session_idle.login_time.hour(), 10);
assert_eq!(
session_idle.idle_time.unwrap().num_minutes(),
1 * 24 * 60 + 5 * 60 + 30
);
assert!(session_idle.messages_on);
let line_idle_off = "On since Mon Mar 1 10:00 (UTC) on pts/3 from host.example.com 47 minutes 1 second idle (messages off)";
let session_idle_off = parse_user_session(line_idle_off).unwrap();
assert_eq!(session_idle_off.tty, "pts/3");
assert_eq!(session_idle_off.host, Some("host.example.com".into()));
assert_eq!(session_idle_off.login_time.weekday(), Weekday::Mon);
assert_eq!(session_idle_off.login_time.hour(), 10);
assert_eq!(session_idle_off.idle_time.unwrap().num_minutes(), 47);
assert!(!session_idle_off.messages_on);
let line_host_and_idle = indoc::indoc! {"
On since Mon Mar 1 10:00 (UTC) on pts/4 from host.example.com
2 hours idle
"}
.trim();
let session_host_and_idle = parse_user_session(line_host_and_idle).unwrap();
assert_eq!(session_host_and_idle.tty, "pts/4");
assert_eq!(session_host_and_idle.host, Some("host.example.com".into()));
assert_eq!(session_host_and_idle.login_time.weekday(), Weekday::Mon);
assert_eq!(session_host_and_idle.login_time.hour(), 10);
assert_eq!(
session_host_and_idle.idle_time.unwrap().num_minutes(),
60 * 2
);
assert!(session_host_and_idle.messages_on);
// FIXME: "CEST" timezone parsing is currently broken
// let line_cest_timezone = indoc::indoc! {"
// On since Thu Apr 30 05:45 (CEST) on pts/4 from 10.0.0.192
// 6 hours 34 minutes idle
// "}
// .trim();
// let session_cest = parse_user_session(line_cest_timezone).unwrap();
// assert_eq!(session_cest.tty, "pts/4");
// assert_eq!(session_cest.host, Some("10.0.0.192".to_string()));
// assert_eq!(session_cest.login_time.weekday(), Weekday::Thu);
// assert_eq!(session_cest.login_time.hour(), 5);
// assert_eq!(session_cest.idle_time.unwrap().num_minutes(), 6 * 60 + 34);
// assert!(session_cest.messages_on);
}
#[test]
fn test_finger_user_entry_parsing_basic() {
let response_content = indoc::indoc! {"
Login: alice Name: Alice Wonderland
Directory: /home/alice Shell: /bin/bash
On since Mon Mar 1 10:00 (UTC) on pts/0 from host.example.com
No mail.
No Plan.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"alice".to_string(),
)
.unwrap();
assert_eq!(user_entry.username, "alice");
assert_eq!(user_entry.full_name, "Alice Wonderland");
assert_eq!(user_entry.home_dir, PathBuf::from("/home/alice"));
assert_eq!(user_entry.shell, PathBuf::from("/bin/bash"));
assert_eq!(user_entry.sessions.len(), 1);
assert_eq!(user_entry.sessions[0].tty, "pts/0");
assert_eq!(user_entry.sessions[0].host, Some("host.example.com".into()));
}
#[test]
#[ignore = "CEST timezone parsing is currently broken"]
fn test_finger_user_entry_parsing_idle_sessions() {
let response_content = indoc::indoc! {"
Login: alice Name: Alice Wonderland
Directory: /home/alice Shell: /bin/bash
On since Thu Apr 30 05:45 (CEST) on pts/4 from 10.0.0.192
6 hours 34 minutes idle
On since Thu Apr 30 05:46 (CEST) on pts/5 from 10.0.0.192
6 hours 33 minutes idle
No mail.
No Plan.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"alice".to_string(),
)
.unwrap();
assert_eq!(user_entry.sessions.len(), 2);
assert_eq!(user_entry.sessions[0].tty, "pts/4");
assert_eq!(user_entry.sessions[0].host, Some("10.0.0.192".into()));
assert_eq!(
user_entry.sessions[0].idle_time.unwrap().num_minutes(),
6 * 60 + 34
);
assert_eq!(user_entry.sessions[1].tty, "pts/5");
assert_eq!(user_entry.sessions[1].host, Some("10.0.0.192".into()));
assert_eq!(
user_entry.sessions[1].idle_time.unwrap().num_minutes(),
6 * 60 + 33
);
}
#[test]
fn test_finger_user_entry_parsing_single_line_office_phone() {
let response_content = indoc::indoc! {"
Login: alice Name: Alice Wonderland
Directory: /home/alice Shell: /bin/bash
Office: 123 Main St, 012-345-6789
Home Phone: +0-123-456-7890
On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
No mail.
No Plan.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"alice".to_string(),
)
.unwrap();
assert_eq!(user_entry.office, Some("123 Main St".to_string()));
assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
}
#[test]
fn test_finger_user_entry_parsing_multiline_office_phone() {
let response_content = indoc::indoc! {"
Login: alice Name: Alice Wonderland
Directory: /home/alice Shell: /bin/bash
Office: 123 Main St
Office Phone: 012-345-6789
Home Phone: +0-123-456-7890
On since Mon Mar 1 10:00 (UTC) on pts/0, idle 5:00, from host.example.com
No mail.
No Plan.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"alice".to_string(),
)
.unwrap();
assert_eq!(user_entry.office, Some("123 Main St".to_string()));
assert_eq!(user_entry.office_phone, Some("012-345-6789".to_string()));
assert_eq!(user_entry.home_phone, Some("+0-123-456-7890".to_string()));
}
#[test]
fn test_finger_user_entry_parsing_never_logged_in() {
let response_content = indoc::indoc! {"
Login: bob Name: Bob Builder
Directory: /home/bob Shell: /bin/zsh
Never logged in.
No mail.
No Plan.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"bob".to_string(),
)
.unwrap();
assert!(user_entry.never_logged_in);
assert!(user_entry.sessions.is_empty());
}
#[test]
fn test_finger_user_entry_parsing_no_mail() {
let response_content = indoc::indoc! {"
Login: bob Name: Bob Builder
Directory: /home/bob Shell: /bin/zsh
Never logged in.
No mail.
No Plan.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"bob".to_string(),
)
.unwrap();
assert_eq!(user_entry.mail_status, Some(MailStatus::NoMail));
}
#[test]
fn test_finger_user_entry_parsing_new_mail_received() {
let response_content = indoc::indoc! {"
Login: bob Name: Bob Builder
Directory: /home/bob Shell: /bin/zsh
Never logged in.
New mail received Mon Mar 1 10:00 (UTC)
Unread since Mon Mar 1 09:00 (UTC)
No Plan.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"bob".to_string(),
)
.unwrap();
assert_eq!(
user_entry.mail_status,
Some(MailStatus::NewMailReceived {
received_time: Utc.with_ymd_and_hms(2021, 3, 1, 10, 0, 0).unwrap(),
unread_since: Utc.with_ymd_and_hms(2021, 3, 1, 9, 0, 0).unwrap(),
})
);
}
#[test]
fn test_finger_user_entry_parsing_mail_last_read() {
let response_content = indoc::indoc! {"
Login: bob Name: Bob Builder
Directory: /home/bob Shell: /bin/zsh
Never logged in.
Mail last read Mon Mar 1 10:00 (UTC)
No Plan.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"bob".to_string(),
)
.unwrap();
assert_eq!(
user_entry.mail_status,
Some(MailStatus::MailLastRead(
Utc.with_ymd_and_hms(2021, 3, 1, 10, 0, 0).unwrap()
))
);
}
#[test]
fn test_finger_user_entry_parsing_single_line_plan_project() {
let response_content = indoc::indoc! {"
Login: bob Name: Bob Builder
Directory: /home/bob Shell: /bin/zsh
Never logged in.
Mail last read Mon Mar 1 10:00 (UTC)
Project: Build a new house.
Plan: Build a new house.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"bob".to_string(),
)
.unwrap();
assert_eq!(user_entry.project, Some("Build a new house.".to_string()));
assert_eq!(user_entry.plan, Some("Build a new house.".to_string()));
}
#[test]
fn test_finger_user_entry_parsing_multiline_pgp_plan_project() {
let response_content = indoc::indoc! {"
Login: bob Name: Bob Builder
Directory: /home/bob Shell: /bin/zsh
Never logged in.
Mail last read Mon Mar 1 10:00 (UTC)
PGP key:
-----BEGIN PGP KEY-----
Version: GnuPG v1
ABCDEFGHIJKLMNOPQRSTUVWXYZ
-----END PGP KEY-----
Project:
Build a new house.
Need to buy materials.
Plan:
Build a new house.
Need to buy materials.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"bob".to_string(),
)
.unwrap();
assert_eq!(
user_entry.pgp_key,
Some("-----BEGIN PGP KEY-----\nVersion: GnuPG v1\nABCDEFGHIJKLMNOPQRSTUVWXYZ\n-----END PGP KEY-----".to_string()),
);
assert_eq!(
user_entry.project,
Some("Build a new house.\n\nNeed to buy materials.".to_string())
);
assert_eq!(
user_entry.plan,
Some("Build a new house.\n\nNeed to buy materials.".to_string())
);
}
#[test]
fn test_finger_user_entry_parsing_plan_keyword_in_plan() {
let response_content = indoc::indoc! {"
Login: bob Name: Bob Builder
Directory: /home/bob Shell: /bin/zsh
Never logged in.
Mail last read Mon Mar 1 10:00 (UTC)
Project:
I put an extra Plan: keyword here for kaos.
:3:3:3
Plan:
Build a new house.
Plan:
Need to buy materials.
The plan is to build a new house.
"}
.trim();
let response = RawFingerResponse::from(response_content.to_string());
let user_entry = FingerResponseStructuredUserEntry::try_from_raw_finger_response(
&response,
"bob".to_string(),
)
.unwrap();
assert_eq!(
user_entry.project,
Some("I put an extra Plan: keyword here for kaos.\n\n:3:3:3".to_string())
);
assert_eq!(
user_entry.plan,
Some("Build a new house.\n\nPlan:\nNeed to buy materials.\n\nThe plan is to build a new house.".to_string())
);
}
}
+417 -40
View File
@@ -236,6 +236,85 @@ impl Whod {
/// All values are multiplied by 100.
pub type LoadAverage = (i32, i32, i32);
// NOTE: the original rwhod protocol uses 32-bit integers for timestamps,
// which will cause overflow issues after 2038-01-19. To mitigate this,
// we decode timestamps by looking at the time the packet was received,
// comparing it to the current time or the time the packet was received,
// and ensuring any overflowed timestamps are corrected accordingly.
const RWHOD_TIMESTAMP_CORRECTION_WINDOW: i64 = 0x40000000_i64;
const RWHOD_TIMESTAMP_WRAP_INCREMENT: i64 = 0x70000000_i64 + 0x70000000_i64 + 0x20000000_i64;
fn decode_rwhod_timestamp(raw: i32, correction: i64) -> Result<DateTime<Utc>, String> {
DateTime::from_timestamp_secs(i64::from(raw) + correction).ok_or(format!(
"Invalid timestamp: {} with correction {}",
raw, correction
))
}
fn rwhod_time_correction(now: DateTime<Utc>, recvtime: i32) -> i64 {
let delta = now.timestamp() - i64::from(recvtime);
if delta <= RWHOD_TIMESTAMP_CORRECTION_WINDOW {
return 0;
}
let wraps = (delta - RWHOD_TIMESTAMP_CORRECTION_WINDOW - 1)
.div_euclid(RWHOD_TIMESTAMP_WRAP_INCREMENT)
+ 1;
wraps * RWHOD_TIMESTAMP_WRAP_INCREMENT
}
fn decode_rwhod_timestamp_not_after(
raw: i32,
base_correction: i64,
upper_bound: DateTime<Utc>,
) -> Result<DateTime<Utc>, String> {
let upper_bound = upper_bound.timestamp();
for offset in [1_i64, 0, -1] {
let correction = base_correction + offset * RWHOD_TIMESTAMP_WRAP_INCREMENT;
let candidate = i64::from(raw) + correction;
if candidate <= upper_bound {
return DateTime::from_timestamp_secs(candidate).ok_or(format!(
"Invalid timestamp: {} with correction {}",
raw, correction
));
}
}
decode_rwhod_timestamp(raw, base_correction - RWHOD_TIMESTAMP_WRAP_INCREMENT)
}
fn decode_rwhod_timestamp_near_time(
raw: i32,
time: DateTime<Utc>,
) -> Result<DateTime<Utc>, String> {
let base_correction = rwhod_time_correction(time, raw);
let mut best_candidate = None;
for offset in [1_i64, 0, -1] {
let correction = base_correction + offset * RWHOD_TIMESTAMP_WRAP_INCREMENT;
let candidate = i64::from(raw) + correction;
let distance = (time.timestamp() - candidate).abs();
match best_candidate {
Some((best_distance, _, _)) if best_distance <= distance => {}
_ => best_candidate = Some((distance, candidate, correction)),
}
}
let (_, candidate, correction) = best_candidate.expect("candidate list should not be empty");
DateTime::from_timestamp_secs(candidate).ok_or(format!(
"Invalid timestamp: {} with correction {}",
raw, correction
))
}
fn encode_rwhod_timestamp(timestamp: DateTime<Utc>) -> i32 {
timestamp.timestamp() as i32
}
/// High-level representation of a rwhod status update.
///
/// This struct is intended for easier use in Rust code, with proper types and dynamic arrays.
@@ -339,9 +418,8 @@ impl TryFrom<Whoent> for WhodUserEntry {
let user_id = String::from_utf8(value.we_utmp.out_name[..user_id_end].to_vec())
.map_err(|e| format!("Invalid UTF-8 in user ID: {}", e))?;
let login_time = DateTime::from_timestamp_secs(value.we_utmp.out_time as i64).ok_or(
format!("Invalid login time timestamp: {}", value.we_utmp.out_time),
)?;
let now = Utc::now();
let login_time = decode_rwhod_timestamp_near_time(value.we_utmp.out_time, now)?;
Ok(WhodUserEntry {
tty,
@@ -363,20 +441,28 @@ impl TryFrom<Whod> for WhodStatusUpdate {
));
}
let sendtime = DateTime::from_timestamp_secs(value.wd_sendtime as i64).ok_or(format!(
"Invalid send time timestamp: {}",
value.wd_sendtime
))?;
let now = Utc::now();
let recvtime_correction = rwhod_time_correction(now, value.wd_recvtime);
let recvtime = if value.wd_recvtime == 0 {
None
} else {
Some(
DateTime::from_timestamp_secs(value.wd_recvtime as i64).ok_or(format!(
"Invalid receive time timestamp: {}",
value.wd_recvtime
))?,
)
Some(decode_rwhod_timestamp(
value.wd_recvtime,
recvtime_correction,
)?)
};
let recvtime_upper_bound = recvtime.unwrap_or(now);
let sendtime = if recvtime.is_some() {
decode_rwhod_timestamp_not_after(
value.wd_sendtime,
recvtime_correction,
recvtime_upper_bound,
)?
} else {
decode_rwhod_timestamp_near_time(value.wd_sendtime, now)?
};
let hostname_end = value
@@ -387,17 +473,37 @@ impl TryFrom<Whod> for WhodStatusUpdate {
let hostname = String::from_utf8(value.wd_hostname[..hostname_end].to_vec())
.map_err(|e| format!("Invalid UTF-8 in hostname: {}", e))?;
let boot_time = DateTime::from_timestamp_secs(value.wd_boottime as i64).ok_or(format!(
"Invalid boot time timestamp: {}",
value.wd_boottime
))?;
let boot_time = if recvtime.is_some() {
decode_rwhod_timestamp_not_after(value.wd_boottime, recvtime_correction, sendtime)?
} else {
decode_rwhod_timestamp_not_after(
value.wd_boottime,
rwhod_time_correction(sendtime, value.wd_boottime),
sendtime,
)?
};
let users = value
.wd_we
.iter()
.take_while(|whoent| !whoent.is_zeroed())
.cloned()
.map(WhodUserEntry::try_from)
.map(|whoent| {
let mut user = WhodUserEntry::try_from(whoent.clone())?;
user.login_time = if recvtime.is_some() {
decode_rwhod_timestamp_not_after(
whoent.we_utmp.out_time,
recvtime_correction,
recvtime_upper_bound,
)?
} else {
decode_rwhod_timestamp_not_after(
whoent.we_utmp.out_time,
rwhod_time_correction(sendtime, whoent.we_utmp.out_time),
sendtime,
)?
};
Ok(user)
})
.collect::<Result<Vec<WhodUserEntry>, String>>()?;
Ok(WhodStatusUpdate {
@@ -417,16 +523,15 @@ impl TryFrom<WhodUserEntry> for Whoent {
fn try_from(value: WhodUserEntry) -> Result<Self, Self::Error> {
let mut out_line = [0u8; Outmp::MAX_TTY_NAME_LEN];
let tty_bytes = value.tty.as_bytes();
out_line[..tty_bytes.len().min(Outmp::MAX_TTY_NAME_LEN)].copy_from_slice(tty_bytes);
let tty_len = tty_bytes.len().min(Outmp::MAX_TTY_NAME_LEN);
out_line[..tty_len].copy_from_slice(&tty_bytes[..tty_len]);
let mut out_name = [0u8; Outmp::MAX_USER_ID_LEN];
let user_id_bytes = value.user_id.as_bytes();
out_name[..user_id_bytes.len().min(Outmp::MAX_USER_ID_LEN)].copy_from_slice(user_id_bytes);
let user_id_len = user_id_bytes.len().min(Outmp::MAX_USER_ID_LEN);
out_name[..user_id_len].copy_from_slice(&user_id_bytes[..user_id_len]);
let out_time = value
.login_time
.timestamp()
.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
let out_time = encode_rwhod_timestamp(value.login_time);
let we_idle = value
.idle_time
@@ -450,22 +555,12 @@ impl TryFrom<WhodStatusUpdate> for Whod {
fn try_from(value: WhodStatusUpdate) -> Result<Self, Self::Error> {
let mut wd_hostname = [0u8; Whod::MAX_HOSTNAME_LEN];
let hostname_bytes = value.hostname.as_bytes();
wd_hostname[..hostname_bytes.len().min(Whod::MAX_HOSTNAME_LEN)]
.copy_from_slice(hostname_bytes);
let hostname_len = hostname_bytes.len().min(Whod::MAX_HOSTNAME_LEN);
wd_hostname[..hostname_len].copy_from_slice(&hostname_bytes[..hostname_len]);
let wd_sendtime = value
.sendtime
.timestamp()
.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
let wd_recvtime = value.recvtime.map_or(0, |dt| {
dt.timestamp().clamp(i32::MIN as i64, i32::MAX as i64) as i32
});
let wd_boottime = value
.boot_time
.timestamp()
.clamp(i32::MIN as i64, i32::MAX as i64) as i32;
let wd_sendtime = encode_rwhod_timestamp(value.sendtime);
let wd_recvtime = value.recvtime.map_or(0, encode_rwhod_timestamp);
let wd_boottime = encode_rwhod_timestamp(value.boot_time);
let wd_we = value
.users
@@ -555,4 +650,286 @@ mod tests {
invalid_type_bytes[1] = 99; // invalid type
assert!(Whod::from_bytes(&invalid_type_bytes).is_err());
}
#[test]
fn test_whod_user_entry_conversion() {
let user_entry = WhodUserEntry::new(
"tty1".to_string(),
"user1".to_string(),
Utc.with_ymd_and_hms(2024, 6, 1, 10, 0, 0).unwrap(),
Duration::minutes(5),
);
let whoent = Whoent::try_from(user_entry.clone()).expect("Conversion to Whoent failed");
let converted_back =
WhodUserEntry::try_from(whoent).expect("Conversion from Whoent failed");
assert_eq!(user_entry, converted_back);
}
#[test]
fn test_whod_status_update_conversion() {
let status_update = WhodStatusUpdate::new(
Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap(),
Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 5, 0).unwrap()),
"testhost".to_string(),
(25, 20, 18),
Utc.with_ymd_and_hms(2024, 5, 31, 8, 0, 0).unwrap(),
vec![
WhodUserEntry::new(
"tty1".to_string(),
"user1".to_string(),
Utc.with_ymd_and_hms(2024, 6, 1, 10, 0, 0).unwrap(),
Duration::minutes(5),
),
WhodUserEntry::new(
"tty2".to_string(),
"user2".to_string(),
Utc.with_ymd_and_hms(2024, 6, 1, 11, 0, 0).unwrap(),
Duration::minutes(10),
),
],
);
let whod_struct = Whod::try_from(status_update.clone()).expect("Conversion to Whod failed");
let converted_back =
WhodStatusUpdate::try_from(whod_struct).expect("Conversion from Whod failed");
assert_eq!(status_update, converted_back);
}
#[test]
fn test_whod_user_entry_invalid_utf8() {
let mut whoent = Whoent::zeroed();
whoent.we_utmp.out_line = [0xff, 0xfe, 0xfd, 0, 0, 0, 0, 0]; // Invalid UTF-8
whoent.we_utmp.out_name = [0xff, 0xfe, 0xfd, 0, 0, 0, 0, 0]; // Invalid UTF-8
whoent.we_utmp.out_time = 1_700_000_000; // Some valid timestamp
whoent.we_idle = 60; // 1 minute
let result = WhodUserEntry::try_from(whoent);
assert!(result.is_err());
}
#[test]
fn test_whod_user_entry_conversion_username_gets_truncated() {
let mut whoent = Whoent::zeroed();
whoent.we_utmp.out_name = [b'a'; Outmp::MAX_USER_ID_LEN];
whoent.we_utmp.out_time = 1_700_000_000;
whoent.we_idle = 60;
let result = WhodUserEntry::try_from(whoent);
assert!(result.is_ok());
assert_eq!(
result.unwrap().user_id,
[b'a'; Outmp::MAX_USER_ID_LEN]
.iter()
.map(|&c| c as char)
.collect::<String>()
);
}
#[test]
fn test_whod_user_entry_conversion_tty_gets_truncated() {
let mut whoent = Whoent::zeroed();
whoent.we_utmp.out_line = [b'b'; Outmp::MAX_TTY_NAME_LEN];
whoent.we_utmp.out_time = 1_700_000_000;
whoent.we_idle = 60;
let result = WhodUserEntry::try_from(whoent);
assert!(result.is_ok());
assert_eq!(
result.unwrap().tty,
[b'b'; Outmp::MAX_TTY_NAME_LEN]
.iter()
.map(|&c| c as char)
.collect::<String>()
);
}
#[test]
fn test_whod_status_update_hostname_gets_truncated() {
let long_hostname = "a".repeat(Whod::MAX_HOSTNAME_LEN + 10);
let status_update = WhodStatusUpdate::new(
Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap(),
Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 5, 0).unwrap()),
long_hostname.clone(),
(25, 20, 18),
Utc.with_ymd_and_hms(2024, 5, 31, 8, 0, 0).unwrap(),
vec![],
);
let whod_struct = Whod::try_from(status_update.clone()).expect("Conversion to Whod failed");
let converted_back =
WhodStatusUpdate::try_from(whod_struct).expect("Conversion from Whod failed");
assert_eq!(
converted_back.hostname,
long_hostname[..Whod::MAX_HOSTNAME_LEN].to_string()
);
}
#[test]
fn test_whod_status_update_users_get_truncated() {
let users = (0..(Whod::MAX_WHOENTRIES + 10))
.map(|i| {
WhodUserEntry::new(
format!("tty{}", i),
format!("user{}", i),
Utc.with_ymd_and_hms(2024, 6, 1, 10, 0, 0).unwrap(),
Duration::minutes(i as i64),
)
})
.collect::<Vec<_>>();
let status_update = WhodStatusUpdate::new(
Utc.with_ymd_and_hms(2024, 6, 1, 12, 0, 0).unwrap(),
Some(Utc.with_ymd_and_hms(2024, 6, 1, 12, 5, 0).unwrap()),
"testhost".to_string(),
(25, 20, 18),
Utc.with_ymd_and_hms(2024, 5, 31, 8, 0, 0).unwrap(),
users,
);
let whod_struct = Whod::try_from(status_update.clone()).expect("Conversion to Whod failed");
let converted_back =
WhodStatusUpdate::try_from(whod_struct).expect("Conversion from Whod failed");
assert_eq!(converted_back.users.len(), Whod::MAX_WHOENTRIES);
for (i, user) in converted_back.users.iter().enumerate() {
assert_eq!(user.tty, format!("tty{}", i));
assert_eq!(user.user_id, format!("user{}", i));
}
}
#[test]
fn test_whod_status_update_usernames_and_ttys_get_truncated() {
let long_tty = "a".repeat(Outmp::MAX_TTY_NAME_LEN + 10);
let long_user_id = "b".repeat(Outmp::MAX_USER_ID_LEN + 10);
let user_entry = WhodUserEntry::new(
long_tty.clone(),
long_user_id.clone(),
Utc.with_ymd_and_hms(2024, 6, 1, 10, 0, 0).unwrap(),
Duration::minutes(5),
);
let whoent = Whoent::try_from(user_entry.clone()).expect("Conversion to Whoent failed");
let converted_back =
WhodUserEntry::try_from(whoent).expect("Conversion from Whoent failed");
assert_eq!(
converted_back.tty,
long_tty[..Outmp::MAX_TTY_NAME_LEN].to_string()
);
assert_eq!(
converted_back.user_id,
long_user_id[..Outmp::MAX_USER_ID_LEN].to_string()
);
}
#[test]
fn test_rwhod_timestamp_correction_for_received_packets() {
let now = Utc.with_ymd_and_hms(2045, 1, 1, 0, 0, 0).unwrap();
let corrected_recvtime = now - chrono::Duration::days(1);
let raw_recvtime = corrected_recvtime.timestamp() as i32;
let correction = rwhod_time_correction(now, raw_recvtime);
assert_eq!(correction, 1i64 << 32);
assert_eq!(
decode_rwhod_timestamp(raw_recvtime, correction).unwrap(),
corrected_recvtime
);
}
#[test]
fn test_whod_status_update_roundtrip_corrects_wrapped_timestamps() {
let original = WhodStatusUpdate::new(
Utc.with_ymd_and_hms(2044, 12, 31, 23, 0, 0).unwrap(),
Some(Utc.with_ymd_and_hms(2045, 1, 1, 0, 0, 0).unwrap()),
"testhost".to_string(),
(25, 20, 18),
Utc.with_ymd_and_hms(2044, 12, 30, 0, 0, 0).unwrap(),
vec![WhodUserEntry::new(
"tty1".to_string(),
"user".to_string(),
Utc.with_ymd_and_hms(2044, 12, 31, 22, 0, 0).unwrap(),
Duration::seconds(60),
)],
);
let whod = Whod::try_from(original.clone()).expect("Conversion to Whod failed");
let converted = WhodStatusUpdate::try_from(whod).expect("Conversion from Whod failed");
assert_eq!(converted, original);
}
#[test]
fn test_whod_status_update_roundtrip_sendtime_before_wrap_recvtime_after_wrap() {
let original = WhodStatusUpdate::new(
Utc.with_ymd_and_hms(2038, 1, 19, 3, 13, 0).unwrap(),
Some(Utc.with_ymd_and_hms(2038, 1, 19, 3, 14, 30).unwrap()),
"testhost".to_string(),
(25, 20, 18),
Utc.with_ymd_and_hms(2038, 1, 18, 0, 0, 0).unwrap(),
vec![WhodUserEntry::new(
"tty1".to_string(),
"user".to_string(),
Utc.with_ymd_and_hms(2038, 1, 19, 3, 12, 0).unwrap(),
Duration::seconds(60),
)],
);
let whod = Whod::try_from(original.clone()).expect("Conversion to Whod failed");
let converted = WhodStatusUpdate::try_from(whod).expect("Conversion from Whod failed");
assert_eq!(converted, original);
}
#[test]
fn test_whod_status_update_roundtrip_corrects_wrapped_timestamps_without_recvtime() {
let original = WhodStatusUpdate::new(
Utc.with_ymd_and_hms(2045, 1, 1, 0, 0, 0).unwrap(),
None,
"testhost".to_string(),
(25, 20, 18),
Utc.with_ymd_and_hms(2044, 12, 30, 0, 0, 0).unwrap(),
vec![WhodUserEntry::new(
"tty1".to_string(),
"user".to_string(),
Utc.with_ymd_and_hms(2044, 12, 31, 22, 0, 0).unwrap(),
Duration::seconds(60),
)],
);
let whod = Whod::try_from(original.clone()).expect("Conversion to Whod failed");
let converted = WhodStatusUpdate::try_from(whod).expect("Conversion from Whod failed");
assert_eq!(converted, original);
}
#[test]
fn test_whod_user_entry_roundtrip_corrects_wrapped_timestamp() {
let original = WhodUserEntry::new(
"tty1".to_string(),
"user".to_string(),
Utc.with_ymd_and_hms(2045, 1, 1, 0, 0, 0).unwrap(),
Duration::seconds(60),
);
let whoent = Whoent::try_from(original.clone()).expect("Conversion to Whoent failed");
let converted_back =
WhodUserEntry::try_from(whoent).expect("Conversion from Whoent failed");
assert_eq!(converted_back, original);
}
#[test]
fn test_encode_rwhod_timestamp_wraps_like_i32_cast() {
let timestamp = Utc.with_ymd_and_hms(2045, 1, 1, 0, 0, 0).unwrap();
assert_eq!(
encode_rwhod_timestamp(timestamp),
timestamp.timestamp() as i32
);
}
}
+2
View File
@@ -1,3 +1,5 @@
pub mod config;
pub mod fingerd;
pub mod ignore_list;
pub mod rwhod;
pub mod varlink_api;
+27 -1
View File
@@ -1,4 +1,4 @@
use std::net::SocketAddrV4;
use std::{net::SocketAddrV4, path::PathBuf};
use serde::{Deserialize, Serialize};
@@ -8,20 +8,37 @@ pub const DEFAULT_CLIENT_SOCKET_PATH: &str = "/run/roowho2/server_client.sock";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
/// Logging level for the daemon.
pub log_level: Option<LogLevel>,
/// Configuration for the rwhod server.
pub rwhod: RwhodConfig,
/// Configuration for the fingerd server.
pub fingerd: FingerdConfig,
/// Path to the Unix domain socket for client-server communication.
///
/// If left as `None`, the server expects to be served a file descriptor to the socket named 'client'.
pub client_socket_path: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Info,
Debug,
Trace,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RwhodConfig {
/// Enable or disable the rwhod server functionality.
pub enable: bool,
/// Path to the ignore list for users that should be hidden from rwhod.
pub ignore_list_path: Option<PathBuf>,
/// Network interfaces to listen on (e.g., ["eth0", "wlan0"]).
///
/// If left as `None`, the server will automatically determine relevant interfaces.
@@ -34,3 +51,12 @@ pub struct RwhodConfig {
/// If left as `None`, the server will automatically determine broadcast addresses for the selected interfaces.
pub broadcast_addresses: Option<Vec<SocketAddrV4>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FingerdConfig {
/// Enable or disable the fingerd server functionality.
pub enable: bool,
/// Path to the ignore list for users that should be hidden from fingerd.
pub ignore_list_path: Option<PathBuf>,
}
+21
View File
@@ -0,0 +1,21 @@
mod local_email;
mod local_user_info;
mod remote_user_info;
pub use local_user_info::{finger_utmp_users, search_for_user};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FingerRequestNetworking {
Any,
IPv4Only,
IPv6Only,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FingerRequestInfo {
ShortHost { restrict_gecos: bool },
ShortOffice { restrict_gecos: bool },
Long { prevent_files: bool },
}
+208
View File
@@ -0,0 +1,208 @@
use chrono::{TimeZone, Utc};
use std::path::{Path, PathBuf};
use crate::proto::finger_protocol::MailStatus;
pub fn detect_new_mail_for_user(
username: &str,
homedir: &Path,
) -> anyhow::Result<Option<MailStatus>> {
let mail_storage_paths: Vec<PathBuf> = vec![
homedir.join("Mail"),
homedir.join("Mailbox"),
homedir.join("Maildir"),
PathBuf::from("/var/mail").join(username),
PathBuf::from("/var/spool/mail").join(username),
PathBuf::from("/var/maildir").join(username),
PathBuf::from("/var/spool/maildir").join(username),
];
for path in mail_storage_paths {
tracing::trace!("Checking for mail at path: {}", path.display());
if path.is_dir() && path.join("new").is_dir() && path.join("cur").is_dir() {
tracing::trace!("Detected maildir structure at path: {}", path.display());
if let Ok(status) = detect_new_mail_by_maildir(&path) {
return Ok(Some(status));
}
} else if path.is_file() {
tracing::trace!("Detected mailbox file at path: {}", path.display());
if let Ok(status) = detect_new_mail_by_mailbox(&path) {
return Ok(Some(status));
}
}
}
Ok(None)
}
fn detect_new_mail_by_mailbox(mailbox_path: &Path) -> anyhow::Result<MailStatus> {
let stat = nix::sys::stat::stat(mailbox_path)?;
let mail_recv = stat.st_mtime;
let mail_read = stat.st_atime;
if mail_recv > mail_read {
Ok(MailStatus::NewMailReceived {
received_time: Utc
.timestamp_opt(mail_recv, 0)
.single()
.unwrap_or_else(Default::default),
unread_since: Utc
.timestamp_opt(mail_read, 0)
.single()
.unwrap_or_else(Default::default),
})
} else {
Ok(MailStatus::MailLastRead(
Utc.timestamp_opt(mail_read, 0)
.single()
.unwrap_or_else(Default::default),
))
}
}
fn detect_new_mail_by_maildir(maildir_path: &Path) -> anyhow::Result<MailStatus> {
let new_mail_path = maildir_path.join("new");
let cur_mail_path = maildir_path.join("cur");
let mail_recv = nix::sys::stat::stat(&new_mail_path)?.st_mtime;
let mail_read = nix::sys::stat::stat(&cur_mail_path)?.st_mtime;
if new_mail_path.read_dir()?.next().is_none() && cur_mail_path.read_dir()?.next().is_none() {
Ok(MailStatus::NoMail)
} else if mail_recv > mail_read {
Ok(MailStatus::NewMailReceived {
received_time: Utc
.timestamp_opt(mail_recv, 0)
.single()
.unwrap_or_else(Default::default),
unread_since: Utc
.timestamp_opt(mail_read, 0)
.single()
.unwrap_or_else(Default::default),
})
} else {
Ok(MailStatus::MailLastRead(
Utc.timestamp_opt(mail_read, 0)
.single()
.unwrap_or_else(Default::default),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use nix::{
fcntl::AT_FDCWD,
sys::{stat::UtimensatFlags, time::TimeSpec},
};
use std::{fs, os::unix::fs::MetadataExt};
use tempfile::TempDir;
fn set_file_times(path: &Path, atime: i64, mtime: i64) {
nix::sys::stat::utimensat(
AT_FDCWD,
path,
&TimeSpec::new(atime, 0),
&TimeSpec::new(mtime, 0),
UtimensatFlags::FollowSymlink,
)
.unwrap();
}
fn set_dir_mtime(path: &Path, mtime: i64) {
let metadata = fs::metadata(path).unwrap();
set_file_times(path, metadata.atime(), mtime);
}
#[test]
fn test_detect_new_mail_by_mailbox_new_mail_received() {
let tempdir = TempDir::new().unwrap();
let mailbox = tempdir.path().join("Mailbox");
fs::write(&mailbox, b"mail").unwrap();
set_file_times(&mailbox, 1_700_000_000, 1_700_000_100);
let status = detect_new_mail_by_mailbox(&mailbox).unwrap();
assert_eq!(
status,
MailStatus::NewMailReceived {
received_time: Utc.timestamp_opt(1_700_000_100, 0).single().unwrap(),
unread_since: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(),
}
);
}
#[test]
fn test_detect_new_mail_by_mailbox_mail_last_read() {
let tempdir = TempDir::new().unwrap();
let mailbox = tempdir.path().join("Mailbox");
fs::write(&mailbox, b"mail").unwrap();
set_file_times(&mailbox, 1_700_000_100, 1_700_000_000);
let status = detect_new_mail_by_mailbox(&mailbox).unwrap();
assert_eq!(
status,
MailStatus::MailLastRead(Utc.timestamp_opt(1_700_000_100, 0).single().unwrap())
);
}
#[test]
fn test_detect_new_mail_by_maildir_no_mail() {
let tempdir = TempDir::new().unwrap();
let maildir = tempdir.path().join("Maildir");
let new_dir = maildir.join("new");
let cur_dir = maildir.join("cur");
fs::create_dir_all(&new_dir).unwrap();
fs::create_dir_all(&cur_dir).unwrap();
let status = detect_new_mail_by_maildir(&maildir).unwrap();
assert_eq!(status, MailStatus::NoMail);
}
#[test]
fn test_detect_new_mail_by_maildir_new_mail_received() {
let tempdir = TempDir::new().unwrap();
let maildir = tempdir.path().join("Maildir");
let new_dir = maildir.join("new");
let cur_dir = maildir.join("cur");
fs::create_dir_all(&new_dir).unwrap();
fs::create_dir_all(&cur_dir).unwrap();
fs::write(new_dir.join("msg"), b"mail").unwrap();
set_dir_mtime(&cur_dir, 1_700_000_000);
set_dir_mtime(&new_dir, 1_700_000_100);
let status = detect_new_mail_by_maildir(&maildir).unwrap();
assert_eq!(
status,
MailStatus::NewMailReceived {
received_time: Utc.timestamp_opt(1_700_000_100, 0).single().unwrap(),
unread_since: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(),
}
);
}
#[test]
fn test_detect_new_mail_by_maildir_mail_last_read() {
let tempdir = TempDir::new().unwrap();
let maildir = tempdir.path().join("Maildir");
let new_dir = maildir.join("new");
let cur_dir = maildir.join("cur");
fs::create_dir_all(&new_dir).unwrap();
fs::create_dir_all(&cur_dir).unwrap();
fs::write(cur_dir.join("msg"), b"mail").unwrap();
set_dir_mtime(&new_dir, 1_700_000_000);
set_dir_mtime(&cur_dir, 1_700_000_100);
let status = detect_new_mail_by_maildir(&maildir).unwrap();
assert_eq!(
status,
MailStatus::MailLastRead(Utc.timestamp_opt(1_700_000_100, 0).single().unwrap())
);
}
}
+286
View File
@@ -0,0 +1,286 @@
use std::{
net::hostname,
os::unix::fs::{MetadataExt, PermissionsExt},
path::Path,
};
use chrono::{DateTime, Duration, Timelike, Utc};
use itertools::Itertools;
use nix::sys::stat::stat;
use users::all_users;
use uucore::utmpx::{Utmpx, UtmpxRecord};
use crate::{
proto::finger_protocol::{FingerResponseStructuredUserEntry, FingerResponseUserSession},
server::{
fingerd::{FingerRequestInfo, local_email},
ignore_list::IgnoreList,
},
};
/// Search for users whose username or full name contains the search string.
pub fn search_for_user(
search_string: &str,
match_fullnames: bool,
request_info: &FingerRequestInfo,
ignore_list: Option<&IgnoreList>,
) -> Vec<anyhow::Result<FingerResponseStructuredUserEntry>> {
(unsafe { all_users() })
.filter_map(|user| {
let user = match nix::unistd::User::from_uid(user.uid().into()) {
Ok(Some(user)) => user,
Ok(None) => {
tracing::warn!(
"User with UID {} exists but could not retrieve user entry",
user.uid()
);
return None;
}
Err(e) => {
return Some(Err(anyhow::anyhow!(
"Failed to get user entry for UID {}: {}",
user.uid(),
e
)));
}
};
let username = user.name;
let full_name = String::from_utf8_lossy(
user.gecos
.as_bytes()
.split(|&b| b == b',')
.next()
.unwrap_or(&[]),
)
.to_string();
if ignore_list.is_some_and(|ignore_list| ignore_list.ignores_uid(user.uid.as_raw())) {
return None;
}
let matches_username = username.contains(search_string);
let matches_fullname = match_fullnames && full_name.contains(search_string);
if matches_username || matches_fullname {
match get_local_user(&username, None, request_info, ignore_list) {
Ok(Some(user_entry)) => Some(Ok(user_entry)),
Ok(None) => None, // User exists but has .nofinger, skip
Err(err) => Some(Err(err)),
}
} else {
None
}
})
.collect()
}
/// Retrieve information about all users currently logged in, based on utmpx records.
pub fn finger_utmp_users(
request_info: &FingerRequestInfo,
ignore_list: Option<&IgnoreList>,
) -> Vec<anyhow::Result<FingerResponseStructuredUserEntry>> {
Utmpx::iter_all_records()
.filter(|entry| entry.is_user_process())
.into_group_map_by(|entry| entry.user())
.into_iter()
.filter(|(username, _)| {
!ignore_list.is_some_and(|ignore_list| ignore_list.ignores_username(username))
})
.map(|(username, records)| {
get_local_user(&username, Some(records), request_info, ignore_list)
})
.filter_map(|result| match result {
Ok(Some(user_entry)) => Some(Ok(user_entry)),
Ok(None) => None, // User has .nofinger, skip
Err(err) => Some(Err(err)),
})
.collect()
}
/// Helper function to read the content of a file if it exists and is readable,
/// returning None if the file does not exist or is not readable.
fn read_file_content_if_exists(path: &Path) -> anyhow::Result<Option<String>> {
let file_is_readable = path.exists()
&& path.is_file()
&& (((path.metadata()?.permissions().mode() & 0o400 != 0
&& nix::unistd::geteuid().as_raw() == path.metadata()?.uid())
|| (path.metadata()?.permissions().mode() & 0o040 != 0
&& nix::unistd::getegid().as_raw() == path.metadata()?.gid())
|| (path.metadata()?.permissions().mode() & 0o004 != 0))
|| caps::has_cap(
None,
caps::CapSet::Effective,
caps::Capability::CAP_DAC_READ_SEARCH,
)?)
&& path.metadata()?.len() > 0;
if file_is_readable {
Ok(Some(std::fs::read_to_string(path)?.trim().to_string()))
} else {
Ok(None)
}
}
/// Retrieve local user information for the given username.
///
/// Returns None if the user does not exist.
fn get_local_user(
username: &str,
utmp_records: Option<Vec<UtmpxRecord>>,
_request_info: &FingerRequestInfo,
ignore_list: Option<&IgnoreList>,
) -> anyhow::Result<Option<FingerResponseStructuredUserEntry>> {
tracing::trace!(
"Retrieving local user information for username: {}",
username
);
let username = username.to_string();
let user_entry = match nix::unistd::User::from_name(&username) {
Ok(Some(user)) => user,
Ok(None) => return Ok(None),
Err(err) => {
return Err(anyhow::anyhow!(
"Failed to get user entry for {}: {}",
username,
err
));
}
};
if ignore_list.is_some_and(|ignore_list| ignore_list.ignores_uid(user_entry.uid.as_raw())) {
return Ok(None);
}
let nofinger_path = user_entry.dir.join(".nofinger");
if nofinger_path.exists() {
return Ok(None);
}
let full_name = user_entry.name;
let home_dir = user_entry.dir.clone();
let shell = user_entry.shell;
let gecos_fields: Vec<&str> = full_name.split(',').collect();
let office = gecos_fields.get(1).map(|s| s.to_string());
let office_phone = gecos_fields.get(2).map(|s| s.to_string());
let home_phone = gecos_fields.get(3).map(|s| s.to_string());
let hostname = hostname()?.to_str().unwrap_or("localhost").to_string();
let utmpx_records = match utmp_records {
Some(records) => records,
None => Utmpx::iter_all_records()
.filter(|entry| entry.user() == username)
.filter(|entry| entry.is_user_process())
.collect::<Vec<_>>(),
};
// TODO: Don't use utmp entries for this, read from lastlog instead
let user_never_logged_in = utmpx_records.is_empty();
let now = Utc::now().with_nanosecond(0).unwrap_or(Utc::now());
let sessions: Vec<FingerResponseUserSession> = utmpx_records
.into_iter()
.filter_map(|entry| {
let login_time = entry
.login_time()
.checked_to_utc()
.and_then(|t| DateTime::<Utc>::from_timestamp_secs(t.unix_timestamp()))?;
let tty_device_path = Path::new("/dev").join(entry.tty_device());
let tty_device_stat = stat(&tty_device_path).ok();
let tty_is_x_console = entry.tty_device().starts_with(':');
let idle_time = if tty_is_x_console {
None
} else {
tty_device_stat.and_then(|st| {
let last_active = DateTime::<Utc>::from_timestamp_secs(st.st_atime)?;
let result = (now - last_active).max(Duration::zero());
if result == Duration::zero() {
None
} else {
debug_assert!(
result.num_seconds() >= 0,
"Idle time should never be negative"
);
Some(result)
}
})
};
let messages_on =
// X console logins does not show the tty, so messages should be considered off in that case
!tty_is_x_console &&
// Check if the user has write permissions to the tty device,
// indicating whether messages are on or off
tty_device_stat
.map(|st| st.st_mode & 0o220 == 0o220)
.unwrap_or(false);
Some(FingerResponseUserSession::new(
entry.tty_device(),
login_time,
Some(hostname.clone()),
idle_time,
messages_on,
))
})
.collect();
let email_status = local_email::detect_new_mail_for_user(&username, &home_dir)?;
let forward_path = user_entry.dir.join(".forward");
let forward = read_file_content_if_exists(&forward_path)?;
let pgpkey_path = user_entry.dir.join(".pgpkey");
let pgpkey = read_file_content_if_exists(&pgpkey_path)?;
let project_path = user_entry.dir.join(".project");
let project = read_file_content_if_exists(&project_path)?;
let plan_path = user_entry.dir.join(".plan");
let plan = read_file_content_if_exists(&plan_path)?;
Ok(Some(FingerResponseStructuredUserEntry::new(
username,
full_name,
home_dir,
shell,
office,
office_phone,
home_phone,
user_never_logged_in,
sessions,
forward,
email_status,
pgpkey,
project,
plan,
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_finger_root() {
let user_entry = get_local_user(
"root",
None,
&FingerRequestInfo::Long {
prevent_files: false,
},
None,
)
.unwrap()
.unwrap();
assert_eq!(user_entry.username, "root");
}
// TODO: test serialization roundtrip
}
+33
View File
@@ -0,0 +1,33 @@
// /// Retrieve remote user information for the given username on the specified host.
// ///
// /// Returns None if the user does not exist or no information is available.
// async fn get_remote_user(username: &str, host: &str) -> anyhow::Result<Option<RawFingerResponse>> {
// let addr = format!("{}:79", host);
// let socket_addrs: Vec<SocketAddr> = addr.to_socket_addrs()?.collect();
// if socket_addrs.is_empty() {
// return Err(anyhow::anyhow!(
// "Could not resolve address for host {}",
// host
// ));
// }
// let socket_addr = socket_addrs[0];
// let mut stream = TcpStream::connect(socket_addr).await?;
// let request = FingerRequest::new(false, username.to_string());
// let request_bytes = request.to_bytes();
// stream.write_all(&request_bytes).await?;
// let mut response_bytes = Vec::new();
// stream.read_to_end(&mut response_bytes).await?;
// let response = RawFingerResponse::from_bytes(&response_bytes);
// if response.is_empty() {
// Ok(None)
// } else {
// Ok(Some(response))
// }
// }
+142
View File
@@ -0,0 +1,142 @@
use std::{collections::HashSet, path::Path};
use anyhow::Context;
use nix::unistd::{Uid, User};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum IgnoreEntry {
Uid(u32),
User(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IgnoreList {
entries: HashSet<IgnoreEntry>,
}
impl IgnoreList {
pub fn load_optional(path: Option<&Path>) -> anyhow::Result<Option<Self>> {
match path {
Some(path) => Self::load(path).map(Some),
None => Ok(None),
}
}
pub fn load(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read ignore list {}", path.display()))?;
Self::parse(&content)
}
pub fn parse(content: &str) -> anyhow::Result<Self> {
let mut entries = HashSet::new();
for (idx, raw_line) in content.lines().enumerate() {
let line = raw_line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let entry = if let Some(uid) = line.strip_prefix("uid:") {
let uid = uid.trim().parse::<u32>().with_context(|| {
format!("Invalid uid on ignore list line {}: {}", idx + 1, raw_line)
})?;
IgnoreEntry::Uid(uid)
} else if let Some(user) = line.strip_prefix("user:") {
let user = user.trim();
if user.is_empty() {
anyhow::bail!("Invalid user on ignore list line {}: {}", idx + 1, raw_line);
}
IgnoreEntry::User(user.to_string())
} else {
anyhow::bail!(
"Invalid ignore list entry on line {}: {}",
idx + 1,
raw_line
);
};
entries.insert(entry);
}
Ok(Self { entries })
}
pub fn ignores_username(&self, username: &str) -> bool {
if self
.entries
.contains(&IgnoreEntry::User(username.to_string()))
{
return true;
}
match User::from_name(username) {
Ok(Some(user)) => self.entries.contains(&IgnoreEntry::Uid(user.uid.as_raw())),
Ok(None) => false,
Err(err) => {
tracing::warn!(
"Failed to resolve username '{}' for ignore list lookup: {}",
username,
err
);
false
}
}
}
pub fn ignores_uid(&self, uid: u32) -> bool {
if self.entries.contains(&IgnoreEntry::Uid(uid)) {
return true;
}
match User::from_uid(Uid::from_raw(uid)) {
Ok(Some(user)) => self.entries.contains(&IgnoreEntry::User(user.name)),
Ok(None) => false,
Err(err) => {
tracing::warn!(
"Failed to resolve uid {} for ignore list lookup: {}",
uid,
err
);
false
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ignore_list() {
let list = IgnoreList::parse(
&["uid:1000", "user:alice", " user:bob "].join("\n"), // "\n# comment\nuid:1000\nuser:alice # trailing comment\n\nuser:bob\n",
)
.unwrap();
assert!(list.ignores_uid(1000));
assert!(list.ignores_username("alice"));
assert!(list.ignores_username("bob"));
}
#[test]
fn test_parse_ignore_list_with_comments() {
let list = IgnoreList::parse(
&[
"# This is a comment",
"uid:1000",
"user:alice # trailing comment",
"",
"user:bob",
]
.join("\n"),
)
.unwrap();
assert!(list.ignores_uid(1000));
assert!(list.ignores_username("alice"));
assert!(list.ignores_username("bob"));
}
}
+9 -237
View File
@@ -1,242 +1,14 @@
use std::{
collections::{HashMap, HashSet},
net::{IpAddr, SocketAddr},
path::Path,
sync::Arc,
};
mod packet_receiver;
mod packet_sender;
mod rwhod_status;
use anyhow::Context;
use chrono::{DateTime, Duration, Timelike, Utc};
use nix::{
ifaddrs::getifaddrs,
net::if_::InterfaceFlags,
sys::{stat::stat, sysinfo::sysinfo},
unistd::gethostname,
};
use tokio::{
net::UdpSocket,
sync::RwLock,
time::{Duration as TokioDuration, interval},
};
use uucore::utmpx::Utmpx;
pub use packet_receiver::rwhod_packet_receiver_task;
pub use packet_sender::{determine_relevant_interfaces, rwhod_packet_sender_task};
pub use rwhod_status::{generate_rwhod_status_update, generate_rwhod_user_entries};
use crate::proto::{Whod, WhodStatusUpdate, WhodUserEntry};
use std::{collections::HashMap, sync::Arc};
use tokio::sync::RwLock;
/// Default port for rwhod communication.
pub const RWHOD_BROADCAST_PORT: u16 = 513;
use crate::proto::WhodStatusUpdate;
pub type RwhodStatusStore = Arc<RwLock<HashMap<String, WhodStatusUpdate>>>;
/// Reads utmp entries to determine currently logged-in users.
pub fn generate_rwhod_user_entries(now: DateTime<Utc>) -> anyhow::Result<Vec<WhodUserEntry>> {
Utmpx::iter_all_records()
.filter(|entry| entry.is_user_process())
.map(|entry| {
let login_time = entry
.login_time()
.checked_to_utc()
.and_then(|t| DateTime::<Utc>::from_timestamp_secs(t.unix_timestamp()))
.ok_or_else(|| anyhow::anyhow!("Failed to convert login time to UTC"))?;
let idle_time = stat(&Path::new("/dev").join(entry.tty_device()))
.ok()
.and_then(|st| {
let last_active = DateTime::<Utc>::from_timestamp_secs(st.st_atime)?;
Some((now - last_active).max(Duration::zero()))
})
.unwrap_or(Duration::zero());
debug_assert!(
idle_time.num_seconds() >= 0,
"Idle time should never be negative"
);
Ok(WhodUserEntry::new(
entry.tty_device(),
entry.user(),
login_time,
idle_time,
))
})
.collect()
}
/// Generate a rwhod status update packet representing the current system state.
pub fn generate_rwhod_status_update() -> anyhow::Result<WhodStatusUpdate> {
let sysinfo = sysinfo().unwrap();
let load_average = sysinfo.load_average();
let uptime = sysinfo.uptime();
let hostname = gethostname()?.to_str().unwrap().to_string();
let now = Utc::now().with_nanosecond(0).unwrap_or(Utc::now());
let result = WhodStatusUpdate::new(
now,
None,
hostname,
(
(load_average.0 * 100.0).abs() as i32,
(load_average.1 * 100.0).abs() as i32,
(load_average.2 * 100.0).abs() as i32,
),
now - uptime,
generate_rwhod_user_entries(now)?,
);
Ok(result)
}
#[derive(Debug, Clone)]
pub struct RwhodSendTarget {
/// Name of the network interface.
pub name: String,
/// Address to send rwhod packets to.
/// This is either the broadcast address (for broadcast interfaces)
/// or the point-to-point destination address (for point-to-point interfaces).
pub addr: IpAddr,
}
/// Find all networks network interfaces suitable for rwhod communication.
pub fn determine_relevant_interfaces() -> anyhow::Result<Vec<RwhodSendTarget>> {
getifaddrs().map_err(|e| e.into()).map(|ifaces| {
ifaces
// interface must be up
.filter(|iface| iface.flags.contains(InterfaceFlags::IFF_UP))
// interface must be broadcast or point-to-point
.filter(|iface| {
iface
.flags
.intersects(InterfaceFlags::IFF_BROADCAST | InterfaceFlags::IFF_POINTOPOINT)
})
.filter_map(|iface| {
let neighbor_addr = if iface.flags.contains(InterfaceFlags::IFF_BROADCAST) {
iface.broadcast
} else if iface.flags.contains(InterfaceFlags::IFF_POINTOPOINT) {
iface.destination
} else {
None
};
match neighbor_addr {
Some(addr) => addr
.as_sockaddr_in()
.map(|sa| IpAddr::V4(sa.ip()))
.or_else(|| addr.as_sockaddr_in6().map(|sa| IpAddr::V6(sa.ip())))
.map(|ip_addr| RwhodSendTarget {
name: iface.interface_name,
addr: ip_addr,
}),
None => None,
}
})
// keep first occurrence per interface name
.scan(HashSet::new(), |seen, n| {
if seen.insert(n.name.clone()) {
Some(n)
} else {
None
}
})
.collect::<Vec<RwhodSendTarget>>()
})
}
pub async fn send_rwhod_packet_to_interface(
socket: Arc<UdpSocket>,
interface: &RwhodSendTarget,
packet: &Whod,
) -> anyhow::Result<()> {
let serialized_packet = packet.to_bytes();
// TODO: the old rwhod daemon doesn't actually ever listen to ipv6, maybe remove it
let target_addr = match interface.addr {
IpAddr::V4(addr) => SocketAddr::new(IpAddr::V4(addr), RWHOD_BROADCAST_PORT),
IpAddr::V6(addr) => SocketAddr::new(IpAddr::V6(addr), RWHOD_BROADCAST_PORT),
};
tracing::debug!(
"Sending rwhod packet to interface {} at address {}",
interface.name,
target_addr
);
socket
.send_to(&serialized_packet, &target_addr)
.await
.map_err(|e| anyhow::anyhow!("Failed to send rwhod packet: {}", e))?;
Ok(())
}
pub async fn rwhod_packet_receiver_task(
socket: Arc<UdpSocket>,
whod_status_store: RwhodStatusStore,
) -> anyhow::Result<()> {
let mut buf = [0u8; Whod::MAX_SIZE];
loop {
let (len, src) = socket.recv_from(&mut buf).await?;
tracing::debug!("Received rwhod packet of length {} bytes from {}", len, src);
if len < Whod::HEADER_SIZE {
tracing::error!(
"Received too short packet from {src}: {len} bytes (needs to be at least {} bytes)",
Whod::HEADER_SIZE
);
continue;
}
let result = Whod::from_bytes(&buf[..len])
.context("Failed to parse whod packet")?
.try_into()
.map(|mut status_update: WhodStatusUpdate| {
let timestamp = Utc::now().with_nanosecond(0).unwrap_or(Utc::now());
status_update.recvtime = Some(timestamp);
status_update
})
.map_err(|e| anyhow::anyhow!("Invalid whod packet: {}", e));
match result {
Ok(status_update) => {
tracing::debug!("Processed whod packet from {src}: {:?}", status_update);
let mut store = whod_status_store.write().await;
store.insert(status_update.hostname.clone(), status_update);
}
Err(err) => {
tracing::error!("Error processing whod packet from {src}: {err}");
}
}
}
}
pub async fn rwhod_packet_sender_task(
socket: Arc<UdpSocket>,
interfaces: Vec<RwhodSendTarget>,
) -> anyhow::Result<()> {
let mut interval = interval(TokioDuration::from_secs(60));
loop {
interval.tick().await;
let status_update = generate_rwhod_status_update()?;
tracing::debug!("Generated rwhod packet: {:?}", status_update);
let packet = status_update
.try_into()
.map_err(|e| anyhow::anyhow!("{}", e))?;
for interface in &interfaces {
if let Err(e) = send_rwhod_packet_to_interface(socket.clone(), interface, &packet).await
{
tracing::error!(
"Failed to send rwhod packet on interface {}: {}",
interface.name,
e
);
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
use std::sync::Arc;
use anyhow::Context;
use chrono::{Timelike, Utc};
use tokio::net::UdpSocket;
use crate::{
proto::{Whod, WhodStatusUpdate},
server::rwhod::RwhodStatusStore,
};
pub async fn rwhod_packet_receiver_task(
socket: Arc<UdpSocket>,
whod_status_store: RwhodStatusStore,
) -> anyhow::Result<()> {
let mut buf = [0u8; Whod::MAX_SIZE];
loop {
let (len, src) = socket.recv_from(&mut buf).await?;
tracing::debug!("Received rwhod packet of length {} bytes from {}", len, src);
if len < Whod::HEADER_SIZE {
tracing::error!(
"Received too short packet from {src}: {len} bytes (needs to be at least {} bytes)",
Whod::HEADER_SIZE
);
continue;
}
let result = Whod::from_bytes(&buf[..len])
.context("Failed to parse whod packet")?
.try_into()
.map(|mut status_update: WhodStatusUpdate| {
let timestamp = Utc::now().with_nanosecond(0).unwrap_or(Utc::now());
status_update.recvtime = Some(timestamp);
status_update
})
.map_err(|e| anyhow::anyhow!("Invalid whod packet: {}", e));
match result {
Ok(status_update) => {
tracing::debug!("Processed whod packet from {src}: {:?}", status_update);
if status_update.boot_time > status_update.sendtime {
tracing::warn!(
"Received whod packet from {src} with boot time {} after send time {}",
status_update.boot_time,
status_update.sendtime
);
}
if let Some(recvtime) = status_update.recvtime
&& recvtime < status_update.sendtime
{
tracing::warn!(
"Received whod packet from {src} with recv time {} before send time {}",
recvtime,
status_update.sendtime
);
}
let mut store = whod_status_store.write().await;
store.insert(status_update.hostname.clone(), status_update);
}
Err(err) => {
tracing::error!("Error processing whod packet from {src}: {err}");
}
}
}
}
+145
View File
@@ -0,0 +1,145 @@
use nix::{ifaddrs::getifaddrs, net::if_::InterfaceFlags};
use std::{
collections::HashSet,
net::{IpAddr, SocketAddr},
sync::Arc,
};
use tokio::{
net::UdpSocket,
time::{Duration as TokioDuration, interval},
};
use crate::{
proto::Whod,
server::{ignore_list::IgnoreList, rwhod::rwhod_status::generate_rwhod_status_update},
};
/// Default port for rwhod communication.
pub const RWHOD_BROADCAST_PORT: u16 = 513;
#[derive(Debug, Clone)]
pub struct RwhodSendTarget {
/// Name of the network interface.
pub name: String,
/// Address to send rwhod packets to.
/// This is either the broadcast address (for broadcast interfaces)
/// or the point-to-point destination address (for point-to-point interfaces).
pub addr: IpAddr,
}
/// Find all networks network interfaces suitable for rwhod communication.
pub fn determine_relevant_interfaces() -> anyhow::Result<Vec<RwhodSendTarget>> {
getifaddrs().map_err(|e| e.into()).map(|ifaces| {
ifaces
// interface must be up
.filter(|iface| iface.flags.contains(InterfaceFlags::IFF_UP))
// interface must be broadcast or point-to-point
.filter(|iface| {
iface
.flags
.intersects(InterfaceFlags::IFF_BROADCAST | InterfaceFlags::IFF_POINTOPOINT)
})
.filter_map(|iface| {
let neighbor_addr = if iface.flags.contains(InterfaceFlags::IFF_BROADCAST) {
iface.broadcast
} else if iface.flags.contains(InterfaceFlags::IFF_POINTOPOINT) {
iface.destination
} else {
None
};
match neighbor_addr {
Some(addr) => addr
.as_sockaddr_in()
.map(|sa| IpAddr::V4(sa.ip()))
.or_else(|| addr.as_sockaddr_in6().map(|sa| IpAddr::V6(sa.ip())))
.map(|ip_addr| RwhodSendTarget {
name: iface.interface_name,
addr: ip_addr,
}),
None => None,
}
})
// keep first occurrence per interface name
.scan(HashSet::new(), |seen, n| {
if seen.insert(n.name.clone()) {
Some(n)
} else {
None
}
})
.collect::<Vec<RwhodSendTarget>>()
})
}
pub async fn send_rwhod_packet_to_interface(
socket: Arc<UdpSocket>,
interface: &RwhodSendTarget,
packet: &Whod,
) -> anyhow::Result<()> {
let serialized_packet = packet.to_bytes();
// TODO: the old rwhod daemon doesn't actually ever listen to ipv6, maybe remove it
let target_addr = match interface.addr {
IpAddr::V4(addr) => SocketAddr::new(IpAddr::V4(addr), RWHOD_BROADCAST_PORT),
IpAddr::V6(addr) => SocketAddr::new(IpAddr::V6(addr), RWHOD_BROADCAST_PORT),
};
tracing::debug!(
"Sending rwhod packet to interface {} at address {}",
interface.name,
target_addr
);
socket
.send_to(&serialized_packet, &target_addr)
.await
.map_err(|e| anyhow::anyhow!("Failed to send rwhod packet: {}", e))?;
Ok(())
}
pub async fn rwhod_packet_sender_task(
socket: Arc<UdpSocket>,
interfaces: Vec<RwhodSendTarget>,
ignore_list: Option<IgnoreList>,
) -> anyhow::Result<()> {
let mut interval = interval(TokioDuration::from_secs(60));
loop {
interval.tick().await;
let status_update = generate_rwhod_status_update(ignore_list.as_ref())?;
tracing::debug!("Generated rwhod packet: {:?}", status_update);
let packet = status_update
.try_into()
.map_err(|e| anyhow::anyhow!("{}", e))?;
for interface in &interfaces {
if let Err(e) = send_rwhod_packet_to_interface(socket.clone(), interface, &packet).await
{
tracing::error!(
"Failed to send rwhod packet on interface {}: {}",
interface.name,
e
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_determine_relevant_interfaces() {
let interfaces = determine_relevant_interfaces().unwrap();
for interface in interfaces {
println!("Interface: {} Address: {}", interface.name, interface.addr);
}
}
}
+90
View File
@@ -0,0 +1,90 @@
use std::path::Path;
use chrono::{DateTime, Duration, Timelike, Utc};
use nix::{
sys::{stat::stat, sysinfo::sysinfo},
unistd::gethostname,
};
use uucore::utmpx::Utmpx;
use crate::{
proto::{WhodStatusUpdate, WhodUserEntry},
server::ignore_list::IgnoreList,
};
/// Reads utmp entries to determine currently logged-in users.
pub fn generate_rwhod_user_entries(
now: DateTime<Utc>,
ignore_list: Option<&IgnoreList>,
) -> anyhow::Result<Vec<WhodUserEntry>> {
Utmpx::iter_all_records()
.filter(|entry| entry.is_user_process())
.filter(|entry| {
!ignore_list.is_some_and(|ignore_list| ignore_list.ignores_username(&entry.user()))
})
.map(|entry| {
let login_time = entry
.login_time()
.checked_to_utc()
.and_then(|t| DateTime::<Utc>::from_timestamp_secs(t.unix_timestamp()))
.ok_or_else(|| anyhow::anyhow!("Failed to convert login time to UTC"))?;
let idle_time = stat(&Path::new("/dev").join(entry.tty_device()))
.ok()
.and_then(|st| {
let last_active = DateTime::<Utc>::from_timestamp_secs(st.st_atime)?;
Some((now - last_active).max(Duration::zero()))
})
.unwrap_or(Duration::zero());
debug_assert!(
idle_time.num_seconds() >= 0,
"Idle time should never be negative"
);
Ok(WhodUserEntry::new(
entry.tty_device(),
entry.user(),
login_time,
idle_time,
))
})
.collect()
}
/// Generate a rwhod status update packet representing the current system state.
pub fn generate_rwhod_status_update(
ignore_list: Option<&IgnoreList>,
) -> anyhow::Result<WhodStatusUpdate> {
let sysinfo = sysinfo().unwrap();
let load_average = sysinfo.load_average();
let uptime = sysinfo.uptime();
let hostname = gethostname()?.to_str().unwrap().to_string();
let now = Utc::now().with_nanosecond(0).unwrap_or(Utc::now());
let result = WhodStatusUpdate::new(
now,
None,
hostname,
(
(load_average.0 * 100.0).abs() as i32,
(load_average.1 * 100.0).abs() as i32,
(load_average.2 * 100.0).abs() as i32,
),
now - uptime,
generate_rwhod_user_entries(now, ignore_list)?,
);
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_rwhod_status_update() {
let status_update = generate_rwhod_status_update(None).unwrap();
println!("{:?}", status_update);
}
}
+303 -62
View File
@@ -1,27 +1,38 @@
use std::{os::fd::OwnedFd, time::Duration};
use anyhow::Context;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use tokio::time::timeout;
use zlink::{ReplyError, service::MethodReply};
use crate::{
proto::{WhodStatusUpdate, WhodUserEntry, finger_protocol::FingerPerson},
server::rwhod::RwhodStatusStore,
proto::{WhodStatusUpdate, WhodUserEntry, finger_protocol::FingerResponseUserEntry},
server::{
fingerd::{self, FingerRequestInfo, FingerRequestNetworking, finger_utmp_users},
ignore_list::IgnoreList,
rwhod::RwhodStatusStore,
},
};
// Types for 'no.ntnu.pvv.roowho2.rwhod'
#[zlink::proxy("no.ntnu.pvv.roowho2.rwhod")]
pub trait RwhodClientProxy {
pub trait VarlinkRwhodClientProxy {
async fn rwho(
&mut self,
all: bool,
) -> zlink::Result<Result<Vec<(String, WhodUserEntry)>, RwhodClientError>>;
) -> zlink::Result<Result<VarlinkRwhoResponse, VarlinkRwhodClientError>>;
async fn ruptime(&mut self) -> zlink::Result<Result<Vec<WhodStatusUpdate>, RwhodClientError>>;
async fn ruptime(
&mut self,
all: bool,
) -> zlink::Result<Result<VarlinkRuptimeResponse, VarlinkRwhodClientError>>;
}
#[derive(Debug, Deserialize)]
#[serde(tag = "method", content = "parameters")]
pub enum RwhodClientRequest {
pub enum VarlinkRwhodClientRequest {
#[serde(rename = "no.ntnu.pvv.roowho2.rwhod.Rwho")]
Rwho {
/// Retrieve all users, even those that have been idle for a long time.
@@ -29,54 +40,73 @@ pub enum RwhodClientRequest {
},
#[serde(rename = "no.ntnu.pvv.roowho2.rwhod.Ruptime")]
Ruptime,
Ruptime {
/// Count all users, even those that have been idle for a long time.
all: bool,
},
}
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(untagged)]
pub enum RwhodClientResponse {
Rwho(RwhoResponse),
Ruptime(RuptimeResponse),
pub enum VarlinkRwhodClientResponse {
Rwho(VarlinkRwhoResponse),
Ruptime(VarlinkRuptimeResponse),
}
pub type RwhoResponse = Vec<(String, WhodUserEntry)>;
pub type RuptimeResponse = Vec<WhodStatusUpdate>;
pub type VarlinkRwhoResponse = Vec<(String, WhodUserEntry)>;
pub type VarlinkRuptimeResponse = Vec<WhodStatusUpdate>;
#[derive(Debug, Clone, PartialEq, ReplyError)]
#[zlink(interface = "no.ntnu.pvv.roowho2.rwhod")]
pub enum RwhodClientError {
pub enum VarlinkRwhodClientError {
InvalidRequest,
TimedOut,
Disabled,
}
// Types for 'no.ntnu.pvv.roowho2.finger'
#[zlink::proxy("no.ntnu.pvv.roowho2.finger")]
pub trait FingerClientProxy {
pub trait VarlinkFingerClientProxy {
async fn finger(
&mut self,
user_queries: Vec<String>,
) -> zlink::Result<Result<Vec<FingerPerson>, FingerClientError>>;
user_queries: Option<Vec<String>>,
match_fullnames: bool,
request_info: FingerRequestInfo,
request_networking: FingerRequestNetworking,
disable_user_account_db: bool,
raw_remote_output: bool,
) -> zlink::Result<Result<VarlinkFingerResponse, VarlinkFingerClientError>>;
}
#[derive(Debug, Deserialize)]
#[serde(tag = "method", content = "parameters")]
pub enum FingerClientRequest {
pub enum VarlinkFingerClientRequest {
#[serde(rename = "no.ntnu.pvv.roowho2.finger.Finger")]
Finger { user_queries: Vec<String> },
Finger {
user_queries: Option<Vec<String>>,
match_fullnames: bool,
request_info: FingerRequestInfo,
request_networking: FingerRequestNetworking,
disable_user_account_db: bool,
raw_remote_output: bool,
},
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub enum FingerClientResponse {
Finger(FingerResponse),
pub enum VarlinkFingerClientResponse {
Finger(VarlinkFingerResponse),
}
pub type FingerResponse = Vec<FingerPerson>;
pub type VarlinkFingerResponse = Vec<FingerResponseUserEntry>;
#[derive(Debug, Clone, PartialEq, ReplyError)]
#[zlink(interface = "no.ntnu.pvv.roowho2.finger")]
pub enum FingerClientError {
pub enum VarlinkFingerClientError {
InvalidRequest,
TimedOut,
Disabled,
}
// --------------------
@@ -84,82 +114,285 @@ pub enum FingerClientError {
#[derive(Debug, Deserialize)]
#[serde(untagged)]
#[allow(unused)]
enum Method {
Rwhod(RwhodClientRequest),
Finger(FingerClientRequest),
pub enum VarlinkMethod {
Rwhod(VarlinkRwhodClientRequest),
Finger(VarlinkFingerClientRequest),
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
#[allow(unused)]
enum Reply {
Rwhod(RwhodClientResponse),
Finger(FingerClientResponse),
pub enum VarlinkReply {
Rwhod(VarlinkRwhodClientResponse),
Finger(VarlinkFingerClientResponse),
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(untagged)]
#[allow(unused)]
enum ReplyError {
Rwhod(RwhodClientError),
Finger(FingerClientError),
pub enum VarlinkReplyError {
Rwhod(VarlinkRwhodClientError),
Finger(VarlinkFingerClientError),
}
#[derive(Debug, Clone)]
pub struct Roowhoo2ClientServer {
pub struct VarlinkRoowhoo2ClientServer {
whod_status_store: RwhodStatusStore,
rwhod_enabled: bool,
fingerd_enabled: bool,
finger_ignore_list: Option<IgnoreList>,
}
impl Roowhoo2ClientServer {
pub fn new(whod_status_store: RwhodStatusStore) -> Self {
Self { whod_status_store }
impl VarlinkRoowhoo2ClientServer {
pub fn new(
whod_status_store: RwhodStatusStore,
rwhod_enabled: bool,
fingerd_enabled: bool,
finger_ignore_list: Option<IgnoreList>,
) -> Self {
Self {
whod_status_store,
rwhod_enabled,
fingerd_enabled,
finger_ignore_list,
}
}
}
impl Roowhoo2ClientServer {
// TODO: handle 'all' parameter
async fn handle_rwho_request(&self, _all: bool) -> RwhoResponse {
impl VarlinkRoowhoo2ClientServer {
async fn handle_rwho_request(&self, all: bool) -> VarlinkRwhoResponse {
tracing::debug!(all, "Handling Rwho request");
let store = self.whod_status_store.read().await;
let mut all_user_entries = Vec::with_capacity(store.len());
for status_update in store.values() {
all_user_entries.extend_from_slice(
&status_update
all_user_entries.extend(
status_update
.users
.iter()
.map(|user| (status_update.hostname.clone(), user.clone()))
.collect::<Vec<(String, WhodUserEntry)>>(),
.filter(|user| all || user.idle_time < chrono::Duration::hours(1))
.cloned()
.map(|user| (status_update.hostname.clone(), user)),
);
}
all_user_entries
}
async fn handle_ruptime_request(&self) -> RuptimeResponse {
async fn handle_ruptime_request(&self, all: bool) -> VarlinkRuptimeResponse {
tracing::debug!(all, "Handling Ruptime request");
let store = self.whod_status_store.read().await;
store.values().cloned().collect()
store
.values()
.cloned()
.map(|mut status_update| {
if !all {
status_update
.users
.retain(|user| user.idle_time < chrono::Duration::hours(1));
}
status_update
})
.collect()
}
async fn handle_finger_request(
&self,
user_queries: Option<Vec<String>>,
match_fullnames: bool,
request_info: FingerRequestInfo,
_request_networking: FingerRequestNetworking,
_disable_user_account_db: bool,
_raw_remote_output: bool,
) -> VarlinkFingerResponse {
tracing::debug!(
user_queries = ?user_queries,
match_fullnames = match_fullnames,
request_info = ?request_info,
"Handling Finger request",
);
match user_queries {
Some(usernames) => usernames
.into_iter()
.flat_map::<Vec<_>, _>(|username| {
fingerd::search_for_user(
&username,
match_fullnames,
&request_info,
self.finger_ignore_list.as_ref(),
)
.into_iter()
.map(|res| (username.clone(), res))
.collect()
})
.dedup_by(|a, b| match (&a.1, &b.1) {
(Ok(user_a), Ok(user_b)) => user_a.username == user_b.username,
_ => false,
})
.filter_map(|(username, user)| match user {
Ok(user_info) => Some(user_info),
Err(err) => {
tracing::error!(
"Error retrieving local user information for '{}': {}",
username,
err
);
None
}
})
.map(Box::new)
.map(FingerResponseUserEntry::Structured)
.collect(),
None => finger_utmp_users(&request_info, self.finger_ignore_list.as_ref())
.into_iter()
.filter_map(|res| match res {
Ok(user_info) => Some(user_info),
Err(err) => {
tracing::error!("Error retrieving local user information: {}", err);
None
}
})
.map(Box::new)
.map(FingerResponseUserEntry::Structured)
.collect(),
}
}
}
impl zlink::Service for Roowhoo2ClientServer {
type MethodCall<'de> = RwhodClientRequest;
type ReplyParams<'se> = RwhodClientResponse;
impl zlink::Service<zlink::unix::Stream> for VarlinkRoowhoo2ClientServer {
type MethodCall<'de> = VarlinkMethod;
type ReplyParams<'se> = VarlinkReply;
type ReplyStreamParams = ();
type ReplyStream = futures_util::stream::Empty<zlink::Reply<()>>;
type ReplyError<'se> = RwhodClientError;
type ReplyStream = futures_util::stream::Empty<(zlink::Reply<()>, Vec<OwnedFd>)>;
type ReplyError<'se> = VarlinkReplyError;
async fn handle<'ser, 'de: 'ser, Sock: zlink::connection::Socket>(
&'ser mut self,
call: zlink::Call<Self::MethodCall<'de>>,
_conn: &mut zlink::Connection<Sock>,
) -> MethodReply<Self::ReplyParams<'ser>, Self::ReplyStream, Self::ReplyError<'ser>> {
async fn handle<'service>(
&'service mut self,
call: &'service zlink::Call<Self::MethodCall<'_>>,
_conn: &mut zlink::Connection<zlink::unix::Stream>,
_fds: Vec<std::os::fd::OwnedFd>,
) -> zlink::service::HandleResult<
Self::ReplyParams<'service>,
Self::ReplyStream,
Self::ReplyError<'service>,
> {
match call.method() {
RwhodClientRequest::Rwho { all } => MethodReply::Single(Some(
RwhodClientResponse::Rwho(self.handle_rwho_request(*all).await),
)),
RwhodClientRequest::Ruptime => MethodReply::Single(Some(RwhodClientResponse::Ruptime(
self.handle_ruptime_request().await,
))),
VarlinkMethod::Rwhod(VarlinkRwhodClientRequest::Rwho { all }) => {
if !self.rwhod_enabled {
return (
MethodReply::Error(VarlinkReplyError::Rwhod(
VarlinkRwhodClientError::Disabled,
)),
Default::default(),
);
}
let result =
match timeout(Duration::from_secs(2), self.handle_rwho_request(*all)).await {
Ok(response) => response,
Err(_) => {
tracing::error!("Rwho request timed out after 2 seconds");
return (
MethodReply::Error(VarlinkReplyError::Rwhod(
VarlinkRwhodClientError::TimedOut,
)),
Default::default(),
);
}
};
(
MethodReply::Single(Some(VarlinkReply::Rwhod(
VarlinkRwhodClientResponse::Rwho(result),
))),
Default::default(),
)
}
VarlinkMethod::Rwhod(VarlinkRwhodClientRequest::Ruptime { all }) => {
if !self.rwhod_enabled {
return (
MethodReply::Error(VarlinkReplyError::Rwhod(
VarlinkRwhodClientError::Disabled,
)),
Default::default(),
);
}
let result = match timeout(
Duration::from_secs(2),
self.handle_ruptime_request(*all),
)
.await
{
Ok(response) => response,
Err(_) => {
tracing::error!("Ruptime request timed out after 2 seconds");
return (
MethodReply::Error(VarlinkReplyError::Rwhod(
VarlinkRwhodClientError::TimedOut,
)),
Default::default(),
);
}
};
(
MethodReply::Single(Some(VarlinkReply::Rwhod(
VarlinkRwhodClientResponse::Ruptime(result),
))),
Default::default(),
)
}
VarlinkMethod::Finger(VarlinkFingerClientRequest::Finger {
user_queries,
match_fullnames,
request_info,
request_networking,
disable_user_account_db,
raw_remote_output,
}) => {
if !self.fingerd_enabled {
return (
MethodReply::Error(VarlinkReplyError::Finger(
VarlinkFingerClientError::Disabled,
)),
Default::default(),
);
}
let result = match timeout(
Duration::from_secs(2),
self.handle_finger_request(
user_queries.clone(),
*match_fullnames,
request_info.clone(),
request_networking.clone(),
*disable_user_account_db,
*raw_remote_output,
),
)
.await
{
Ok(response) => response,
Err(_) => {
tracing::error!("Finger request timed out after 2 seconds");
return (
MethodReply::Error(VarlinkReplyError::Finger(
VarlinkFingerClientError::TimedOut,
)),
Default::default(),
);
}
};
(
MethodReply::Single(Some(VarlinkReply::Finger(
VarlinkFingerClientResponse::Finger(result),
))),
Default::default(),
)
}
}
}
}
@@ -167,8 +400,16 @@ impl zlink::Service for Roowhoo2ClientServer {
pub async fn varlink_client_server_task(
socket: zlink::unix::Listener,
whod_status_store: RwhodStatusStore,
rwhod_enabled: bool,
fingerd_enabled: bool,
finger_ignore_list: Option<IgnoreList>,
) -> anyhow::Result<()> {
let service = Roowhoo2ClientServer::new(whod_status_store);
let service = VarlinkRoowhoo2ClientServer::new(
whod_status_store,
rwhod_enabled,
fingerd_enabled,
finger_ignore_list,
);
let server = zlink::Server::new(socket, service);