Skip to content

Commit 241fad5

Browse files
committed
Keep sessions alive across etserver restarts
When the router goes away, etterminal keeps its shell and re-registers with the same credentials, retrying indefinitely with backoff capped at 30s, and the new server resumes the session with a reset. For 60s after startup the server answers unknown ids with RETRY_LATER so clients wait for re-registration. -T sessions end instead, since their packet-framed stream cannot be resumed. Stopping etserver leaves shells running until they exit or --disconnect-timeout closes them.
1 parent fa8ca9d commit 241fad5

23 files changed

Lines changed: 2119 additions & 153 deletions

‎.github/workflows/linux_ci.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ jobs:
185185
- name: Named sessions survive client death
186186
run: ./test/system_tests/named_sessions.sh
187187

188+
- name: Sessions survive etserver restart
189+
run: ./test/system_tests/router_restart.sh
190+
188191
- name: Stop sshd for act
189192
if: ${{ always() }}
190193
run: |

‎CMakeLists.txt‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -833,7 +833,8 @@ else(WIN32)
833833
TEST_PREFIX "et-test."
834834
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
835835
ADD_TAGS_AS_LABELS
836-
PROPERTIES TIMEOUT 600
836+
# RouterRestartTest runs full client/server/terminal stacks.
837+
PROPERTIES TIMEOUT 900
837838
)
838839
else()
839840
add_test(NAME et-test COMMAND et-test "~[.ghostty]")

‎docs/protocol.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ Once etterminal launches:
5454
- It [locates the server fifo](https://github.com/MisterTea/EternalTerminal/blob/113fb23133eabce3d11681392d75ba4772814b44/src/terminal/ServerFifoPath.cpp) to connect to the etserver process:
5555
- If `/var/run/etserver.idpasskey.fifo` exists, when etserver is running as root, this path is used.
5656
- Otherwise, `$XDG_RUNTIME_DIR/etserver/etserver.ifpasskey.fifo` is used, resolving `$XDG_RUNTIME_DIR` to `$HOME/.local/share` if the environment variable is not set.
57-
- Once it connects to the server, it sends a `TERMINAL_USER_INFO` packet with [TerminalUserInfo](../proto/ETerminal.proto#L96-L102) containing the **client-id** and **passkey** to register the terminal with the server. These are registered into the ServerConnection [`clientKeys` map](https://github.com/MisterTea/EternalTerminal/blob/113fb23133eabce3d11681392d75ba4772814b44/src/base/ServerConnection.hpp#L37-L40) awaiting a user connection.
57+
- Once it connects to the server, it sends a `TERMINAL_USER_INFO` packet with [TerminalUserInfo](../proto/ETerminal.proto#L97-L110) containing the **client-id** and **passkey** to register the terminal with the server. These are registered into the ServerConnection [`clientKeys` map](https://github.com/MisterTea/EternalTerminal/blob/113fb23133eabce3d11681392d75ba4772814b44/src/base/ServerConnection.hpp#L37-L40) awaiting a user connection.
5858
- After etterminal connects to etserver, it outputs the **client-id** and **passkey**, to inform the client in cases where it regenerated them.
59-
- etterminal then waits for a client connect, waiting for a `TERMINAL_INIT` ([TermInit](../proto/ETerminal.proto#L89-L94)) packet.
59+
- etterminal then waits for a client connect, waiting for a `TERMINAL_INIT` ([TermInit](../proto/ETerminal.proto#L89-L95)) packet.
6060
- After receiving this packet UserTerminalHandler enters the `runUserTerminal` run loop, and proxies input/output until the terminal exits. See the [Terminal Run Loop](#terminal-run-loop).
6161

6262
## Client Connection
@@ -92,13 +92,13 @@ sequenceDiagram
9292

9393
After the terminal launches, **et** connects to the **etserver** over the EternalTerminal port (defaults to 2022), and sends a [ConnectRequest](../proto/ET.proto#L12-L19) message containing the **client-id** and protocol version. Since encryption is client-specific, this client-id is sent unencrypted.
9494

95-
The server answers a version mismatch with `MISMATCHED_PROTOCOL` and closes the socket, and an unknown **client-id** with `INVALID_KEY`. For a registered client that sets `supportsChallenge`, the server first sends a [ConnectResponse](../proto/ET.proto#L28-L40) carrying a fresh `authChallenge`. The client answers with [ConnectAuth](../proto/ET.proto#L42-L45), a keyed proof over the client id, protocol version, and challenge. If the proof checks out, the server sends the final `NEW_CLIENT` or `RETURNING_CLIENT` response and creates or resumes the ServerClientConnection, which holds the BackedReader and BackedWriter used for EternalTCP buffering. A bad proof gets `INVALID_KEY`.
95+
The server answers a version mismatch with `MISMATCHED_PROTOCOL` and closes the socket, and an unknown **client-id** with `INVALID_KEY` (or `RETRY_LATER` during the recovery grace period after an etserver restart). For a registered client that sets `supportsChallenge`, the server first sends a [ConnectResponse](../proto/ET.proto#L29-L41) carrying a fresh `authChallenge`. The client answers with [ConnectAuth](../proto/ET.proto#L43-L46), a keyed proof over the client id, protocol version, and challenge. If the proof checks out, the server sends the final `NEW_CLIENT` or `RETURNING_CLIENT` response and creates or resumes the ServerClientConnection, which holds the BackedReader and BackedWriter used for EternalTCP buffering. A bad proof gets `INVALID_KEY`.
9696

9797
The final response carries `resetProof`, a keyed proof over the challenge, `status`, `resetRequired`, and `resetSalt`, so it cannot be replayed into another handshake.
9898

9999
### Legacy handshake
100100

101-
The challenge is a capability within protocol 6, not a version bump. A request without `supportsChallenge` gets the original single `ConnectResponse`, with no challenge or reset fields. A client that gets no `authChallenge` treats the first response as final; if it asked to reattach (`resetIntent`) and gets `RETURNING_CLIENT`, it fails with "Server does not support session reattach; upgrade etserver". The legacy path goes away at the next `PROTOCOL_VERSION` bump.
101+
The challenge is a capability within protocol 6, not a version bump. A request without `supportsChallenge` gets the original single `ConnectResponse`, with no challenge, reset fields, or `RETRY_LATER`. A client that gets no `authChallenge` treats the first response as final; if it asked to reattach (`resetIntent`) and gets `RETURNING_CLIENT`, it fails with "Server does not support session reattach; upgrade etserver". The legacy path goes away at the next `PROTOCOL_VERSION` bump.
102102

103103
The client then sends an `INITIAL_PAYLOAD` (with an [InitialPayload](../proto/ETerminal.proto#L71-L78)), which contains port forwarding information or the jumphost flag, to which the server responds with an `INITIAL_RESPONSE` ([InitialResponse](../proto/ETerminal.proto#L80-L82)). If there's an error during connect, the InitialResponse will contain an error string.
104104

@@ -134,7 +134,9 @@ Based on this, a CatchupBuffer protobufs are swapped, containing the missing enc
134134

135135
### Reset recovery
136136

137-
A fresh client process (`--attach`, or any initial connect) sets `ConnectRequest.resetIntent`, since it has no sequence history. When one side has lost its history, the final `ConnectResponse` sets `resetRequired` with a fresh `resetSalt`. Both peers echo the salt in their [SequenceHeader](../proto/ET.proto#L47-L55), exchange empty catchup buffers, and start over at sequence zero under a key derived from the salt. A `reset` bit that the authenticated handshake did not select is rejected.
137+
A fresh client process (`--attach`, or any initial connect) sets `ConnectRequest.resetIntent`, since it has no sequence history. When one side has lost its history, the final `ConnectResponse` sets `resetRequired` with a fresh `resetSalt`. Both peers echo the salt in their [SequenceHeader](../proto/ET.proto#L48-L56), exchange empty catchup buffers, and start over at sequence zero under a key derived from the salt. A `reset` bit that the authenticated handshake did not select is rejected.
138+
139+
After an etserver restart, the surviving etterminal re-registers with `TerminalUserInfo.ptyactive` set, and the server resumes its shell instead of bootstrapping a new one. Until then clients get `RETRY_LATER`.
138140

139141
### Ending a session
140142

‎proto/ET.proto‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ enum ConnectStatus {
2323
RETURNING_CLIENT = 2;
2424
INVALID_KEY = 3;
2525
MISMATCHED_PROTOCOL = 4;
26+
RETRY_LATER = 5;
2627
}
2728

2829
message ConnectResponse {

‎proto/ETerminal.proto‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ message TermInit {
9191
repeated string environmentvalues = 2;
9292
optional bool no_pty = 3 [default = false];
9393
optional string command = 4;
94+
optional bool hadreversetunnels = 5 [default = false];
9495
}
9596

9697
message TerminalUserInfo {
@@ -99,4 +100,11 @@ message TerminalUserInfo {
99100
optional int64 uid = 3;
100101
optional int64 gid = 4;
101102
optional int64 fd = 5;
103+
// Set when a terminal re-registers after its router went away: the pty and
104+
// its shell are already running, so the server must resume the session
105+
// instead of bootstrapping a fresh one.
106+
optional bool ptyactive = 6;
107+
// Preserved by etterminal so a replacement server can warn that the
108+
// server-side listeners from the original bootstrap no longer exist.
109+
optional bool hadreversetunnels = 7 [default = false];
102110
}

‎src/base/ClientConnection.cpp‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ bool ClientConnection::connect() {
3333
et::ConnectResponse response;
3434
connectHandshake(socketFd, &response, resetOnConnect);
3535
lastStatus_.store(response.status());
36+
if (response.status() == RETRY_LATER) {
37+
throw std::runtime_error("Server is recovering; retry later");
38+
}
3639
if (response.status() != NEW_CLIENT &&
3740
response.status() != RETURNING_CLIENT) {
3841
// Note: the response can be returning client if the client died while
@@ -205,7 +208,11 @@ void ClientConnection::pollReconnect() {
205208
socketHandler->close(newSocketFd);
206209
return;
207210
}
208-
if (response.status() != RETURNING_CLIENT) {
211+
if (response.status() == RETRY_LATER) {
212+
LOG(INFO) << "Server is still recovering; retrying reconnect "
213+
"shortly.";
214+
socketHandler->close(newSocketFd);
215+
} else if (response.status() != RETURNING_CLIENT) {
209216
STERROR << "Error reconnecting to server: " << response.status()
210217
<< ": " << response.error();
211218
CLOG(INFO, "stdout")

‎src/base/ServerClientConnection.cpp‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ bool ServerClientConnection::recoverClient(int newSocketFd, bool forceReset,
7070

7171
bool success = recover(newSocketFd, forceReset, resetSalt);
7272
if (success) {
73-
if (oldSocketFd != -1) {
73+
// A resumed connection was constructed with newSocketFd.
74+
if (oldSocketFd != -1 && oldSocketFd != newSocketFd) {
7475
socketHandler->close(oldSocketFd);
7576
}
7677
return true;

‎src/base/ServerConnection.cpp‎

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ ServerConnection::ServerConnection(
55
const SocketEndpoint& _serverEndpoint)
66
: socketHandler(_socketHandler),
77
serverEndpoint(_serverEndpoint),
8+
startTime_(time(NULL)),
89
clientHandlerThreadPool(new ThreadPool(8)) {
910
socketHandler->listen(serverEndpoint);
1011
}
@@ -26,9 +27,13 @@ bool ServerConnection::acceptNewConnection(int fd) {
2627
}
2728

2829
void ServerConnection::shutdown() {
29-
lock_guard<std::recursive_mutex> guard(classMutex);
30-
socketHandler->stopListening(serverEndpoint);
30+
{
31+
lock_guard<std::recursive_mutex> guard(classMutex);
32+
socketHandler->stopListening(serverEndpoint);
33+
}
34+
// In-flight clientHandlers take classMutex, so join the pool without it.
3135
clientHandlerThreadPool.reset();
36+
lock_guard<std::recursive_mutex> guard(classMutex);
3237
for (const auto& it : clientConnections) {
3338
it.second->shutdown();
3439
}
@@ -67,6 +72,7 @@ void ServerConnection::clientHandler(int clientSocketFd) {
6772
clientId = request.clientid();
6873
shared_ptr<ServerClientConnection> serverClientState = NULL;
6974
bool clientKeyExistsNow;
75+
bool clientWasRemoved;
7076
string clientKey;
7177

7278
{
@@ -80,6 +86,9 @@ void ServerConnection::clientHandler(int clientSocketFd) {
8086
if (clientKeyExistsNow) {
8187
clientKey = clientKeys.at(clientId);
8288
}
89+
pruneRemovedClientIds(time(NULL));
90+
clientWasRemoved =
91+
removedClientIds.find(clientId) != removedClientIds.end();
8392
}
8493

8594
// Legacy handshake for peers without the challenge capability. Remove at
@@ -119,7 +128,15 @@ void ServerConnection::clientHandler(int clientSocketFd) {
119128
std::ostringstream errorStream;
120129
errorStream << "Client is not registered";
121130
response.set_error(errorStream.str());
122-
response.set_status(INVALID_KEY);
131+
// Right after a restart, terminals may not have re-registered yet.
132+
if (!legacyPeer && !clientWasRemoved &&
133+
time(NULL) - startTime_ < recoveryGraceSeconds) {
134+
LOG(INFO) << "Within the recovery grace window; asking client "
135+
<< clientId << " to retry.";
136+
response.set_status(RETRY_LATER);
137+
} else {
138+
response.set_status(INVALID_KEY);
139+
}
123140
socketHandler->writeProto(clientSocketFd, response, true);
124141

125142
socketHandler->close(clientSocketFd);
@@ -131,28 +148,59 @@ void ServerConnection::clientHandler(int clientSocketFd) {
131148
socketHandler->writeProto(clientSocketFd, response, true);
132149
socketHandler->close(clientSocketFd);
133150
} else if (createdClientConnection) {
151+
// A known key with no connection is either a new session or a terminal
152+
// that re-registered after an etserver restart.
153+
const bool resume = shouldResumeAsReturning(clientId);
154+
if (legacyPeer && resume) {
155+
// Legacy peers can't reset; answer as a pre-restart-recovery server.
156+
LOG(INFO) << "Legacy client " << clientId
157+
<< " cannot resume after restart";
158+
et::ConnectResponse response;
159+
response.set_status(INVALID_KEY);
160+
response.set_error("Client is not registered");
161+
socketHandler->writeProto(clientSocketFd, response, true);
162+
// The partial connection owns clientSocketFd and closes it.
163+
destroyPartialConnection(clientId);
164+
return;
165+
}
166+
const string resetSalt =
167+
resume ? CryptoHandler::randomBytes(CryptoHandler::EPOCH_SALT_BYTES)
168+
: string();
169+
const ConnectStatus status = resume ? RETURNING_CLIENT : NEW_CLIENT;
134170
et::ConnectResponse response;
135-
response.set_status(NEW_CLIENT);
171+
response.set_status(status);
136172
if (!legacyPeer) {
137-
response.set_resetrequired(false);
138-
response.set_resetsalt(string());
173+
response.set_resetrequired(resume);
174+
response.set_resetsalt(resetSalt);
139175
response.set_resetproof(CryptoHandler::resetDecisionProof(
140-
clientKey, clientId, PROTOCOL_VERSION, authChallenge, NEW_CLIENT,
141-
false, string()));
176+
clientKey, clientId, PROTOCOL_VERSION, authChallenge, status,
177+
resume, resetSalt));
142178
}
143179
socketHandler->writeProto(clientSocketFd, response, true);
144180

145-
LOG(INFO) << "New client. Setting up connection";
146-
VLOG(1) << "Created client with id " << clientId;
181+
if (resume) {
182+
LOG(INFO) << "Resuming existing session for " << clientId;
183+
if (!serverClientState->recoverClient(clientSocketFd,
184+
/*forceReset=*/true, resetSalt)) {
185+
LOG(WARNING) << "Resume handshake failed for " << clientId;
186+
// Keep the key: the terminal is still live.
187+
destroyPartialConnection(clientId);
188+
} else {
189+
resumeClient(serverClientState);
190+
}
191+
} else {
192+
LOG(INFO) << "New client. Setting up connection";
193+
VLOG(1) << "Created client with id " << clientId;
147194

148-
{
149-
lock_guard<std::recursive_mutex> guard(classMutex);
195+
{
196+
lock_guard<std::recursive_mutex> guard(classMutex);
150197

151-
if (!newClient(serverClientState)) {
152-
VLOG(1) << "newClient failed";
153-
// Client creation failed, Destroy the new client
154-
removeClient(clientId);
155-
socketHandler->close(clientSocketFd);
198+
if (!newClient(serverClientState)) {
199+
VLOG(1) << "newClient failed";
200+
// Client creation failed, Destroy the new client
201+
removeClient(clientId);
202+
socketHandler->close(clientSocketFd);
203+
}
156204
}
157205
}
158206
} else {
@@ -225,6 +273,9 @@ bool ServerConnection::removeClient(const string& id) {
225273
if (clientKeys.find(id) == clientKeys.end()) {
226274
return false;
227275
}
276+
const time_t now = time(NULL);
277+
pruneRemovedClientIds(now);
278+
removedClientIds[id] = now;
228279
clientKeys.erase(id);
229280
const auto it = clientConnections.find(id);
230281
if (it == clientConnections.end()) {
@@ -239,6 +290,17 @@ bool ServerConnection::removeClient(const string& id) {
239290
return true;
240291
}
241292

293+
void ServerConnection::pruneRemovedClientIds(time_t now) {
294+
for (auto it = removedClientIds.begin(); it != removedClientIds.end();) {
295+
if (now >= it->second &&
296+
now - it->second > static_cast<time_t>(recoveryGraceSeconds)) {
297+
it = removedClientIds.erase(it);
298+
} else {
299+
++it;
300+
}
301+
}
302+
}
303+
242304
void ServerConnection::destroyPartialConnection(const string& clientId) {
243305
shared_ptr<ServerClientConnection> connection;
244306
{

‎src/base/ServerConnection.hpp‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class ServerConnection {
5252
inline void addClientKey(const string& id, const string& passkey) {
5353
lock_guard<std::recursive_mutex> guard(classMutex);
5454
clientKeys[id] = passkey;
55+
removedClientIds.erase(id);
5556
}
5657

5758
/**
@@ -64,14 +65,20 @@ class ServerConnection {
6465
*/
6566
bool removeClient(const string& id);
6667

67-
shared_ptr<ServerClientConnection> getClientConnection(
68+
shared_ptr<ServerClientConnection> tryGetClientConnection(
6869
const string& clientId) {
6970
lock_guard<std::recursive_mutex> guard(classMutex);
7071
auto it = clientConnections.find(clientId);
71-
if (it == clientConnections.end()) {
72+
return it == clientConnections.end() ? nullptr : it->second;
73+
}
74+
75+
shared_ptr<ServerClientConnection> getClientConnection(
76+
const string& clientId) {
77+
auto connection = tryGetClientConnection(clientId);
78+
if (!connection) {
7279
STFATAL << "Error: Tried to get a client connection that doesn't exist";
7380
}
74-
return it->second;
81+
return connection;
7582
}
7683

7784
/**
@@ -81,6 +88,11 @@ class ServerConnection {
8188
virtual bool newClient(
8289
shared_ptr<ServerClientConnection> serverClientState) = 0;
8390

91+
// A known id whose terminal re-registered with a live pty is resumed
92+
// instead of bootstrapped.
93+
virtual bool shouldResumeAsReturning(const string& clientId) { return false; }
94+
virtual void resumeClient(shared_ptr<ServerClientConnection> state) {}
95+
8496
protected:
8597
bool authenticateClient(int clientSocketFd, const string& clientId,
8698
const string& clientKey, int protocolVersion,
@@ -97,6 +109,8 @@ class ServerConnection {
97109
SocketEndpoint serverEndpoint;
98110
/** @brief Map of client IDs to their registered passkeys. */
99111
std::unordered_map<string, string> clientKeys;
112+
// Client ids whose sessions ended in this process, with removal time.
113+
std::unordered_map<string, time_t> removedClientIds;
100114
/** @brief Active client connections indexed by ID. */
101115
std::unordered_map<string, shared_ptr<ServerClientConnection>>
102116
clientConnections;
@@ -106,6 +120,12 @@ class ServerConnection {
106120
recursive_mutex classMutex;
107121
/** @brief Serializes connect/disconnect events. */
108122
mutex connectMutex;
123+
// After a restart, unknown ids get RETRY_LATER instead of INVALID_KEY for
124+
// this long so terminals can re-register.
125+
int recoveryGraceSeconds = 60;
126+
time_t startTime_;
127+
128+
void pruneRemovedClientIds(time_t now);
109129
};
110130
} // namespace et
111131

0 commit comments

Comments
 (0)