Skip to content

GUACAMOLE-2300: Add IPMI Serial-over-LAN protocol support#692

Open
ciroiriarte wants to merge 14 commits into
apache:mainfrom
ciroiriarte:feature/ipmi-protocol
Open

GUACAMOLE-2300: Add IPMI Serial-over-LAN protocol support#692
ciroiriarte wants to merge 14 commits into
apache:mainfrom
ciroiriarte:feature/ipmi-protocol

Conversation

@ciroiriarte

@ciroiriarte ciroiriarte commented Jul 13, 2026

Copy link
Copy Markdown

This adds a new ipmi protocol module to guacamole-server providing Serial-over-LAN (SOL) console access and chassis power management for BMCs (baseboard management controllers), built on FreeIPMI (libipmiconsole for SOL, libfreeipmi for chassis control).

JIRA: https://issues.apache.org/jira/browse/GUACAMOLE-2300

What's included

  • New libguac-client-ipmi protocol module — SOL console rendered through the shared guac_terminal.
  • Chassis control over a client-agnostic ipmi-control pipe channel (newline-delimited JSON): power on/off/cycle/reset, soft shutdown, diagnostic interrupt (NMI), chassis identify, System Event Log (SEL) viewer, and live power/SOL status.
  • In-terminal control menu (Ctrl+]) with an alternate-screen UI, run asynchronously so BMC round-trips never block the console.
  • Multivendor support: configurable cipher suite / privilege level / workaround flags, hex K_g, encryption policy. Verified end-to-end against Supermicro (ATEN), Lenovo XCC, and Dell iDRAC 9.
  • Connection parameters including persistent boot-device selection and keepalive interval.
  • CUnit tests for the control-channel JSON parser, wired into make check.

Testing

  • 12/12 unit tests pass.
  • AddressSanitizer clean on live SOL sessions. Two memory bugs were found and fixed during review: a NULL-deref on teardown, and a use-after-free that freed the terminal before joining the SOL worker.
  • Full-stack live regression (browser → guacamole-client → guacd → BMC): SOL console I/O, live status push, chassis identify, SEL read, and Send Break all verified non-destructively against a Supermicro BMC.

Related PRs

…management

Implement a native `ipmi` protocol module (src/protocols/ipmi/) providing
IPMI 2.0 Serial-over-LAN console access via FreeIPMI libipmiconsole, modeled
on the terminal-based telnet module (guac_terminal for rendering, scrollback,
input; recording and typescript support).

Add standard chassis power management via libfreeipmi: power on/off/cycle/
reset/soft-shutdown/diagnostic-interrupt, next-boot device override, chassis
identify LED, and power status. These are exposed as pre-connect parameters
(power-on-connect, boot-device) applied after the SOL session attaches so the
user can watch the system boot, and on demand via an in-terminal control menu
(Ctrl+]) with y/N confirmation for destructive actions.

Wire the module into the autotools build behind --with-ipmi (ENABLE_IPMI),
detecting libipmiconsole and libfreeipmi, and add it to the configure summary.

Companion web-UI work is tracked in ciroiriarte/guacamole-client#1.

Implements #1.
Move @IPMI_LIBS@ (-lipmiconsole -lfreeipmi) from _LDFLAGS to _LIBADD so the
libraries follow the object files on the link line. With the GNU linker's
--as-needed behavior (default on openSUSE, and modern Debian/Ubuntu/Fedora),
libraries listed in LDFLAGS precede the objects that reference them and are
dropped, leaving them out of DT_NEEDED. The .so still builds, but guacd's
dlopen() then fails at runtime with 'undefined symbol:
tmpl_cmd_set_system_boot_options_rs' and reports the protocol as not installed.
…ine refcount, hex K_g

M1 hardening for real-world multivendor BMC support:

- workaround-flags parameter: comma-separated FreeIPMI workaround tokens and
  vendor presets (supermicro/intel/sun/dell/hpe/lenovo), resolved into separate
  libipmiconsole (SOL) and libfreeipmi (chassis) masks and applied to both
  sessions, replacing the hardcoded 0.
- encryption-policy parameter (required default / preferred / none): validates
  that the configured cipher suite provides confidentiality before connecting;
  aborts under 'required' for non-encrypting suites (0,1,2,6,7,8,11,15).
- Reference-counted, mutex-guarded libipmiconsole engine init/teardown so
  concurrent IPMI connections no longer tear down the shared engine out from
  under each other.
- K_g now accepts a 0x-prefixed hex value decoded to a raw byte buffer with an
  explicit length, so binary BMC keys (which may contain NUL bytes) are no
  longer truncated by strlen().
…al parameters

- boot-device-persistent: applies a boot device override persistently across
  all subsequent boots rather than only the next boot (the underlying chassis
  call already supported persistence; it was previously hardcoded to one-time).
- keepalive-interval: sets the SOL keepalive interval (seconds), mapped to
  libipmiconsole's keepalive_timeout_len. Prevents BMCs that silently drop idle
  sessions (e.g. Supermicro after ~60s) from tearing down the console.
…n control menu

- SEL viewer: guac_ipmi_chassis_sel() reads the most recent System Event Log
  entries via the libfreeipmi SEL parse API and formats them as text; exposed
  in the Ctrl+] control menu as [e] View Event Log. Invaluable for diagnosing
  crash/no-boot conditions while on the serial console.
- Alternate screen: the control menu now renders on the terminal's alternate
  screen buffer (CSI ?1049h/l), so it no longer pollutes the serial console's
  scrollback and session recording, nor corrupts a full-screen application
  drawn on the primary screen. Command output is held on the alternate screen
  until dismissed with a keypress, then the console is restored intact.
Chassis operations (power, status, identify, SEL) opened a fresh IPMI session
synchronously from the user input thread, freezing that user's input for the
3-5s RMCP+ handshake. They now run on a background worker thread: the menu
prints a 'working...' message immediately and renders the result when the
worker completes, keeping the console responsive.

Only one operation runs at a time (guarded by chassis_busy under menu_lock);
control-menu keystrokes are ignored while an operation is in flight. The worker
thread is joined in the free handler before the terminal is destroyed, so an
abrupt disconnect mid-operation cannot cause a use-after-free (verified by a
disconnect-during-op stress test). Serial break remains synchronous as it is
fast and operates on the existing SOL context.
…ic control/status

Implements the out-of-band control/status channel (#28)
that keeps chassis control and telemetry off the SOL console stream, so any
client (browser panel, native TUI) can render it natively. The in-terminal
Ctrl+] menu remains a portable fallback.

A client opens an inbound pipe named 'ipmi-control' and sends newline-delimited
JSON commands; the server pushes JSON state/result/SEL messages back on
outbound 'ipmi-control' pipes:

  {"id":"..","type":"command","command":"power-cycle"}
  {"type":"state","power":"on","health":"sol-connected"}
  {"id":"..","type":"result","ok":true,"message":".."}
  {"type":"sel","text":".."}

Commands: power on/off/cycle/hard-reset/soft-shutdown/diagnostic-interrupt,
identify, send-break, refresh-status, read-sel. Chassis operations serialize
with the Ctrl+] menu via the shared chassis lock, and the channel is only
available to non-read-only users (the pipe handler is not registered for
read-only). Validated end-to-end against a Supermicro BMC (state query,
identify, SEL read, error handling).

Follow-ups: structured per-entry SEL, async command dispatch, periodic state
polling / push-on-change, passive state broadcast to read-only viewers.
The out-of-band control channel reported SOL health as a one-shot
snapshot taken when the panel first opened the ipmi-control pipe. Since
the browser connects and opens that pipe while the SOL session is still
being established (the blocking ipmiconsole_engine_submit_block on the
client thread), the snapshot captured console_ctx == NULL and the panel
showed "SOL DISCONNECTED" even while the console streamed live output —
correcting only after a manual Refresh.

Track SOL liveness in a dedicated, mutex-guarded sol_connected flag
(separate from console_ctx, which stays non-NULL until teardown) and
broadcast the state to all connected users via guac_client_foreach_user()
immediately after the session establishes and again when the read loop
exits. The push is health-only (omits power) so it refreshes the
connection badge without regressing the last-known power state to
"unknown"; an actively-queried power value is still sent for
refresh-status.

Server-only change; the existing client handler already ignores an
absent power field. Verified against a Supermicro BMC: the badge reaches
SOL CONNECTED on its own with power intact, no manual refresh.
…nd EAGAIN

__guac_ipmi_write_all() treated any write() result <= 0 as fatal and
broke the input-forwarding loop, which would silently stop forwarding
terminal keystrokes to the SOL session on a transient condition. Handle
partial writes by continuing with the remainder, and, should the
libipmiconsole descriptor ever be non-blocking, wait for POLLOUT and
retry on EAGAIN/EWOULDBLOCK rather than dropping input. Any real error
or EOF remains fatal. No behavioural change for the current blocking
descriptor.
Add CUnit tests (wired into 'make check' like the Kubernetes/RDP module
tests) for the two pure helpers that parse untrusted, browser-supplied
control-channel messages: guac_ipmi_control_json_get() and
guac_ipmi_control_json_escape(), which are now exposed via control.h for
testing. Coverage includes key-vs-value matching (a token appearing only
as a value must not be read as a key), whitespace tolerance, escape
handling/unescaping, dropping of non-printable control characters, and
output-buffer bounds. 12 cases, all passing.
…terminal

guac_ipmi_client_free_handler() unconditionally called guac_terminal_free()
on ipmi_client->term, but the terminal is created by the client thread; if
the connection is torn down before that thread reaches terminal creation,
term is still NULL and guac_terminal_free() -> guac_terminal_stop()
dereferences it, crashing the connection process. Guard the free with a
NULL check. Found via an AddressSanitizer run over a rapid connect/teardown
of the ipmi-control channel; fix confirmed clean under ASAN.
…fore freeing the terminal

The connection free handler freed the terminal before joining the SOL
worker thread, and the join was gated on console_ctx being non-NULL. The
worker's read loop writes SOL output to the terminal (holding term->lock),
so freeing the terminal first could destroy the lock/buffers while the
worker was mid-write -> use-after-free. The conditional join also leaked
the joinable worker thread on every session-setup failure path (terminal
init, encryption-required abort, engine-ref failure, SOL failure), which
all leave console_ctx NULL.

Mirror the SSH module's ordering: close the SOL fd and guac_terminal_stop()
to unblock both threads without freeing the terminal, join the worker
unconditionally (tracked by a new client_thread_valid flag, like
chassis_thread_valid), then destroy the SOL context and finally free the
terminal. Found by independent code + security review; verified clean
under AddressSanitizer.
…ak, bounded printf

Address remaining code/security review findings:
- guac_ipmi_settings_free() now explicit_bzero()s the password and the
  decoded K_g BMC key before freeing, so these secrets do not linger in
  freed heap.
- send-break (control channel and Ctrl+] menu) now tests sol_connected
  under state_lock before dereferencing console_ctx, closing a narrow
  race with SOL teardown.
- argv.c uses snprintf() instead of sprintf() into the fixed font-size
  buffer.

Menu-state cross-user locking, alt-screen-vs-live-SOL suppression, and
the dead identify 'force' parameter are noted as follow-ups.
Thread-safety and cleanup items surfaced by the code/security review:

- Serialize the shared control-menu state (menu_open, menu_pending_action,
  menu_awaiting_dismiss) under menu_lock: the key handler now snapshots it
  in one short critical section and the menu open/close/confirm paths mutate
  it under the lock, eliminating the data race across concurrent user key
  threads and the chassis worker. Critical sections are kept short (no lock
  held across thread creation or terminal writes) to avoid deadlock.
- Suppress SOL console output while the control menu owns the alternate
  screen, so live serial data no longer scrambles the rendered menu.
- Serialize status/SEL reads with the other chassis operations (they now
  take the chassis lock too) so a single connection cannot open many
  concurrent RMCP+ sessions and exhaust the BMC's session slots.
- guac_ipmi_chassis_status() now outputs a decoded guac_ipmi_power_state
  enum; the control channel uses it instead of substring-matching the
  human-readable status string.
- Drop the always-false 'force' parameter from guac_ipmi_chassis_identify().

Built, unit-tested (12/12), functionally verified (status/identify/SEL),
and re-checked clean under AddressSanitizer (no UAF/leak/deadlock).

@necouchman necouchman left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ciroiriarte This looks really great, I'm excited to have this in the Guacamole code! I do have some initial comments - don't despair, most of these are style and documentation clean-up, although some deal with implementation details. Generally:

  • In several places you've defined Guacamole-specific data structures or enums that mirror ones provided by the IPMI libraries. Is there any reason to do this? It seems like it would simplify things and cut down the amount of code to just reuse what is already there, unless there's a technical or implementation-specific reason why not?
  • Make sure you've fully-documented all of the functions and/or function prototypes. There are several places where thinks like @param and @return tags are missing, and a handful where there was no documentation.
  • There are a few places where I think some more verbose comments within the code would help people more easily understand what's being done and why.
  • I see what you're doing with the IPMI menu, but I wonder if there's a way to use Guacamole's built-in menu to handle these functions rather than implementing the text-based menu you have and then having to implement the JSON-related code around it? I haven't looked at this in any detail, it just seems to me that that's what our existing hidden Guacamole menu is meant for, and there ought to be a way to handle things like chassis commands and the like similar to how we handle file transfers, terminal parameters, etc.

Comment on lines +76 to +84
/**
* Verifies that a non-string value (e.g. a number) is not matched, as only
* quoted string values are extracted.
*/
void test_json_get__non_string_value(void) {
char out[64];
CU_ASSERT_FALSE(guac_ipmi_control_json_get(
"{\"n\":123}", "n", out, sizeof(out)));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are only string values allowed? My experience in general with JSON is that string values are expected for strings, but other values - boolean, numeric, etc. - are perfectly valid JSON?

Comment thread src/protocols/ipmi/argv.c
* under the License.
*/

#include "config.h"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mike recently submitted a PR that makes including config.h unnecessary - see #691.

Comment on lines +51 to +74
/**
* Maps a guac_ipmi_privilege_level to the corresponding libfreeipmi
* IPMI_PRIVILEGE_LEVEL_* constant.
*
* @param privilege_level
* The privilege level to map.
*
* @return
* The equivalent IPMI_PRIVILEGE_LEVEL_* constant.
*/
static uint8_t guac_ipmi_map_privilege_level(
guac_ipmi_privilege_level privilege_level) {

switch (privilege_level) {
case GUAC_IPMI_PRIVILEGE_USER:
return IPMI_PRIVILEGE_LEVEL_USER;
case GUAC_IPMI_PRIVILEGE_OPERATOR:
return IPMI_PRIVILEGE_LEVEL_OPERATOR;
case GUAC_IPMI_PRIVILEGE_ADMIN:
default:
return IPMI_PRIVILEGE_LEVEL_ADMIN;
}

}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a particular reason we need to define new values for these privilege levels as GUAC_ constants and shouldn't just re-use the IPMI_PRIVILEGE_LEVEL_ values throughout the code?

Comment on lines +76 to +105
/**
* Maps a guac_ipmi_power_action to the corresponding libfreeipmi
* IPMI_CHASSIS_CONTROL_* constant.
*
* @param action
* The power action to map. Must not be GUAC_IPMI_POWER_NONE.
*
* @return
* The equivalent IPMI_CHASSIS_CONTROL_* constant.
*/
static uint8_t guac_ipmi_map_power_action(guac_ipmi_power_action action) {

switch (action) {
case GUAC_IPMI_POWER_ON:
return IPMI_CHASSIS_CONTROL_POWER_UP;
case GUAC_IPMI_POWER_OFF:
return IPMI_CHASSIS_CONTROL_POWER_DOWN;
case GUAC_IPMI_POWER_CYCLE:
return IPMI_CHASSIS_CONTROL_POWER_CYCLE;
case GUAC_IPMI_POWER_RESET:
return IPMI_CHASSIS_CONTROL_HARD_RESET;
case GUAC_IPMI_POWER_SOFT_SHUTDOWN:
return IPMI_CHASSIS_CONTROL_INITIATE_SOFT_SHUTDOWN;
case GUAC_IPMI_POWER_DIAGNOSTIC_INTERRUPT:
return IPMI_CHASSIS_CONTROL_PULSE_DIAGNOSTIC_INTERRUPT;
default:
return IPMI_CHASSIS_CONTROL_POWER_DOWN;
}

}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the privilege levels - is there a particular reason that we need to create and map them rather than just using the ones provided by FreeIPMI?

Comment on lines +107 to +132
/**
* Maps a guac_ipmi_boot_device to the corresponding libfreeipmi
* IPMI_SYSTEM_BOOT_OPTION_BOOT_FLAG_BOOT_DEVICE_FORCE_* constant.
*
* @param device
* The boot device to map. Must not be GUAC_IPMI_BOOT_NONE.
*
* @return
* The equivalent boot device constant.
*/
static uint8_t guac_ipmi_map_boot_device(guac_ipmi_boot_device device) {

switch (device) {
case GUAC_IPMI_BOOT_PXE:
return IPMI_SYSTEM_BOOT_OPTION_BOOT_FLAG_BOOT_DEVICE_FORCE_PXE;
case GUAC_IPMI_BOOT_DISK:
return IPMI_SYSTEM_BOOT_OPTION_BOOT_FLAG_BOOT_DEVICE_FORCE_HARD_DRIVE;
case GUAC_IPMI_BOOT_CDROM:
return IPMI_SYSTEM_BOOT_OPTION_BOOT_FLAG_BOOT_DEVICE_FORCE_CD_DVD;
case GUAC_IPMI_BOOT_BIOS:
return IPMI_SYSTEM_BOOT_OPTION_BOOT_FLAG_BOOT_DEVICE_FORCE_BIOS_SETUP;
default:
return IPMI_SYSTEM_BOOT_OPTION_BOOT_FLAG_BOOT_DEVICE_NO_OVERRIDE;
}

}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And, similar to above two - why do we need to redefine these?

Comment on lines +591 to +599
/**
* Parses the raw K_g argument into a newly-allocated byte buffer, returning
* the buffer and storing its length in *length. If the value begins with "0x",
* it is decoded as a hexadecimal byte string (allowing binary keys that may
* contain NUL bytes); otherwise it is treated as a raw passphrase. Returns NULL
* (with *length set to 0) if the value is NULL or invalid.
*/
static char* guac_ipmi_parse_k_g(guac_user* user, const char* raw,
int* length) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please document parameters and return value.

Comment on lines +603 to +604
if (raw == NULL || *raw == '\0')
return NULL;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good to log this at WARN or DEBUG level?

Comment on lines +75 to +79
typedef enum guac_ipmi_privilege_level {
GUAC_IPMI_PRIVILEGE_USER = 0,
GUAC_IPMI_PRIVILEGE_OPERATOR = 1,
GUAC_IPMI_PRIVILEGE_ADMIN = 2
} guac_ipmi_privilege_level;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please document each of the values. Also, as mentioned in one of the other files, is there any reason not to just use the IPMICONSOLE_PRIVILEGE_* values?? Why redefine them?

Comment thread src/protocols/ipmi/ipmi.c
Comment on lines +45 to +51
/**
* Lock guarding the process-global libipmiconsole engine reference count. The
* libipmiconsole engine is a single process-wide resource; initializing or
* tearing it down per connection would race with, and disrupt, any other IPMI
* connections active within the same guacd process.
*/
static pthread_mutex_t guac_ipmi_engine_lock = PTHREAD_MUTEX_INITIALIZER;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you say "any other IPMI connections active within the same guacd process" - does this mean:

  • Within the same guacd parent process?
  • Within the same guacd child/connection process?

guacd forks a new process for each connection, and, since this particular lock would only be initialized in the context of a single connection, and that would be after the child connection has been forked by guacd, there actually should not be any issue with initializing it or tearing it down...

...UNLESS, you also mean that the IPMI chassis connections would interfere with the SOL connection, in which case, this would still make sense.

Comment thread src/protocols/ipmi/menu.c
Comment on lines +62 to +69
/**
* Displays a "press any key to return" prompt and marks the current control
* menu operation complete. Forward-declared for use by the chassis worker.
*
* @param client
* The guac_client associated with the IPMI connection.
*/
static void guac_ipmi_menu_await_dismiss(guac_client* client);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our practice throughout the code for static functions is generally just to implement them where they need to be in the code and not prototype them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants