001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.console.bus; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.security.*; 022import java.time.*; 023import java.util.*; 024import java.util.concurrent.*; 025import java.util.concurrent.atomic.*; 026import java.util.function.*; 027import java.util.logging.*; 028import java.util.stream.*; 029 030import org.apache.juneau.marshall.collections.*; 031import org.apache.juneau.marshall.marshaller.*; 032import org.apache.juneau.rest.server.sse.*; 033 034import jakarta.servlet.http.*; 035 036/** 037 * The server half of the console message bus: a retained-value store plus the sessions that bridge it to pages. 038 * 039 * <p> 040 * Publish from anywhere in the application; every connected page whose bridge was granted the topic receives it 041 * (and every page that connects later receives the retained value). Topics not allowed by the {@link BusPolicy} 042 * are refused loudly (E-56). 043 * 044 * <p> 045 * The methods under "transport SPI" are public for {@link BusEventsMixin} and {@code BusWebSockets}; application code 046 * does not call them. 047 * 048 * <h5 class='section'>Example:</h5> 049 * <p class='bjava'> 050 * <jc>// One per application (or per resource), shared by the REST resource and any background work.</jc> 051 * ServerBus <jv>bus</jv> = ServerBus.<jsm>create</jsm>(BusPolicy.<jsm>create</jsm>() 052 * .downstream(<js>"ops.jobs"</js>, <jk>true</jk>) 053 * .downstream(<js>"cmd:jobs"</js>, <jk>false</jk>) 054 * .build()); 055 * 056 * <jv>bus</jv>.publish(<js>"ops.jobs"</js>, JsonMap.<jsm>of</jsm>(<js>"schemaVersion"</js>, 1, <js>"running"</js>, <jv>running</jv>)); 057 * <jv>bus</jv>.publish(<js>"cmd:jobs"</js>, JsonMap.<jsm>of</jsm>(<js>"schemaVersion"</js>, 1, <js>"op"</js>, <js>"reload"</js>)); 058 * 059 * <jc>// Only to one user's pages (never retained):</jc> 060 * <jv>bus</jv>.publishTo(<jv>s</jv> -> <js>"jb"</js>.equals(<jv>s</jv>.principalName()), <js>"ops.jobs"</js>, <jv>mine</jv>); 061 * </p> 062 * 063 * @since 10.0.0 064 */ 065@SuppressWarnings({ 066 "java:S1192", // Transport names and refusal reasons are protocol literals; named constants would not clarify them. 067 "java:S3776" // Branching is inherent to openSession() and receive(). 068}) 069public final class ServerBus implements AutoCloseable { 070 071 private static final Logger LOGGER = Logger.getLogger(ServerBus.class.getName()); 072 private static final SecureRandom RANDOM = new SecureRandom(); 073 private static final AtomicLong STREAM_KEYS = new AtomicLong(); 074 075 /** Session id strength: 256 bits, written as 64 hex characters. */ 076 static final int ID_BYTES = 32; 077 078 /** The transports a session POST may request. */ 079 static final Set<String> TRANSPORTS = Set.of("sse", "websocket"); 080 081 /** The longest denied topic a 403 body echoes back. */ 082 static final int MAX_ECHO_LENGTH = 128; 083 084 /** The most topics one session POST may request in each direction. */ 085 static final int MAX_TOPICS = 256; 086 087 private final BusPolicy policy; 088 private final Clock clock; 089 private final ScheduledExecutorService timer; 090 private final SseBroadcaster sseStreams; 091 private final Object lock = new Object(); 092 093 // Guarded by lock. 094 private final SortedMap<String,String> retained = new TreeMap<>(); 095 private final Map<String,SessionImpl> sessions = new LinkedHashMap<>(); 096 private boolean closed; 097 098 private volatile String webSocketPrefix; 099 100 ServerBus(BusPolicy policy, Clock clock, ScheduledExecutorService timer) { 101 this.policy = Objects.requireNonNull(policy, "policy"); 102 this.clock = Objects.requireNonNull(clock, "clock"); 103 this.timer = timer; 104 // One spare slot per queue: SseFrameSink bounds itself at maxQueuedFrames, so drop-oldest never runs. 105 this.sseStreams = new SseBroadcaster(policy.maxQueuedFrames() + 1); 106 } 107 108 /** 109 * Creates a bus and starts its timer (one daemon thread: heartbeat pings and session expiry). 110 * 111 * @param policy The policy. Must not be <jk>null</jk>. 112 * @return A new bus. {@link #close()} it when the application stops. 113 */ 114 public static ServerBus create(BusPolicy policy) { 115 Objects.requireNonNull(policy, "policy"); 116 var timer = Executors.newSingleThreadScheduledExecutor(r -> { 117 var t = new Thread(r, "juneau-bus-timer"); 118 t.setDaemon(true); 119 return t; 120 }); 121 var bus = new ServerBus(policy, Clock.systemUTC(), timer); 122 var period = Math.min(1000L, policy.heartbeat().toMillis()); 123 timer.scheduleAtFixedRate(bus::tickSafely, period, period, TimeUnit.MILLISECONDS); 124 return bus; 125 } 126 127 //----------------------------------------------------------------------------------------------------------------- 128 // Application API 129 //----------------------------------------------------------------------------------------------------------------- 130 131 /** 132 * Publishes a value to every connected session granted the topic, and stores it if the topic is retained. 133 * 134 * <p> 135 * For a retained topic, a value whose JSON equals the stored JSON is not republished (distinct-until-changed). 136 * 137 * @param topic A concrete topic the policy allows downstream (not a {@code :*} pattern). 138 * @param payload The payload, serialized with {@link Json#of(Object)}. Must not be <jk>null</jk>; use 139 * {@link #clear(String)} to remove a retained value. 140 * @return <jk>false</jk> when the topic is retained and the value is unchanged; otherwise <jk>true</jk>. 141 * @throws IllegalArgumentException E-56 when the policy denies the topic or the frame is larger than 142 * {@link BusPolicy#maxFrameBytes()}. 143 */ 144 public boolean publish(String topic, Object payload) { 145 var retain = allowed("publish", topic); 146 var json = encode("publish", topic, payload); 147 synchronized (lock) { 148 if (retain) { 149 if (json.equals(retained.get(topic))) 150 return false; 151 retained.put(topic, json); 152 } 153 for (var s : List.copyOf(sessions.values())) 154 if (s.grants(topic)) 155 s.send(seq -> BusFrames.pub(topic, json, retain, seq)); 156 } 157 return true; 158 } 159 160 /** 161 * Removes a retained value and sends {@code clear} to every connected session granted the topic. 162 * 163 * <p> 164 * Clearing a topic that holds no value does nothing. 165 * 166 * @param topic A retained topic. 167 * @throws IllegalArgumentException E-56 when the policy denies the topic or does not retain it. 168 */ 169 public void clear(String topic) { 170 if (! allowed("clear", topic)) 171 throw e56("ServerBus.clear('%s'): the policy does not retain it", topic); 172 synchronized (lock) { 173 if (retained.remove(topic) == null) 174 return; 175 for (var s : List.copyOf(sessions.values())) 176 if (s.grants(topic)) 177 s.send(seq -> BusFrames.clear(topic, seq)); 178 } 179 } 180 181 /** 182 * Publishes a value to the connected sessions that match an audience. Never retained, even on a retained topic. 183 * 184 * @param audience Selects sessions. Runs under the bus lock: keep it cheap and side-effect free. 185 * @param topic A concrete topic the policy allows downstream. 186 * @param payload The payload. Must not be <jk>null</jk>. 187 * @return The number of sessions that accepted the frame. 188 * @throws IllegalArgumentException E-56, as for {@link #publish(String, Object)}. 189 */ 190 public int publishTo(Predicate<BusSession> audience, String topic, Object payload) { 191 Objects.requireNonNull(audience, "audience"); 192 allowed("publishTo", topic); 193 var json = encode("publishTo", topic, payload); 194 var reached = 0; 195 synchronized (lock) { 196 for (var s : List.copyOf(sessions.values())) 197 if (s.isConnected() && s.grants(topic) && audience.test(s) && s.send(seq -> BusFrames.pub(topic, json, false, seq))) 198 reached++; 199 } 200 return reached; 201 } 202 203 /** 204 * The stored JSON of a retained topic. 205 * 206 * @param topic The topic. 207 * @return The JSON, or empty when nothing is stored. 208 */ 209 public Optional<String> retainedJson(String topic) { 210 synchronized (lock) { 211 return Optional.ofNullable(retained.get(topic)); 212 } 213 } 214 215 /** 216 * The live sessions (connected, or disconnected within their grace period). 217 * 218 * @return An immutable snapshot. 219 */ 220 public List<BusSession> sessions() { 221 synchronized (lock) { 222 sweep(clock.instant()); 223 return List.copyOf(sessions.values()); 224 } 225 } 226 227 /** @return The policy this bus enforces. */ 228 public BusPolicy policy() { 229 return policy; 230 } 231 232 /** 233 * Closes every connection with {@code 1001} ({@code bus:closed}), forgets every session and stops the timer. 234 * Retained values are kept. Idempotent. 235 */ 236 @Override /* AutoCloseable */ 237 public void close() { 238 synchronized (lock) { 239 if (closed) 240 return; 241 closed = true; 242 for (var s : sessions.values()) 243 s.detach(1001, "bus:closed"); 244 sessions.clear(); 245 } 246 if (timer != null) 247 timer.shutdownNow(); 248 } 249 250 //----------------------------------------------------------------------------------------------------------------- 251 // Transport SPI 252 //----------------------------------------------------------------------------------------------------------------- 253 254 /** 255 * Transport SPI: opens a session from a session POST body. 256 * 257 * <p> 258 * Body: {@code {"v":1, "bridge":"ops", "transport":"sse"|"websocket", "downstream":[...], "upstream":[...]}}. 259 * {@code v} defaults to {@code 1} and {@code transport} to {@code "sse"}; {@code bridge} is required. 260 * 261 * @param req The POST request: the authorizer's argument and the source of the principal and session attributes. 262 * @param body The parsed body. <jk>null</jk> is a 400. 263 * @return The new session. 264 * @throws BusRefusal 400 {@code bus:bad-request}, 403 {@code bus:topic-denied}, 429 {@code bus:too-many-sessions}, 265 * 501 {@code bus:transport-unavailable}, or 503 {@code bus:closed}. 266 */ 267 public BusSession openSession(HttpServletRequest req, JsonMap body) throws BusRefusal { 268 Objects.requireNonNull(req, "req"); 269 if (body == null) 270 throw BusRefusal.badRequest("the session request must be a JSON object"); 271 var v = body.get("v"); 272 if (v != null && ! (v instanceof Number n && n.doubleValue() == BusFrames.VERSION)) 273 throw BusRefusal.badRequest("unsupported bus version"); 274 if (! (body.get("bridge") instanceof String bridge) || ! BusPolicy.BRIDGE_ID.matcher(bridge).matches()) 275 throw BusRefusal.badRequest("bridge must be a valid id"); 276 var t = body.get("transport"); 277 if (t != null && ! (t instanceof String x && TRANSPORTS.contains(x))) 278 throw BusRefusal.badRequest("transport must be 'sse' or 'websocket'"); 279 var transport = t == null ? "sse" : (String) t; 280 var downstream = topics(body, "downstream"); 281 var upstream = topics(body, "upstream"); 282 if (downstream.isEmpty() && upstream.isEmpty()) 283 throw BusRefusal.badRequest("the session requests no topics"); 284 if (! upstream.isEmpty() && ! "websocket".equals(transport)) 285 throw BusRefusal.badRequest("upstream topics require the websocket transport"); 286 if ("websocket".equals(transport) && webSocketPrefix == null) 287 throw BusRefusal.transportUnavailable(); 288 289 var authorizer = policy.authorizer(); 290 var denied = Stream.concat( 291 downstream.stream().filter(x -> policy.downstreamRetain(x).isEmpty() || ! authorizer.test(req, x)), 292 upstream.stream().filter(x -> ! policy.allowsUpstream(x) || ! authorizer.test(req, x)) 293 ).toList(); 294 if (! denied.isEmpty()) 295 throw BusRefusal.topicDenied(echoable(denied)); 296 297 var principal = req.getUserPrincipal(); 298 var attributes = policy.sessionAttributes().apply(req); 299 synchronized (lock) { 300 if (closed) 301 throw new BusRefusal(503, "bus:closed", "the bus is closed", null, 0); 302 var now = clock.instant(); 303 sweep(now); 304 if (sessions.size() >= policy.maxSessions()) 305 throw BusRefusal.tooManySessions(); 306 var s = new SessionImpl(newId(), bridge, transport, principal == null ? null : principal.getName(), 307 attributes, downstream, upstream, now); 308 sessions.put(s.id, s); 309 return s; 310 } 311 } 312 313 /** 314 * Transport SPI: finds a live session by its capability id, for the principal that opened it. 315 * 316 * @param id The session id from the request path. Can be <jk>null</jk>. 317 * @param principal The request principal. Can be <jk>null</jk> (anonymous). 318 * @return The session, or empty when the id is unknown or expired, or the principal differs. The three cases are 319 * deliberately indistinguishable. 320 */ 321 public Optional<BusSession> session(String id, Principal principal) { 322 if (id == null) 323 return Optional.empty(); 324 synchronized (lock) { 325 var s = sessions.get(id); 326 if (s == null) 327 return Optional.empty(); 328 if (s.expired(clock.instant())) { 329 expire(s); 330 return Optional.empty(); 331 } 332 if (neq(s.principalName, principal == null ? null : principal.getName())) 333 return Optional.empty(); 334 return Optional.of(s); 335 } 336 } 337 338 /** 339 * Transport SPI: attaches a connection to a session, then sends the resync. 340 * 341 * <p> 342 * Any live sink is replaced and closed with {@code 4409} ({@code bus:replaced}). The resync is 343 * {@code resync-begin}, one retained {@code pub} for every stored value the session's grant covers, in topic order, 344 * then {@code resync-end}. When the bus is closed or the session has expired, the sink is closed at once 345 * ({@code 1001} / {@code 4401}). 346 * 347 * @param session A session of this bus. 348 * @param sink The connection's outbound side. 349 */ 350 public void connect(BusSession session, FrameSink sink) { 351 var s = impl(session); 352 Objects.requireNonNull(sink, "sink"); 353 synchronized (lock) { 354 if (closed) { 355 sink.close(1001, "bus:closed"); 356 return; 357 } 358 if (sessions.get(s.id) != s) { 359 sink.close(4401, "bus:unknown-session"); 360 return; 361 } 362 if (s.expired(clock.instant())) { 363 expire(s); 364 sink.close(4401, "bus:unknown-session"); 365 return; 366 } 367 var old = s.sink.getAndSet(sink); 368 s.lastSent = clock.instant(); 369 if (old != null) 370 old.close(4409, "bus:replaced"); 371 if (! s.send(BusFrames::resyncBegin)) 372 return; 373 for (var e : retained.entrySet()) 374 if (s.grants(e.getKey()) && ! s.send(seq -> BusFrames.pub(e.getKey(), e.getValue(), true, seq))) 375 return; 376 s.send(BusFrames::resyncEnd); 377 } 378 } 379 380 /** 381 * Transport SPI: the connection behind a sink has closed. Starts the session's grace period. 382 * 383 * <p> 384 * A sink that is no longer the session's live sink (it was replaced, or detached as a slow consumer) is ignored. 385 * 386 * @param session A session of this bus. 387 * @param sink The sink passed to {@link #connect(BusSession, FrameSink)}. 388 */ 389 public void disconnect(BusSession session, FrameSink sink) { 390 var s = impl(session); 391 synchronized (lock) { 392 if (sink != null && s.sink.get() == sink) { 393 s.sink.set(null); 394 s.disconnectedAt = clock.instant(); 395 } 396 } 397 } 398 399 /** 400 * Transport SPI: one upstream frame from a WebSocket connection. 401 * 402 * <p> 403 * Refusals are answered with an {@code error} frame and the frame is dropped; the connection stays open. 404 * 405 * @param session A session of this bus. 406 * @param frameText The frame text. 407 */ 408 public void receive(BusSession session, String frameText) { 409 var s = impl(session); 410 synchronized (lock) { 411 if (sessions.get(s.id) != s) 412 return; 413 if (s.expired(clock.instant())) { 414 expire(s); 415 return; 416 } 417 if (! s.isConnected()) 418 return; 419 if (! s.admitUpstream(clock.instant(), policy.maxUpstreamPerSecond())) { 420 s.offer(BusFrames.error("bus:rate-limited", "too many upstream frames", null)); 421 return; 422 } 423 } 424 JsonMap frame; 425 try { 426 if (frameText != null && BusFrames.utf8Length(frameText) > policy.maxFrameBytes()) 427 throw BusRefusal.badFrame("frame too large"); 428 frame = BusFrames.decode(frameText); 429 if (! "pub".equals(frame.get("type"))) 430 throw BusRefusal.badFrame("only pub frames may be sent upstream"); 431 if (! (frame.get("payload") instanceof Map<?,?>)) 432 throw BusRefusal.badFrame("an upstream payload must be a JSON object"); 433 } catch (BusRefusal e) { 434 reply(s, BusFrames.error(e.code(), e.getMessage(), null)); 435 return; 436 } 437 var topic = (String) frame.get("topic"); 438 if (! s.upstream.contains(topic) || ! policy.allowsUpstream(topic)) { 439 var echo = BusPolicy.UPSTREAM_TOPIC.matcher(topic).matches() ? topic : null; 440 reply(s, BusFrames.error("bus:upstream-denied", "the session is not granted this upstream topic", echo)); 441 return; 442 } 443 var p = (Map<?,?>) frame.get("payload"); 444 var payload = p instanceof JsonMap j ? j : JsonMap.of(p); 445 try { 446 policy.upstreamHandler(topic).handle(s, topic, payload); 447 } catch (Exception e) { 448 if (e instanceof InterruptedException) 449 Thread.currentThread().interrupt(); 450 LOGGER.log(Level.WARNING, e, () -> f("bus upstream handler for topic '%s' failed", topic)); 451 reply(s, BusFrames.error("bus:upstream-failed", "handler failed", topic)); 452 } 453 } 454 455 /** 456 * Transport SPI: records that the WebSocket transport is registered, so {@code transport:"websocket"} sessions may 457 * open and the session POST response carries {@code paths.websocket}. 458 * 459 * @param contextRelativePathPrefix The endpoint path without its {@code /{sessionId}} segment, for example 460 * {@code /juneau-bus/ws}. Must start with {@code /} and not end with {@code /}. 461 */ 462 public void enableWebSocket(String contextRelativePathPrefix) { 463 if (contextRelativePathPrefix == null || ! contextRelativePathPrefix.startsWith("/") || contextRelativePathPrefix.endsWith("/")) 464 throw new IllegalArgumentException(f( 465 "ServerBus.enableWebSocket: '%s' must start with '/' and must not end with '/'", contextRelativePathPrefix)); 466 webSocketPrefix = contextRelativePathPrefix; 467 } 468 469 //----------------------------------------------------------------------------------------------------------------- 470 // Package-private: BusEventsMixin and tests 471 //----------------------------------------------------------------------------------------------------------------- 472 473 /** The WebSocket path prefix passed to {@link #enableWebSocket(String)}, or <jk>null</jk>. */ 474 String webSocketPrefix() { 475 return webSocketPrefix; 476 } 477 478 /** The capability id of a session of any {@code ServerBus}. Only the session POST response may carry it. */ 479 static String idOf(BusSession session) { 480 return impl(session).id; 481 } 482 483 /** 484 * The SSE stream's subscription for a session (built on {@link SseBroadcaster}). 485 * 486 * <p> 487 * One per session, keyed by the session's non-secret stream key, never its capability id. Subscribing again 488 * closes the previous subscription, which ends the earlier stream quietly; {@link #connect(BusSession, FrameSink)} 489 * then closes the earlier sink with {@code 4409}. The queue holds {@link BusPolicy#maxQueuedFrames()} {@code + 1} 490 * events; see {@code SseFrameSink}. 491 * 492 * @param session A session of this bus. 493 * @return A new subscription. The caller closes it when its stream ends. 494 */ 495 SseSubscription subscribeSse(BusSession session) { 496 return sseStreams.subscribe(impl(session).streamKey); 497 } 498 499 /** 500 * The session POST's {@code 200} body. 501 * 502 * @param session A session of this bus. 503 * @param busBase The root-relative servlet path plus {@link BusEventsMixin#BUS_PREFIX}, e.g. {@code /rest/ops/juneau-bus}. 504 * @param contextPath The servlet context path ({@code ""} for the root context): WebSocket endpoints are 505 * context-relative, not servlet-relative. 506 * @return The grant. {@code paths.websocket} is present only after {@link #enableWebSocket(String)}. 507 */ 508 JsonMap grant(BusSession session, String busBase, String contextPath) { 509 var s = impl(session); 510 var down = s.downstream.stream().map(t -> JsonMap.of("topic", t, "retain", policy.downstreamRetain(t).orElse(false))).toList(); 511 var paths = JsonMap.of("sse", busBase + "/stream/" + s.id); 512 var ws = webSocketPrefix; 513 if (ws != null) 514 paths.put("websocket", (contextPath == null ? "" : contextPath) + ws + "/" + s.id); 515 return JsonMap.of("v", BusFrames.VERSION, "sessionId", s.id, "heartbeatMs", policy.heartbeat().toMillis(), 516 "graceMs", policy.sessionGrace().toMillis(), "downstream", down, "upstream", List.copyOf(s.upstream), "paths", paths); 517 } 518 519 /** Expires old sessions, closes connections past {@code maxSessionAge}, and pings idle connections. */ 520 void tick() { 521 synchronized (lock) { 522 if (closed) 523 return; 524 var now = clock.instant(); 525 sweep(now); 526 var idle = policy.heartbeat(); 527 for (var s : List.copyOf(sessions.values())) 528 if (s.sink.get() != null && ! now.isBefore(s.lastSent.plus(idle))) 529 s.offer(BusFrames.ping()); 530 } 531 } 532 533 //----------------------------------------------------------------------------------------------------------------- 534 // Internals 535 //----------------------------------------------------------------------------------------------------------------- 536 537 private void tickSafely() { 538 try { 539 tick(); 540 } catch (RuntimeException e) { 541 // A scheduled task that throws is never run again; log and keep the timer alive. 542 LOGGER.log(Level.WARNING, "bus timer tick failed", e); 543 } 544 } 545 546 /** Returns the retain flag, or throws E-56. A {@code :*} pattern is not a publishable topic. */ 547 private boolean allowed(String method, String topic) { 548 if (topic == null || topic.endsWith(":*")) 549 throw e56("ServerBus.%s('%s'): the policy does not allow it downstream", method, topic); 550 return policy.downstreamRetain(topic) 551 .orElseThrow(() -> e56("ServerBus.%s('%s'): the policy does not allow it downstream", method, topic)); 552 } 553 554 /** Serializes a payload and checks the worst-case frame size (a {@code false} flag and the longest {@code seq}). */ 555 private String encode(String method, String topic, Object payload) { 556 if (payload == null) 557 throw new IllegalArgumentException(f("ServerBus.%s('%s'): payload must not be null; use clear(topic)", method, topic)); 558 var json = Json.of(payload); 559 var size = BusFrames.utf8Length(BusFrames.pub(topic, json, false, Long.MAX_VALUE)); 560 if (size > policy.maxFrameBytes()) 561 throw e56("ServerBus.%s('%s'): frame too large (%s bytes, maxFrameBytes %s)", method, topic, size, policy.maxFrameBytes()); 562 return json; 563 } 564 565 private void reply(SessionImpl s, String frame) { 566 synchronized (lock) { 567 s.offer(frame); 568 } 569 } 570 571 /** Guarded by lock. */ 572 private void sweep(Instant now) { 573 for (var s : List.copyOf(sessions.values())) 574 if (s.expired(now)) 575 expire(s); 576 } 577 578 /** Guarded by lock. */ 579 private void expire(SessionImpl s) { 580 sessions.remove(s.id); 581 s.detach(4401, "bus:unknown-session"); 582 } 583 584 /** Keeps only denied topics that are safe to echo in a 403 body: inside a topic grammar and short. */ 585 private static List<String> echoable(List<String> denied) { 586 return denied.stream() 587 .filter(x -> x.length() <= MAX_ECHO_LENGTH && (BusPolicy.DOWNSTREAM_PATTERN.matcher(x).matches() || BusPolicy.UPSTREAM_TOPIC.matcher(x).matches())) 588 .toList(); 589 } 590 591 private static List<String> topics(JsonMap body, String key) throws BusRefusal { 592 var o = body.get(key); 593 if (o == null) 594 return List.of(); 595 if (! (o instanceof List<?> list) || list.size() > MAX_TOPICS) 596 throw BusRefusal.badRequest(f("%s must be a list of at most %s topics", key, MAX_TOPICS)); 597 var out = new LinkedHashSet<String>(); 598 for (var x : list) { 599 if (! (x instanceof String s) || s.isEmpty()) 600 throw BusRefusal.badRequest(f("%s must be a list of at most %s topics", key, MAX_TOPICS)); 601 out.add(s); 602 } 603 return List.copyOf(out); 604 } 605 606 private static String newId() { 607 var bytes = new byte[ID_BYTES]; 608 RANDOM.nextBytes(bytes); 609 return HexFormat.of().formatHex(bytes); 610 } 611 612 private static SessionImpl impl(BusSession session) { 613 if (session instanceof SessionImpl s) 614 return s; 615 throw new IllegalArgumentException("ServerBus: not a session of a ServerBus"); 616 } 617 618 private static IllegalArgumentException e56(String format, Object...args) { 619 return new IllegalArgumentException("E-56: " + f(format, args)); 620 } 621 622 /** A session. Every mutable field is guarded by the bus lock; {@code sink} is also atomic for {@link #isConnected()}. */ 623 private final class SessionImpl implements BusSession { 624 625 final String id; 626 final String streamKey = "bus-" + STREAM_KEYS.incrementAndGet(); 627 final String bridgeId; 628 final String transport; 629 final String principalName; 630 final Map<String,Object> attributes; 631 final Set<String> downstream; 632 final Set<String> upstream; 633 final Instant createdAt; 634 635 final AtomicReference<FrameSink> sink = new AtomicReference<>(); 636 Instant disconnectedAt; 637 Instant lastSent; 638 long seq; 639 Instant windowStart = Instant.EPOCH; 640 int windowCount; 641 642 @SuppressWarnings({ 643 "java:S107" // internal constructor; the parameters are the bus configuration 644 }) 645 SessionImpl(String id, String bridgeId, String transport, String principalName, Map<String,Object> attributes, 646 List<String> downstream, List<String> upstream, Instant now) { 647 this.id = id; 648 this.bridgeId = bridgeId; 649 this.transport = transport; 650 this.principalName = principalName; 651 this.attributes = attributes == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(attributes)); 652 this.downstream = Collections.unmodifiableSet(new LinkedHashSet<>(downstream)); 653 this.upstream = Collections.unmodifiableSet(new LinkedHashSet<>(upstream)); 654 this.createdAt = now; 655 this.disconnectedAt = now; 656 this.lastSent = now; 657 } 658 659 @Override public String bridgeId() { return bridgeId; } 660 @Override public String transport() { return transport; } 661 @Override public String principalName() { return principalName; } 662 @Override public Object attribute(String name) { return attributes.get(name); } 663 @Override public Set<String> downstream() { return downstream; } 664 @Override public Set<String> upstream() { return upstream; } 665 @Override public boolean isConnected() { return sink.get() != null; } 666 667 /** Whether the grant covers a concrete topic: an exact entry, or {@code family:*} for {@code family:key}. */ 668 boolean grants(String topic) { 669 if (downstream.contains(topic)) 670 return true; 671 var i = topic.indexOf(':'); 672 return i > 0 && downstream.contains(topic.substring(0, i) + ":*"); 673 } 674 675 /** Sends a frame that carries the next {@code seq}. Not connected: nothing happens and seq is not consumed. */ 676 boolean send(LongFunction<String> frame) { 677 if (sink.get() == null) 678 return false; 679 return offer(frame.apply(++seq)); 680 } 681 682 /** Offers a frame; on a full queue detaches the sink and closes it with 4429. */ 683 boolean offer(String frame) { 684 var k = sink.get(); 685 if (k == null) 686 return false; 687 if (k.offer(frame)) { 688 lastSent = clock.instant(); 689 return true; 690 } 691 detach(4429, "bus:slow-consumer"); 692 return false; 693 } 694 695 void detach(int code, String reason) { 696 var k = sink.get(); 697 if (k == null) 698 return; 699 sink.set(null); 700 disconnectedAt = clock.instant(); 701 k.close(code, reason); 702 } 703 704 boolean expired(Instant now) { 705 if (! now.isBefore(createdAt.plus(policy.maxSessionAge()))) 706 return true; 707 return sink.get() == null && ! now.isBefore(disconnectedAt.plus(policy.sessionGrace())); 708 } 709 710 boolean admitUpstream(Instant now, int max) { 711 if (! now.isBefore(windowStart.plusSeconds(1))) { 712 windowStart = now; 713 windowCount = 0; 714 } 715 return ++windowCount <= max; 716 } 717 718 @Override /* Object */ 719 public String toString() { 720 return f("BusSession[bridge=%s, transport=%s, principal=%s, connected=%s, id=<redacted>]", 721 bridgeId, transport, principalName, isConnected()); 722 } 723 } 724}