Top Level Namespace
Constant Summary collapse
- MAX_WS_FRAME_SIZE =
Key Variables
50.0
Instance Method Summary collapse
-
#accept_connection(socket, ws_key) ⇒ Object
accept_connection.
-
#build_app ⇒ Object
build_app.
- #build_css ⇒ Object
- #build_error(code) ⇒ Object
-
#get_command(cmd_id) ⇒ Object
get_command.
-
#handle_get(socket, request) ⇒ Object
handle_get.
- #handle_request(socket) ⇒ Object
-
#launch ⇒ Object
launch.
- #launch_server ⇒ Object
- #log_error(*msg) ⇒ Object
- #log_fatal(*msg) ⇒ Object
- #log_info(*msg) ⇒ Object
- #log_warning(*msg) ⇒ Object
- #loop_overwrite(config, yaml) ⇒ Object
-
#overwrite_config(yaml_filename) ⇒ Object
overwrite_config.
-
#parse_ARGV ⇒ Object
parse_ARGV.
-
#read_HTTP_message(socket) ⇒ Object
read_HTTP_message.
-
#receive_msg(socket) ⇒ Object
receive_msg.
-
#run_command_and_stream(socket, cmd_id) ⇒ Object
run_command_and_stream.
-
#run_websocket(socket, initial_request) ⇒ Object
run_websocket.
-
#send_frame(socket, fmsg) ⇒ Object
TODO: Figure out the proper frame size (MAX_WS_FRAME_SIZE).
-
#send_msg(socket, cmd_id, msg_type, msg = nil) ⇒ Object
send_msg.
Instance Method Details
#accept_connection(socket, ws_key) ⇒ Object
accept_connection
Sends back the HTTP header to initialize the websocket connection.
113 114 115 116 117 118 119 120 121 122 |
# File 'lib/easel/websocket.rb', line 113 def accept_connection(socket, ws_key) ws_accept_key = Digest::SHA1.base64digest( ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") socket.write "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: #{ws_accept_key}\r\n" + "\r\n" end |
#build_app ⇒ Object
build_app
20 21 22 23 24 25 26 27 28 29 30 |
# File 'lib/easel/build_pages.rb', line 20 def build_app app_erb = File.new("#{File.dirname(__FILE__)}/../html/app.html.erb").read page = ERB.new(app_erb).result() "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html; charset=UTF-8\r\n" + "Content-Length: #{page.bytesize}\r\n" + "Connection: close\r\n" + "\r\n" + page end |
#build_css ⇒ Object
46 47 48 49 50 51 52 53 54 55 56 |
# File 'lib/easel/build_pages.rb', line 46 def build_css error_erb = File.new("#{File.dirname(__FILE__)}/../html/app.css.erb").read css = ERB.new(error_erb).result(binding) "HTTP/1.1 200 OK\r\n" + "Content-Type: text/css; charset=UTF-8\r\n" + "Content-Length: #{css.bytesize}\r\n" + "Connection: close\r\n" + "\r\n" + css end |
#build_error(code) ⇒ Object
33 34 35 36 37 38 39 40 41 42 43 |
# File 'lib/easel/build_pages.rb', line 33 def build_error code error_erb = File.new("#{File.dirname(__FILE__)}/../html/error.html.erb").read page = ERB.new(error_erb).result(binding) "HTTP/1.1 #{code} #{@code_names[code]}\r\n" + "Content-Type: text/html; charset=UTF-8\r\n" + "Content-Length: #{page.bytesize}\r\n" + "Connection: close\r\n" + "\r\n" + page end |
#get_command(cmd_id) ⇒ Object
get_command
101 102 103 104 105 106 107 108 |
# File 'lib/easel/websocket.rb', line 101 def get_command cmd_id $config[:commands].each { |cmd| if cmd[:id] == cmd_id return cmd[:cmd] end } nil end |
#handle_get(socket, request) ⇒ Object
handle_get
Handle a get request.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/easel/server.rb', line 75 def handle_get(socket, request) case request[:url] when "/", "/index.html" socket.print build_app socket.close when "/app.css" socket.print build_css socket.close else socket.print build_error 404 socket.close end end |
#handle_request(socket) ⇒ Object
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/easel/server.rb', line 40 def handle_request socket log_info "Receieved request: #{socket}" request = socket # TODO: check what the minimum allow handling is. I think there's one more method I need to handle. case request[:method] when "GET" # TODO: respond with app, css file, favicon, or 404 error. if request[:fields][:Upgrade] == "websocket\r\n" run_websocket(socket, request) else handle_get(socket, request) end #when "HEAD" # TODO: Deal with HEAD request. https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4 else # TODO: respond with an appropriate error. response "I don't understand what you sent - go away." socket.print "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: #{response.bytesize}\r\n" + "Connection: close\r\n" + "\r\n" + response socket.close end end |
#launch ⇒ Object
launch
Launches the Easel Dashboard. Check the $config variable for defaults, although everything can be overridden by the YAML file (and some by the command line arguments).
22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
# File 'lib/easel.rb', line 22 def launch parse_ARGV # Load the provided YAML overwrite_config $config[:yaml_file] log_info("YAML loaded successfully (from: #{$config[:yaml_file]})") $config[:commands].each_with_index{ |cmd, i| cmd[:id] = i } # Give commands an ID. $config.freeze # Set config to read only # Lauch the server log_info("Launching server at #{$config[:hostname]}:#{$config[:port]}") launch_server end |
#launch_server ⇒ Object
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
# File 'lib/easel/server.rb', line 14 def launch_server # Lauch the TCPServer begin server = TCPServer.new($config[:hostname], $config[:port]) rescue Exception => e log_fatal "Server could not start. Error message: #{e}" end Thread.abort_on_exception = true # Main Loop begin loop { Thread.start(server.accept) do |client| handle_request client end } # Handle shutting down. rescue Interrupt log_info "Interrupt received, server shutting down..." end end |
#log_error(*msg) ⇒ Object
19 20 21 22 23 |
# File 'lib/easel/logging.rb', line 19 def log_error *msg unless $config[:logging] < 1 $config[:log_file].puts "[#{Time.new.strftime("%Y-%m-%d-%H:%M:%S")}] ERROR: " + msg.join(" ") end end |
#log_fatal(*msg) ⇒ Object
12 13 14 15 16 17 |
# File 'lib/easel/logging.rb', line 12 def log_fatal *msg unless $config[:logging] == 0 $config[:log_file].puts "[#{Time.new.strftime("%Y-%m-%d-%H:%M:%S")}] FATAL: " + msg.join(" ") end exit 1 end |
#log_info(*msg) ⇒ Object
31 32 33 34 35 |
# File 'lib/easel/logging.rb', line 31 def log_info *msg unless $config[:logging] < 3 $config[:log_file].puts "[#{Time.new.strftime("%Y-%m-%d-%H:%M:%S")}] INFO: " + msg.join(" ") end end |
#log_warning(*msg) ⇒ Object
25 26 27 28 29 |
# File 'lib/easel/logging.rb', line 25 def log_warning *msg unless $config[:logging] < 2 $config[:log_file].puts "[#{Time.new.strftime("%Y-%m-%d-%H:%M:%S")}] WARNING: " + msg.join(" ") end end |
#loop_overwrite(config, yaml) ⇒ Object
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
# File 'lib/easel.rb', line 112 def loop_overwrite (config, yaml) yaml.each_key { |key| if yaml[key].is_a? Hash loop_overwrite(config[key.to_sym], yaml[key]) elsif yaml[key].is_a? Array config[key.to_sym] = [] yaml[key].each { |elmnt| element = {} loop_overwrite(element, elmnt) config[key.to_sym] << element } else config[key.to_sym] = yaml[key] end } end |
#overwrite_config(yaml_filename) ⇒ Object
overwrite_config
Overwrites the $config fields that are set in the YAML file provided on input. TODO: Log (error?) every key in the YAML file that does not exist in $config.
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 |
# File 'lib/easel.rb', line 97 def overwrite_config yaml_filename # TODO: Rewrite using pattern matching to allow checking if the # yaml_contents.each is one of the base keys. If so, check that the associated # value matches the expected nesting. Do that 'no other values' check. # TODO: Ensure that the command names are less than 1020 bytes (because I'm # setting the max length of a single websocket message to 1024 (minus 'STOP:')) begin yaml_contents = YAML.load_file $config[:yaml_file] rescue Exception => e log_fatal "YAML failed to load. Error Message: #{e}" end def loop_overwrite (config, yaml) yaml.each_key { |key| if yaml[key].is_a? Hash loop_overwrite(config[key.to_sym], yaml[key]) elsif yaml[key].is_a? Array config[key.to_sym] = [] yaml[key].each { |elmnt| element = {} loop_overwrite(element, elmnt) config[key.to_sym] << element } else config[key.to_sym] = yaml[key] end } end loop_overwrite($config, yaml_contents) end |
#parse_ARGV ⇒ Object
parse_ARGV
Parses the command line arguments (ARGV) using the optparse gem. Optional command line arguments can be seen by running this program with the -h (or –help) flag.
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/easel.rb', line 43 def parse_ARGV opt_parser = OptionParser.new do |opts| opts. = "Useage: launch.rb [flags] configuration.yaml" opts.on("-h", "--help", "Prints this help message.") do puts opts exit end opts.on("-l LOG_LEVEL", "--log LOG_LEVEL", Integer, "Sets the logging level (default=2). 0=Silent, 1=Errors, 2=Warnings, 3=Info.") do |lvl| if [0, 1, 2, 3].include?(lvl) $config[:logging] = lvl else log_fatal "Command argument LOG_LEVEL '#{lvl}' not recognized. Expected 0, 1, 2, or 3." end end opts.on("-p PORT", "--port PORT", Integer, "Sets the port to bind to. Default is #{$config[:port]}.") do |port| if port >= 0 and port <= 65535 $config[:port] = port else log_fatal "Command argument PORT '#{port}' not a valid port. Must be between 0 and 65535 (inclusive)" end end opts.on("-h HOST", "--hostname HOST", "Sets the hostname. Default is '#{$config[:hostname]}'.") do |port| if port >= 0 and port <= 65535 $config[:port] = port else log_fatal "Command argument PORT '#{port}' not a valid port. Must be between 0 and 65535 (inclusive)" end end opts.on("-o [FILE]", "--output [FILE]", "Set a log file.") do |filename| begin $config[:log_file] = File.new(filename, "a") rescue Exception => e log_error "Log file could not be open. Sending log to STDIN. Error message: #{e}" end end end.parse! if ARGV.length != 1 log_fatal "launch.rb takes exactly one file. Try -h for more details." else $config[:yaml_file] = ARGV[0] end end |
#read_HTTP_message(socket) ⇒ Object
read_HTTP_message
Read an HTTP message from the socket, and parse it into a request Hash.
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
# File 'lib/easel/server.rb', line 95 def socket = [] loop do line = socket.gets << line if line == "\r\n" break end end request = {fields: {}} (request[:method], request[:url], request[:protocol]) = [0].split(" ") [1..-1].each{ |line| (key, value) = line.split(": ") request[:fields][key.split("-").join("_").to_sym] = value } request end |
#receive_msg(socket) ⇒ Object
receive_msg
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/easel/websocket.rb', line 128 def receive_msg socket # Check first two bytes byte1 = socket.getbyte byte2 = socket.getbyte if byte1 == 0x88 # Client is requesting that we close the connection. # TODO: Unsure how to properly handle this case. Right now the socket will close and # everything here will shut down - eventually? Kill all child threads first? log_info "Client requested the websocket be closed." socket.close return end fin = byte1 & 0b10000000 opcode = byte1 & 0b00001111 msg_size = byte2 & 0b01111111 is_masked = byte2 & 0b10000000 unless fin and opcode == 1 and is_masked and msg_size < MAX_WS_FRAME_SIZE log_error "Invalid websocket message received. #{byte1}-#{byte2}" puts socket.gets msg_size.times.map { socket.getbyte } # Read message from socket. return end # Get message mask = 4.times.map { socket.getbyte } msg = msg_size.times.map { socket.getbyte }.each_with_index.map { |byte, i| byte ^ mask[i % 4] }.pack('C*').force_encoding('utf-8').inspect msg[1..-2] # Remove quotation marks from message end |
#run_command_and_stream(socket, cmd_id) ⇒ Object
run_command_and_stream
Run a command and stream the stdout and stderr through the websocket.
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/easel/websocket.rb', line 62 def run_command_and_stream(socket, cmd_id) cmd = get_command cmd_id if cmd.nil? log_error "Client requested command ID #{cmd_id} be run, but that ID does not exist." return end Open3::popen3(cmd) do |stdin, stdout, stderr, cmd_thread| continue = true while ready_fds = IO.select([stdout, stderr])[0] ready_fds.each{ |fd| resp = fd.gets if resp.nil? continue = false break end if fd == stdout send_msg(socket, cmd_id, "OUT", resp, ) elsif fd == stderr send_msg(socket, cmd_id, "ERR", resp, ) else raise "Received output from popen3(#{cmd}) that was not via stdout or stderr." end } break unless continue end cmd_thread.join send_msg(socket, cmd_id, "FINISHED") end end |
#run_websocket(socket, initial_request) ⇒ Object
run_websocket
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
# File 'lib/easel/websocket.rb', line 24 def run_websocket(socket, initial_request) accept_connection(socket, initial_request[:fields][:Sec_WebSocket_Key][0..-3]) child_threads = {} loop { msg = receive_msg socket break if msg.nil? # The socket was closed by the client. case msg.split(":")[0] when "RUN" cmd_id = msg.match(/^RUN:(.*)$/)[1].to_i unless child_threads[cmd_id] child_threads[cmd_id] = Thread.new do run_command_and_stream(socket, cmd_id) child_threads[cmd_id] = nil end end when "STOP" cmd_id = msg.match(/^STOP:(.*)$/)[1].to_i unless child_threads[cmd_id].nil? child_threads[cmd_id].kill child_threads[cmd_id] = nil end else log_error "Received an unrecognized message over the websocket: #{msg}" end } end |
#send_frame(socket, fmsg) ⇒ Object
TODO: Figure out the proper frame size (MAX_WS_FRAME_SIZE).
168 169 170 171 |
# File 'lib/easel/websocket.rb', line 168 def send_frame(socket, fmsg) output = [0b10000001, fmsg.size, fmsg] socket.write output.pack("CCA#{fmsg.size}") end |
#send_msg(socket, cmd_id, msg_type, msg = nil) ⇒ Object
send_msg
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/easel/websocket.rb', line 164 def send_msg(socket, cmd_id, msg_type, msg=nil) # TODO: Figure out the proper frame size (MAX_WS_FRAME_SIZE). def send_frame(socket, fmsg) output = [0b10000001, fmsg.size, fmsg] socket.write output.pack("CCA#{fmsg.size}") end case msg_type when "OUT", "ERR" header = "#{cmd_id}:#{msg_type}:" if header.length > MAX_WS_FRAME_SIZE log_error "Message header '#{msg_type}' is too long. Msg: #{msg}." elsif msg.nil? log_error "Message of type '#{msg_type}' sent without a message." else if msg.length > MAX_WS_FRAME_SIZE - header.length msg_part_len = MAX_WS_FRAME_SIZE - header.length msg_parts = (0..(msg.length-1)/msg_part_len).map{ |i| msg[i*msg_part_len,msg_part_len] } msg_parts.each{ |part| send_frame(socket, header + part) } else send_frame(socket, header + msg) end end when "CLEAR", "FINISHED" to_send = "#{cmd_id}:#{msg_type}" if to_send.length > MAX_WS_FRAME_SIZE log_error "Message of type '#{msg_type}' is too long. Msg: #{to_send}." elsif !msg.nil? log_error "Message of type '#{msg_type}' passed a message. Msg: #{msg}." else send_frame(socket, to_send) end else log_error "Trying to send a websocket message with unrecognized type: #{msg_type}" end end |