Files
CosmicClash/Game/scripts/server_control.gd
T

120 lines
3.6 KiB
GDScript

class_name ServerControl
extends Node
# Small loopback HTTP control surface for allocated servers. The Go supervisor
# uses GET /ready as the explicit process-ready probe and POST /drain during a
# controlled termination. Direct/community servers do not start this node.
signal drain_requested
signal initial_connect_ready
var _listener := TCPServer.new()
var _peers: Array = []
var _ready_for_connections := false
var _draining := false
var _drain_token := ""
func start(port: int, drain_token: String = "") -> Error:
if port < 1 or port > 65535:
return ERR_INVALID_PARAMETER
_drain_token = drain_token
return _listener.listen(port, "127.0.0.1")
func stop() -> void:
_listener.stop()
for peer in _peers:
if is_instance_valid(peer):
peer.disconnect_from_host()
_peers.clear()
func set_process_ready(value: bool) -> void:
_ready_for_connections = value and not _draining
func is_draining() -> bool:
return _draining
func _exit_tree() -> void:
stop()
func _process(_delta: float) -> void:
while _listener.is_connection_available():
_peers.append(_listener.take_connection())
for i in range(_peers.size() - 1, -1, -1):
var peer: StreamPeerTCP = _peers[i]
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
_peers.remove_at(i)
continue
var available := peer.get_available_bytes()
if available <= 0:
continue
var request := peer.get_utf8_string(available)
if "\r\n\r\n" not in request:
continue
_respond(peer, request)
_peers.remove_at(i)
func _respond(peer: StreamPeerTCP, request: String) -> void:
var lines := request.split("\r\n")
var first := lines[0].split(" ") if not lines.is_empty() else PackedStringArray()
var method := String(first[0]) if first.size() > 0 else ""
var path := String(first[1]) if first.size() > 1 else ""
var status := 404
var reason := "Not Found"
var body := ""
if method in ["GET", "POST"] and path == "/ready":
status = 200 if _ready_for_connections else 503
reason = "OK" if status == 200 else "Service Unavailable"
elif method in ["GET", "POST"] and path == "/health":
status = 200
reason = "OK"
elif method == "POST" and path == "/drain":
var supplied := ""
for line in lines:
if line.begins_with("Authorization: Bearer "):
supplied = line.substr("Authorization: Bearer ".length())
if _drain_token.is_empty() or not _constant_time_equal(supplied, _drain_token):
status = 401
reason = "Unauthorized"
else:
_draining = true
_ready_for_connections = false
drain_requested.emit()
status = 202
reason = "Accepted"
elif method == "POST" and path == "/initial-connect-ready":
var supplied := ""
for line in lines:
if line.begins_with("Authorization: Bearer "):
supplied = line.substr("Authorization: Bearer ".length())
if _drain_token.is_empty() or not _constant_time_equal(supplied, _drain_token):
status = 401
reason = "Unauthorized"
else:
initial_connect_ready.emit()
status = 202
reason = "Accepted"
else:
status = 405 if method in ["GET", "POST"] else 400
reason = "Method Not Allowed" if status == 405 else "Bad Request"
body = "{\"status\":\"%s\"}" % ("ready" if status == 200 else "not_ready")
var response := "HTTP/1.1 %d %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s" % [status, reason, body.to_utf8_buffer().size(), body]
peer.put_data(response.to_utf8_buffer())
peer.disconnect_from_host()
func _constant_time_equal(a: String, b: String) -> bool:
var left := a.to_utf8_buffer()
var right := b.to_utf8_buffer()
var difference := left.size() ^ right.size()
var length := mini(left.size(), right.size())
for i in length:
difference |= left[i] ^ right[i]
return difference == 0