feat(cloud): reconnect after login
This commit is contained in:
parent
06ede45212
commit
bd2bd22069
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "TrapOS",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"branch": "master",
|
||||
"packages": [
|
||||
"trapos"
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
"trapos-net": "0.3.2",
|
||||
"trapos-ui": "0.2.4",
|
||||
"trapos-ai": "0.8.0",
|
||||
"trapos-cloud": "0.4.1",
|
||||
"trapos-cloud": "0.4.2",
|
||||
"trapos-sandbox-legacy": "0.3.2",
|
||||
"trapos": "0.12.1"
|
||||
"trapos": "0.12.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,7 +197,8 @@ local function createCloud()
|
||||
local queueEvent = opts.queueEvent or function(...) return osLike.queueEvent(...); end;
|
||||
local reconnectDelay = opts.reconnectDelay or 5;
|
||||
local helloTimeout = opts.helloTimeout or 10;
|
||||
local secret = opts.secret;
|
||||
local getSecret = opts.getSecret or function() return opts.secret; end;
|
||||
local secret = getSecret();
|
||||
-- Lifecycle logger: log(level, msg, fields?). Defaults to a no-op so the daemon
|
||||
-- stays silent unless the caller wires a sink (e.g. a /logs file appender).
|
||||
local log = opts.log or function() end;
|
||||
@ -210,6 +211,8 @@ local function createCloud()
|
||||
local state = 'connecting';
|
||||
local lastError = nil;
|
||||
local activeWs = nil;
|
||||
local suppressNextClose = false;
|
||||
local clearReconnectTimeout = nil;
|
||||
-- True while looping on retries. Gates the per-attempt chatter (connecting / socket
|
||||
-- open / connection failed / connection closed) so a long outage logs once on entry
|
||||
-- and once on recovery instead of flooding the file every reconnectDelay seconds.
|
||||
@ -234,6 +237,11 @@ local function createCloud()
|
||||
end
|
||||
|
||||
local function connect()
|
||||
if clearReconnectTimeout then
|
||||
clearReconnectTimeout();
|
||||
clearReconnectTimeout = nil;
|
||||
end
|
||||
secret = getSecret();
|
||||
state = 'connecting';
|
||||
if not reconnecting then
|
||||
log('info', 'connecting', { url = url });
|
||||
@ -253,7 +261,18 @@ local function createCloud()
|
||||
reconnecting = true;
|
||||
log('warn', 'reconnect loop started', { delaySeconds = reconnectDelay });
|
||||
end
|
||||
el.setTimeout(connect, reconnectDelay);
|
||||
clearReconnectTimeout = el.setTimeout(connect, reconnectDelay);
|
||||
end
|
||||
|
||||
local function reconnectNow(reason)
|
||||
log('info', 'reconnect requested', { reason = reason or 'manual' });
|
||||
lastError = nil;
|
||||
reconnecting = false;
|
||||
if activeWs then
|
||||
suppressNextClose = true;
|
||||
closeActive();
|
||||
end
|
||||
connect();
|
||||
end
|
||||
|
||||
local function onHello(ok, _payload, err)
|
||||
@ -326,6 +345,11 @@ local function createCloud()
|
||||
-- of bricking the daemon into a permanent 'displaced' state.
|
||||
el.register('websocket_closed', function(eventUrl, reason, code)
|
||||
if eventUrl ~= url then return; end
|
||||
if suppressNextClose then
|
||||
suppressNextClose = false;
|
||||
log('info', 'ignored intentional close', { reason = reason, code = code });
|
||||
return;
|
||||
end
|
||||
if code == DISPLACED_CLOSE_CODE or reason == 'displaced' then
|
||||
if state == 'ready' then
|
||||
activeWs = nil;
|
||||
@ -375,6 +399,10 @@ local function createCloud()
|
||||
sendFrame(api.buildComputerResponse(traposId, messageId, msgType, ok == true, payload, err));
|
||||
end);
|
||||
|
||||
el.register('trapos_cloud_reconnect', function(reason)
|
||||
reconnectNow(reason);
|
||||
end);
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos-cloud",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"description": "TrapOS cloud gateway client: WS daemon (servers/cloud) + cloud command + MCP exec/write server",
|
||||
"dependencies": ["trapos-core"],
|
||||
"files": [
|
||||
|
||||
@ -3,6 +3,7 @@ local createVersion = require('/apis/libversion');
|
||||
|
||||
local URL_SETTING = 'cloud.url';
|
||||
local PASSWORD_SETTING = 'cloud.password';
|
||||
local RECONNECT_EVENT = 'trapos_cloud_reconnect';
|
||||
|
||||
local rawArgs = table.pack(...);
|
||||
local command = rawArgs[1];
|
||||
@ -15,7 +16,7 @@ local function printUsage()
|
||||
print('cloud usage:');
|
||||
print();
|
||||
print(' cloud status');
|
||||
print(' cloud set-password [--force|-f]');
|
||||
print(' cloud login [--force|-f] [password]');
|
||||
print(' cloud --version');
|
||||
print(' cloud --help');
|
||||
print();
|
||||
@ -32,8 +33,9 @@ local function readPassword()
|
||||
return '';
|
||||
end
|
||||
|
||||
local function parseSetPasswordArgs(args)
|
||||
local function parseLoginArgs(args)
|
||||
local force = false;
|
||||
local password = nil;
|
||||
|
||||
for i = 2, args.n do
|
||||
local arg = args[i];
|
||||
@ -41,12 +43,14 @@ local function parseSetPasswordArgs(args)
|
||||
force = true;
|
||||
elseif arg and string.sub(arg, 1, 1) == '-' then
|
||||
return nil, 'unknown option: ' .. arg;
|
||||
elseif not password then
|
||||
password = arg;
|
||||
else
|
||||
return nil, 'unexpected argument: ' .. tostring(arg);
|
||||
end
|
||||
end
|
||||
|
||||
return { force = force }, nil;
|
||||
return { force = force, password = password }, nil;
|
||||
end
|
||||
|
||||
if command == '--version' or command == '-version' or command == 'version' or command == '-v' then
|
||||
@ -82,8 +86,8 @@ if command == 'status' then
|
||||
return;
|
||||
end
|
||||
|
||||
if command == 'set-password' then
|
||||
local opts, parseError = parseSetPasswordArgs(rawArgs);
|
||||
if command == 'login' then
|
||||
local opts, parseError = parseLoginArgs(rawArgs);
|
||||
if parseError then
|
||||
print(parseError);
|
||||
printUsage();
|
||||
@ -91,11 +95,11 @@ if command == 'set-password' then
|
||||
end
|
||||
|
||||
if not opts.force and not isBlank(settings.get(PASSWORD_SETTING)) then
|
||||
print('cloud.password is already set; use cloud set-password --force to replace it');
|
||||
print('cloud.password is already set; use cloud login --force to replace it');
|
||||
return;
|
||||
end
|
||||
|
||||
local password = readPassword();
|
||||
local password = opts.password or readPassword();
|
||||
if isBlank(password) then
|
||||
print('cloud.password not changed: password cannot be empty');
|
||||
return;
|
||||
@ -103,6 +107,7 @@ if command == 'set-password' then
|
||||
|
||||
settings.set(PASSWORD_SETTING, password);
|
||||
settings.save();
|
||||
os.queueEvent(RECONNECT_EVENT);
|
||||
print('cloud.password set');
|
||||
return;
|
||||
end
|
||||
|
||||
@ -39,14 +39,12 @@ local function fileLog(level, msg, fields)
|
||||
f.close();
|
||||
end
|
||||
|
||||
local secret = settings.get(PASSWORD_SETTING);
|
||||
|
||||
_G.__trapos_cloud_daemon = true;
|
||||
|
||||
createCloud().startSession({
|
||||
eventloop = _G.bootEventLoop,
|
||||
url = url,
|
||||
secret = secret,
|
||||
getSecret = function() return settings.get(PASSWORD_SETTING); end,
|
||||
os = os,
|
||||
log = fileLog,
|
||||
});
|
||||
|
||||
@ -39,6 +39,7 @@ local function runCloud(args, opts)
|
||||
local writes = {};
|
||||
local readMasks = {};
|
||||
local cloudRequests = {};
|
||||
local queuedEvents = {};
|
||||
local settingsLib = opts.settings or fakeSettings();
|
||||
local readValues = opts.readValues or {};
|
||||
|
||||
@ -60,6 +61,11 @@ local function runCloud(args, opts)
|
||||
set = function(key, value) return settingsLib:set(key, value); end,
|
||||
save = function() return settingsLib:save(); end,
|
||||
},
|
||||
os = setmetatable({
|
||||
queueEvent = function(...)
|
||||
queuedEvents[#queuedEvents + 1] = table.pack(...);
|
||||
end,
|
||||
}, { __index = os }),
|
||||
require = function(path)
|
||||
if path == '/apis/libcloud' then
|
||||
return function()
|
||||
@ -94,16 +100,18 @@ local function runCloud(args, opts)
|
||||
writes = writes,
|
||||
readMasks = readMasks,
|
||||
cloudRequests = cloudRequests,
|
||||
queuedEvents = queuedEvents,
|
||||
settings = settingsLib,
|
||||
};
|
||||
end
|
||||
|
||||
testlib.test('cloud help lists status and set-password without health', function()
|
||||
testlib.test('cloud help lists status and login without health', function()
|
||||
local ctx = runCloud({ '--help' });
|
||||
|
||||
testlib.assertTrue(containsLine(ctx.lines, ' cloud status'));
|
||||
testlib.assertTrue(containsLine(ctx.lines, ' cloud set-password [--force|-f]'));
|
||||
testlib.assertTrue(containsLine(ctx.lines, ' cloud login [--force|-f] [password]'));
|
||||
testlib.assertTrue(not containsLine(ctx.lines, ' cloud health'));
|
||||
testlib.assertTrue(not containsText(ctx.lines, 'set-password'));
|
||||
end);
|
||||
|
||||
testlib.test('cloud health is not kept as an alias', function()
|
||||
@ -128,9 +136,17 @@ testlib.test('cloud status probes the server', function()
|
||||
testlib.assertTrue(containsLine(ctx.lines, 'opencode: missing key'));
|
||||
end);
|
||||
|
||||
testlib.test('cloud set-password stores a missing password with masked input', function()
|
||||
testlib.test('cloud set-password is not kept as an alias', function()
|
||||
local ctx = runCloud({ 'set-password', 'hunter2' });
|
||||
|
||||
testlib.assertEquals(ctx.settings.values['cloud.password'], nil);
|
||||
testlib.assertEquals(ctx.settings.saveCount, 0);
|
||||
testlib.assertTrue(containsLine(ctx.lines, 'cloud usage:'));
|
||||
end);
|
||||
|
||||
testlib.test('cloud login stores a missing password with masked input', function()
|
||||
local settingsLib = fakeSettings();
|
||||
local ctx = runCloud({ 'set-password' }, {
|
||||
local ctx = runCloud({ 'login' }, {
|
||||
settings = settingsLib,
|
||||
readValues = { 'hunter2' },
|
||||
});
|
||||
@ -139,12 +155,26 @@ testlib.test('cloud set-password stores a missing password with masked input', f
|
||||
testlib.assertEquals(ctx.readMasks[1], '*');
|
||||
testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
|
||||
testlib.assertEquals(settingsLib.saveCount, 1);
|
||||
testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect');
|
||||
testlib.assertTrue(containsLine(ctx.lines, 'cloud.password set'));
|
||||
end);
|
||||
|
||||
testlib.test('cloud set-password rejects replacing without force', function()
|
||||
testlib.test('cloud login stores a password argument without prompting', function()
|
||||
local settingsLib = fakeSettings();
|
||||
local ctx = runCloud({ 'login', 'hunter2' }, {
|
||||
settings = settingsLib,
|
||||
readValues = { 'unused' },
|
||||
});
|
||||
|
||||
testlib.assertEquals(#ctx.readMasks, 0);
|
||||
testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
|
||||
testlib.assertEquals(settingsLib.saveCount, 1);
|
||||
testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect');
|
||||
end);
|
||||
|
||||
testlib.test('cloud login rejects replacing without force', function()
|
||||
local settingsLib = fakeSettings({ ['cloud.password'] = 'old' });
|
||||
local ctx = runCloud({ 'set-password' }, {
|
||||
local ctx = runCloud({ 'login' }, {
|
||||
settings = settingsLib,
|
||||
readValues = { 'new' },
|
||||
});
|
||||
@ -152,40 +182,44 @@ testlib.test('cloud set-password rejects replacing without force', function()
|
||||
testlib.assertEquals(#ctx.readMasks, 0);
|
||||
testlib.assertEquals(settingsLib.values['cloud.password'], 'old');
|
||||
testlib.assertEquals(settingsLib.saveCount, 0);
|
||||
testlib.assertTrue(containsText(ctx.lines, 'use cloud set-password --force'));
|
||||
testlib.assertEquals(#ctx.queuedEvents, 0);
|
||||
testlib.assertTrue(containsText(ctx.lines, 'use cloud login --force'));
|
||||
end);
|
||||
|
||||
testlib.test('cloud set-password --force replaces an existing password', function()
|
||||
testlib.test('cloud login --force replaces an existing password', function()
|
||||
local settingsLib = fakeSettings({ ['cloud.password'] = 'old' });
|
||||
runCloud({ 'set-password', '--force' }, {
|
||||
local ctx = runCloud({ 'login', '--force' }, {
|
||||
settings = settingsLib,
|
||||
readValues = { 'new' },
|
||||
});
|
||||
|
||||
testlib.assertEquals(settingsLib.values['cloud.password'], 'new');
|
||||
testlib.assertEquals(settingsLib.saveCount, 1);
|
||||
testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect');
|
||||
end);
|
||||
|
||||
testlib.test('cloud set-password -f replaces an existing password', function()
|
||||
testlib.test('cloud login -f replaces an existing password from argument', function()
|
||||
local settingsLib = fakeSettings({ ['cloud.password'] = 'old' });
|
||||
runCloud({ 'set-password', '-f' }, {
|
||||
local ctx = runCloud({ 'login', '-f', 'new' }, {
|
||||
settings = settingsLib,
|
||||
readValues = { 'new' },
|
||||
});
|
||||
|
||||
testlib.assertEquals(#ctx.readMasks, 0);
|
||||
testlib.assertEquals(settingsLib.values['cloud.password'], 'new');
|
||||
testlib.assertEquals(settingsLib.saveCount, 1);
|
||||
testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect');
|
||||
end);
|
||||
|
||||
testlib.test('cloud set-password rejects an empty password', function()
|
||||
testlib.test('cloud login rejects an empty password', function()
|
||||
local settingsLib = fakeSettings();
|
||||
local ctx = runCloud({ 'set-password' }, {
|
||||
local ctx = runCloud({ 'login' }, {
|
||||
settings = settingsLib,
|
||||
readValues = { '' },
|
||||
});
|
||||
|
||||
testlib.assertEquals(settingsLib.values['cloud.password'], nil);
|
||||
testlib.assertEquals(settingsLib.saveCount, 0);
|
||||
testlib.assertEquals(#ctx.queuedEvents, 0);
|
||||
testlib.assertTrue(containsLine(ctx.lines, 'cloud.password not changed: password cannot be empty'));
|
||||
end);
|
||||
|
||||
|
||||
@ -269,6 +269,64 @@ testlib.test('startSession reconnects on an abnormal close with a non-displaced
|
||||
testlib.assertEquals(#ctx.http.connects, 2);
|
||||
end);
|
||||
|
||||
testlib.test('startSession reconnect event reloads the secret immediately', function()
|
||||
local secret = 'old';
|
||||
local ctx = startSession({ getSecret = function() return secret; end });
|
||||
local ws1 = fakes.fakeWs();
|
||||
ctx.el.fire('websocket_success', ctx.url, ws1);
|
||||
local hello1 = textutils.unserializeJSON(ws1.sent[1]);
|
||||
testlib.assertEquals(hello1.payload.secret, 'old');
|
||||
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
|
||||
|
||||
secret = 'new';
|
||||
ctx.el.fire('trapos_cloud_reconnect', 'login');
|
||||
|
||||
testlib.assertEquals(ws1.closed, true);
|
||||
testlib.assertEquals(#ctx.http.connects, 2);
|
||||
local ws2 = fakes.fakeWs();
|
||||
ctx.el.fire('websocket_success', ctx.url, ws2);
|
||||
local hello2 = textutils.unserializeJSON(ws2.sent[1]);
|
||||
testlib.assertEquals(hello2.payload.secret, 'new');
|
||||
end);
|
||||
|
||||
testlib.test('startSession reconnect event suppresses the intentional close', function()
|
||||
local ctx = startSession();
|
||||
local ws1 = fakes.fakeWs();
|
||||
ctx.el.fire('websocket_success', ctx.url, ws1);
|
||||
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
|
||||
|
||||
ctx.el.fire('trapos_cloud_reconnect', 'login');
|
||||
local ws2 = fakes.fakeWs();
|
||||
ctx.el.fire('websocket_success', ctx.url, ws2);
|
||||
local timersAfterOpen = #ctx.el.timers;
|
||||
|
||||
ctx.el.fire('websocket_closed', ctx.url, 'manual close');
|
||||
|
||||
testlib.assertEquals(#ctx.el.timers, timersAfterOpen);
|
||||
testlib.assertEquals(#ctx.http.connects, 2);
|
||||
end);
|
||||
|
||||
testlib.test('startSession reconnect event recovers after unauthorized', function()
|
||||
local secret = 'old';
|
||||
local ctx = startSession({ getSecret = function() return secret; end });
|
||||
local ws1 = fakes.fakeWs();
|
||||
ctx.el.fire('websocket_success', ctx.url, ws1);
|
||||
ctx.el.fire('websocket_message', ctx.url, encode({
|
||||
event = 'server_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad secret' },
|
||||
}));
|
||||
testlib.assertEquals(ctx.session.lastError(), 'unauthorized');
|
||||
|
||||
secret = 'new';
|
||||
ctx.el.fire('trapos_cloud_reconnect', 'login');
|
||||
|
||||
testlib.assertEquals(ctx.session.lastError(), nil);
|
||||
testlib.assertEquals(#ctx.http.connects, 2);
|
||||
local ws2 = fakes.fakeWs();
|
||||
ctx.el.fire('websocket_success', ctx.url, ws2);
|
||||
local hello2 = textutils.unserializeJSON(ws2.sent[1]);
|
||||
testlib.assertEquals(hello2.payload.secret, 'new');
|
||||
end);
|
||||
|
||||
testlib.test('startSession logs the reconnect loop once on entry and once on recovery', function()
|
||||
local logs = {};
|
||||
local function countMsg(msg)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trapos",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"description": "TrapOS full install meta-package",
|
||||
"dependencies": [
|
||||
"trapos-boot",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user