Add Typed SSH Service Controls
Provide admin-only SSH status plus generation-safe start, stop, and single-session disconnect operations through the bounded dispatcher. Include Settings UI coverage, lifecycle safeguards, and host-side regression tests.
This commit is contained in:
@@ -60,6 +60,7 @@ settings = "--settings" in sys.argv
|
||||
serial_settings = "--serial-settings" in sys.argv
|
||||
accounts = "--accounts" in sys.argv
|
||||
broker = "--broker" in sys.argv
|
||||
ssh_settings = "--ssh" in sys.argv
|
||||
display = "--display" in sys.argv
|
||||
if display:
|
||||
HEADERS["nvs_flash.h"] = '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n'
|
||||
@@ -248,7 +249,8 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_ACCOUNTS"] if accounts else []),
|
||||
*(["-DHOST_NETWORK"] if network else []),
|
||||
*(["-DHOST_DISPLAY"] if display else []),
|
||||
*(["-DHOST_BROKER"] if broker else []),
|
||||
*(["-DHOST_BROKER"] if broker else []),
|
||||
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/* Production HTTP policy/store/settings; deterministic dispatcher/SSH owner doubles. */
|
||||
#include "../../src/web_ssh_settings.c"
|
||||
static bool on_dispatcher, queue_fail;
|
||||
static uint32_t queued_id;
|
||||
static unsigned mutations, snapshots;
|
||||
static esp_err_t owner_error;
|
||||
static ssh_transport_management_snapshot_t owner_snapshot;
|
||||
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *out) {
|
||||
assert(!host_lock_depth && !on_dispatcher); ++snapshots;
|
||||
*out = owner_snapshot; return owner_error;
|
||||
}
|
||||
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action, uint32_t target, uint32_t generation) {
|
||||
assert(on_dispatcher && !host_lock_depth && generation == 7);
|
||||
assert(action <= SSH_TRANSPORT_MANAGE_DISCONNECT && target == (action == SSH_TRANSPORT_MANAGE_DISCONNECT ? 9U : 0U));
|
||||
++mutations; return owner_error;
|
||||
}
|
||||
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id) {
|
||||
assert(id && !on_dispatcher && !host_lock_depth);
|
||||
if (queue_fail) return ESP_FAIL;
|
||||
queued_id = id; return ESP_OK;
|
||||
}
|
||||
static void operation_begin(const issued_t *identity, const char *body) {
|
||||
begin("/api/settings/ssh-operation", body ? HTTP_POST : HTTP_GET, body);
|
||||
same_origin(); if (body) add("Content-Type", "application/json");
|
||||
if (identity) {
|
||||
char cookie[100]; snprintf(cookie, sizeof(cookie), "__Host-sak-session=%s", identity->token);
|
||||
add("Cookie", cookie); if (body) add("X-CSRF-Token", identity->view.csrf);
|
||||
}
|
||||
}
|
||||
static void expect_ssh(const char *status, bool snapshot) {
|
||||
unsigned before = mutations;
|
||||
esp_err_t e = snapshot ? web_ssh_settings_handler(&req) : web_ssh_operation_handler(&req);
|
||||
assert(e == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
if (strcmp(response_status, status)) fprintf(stderr, "expected %s got %s: %s\n", status, response_status, output);
|
||||
assert(!strcmp(response_status, status) && mutations == before);
|
||||
assert(strlen(output) < (snapshot ? 768 : 96)); zero(scratch, sizeof(scratch));
|
||||
}
|
||||
static void execute(void) { on_dispatcher = true; web_ssh_settings_execute(queued_id); on_dispatcher = false; }
|
||||
static const char *disconnect_body = "{\"action\":\"disconnect\",\"generation\":7,\"target\":9}";
|
||||
static void submit(const issued_t *who) {
|
||||
operation_begin(who, disconnect_body); expect_ssh("202 Accepted", false); assert(s_operation.state == PENDING);
|
||||
}
|
||||
static void ssh_settings_tests(void) {
|
||||
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
|
||||
receive_fragment = 64;
|
||||
operation_begin(NULL, disconnect_body); expect_ssh("401 Unauthorized", false);
|
||||
operation_begin(&user, disconnect_body); expect_ssh("403 Forbidden", false);
|
||||
operation_begin(&user, NULL); expect_ssh("403 Forbidden", false);
|
||||
for (unsigned mode = 0; mode < 8; ++mode) {
|
||||
operation_begin(&admin, disconnect_body);
|
||||
if (mode == 0) req.content_len = aux.remaining_len = 257;
|
||||
if (mode == 1) req.uri = "/api/settings/ssh-operation?x=1";
|
||||
if (mode == 2) req.method = HTTP_GET;
|
||||
if (mode == 3) add("X-CSRF-Token", "duplicate");
|
||||
if (mode == 4) add("Origin", "https://evil.example");
|
||||
if (mode == 5) add("Transfer-Encoding", "chunked");
|
||||
if (mode == 6) add("Content-Type", "text/plain");
|
||||
if (mode == 7) add("Sec-Fetch-Site", "cross-site");
|
||||
(void)web_ssh_operation_handler(&req);
|
||||
assert(response_status[0] == '4' && !s_next_id && !mutations);
|
||||
}
|
||||
puts("PASS SSH admin/cookie/Origin/CSRF and query/body/framing bounds");
|
||||
const char *invalid[] = {"{}", "[]", "{\"action\":\"disconnect\"}",
|
||||
"{\"action\":\"disconnect\",\"generation\":0,\"target\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":4294967295,\"target\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"start\",\"generation\":7,\"target\":9}",
|
||||
"{\"action\":\"stop\",\"generation\":7,\"target\":9}",
|
||||
"{\"action\":\"reboot\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":4294967296}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":09}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9.0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9e0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":-9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"generation\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9,\"service\":\"web\"}"};
|
||||
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
|
||||
operation_begin(&admin, invalid[i]); expect_ssh("400 Bad Request", false);
|
||||
}
|
||||
ssh_operation_t parsed = {0};
|
||||
for (size_t n = 0; n < strlen(disconnect_body); ++n) assert(!parse(disconnect_body, n, &parsed));
|
||||
assert(parse(disconnect_body, strlen(disconnect_body), &parsed));
|
||||
assert(!parse(disconnect_body, strlen(disconnect_body)+1, &parsed));
|
||||
const char *reordered = " { \"target\":4294967295, \"generation\":4294967294, \"action\":\"disconnect\" } ";
|
||||
assert(parse(reordered, strlen(reordered), &parsed));
|
||||
receive_fragment = 1; operation_begin(&admin, disconnect_body); expect_ssh("400 Bad Request", false); assert(body_offset == 4); receive_fragment = 64;
|
||||
char full[257]; memset(full, ' ', 256); memcpy(full, disconnect_body, strlen(disconnect_body)); full[256] = 0;
|
||||
queue_fail = true; operation_begin(&admin, full); expect_ssh("503 Service Unavailable", false); queue_fail = false;
|
||||
assert(body_offset == 256 && s_operation.state == IDLE);
|
||||
puts("PASS SSH strict typed actions/parser, integer/order/truncation and exact request/receive limits");
|
||||
owner_snapshot.generation = 7; owner_snapshot.running = true;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
ssh_transport_session_snapshot_t *s = &owner_snapshot.sessions[i];
|
||||
s->active = s->principal_valid = true; s->session_id = i + 8; s->state = SSH_TRANSPORT_SESSION_ACTIVE;
|
||||
s->route = i ? SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE : SSH_TRANSPORT_ROUTE_BROKER;
|
||||
memset(s->username, '"', USER_DATABASE_USERNAME_CAPACITY);
|
||||
}
|
||||
for (unsigned mode = 0; mode < 4; ++mode) {
|
||||
unsigned before = snapshots;
|
||||
operation_begin(mode == 0 ? NULL : mode == 1 ? &user : &admin, NULL); req.uri = "/api/settings/ssh";
|
||||
owner_error = mode == 3 ? ESP_FAIL : ESP_OK;
|
||||
expect_ssh(mode == 0 ? "401 Unauthorized" : mode == 1 ? "403 Forbidden" : mode == 3 ? "503 Service Unavailable" : "200 OK", true);
|
||||
if (mode < 2) assert(snapshots == before);
|
||||
if (mode == 2) assert(strstr(output, "22222222222222222222222222222222") && !strstr(output, "socket") && !strstr(output, "principal"));
|
||||
}
|
||||
owner_error = ESP_OK;
|
||||
puts("PASS SSH bounded two-row safe snapshot and unavailable owner isolation");
|
||||
submit(&admin); uint32_t first = queued_id;
|
||||
operation_begin(&other, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
||||
operation_begin(&other, NULL); expect_ssh("200 OK", false); assert(strstr(output, "idle"));
|
||||
execute(); assert(s_operation.state == OK && mutations == 1); zero(&s_operation.principal, sizeof(s_operation.principal));
|
||||
execute(); assert(mutations == 1);
|
||||
for (unsigned action = 0; action < 2; ++action) {
|
||||
char body[80]; snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7,\"target\":0}", s_actions[action]);
|
||||
operation_begin(&admin, body); expect_ssh("202 Accepted", false); execute();
|
||||
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, s_actions[action]));
|
||||
}
|
||||
esp_err_t failures[] = {ESP_ERR_INVALID_STATE, ESP_ERR_NOT_FOUND, ESP_FAIL};
|
||||
for (unsigned i = 0; i < 3; ++i) { owner_error = failures[i]; submit(&admin); execute(); assert(s_operation.state == (i < 2 ? CONFLICT : FAILED)); }
|
||||
owner_error = ESP_OK;
|
||||
puts("PASS SSH all three actions on dispatcher only, duplicate/stale IDs, conflicts and login-isolated results");
|
||||
submit(&admin); unsigned before = mutations;
|
||||
web_ssh_settings_execute(0); web_ssh_settings_execute(first); assert(mutations == before && s_operation.state == PENDING);
|
||||
now += 30000000; execute(); assert(s_operation.state == CANCELLED && mutations == before);
|
||||
submit(&admin); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); stale_user = alice.user_id; execute(); stale_user = 0; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); now = admin.view.expires_at_us - 1; submit(&admin); now = admin.view.expires_at_us; execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "idle"));
|
||||
assert(mutations == before);
|
||||
puts("PASS SSH queue deadline, expiry/revocation/currentness failure, HTTPS auth lifecycle fencing");
|
||||
send_fail = true; submit(&admin); send_fail = false; execute(); assert(s_operation.state == OK);
|
||||
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "ok"));
|
||||
s_next_id = UINT32_MAX; operation_begin(&admin, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
||||
puts("PASS SSH lost acknowledgement retained result and nonwrapping operation IDs");
|
||||
}
|
||||
@@ -149,6 +149,9 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_BROKER
|
||||
#include "broker_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_SSH_SETTINGS
|
||||
#include "ssh_settings_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -314,6 +317,9 @@ int main(void) {
|
||||
#endif
|
||||
#ifdef HOST_BROKER
|
||||
broker_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_SSH_SETTINGS
|
||||
ssh_settings_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user