ChannelEndpoint.vala 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Astralis;
  4. namespace Statum {
  5. /**
  6. * The realtime channel service.
  7. *
  8. * Registered as a singleton (as itself and as {@link ChannelService}).
  9. * Each `GET /_statum/channel` opens a long-lived SSE stream whose first event
  10. * is `event: channel` carrying a fresh channel id; subsequent `set`/`clear`
  11. * events are delivered only for the slot keys the channel has subscribed to
  12. * (via `PATCH /_statum/channel/{id}`).
  13. *
  14. * {@link StateService.update}/{@link StateService.clear_slot} call
  15. * {@link push_set}/{@link push_clear}, which fan out to the subscribed
  16. * streams — a no-op when nothing is subscribed, giving selective fan-out for
  17. * free.
  18. *
  19. * Resource bounds (SA-15): every connection is a long-lived stream with an
  20. * unbounded subscription set, so {@link MAX_TOTAL_CONNECTIONS},
  21. * {@link MAX_CONNECTIONS_PER_IP} and {@link MAX_SUBSCRIPTIONS_PER_CHANNEL}
  22. * cap how much of the server an anonymous client can pin down. Admission
  23. * control itself lives in {@link ChannelGatewayEndpoint}, which owns the
  24. * `/_statum/channel` route and calls {@link try_open_connection} before any
  25. * stream is created.
  26. */
  27. public class ChannelEndpoint : SseEndpoint, ChannelService {
  28. /**
  29. * Concurrent channel connections across all clients (SA-15). Each one
  30. * holds a stream, a connection object and its subscriptions open for
  31. * as long as the client stays connected.
  32. */
  33. public const int MAX_TOTAL_CONNECTIONS = 256;
  34. /**
  35. * Concurrent channel connections from a single remote address
  36. * (SA-15): one browser legitimately holds a handful of tabs, not
  37. * hundreds of EventSource streams.
  38. */
  39. public const int MAX_CONNECTIONS_PER_IP = 16;
  40. /**
  41. * Subscriptions one channel may hold (SA-15): each subscription adds
  42. * a fan-out write to every {@link push_set} for that key.
  43. */
  44. public const int MAX_SUBSCRIPTIONS_PER_CHANNEL = 64;
  45. public override uint retry_interval { get { return 3000; } }
  46. private Series<ChannelConnection> connections = new Series<ChannelConnection>();
  47. /**
  48. * Global/per-IP connection accounting (SA-15). Lives on the singleton
  49. * so every gateway admission decision sees the same counts.
  50. */
  51. public ConnectionTracker connection_tracker { get; private set; }
  52. construct {
  53. connection_tracker = new ConnectionTracker();
  54. }
  55. /**
  56. * Whether one more channel connection may be opened from `ip`, given
  57. * the SA-15 global and per-IP caps. Checked by the gateway before a
  58. * stream is created; the count is released on disconnect.
  59. */
  60. public bool can_accept_connection(string? ip) {
  61. drop_closed();
  62. return connection_tracker.can_accept(ip);
  63. }
  64. public override async void new_connection(HttpContext http_context, RouteContext route_context, SseStream stream) {
  65. var connection = new ChannelConnection() {
  66. stream = stream,
  67. id = generate_channel_id(),
  68. ip = http_context.request.remote_address
  69. };
  70. connections.add(connection);
  71. connection_tracker.acquired(connection.ip);
  72. stream.disconnected.connect(() => {
  73. connections.remove(connection);
  74. connection_tracker.released(connection.ip);
  75. });
  76. try {
  77. yield stream.send_event(new SseEvent.with_type("channel", connection.id));
  78. } catch (GLib.Error e) {
  79. stream.close();
  80. }
  81. }
  82. /** Drops closed connections and releases their counted slots. */
  83. private void drop_closed() {
  84. // Closed streams are normally removed by the disconnect handler;
  85. // sweep any that slipped through (e.g. a stream closed before its
  86. // handler ran) so the caps measure live connections only.
  87. var closed = connections.where(c => c.stream.is_closed).to_series();
  88. foreach (var connection in closed) {
  89. connections.remove(connection);
  90. connection_tracker.released(connection.ip);
  91. }
  92. }
  93. /** Pushes a `set` event carrying `frame_json` to subscribed channels. */
  94. public async void push_set(string key, string frame_json) {
  95. foreach (var connection in open_subscribers(key)) {
  96. try {
  97. yield connection.stream.send_event(new SseEvent.with_type("set", frame_json));
  98. } catch (GLib.Error e) {
  99. connection.stream.close();
  100. }
  101. }
  102. }
  103. /** Pushes a `clear` event for `key` to subscribed channels. */
  104. public async void push_clear(string key) {
  105. foreach (var connection in open_subscribers(key)) {
  106. try {
  107. yield connection.stream.send_event(new SseEvent.with_type("clear", key));
  108. } catch (GLib.Error e) {
  109. connection.stream.close();
  110. }
  111. }
  112. }
  113. /** Whether a channel with `id` is currently open. */
  114. public bool has_channel(string id) {
  115. return connections.any(c => !c.stream.is_closed && c.id == id);
  116. }
  117. /**
  118. * Adds `key` to channel `id`'s subscriptions, bounded by
  119. * {@link MAX_SUBSCRIPTIONS_PER_CHANNEL} (SA-15).
  120. *
  121. * @return `true` when the subscription was recorded.
  122. */
  123. public bool subscribe(string id, string key) {
  124. var connection = find_connection(id);
  125. if (connection != null) {
  126. if (((!)connection).subs.length >= MAX_SUBSCRIPTIONS_PER_CHANNEL) {
  127. warning("[Statum] channel %s hit the %d-subscription cap; ignoring key \"%s\"",
  128. id, MAX_SUBSCRIPTIONS_PER_CHANNEL, key);
  129. return false;
  130. }
  131. ((!)connection).subs.add(key);
  132. return true;
  133. }
  134. return false;
  135. }
  136. /** Removes `key` from channel `id`'s subscriptions. */
  137. public void unsubscribe(string id, string key) {
  138. var connection = find_connection(id);
  139. if (connection != null) {
  140. ((!)connection).subs.remove(key);
  141. }
  142. }
  143. private ChannelConnection? find_connection(string id) {
  144. foreach (var connection in connections) {
  145. if (!connection.stream.is_closed && connection.id == id) {
  146. return connection;
  147. }
  148. }
  149. return null;
  150. }
  151. private Lot<ChannelConnection> open_subscribers(string key) {
  152. return connections
  153. .where(c => !c.stream.is_closed && c.subs.contains(key))
  154. .to_immutable_buffer();
  155. }
  156. private static string generate_channel_id() {
  157. var bytes = new uint8[16];
  158. Sodium.Random.random_bytes(bytes);
  159. var builder = new StringBuilder();
  160. foreach (var b in bytes) {
  161. builder.append_printf("%02x", b);
  162. }
  163. return builder.str;
  164. }
  165. private class ChannelConnection : Object {
  166. public SseStream stream { get; set; }
  167. public string id { get; set; }
  168. /** Remote address, released from the per-IP count on disconnect. */
  169. public string? ip { get; set; }
  170. public Set<string> subs { get; private set; }
  171. public ChannelConnection() {
  172. subs = new HashSet<string>();
  173. }
  174. }
  175. }
  176. }