Top Level Namespace

Constant Summary collapse

MAX_WS_FRAME_SIZE =

Key Variables

50.0

Instance Method Summary collapse

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_appObject

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_cssObject


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 = read_HTTP_message 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

#launchObject

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_serverObject


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_ARGVObject

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.banner = "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 read_HTTP_message socket
  message = []
  loop do
    line = socket.gets
    message << line
    if line == "\r\n"
      break
    end
  end


  request = {fields: {}}
  (request[:method], request[:url], request[:protocol]) = message[0].split(" ")

  message[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