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.terminal.bus; 018 019import static java.nio.charset.StandardCharsets.*; 020 021import java.util.*; 022import java.util.concurrent.atomic.*; 023import java.util.logging.*; 024 025import org.apache.juneau.marshall.collections.*; 026import org.apache.juneau.console.bus.*; 027import org.apache.juneau.terminal.report.*; 028import org.apache.juneau.terminal.region.*; 029 030/** 031 * The {@code term.input} upstream handler: hands keystrokes from a browser terminal card to a {@link TerminalSink}. 032 * 033 * <p> 034 * Wiring. The handler answers through the bus, but the bus is built from the policy that holds the handler, so the 035 * bus is bound afterwards, once: 036 * <p class='bjava'> 037 * TerminalInput <jv>input</jv> = TerminalInput.<jsm>create</jsm>(<jv>access</jv>); 038 * ServerBus <jv>bus</jv> = ServerBus.<jsm>create</jsm>(BusPolicy.<jsm>create</jsm>() 039 * .upstream(TerminalInput.<jsf>TOPIC</jsf>, <jv>input</jv>) 040 * .downstream(TerminalInput.<jsf>STATUS_TOPIC</jsf>, <jk>false</jk>) 041 * .build()); 042 * <jv>input</jv>.bind(<jv>bus</jv>); 043 * </p> 044 * 045 * <p> 046 * A frame's payload is <c>{"id": <terminal id>, "data": <string>}</c>; an empty {@code data} is a probe. 047 * Every probe, and every refused data frame, is answered on {@link #STATUS_TOPIC} to the sending session only, with 048 * <c>{"id", "state"}</c>, where the state is {@code live}, {@code denied}, {@code closed}, {@code busy} or {@code bad}. 049 * An accepted data frame gets no answer. The id is echoed only when it is a valid terminal id; otherwise the answer has 050 * no {@code id} key. 051 * 052 * <p> 053 * Typing needs both {@link TerminalAccess#canWatch(TerminalCaller, String)} and a present 054 * {@link TerminalAccess#inputSink(TerminalCaller, String)}. The bus's {@code maxUpstreamPerSecond} policy (20 by 055 * default) bounds the frame rate per session, which is why the client batches keystrokes. 056 * 057 * <p> 058 * Each accepted non-empty write is audited at INFO on the {@code juneau.terminal.input} logger as 059 * <c>principal=<name or -> terminal=<id> bytes=<n> keys=<list></c>, where the list names the 060 * control keys present (for example <c>keys=^C,ESC,CR</c>), comma-separated, in order of first appearance, at most 8. 061 * The typed content is never logged. Refusals log at FINE with the state only. 062 */ 063public final class TerminalInput implements UpstreamHandler { 064 065 /** The upstream topic carrying keystrokes. */ 066 public static final String TOPIC = "term.input"; 067 068 /** The downstream topic carrying the per-session answers. Never retained. */ 069 public static final String STATUS_TOPIC = "term.input-status"; 070 071 private static final Logger AUDIT = Logger.getLogger("juneau.terminal.input"); 072 private static final int MAX_KEYS = 8; 073 private static final Set<String> KEYS = Set.of("id", "data"); 074 075 private final TerminalAccess access; 076 private final AtomicReference<ServerBus> bus = new AtomicReference<>(); 077 078 private TerminalInput(TerminalAccess access) { 079 this.access = access; 080 } 081 082 /** 083 * Creates a handler. 084 * 085 * @param access Who may watch and who may type. Must not be <jk>null</jk>. 086 * @return A new, unbound handler. 087 * @throws IllegalArgumentException If {@code access} is <jk>null</jk>. 088 */ 089 public static TerminalInput create(TerminalAccess access) { 090 if (access == null) 091 throw new IllegalArgumentException("TerminalInput.create: access must not be null"); 092 return new TerminalInput(access); 093 } 094 095 /** 096 * Binds the bus the answers are published on. May be called once. 097 * 098 * @param bus The bus whose policy holds this handler. Must not be <jk>null</jk>. 099 * @return This object. 100 * @throws IllegalArgumentException If {@code bus} is <jk>null</jk>. 101 * @throws IllegalStateException If a bus is already bound. 102 */ 103 public TerminalInput bind(ServerBus bus) { 104 if (bus == null) 105 throw new IllegalArgumentException("TerminalInput.bind: bus must not be null"); 106 if (! this.bus.compareAndSet(null, bus)) 107 throw new IllegalStateException("TerminalInput.bind: already bound"); 108 return this; 109 } 110 111 /** 112 * Handles one {@code term.input} frame. 113 * 114 * @param session The sending session. Answers go to it only. 115 * @param topic The topic, {@link #TOPIC}. 116 * @param payload The frame payload, <c>{"id", "data"}</c>. 117 * @throws IllegalStateException If no bus is bound. 118 * @throws Exception Whatever the sink's {@code write} throws; the bus answers {@code bus:upstream-failed}. 119 */ 120 @Override /* UpstreamHandler */ 121 public void handle(BusSession session, String topic, JsonMap payload) throws Exception { 122 var b = bus.get(); 123 if (b == null) 124 throw new IllegalStateException("TerminalInput: handle called before bind"); 125 var caller = new SessionCaller(session); 126 127 var rawId = payload.get("id"); 128 var id = rawId instanceof String s && TerminalViewChecks.isRunId(s) ? s : null; 129 if (id == null || ! (payload.get("data") instanceof String data) || ! KEYS.equals(payload.keySet())) { 130 refuse(b, session, caller, id, "bad"); 131 return; 132 } 133 134 var sink = access.canWatch(caller, id) ? access.inputSink(caller, id).orElse(null) : null; 135 if (sink == null) { 136 refuse(b, session, caller, id, "denied"); 137 return; 138 } 139 if (! sink.acceptsInput()) { 140 refuse(b, session, caller, id, "closed"); 141 return; 142 } 143 if (data.isEmpty()) { 144 answer(b, session, id, "live"); 145 return; 146 } 147 var bytes = data.getBytes(UTF_8); 148 if (! sink.write(bytes)) { 149 // A refused write is "busy" unless the process stopped accepting input since the check above. 150 refuse(b, session, caller, id, sink.acceptsInput() ? "busy" : "closed"); 151 return; 152 } 153 AUDIT.info(() -> String.format(Locale.ROOT, "principal=%s terminal=%s bytes=%d keys=%s", principal(caller), id, bytes.length, keys(bytes))); 154 } 155 156 private static void refuse(ServerBus b, BusSession session, TerminalCaller caller, String id, String state) { 157 AUDIT.fine(() -> String.format(Locale.ROOT, "principal=%s terminal=%s state=%s", principal(caller), id == null ? "-" : id, state)); 158 answer(b, session, id, state); 159 } 160 161 private static void answer(ServerBus b, BusSession session, String id, String state) { 162 // The bus serializer writes null map values, so an id that failed the grammar is left out rather than nulled. 163 var payload = id == null ? JsonMap.of("state", state) : JsonMap.of("id", id, "state", state); 164 b.publishTo(s -> s == session, STATUS_TOPIC, payload); 165 } 166 167 /** 168 * The principal for an audit record: {@code -} when anonymous, otherwise the name with every character 169 * {@code <= 0x20}, every ISO control (0x7F, 0x80-0x9F), U+2028, U+2029, {@code '='}, {@code '\'} and {@code '%'} 170 * percent-encoded as its UTF-8 bytes ({@code %XX} per byte, upper-case hex; for example U+2028 is {@code %E2%80%A8}), 171 * so a name can neither split the record nor forge a {@code key=value} field. 172 */ 173 private static String principal(TerminalCaller c) { 174 var name = c.principalName(); 175 if (name == null) 176 return "-"; 177 var sb = new StringBuilder(name.length()); 178 for (var i = 0; i < name.length(); i++) { 179 var ch = name.charAt(i); 180 if (ch <= 0x20 || Character.isISOControl(ch) || ch == '\u2028' || ch == '\u2029' || ch == '=' || ch == '\\' || ch == '%') 181 for (var x : String.valueOf(ch).getBytes(UTF_8)) 182 sb.append('%').append(String.format(Locale.ROOT, "%02X", x & 0xFF)); 183 else 184 sb.append(ch); 185 } 186 return sb.toString(); 187 } 188 189 /** The control keys present, comma-separated, in order of first appearance, at most {@value #MAX_KEYS}. */ 190 private static String keys(byte[] bytes) { 191 var names = new LinkedHashSet<String>(); 192 for (var x : bytes) { 193 var name = keyName(x & 0xFF); 194 if (name != null) { 195 names.add(name); 196 if (names.size() == MAX_KEYS) 197 break; 198 } 199 } 200 return String.join(",", names); 201 } 202 203 /** 204 * The audit name of a control byte, or <jk>null</jk> for any other byte. 205 * 206 * <p> 207 * {@code ^C}, {@code ^D}, {@code ^Z}, {@code ESC}, {@code CR}, {@code TAB} and {@code BS} (0x08 and 0x7F) are named; 208 * every other C0 byte uses standard caret notation ({@code b + 0x40}): {@code ^A}..{@code ^Y} for the letters, 209 * {@code ^J} for LF, and {@code ^@}, {@code ^\}, {@code ^]}, {@code ^^}, {@code ^_} for 0x00 and 0x1C-0x1F. 210 */ 211 private static String keyName(int b) { 212 return switch (b) { 213 case 0x03 -> "^C"; 214 case 0x04 -> "^D"; 215 case 0x1A -> "^Z"; 216 case 0x1B -> "ESC"; 217 case 0x0D -> "CR"; 218 case 0x09 -> "TAB"; 219 case 0x08 -> "BS"; 220 case 0x7F -> "BS"; // xterm's Backspace sends DEL (0x7F) by default. 221 default -> b < 0x20 ? "^" + (char)(b + 0x40) : null; 222 }; 223 } 224}