vines-agent 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/conf/config.rb ADDED
@@ -0,0 +1,16 @@
1
+ # encoding: UTF-8
2
+
3
+ # This is the Vines agent configuration file. Restart the agent with
4
+ # 'vines-agent restart' after updating this file.
5
+
6
+ Vines::Agent::Config.configure do
7
+ # Set the logging level to debug, info, warn, error, or fatal. The debug
8
+ # level logs all XML sent and received by the agent.
9
+ log :info
10
+
11
+ domain 'wonderland.lit' do
12
+ upstream 'localhost', 5222
13
+ password 'secr3t'
14
+ download 'data'
15
+ end
16
+ end
@@ -0,0 +1,30 @@
1
+ # encoding: UTF-8
2
+
3
+ %w[
4
+ logger
5
+ blather
6
+ digest
7
+ etc
8
+ fiber
9
+ fileutils
10
+ json
11
+ ohai
12
+ session
13
+ slave
14
+
15
+ blather/client/client
16
+
17
+ vines/log
18
+ vines/daemon
19
+
20
+ vines/agent/version
21
+ vines/agent/agent
22
+ vines/agent/config
23
+ vines/agent/connection
24
+ vines/agent/shell
25
+
26
+ vines/agent/command/init
27
+ vines/agent/command/restart
28
+ vines/agent/command/start
29
+ vines/agent/command/stop
30
+ ].each {|f| require f }
@@ -0,0 +1,24 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ module Agent
5
+ # The main starting point for the Vines Agent process. Starts the
6
+ # EventMachine processing loop and registers the agent with the configured
7
+ # servers.
8
+ class Agent
9
+ include Vines::Log
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ end
14
+
15
+ def start
16
+ log.info('Vines agent started')
17
+ at_exit { log.fatal('Vines agent stopped') }
18
+ EM.run do
19
+ @config.start
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,55 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ module Agent
5
+ module Command
6
+ class Init
7
+ def run(opts)
8
+ raise 'vines-agent init <domain>' unless opts[:args].size == 1
9
+ domain = opts[:args].first.downcase
10
+ dir = File.expand_path(domain)
11
+ raise "Directory already initialized: #{domain}" if File.exists?(dir)
12
+ Dir.mkdir(dir)
13
+
14
+ FileUtils.cp_r(File.expand_path("../../../../../conf", __FILE__), dir)
15
+
16
+ data, log, pid = %w[data log pid].map do |sub|
17
+ File.join(dir, sub).tap {|subdir| Dir.mkdir(subdir) }
18
+ end
19
+
20
+ update_config(domain, File.expand_path('conf/config.rb', dir))
21
+ fix_perms(dir)
22
+
23
+ puts "Initialized agent directory: #{domain}"
24
+ puts "Run 'cd #{domain} && vines-agent start' to begin"
25
+ end
26
+
27
+ private
28
+
29
+ # The config.rb file contains the agent's password so restrict access
30
+ # to just the agent user.
31
+ def fix_perms(dir)
32
+ File.chmod(0600, File.expand_path('conf/config.rb', dir))
33
+ end
34
+
35
+ def update_config(domain, config)
36
+ text = File.read(config)
37
+ File.open(config, 'w') do |f|
38
+ replaced = text
39
+ .gsub('wonderland.lit', domain.downcase)
40
+ .gsub('secr3t', password)
41
+ f.write(replaced)
42
+ end
43
+ end
44
+
45
+ # Create a large, random password with which to authenticate the
46
+ # agent bot's JID.
47
+ def password
48
+ hash = Digest::SHA512.new
49
+ 1024.times { hash << rand.to_s }
50
+ hash.hexdigest
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ module Agent
5
+ module Command
6
+ class Restart
7
+ def run(opts)
8
+ Stop.new.run(opts)
9
+ Start.new.run(opts)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,30 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ module Agent
5
+ module Command
6
+ class Start
7
+ def run(opts)
8
+ raise 'vines-agent [--pid FILE] start' unless opts[:args].size == 0
9
+ require opts[:config]
10
+ agent = Vines::Agent::Agent.new(Config.instance)
11
+ daemonize(opts) if opts[:daemonize]
12
+ agent.start
13
+ end
14
+
15
+ private
16
+
17
+ def daemonize(opts)
18
+ daemon = Vines::Daemon.new(:pid => opts[:pid], :stdout => opts[:log],
19
+ :stderr => opts[:log])
20
+ if daemon.running?
21
+ raise "The vines agent is running as process #{daemon.pid}"
22
+ else
23
+ puts "The vines agent has started"
24
+ daemon.start
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,20 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ module Agent
5
+ module Command
6
+ class Stop
7
+ def run(opts)
8
+ raise 'vines-agent [--pid FILE] stop' unless opts[:args].size == 0
9
+ daemon = Vines::Daemon.new(:pid => opts[:pid])
10
+ if daemon.running?
11
+ daemon.stop
12
+ puts 'The vines agent has been shutdown'
13
+ else
14
+ puts 'The vines agent is not running'
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,106 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ module Agent
5
+
6
+ # A Config object is passed to the xmpp connections to give them access
7
+ # to configuration information like server host names, passwords, etc.
8
+ # This class provides the DSL methods used in the conf/config.rb file.
9
+ class Config
10
+ LOG_LEVELS = %w[debug info warn error fatal].freeze
11
+
12
+ @@instance = nil
13
+ def self.configure(&block)
14
+ @@instance = self.new(&block)
15
+ end
16
+
17
+ def self.instance
18
+ @@instance
19
+ end
20
+
21
+ def initialize(&block)
22
+ @domain = nil
23
+ instance_eval(&block)
24
+ raise "must define a domain" unless @domain
25
+ end
26
+
27
+ def log(level)
28
+ const = Logger.const_get(level.to_s.upcase) rescue nil
29
+ unless LOG_LEVELS.include?(level.to_s) && const
30
+ raise "log level must be one of: #{LOG_LEVELS.join(', ')}"
31
+ end
32
+ log = Class.new.extend(Vines::Log).log
33
+ log.progname = 'vines-agent'
34
+ log.level = const
35
+ end
36
+
37
+ def domain(name, &block)
38
+ raise 'multiple domains not allowed' if @domain
39
+ @domain = Domain.new(name, &block)
40
+ end
41
+
42
+ def start
43
+ @domain.start
44
+ end
45
+
46
+ class Domain
47
+ def initialize(name, &block)
48
+ @name, @password, @upstream = name, nil, []
49
+ instance_eval(&block) if block
50
+ validate_domain(@name)
51
+ raise "password required" unless @password && !@password.strip.empty?
52
+ raise "duplicate upstream connections not allowed" if @upstream.uniq!
53
+ unless @download
54
+ @download = File.expand_path('data')
55
+ FileUtils.mkdir_p(@download)
56
+ end
57
+ end
58
+
59
+ def password(password)
60
+ @password = password
61
+ end
62
+
63
+ def download(dir)
64
+ @download = File.expand_path(dir)
65
+ begin
66
+ FileUtils.mkdir_p(@download)
67
+ rescue
68
+ raise "can't create #{@download}"
69
+ end
70
+ end
71
+
72
+ def upstream(host, port)
73
+ raise 'host and port required for upstream connections' unless host && port
74
+ @upstream << {host: host, port: port}
75
+ end
76
+
77
+ def start
78
+ base = {
79
+ password: @password,
80
+ domain: @name,
81
+ download: @download
82
+ }
83
+ options = @upstream.map do |info|
84
+ base.clone.tap do |opts|
85
+ opts[:host] = info[:host]
86
+ opts[:port] = info[:port]
87
+ end
88
+ end
89
+ # no upstream so use DNS SRV discovery for host and port
90
+ options << base if options.empty?
91
+ options.each do |args|
92
+ Vines::Agent::Connection.new(args).start
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ # Prevent domains in config files that won't form valid JID's.
99
+ def validate_domain(name)
100
+ jid = Blather::JID.new(name)
101
+ raise "incorrect domain: #{name}" if jid.node || jid.resource
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,274 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ module Agent
5
+
6
+ # Connects the agent process to the chat server and provides service
7
+ # discovery and command execution abilities. Users are authorized against
8
+ # an access control list before being allowed to run commands.
9
+ class Connection
10
+ include Vines::Log
11
+
12
+ NS = 'http://getvines.com/protocol'.freeze
13
+ SYSTEMS = 'http://getvines.com/protocol/systems'.freeze
14
+
15
+ def initialize(options)
16
+ domain, password, host, port, download =
17
+ *options.values_at(:domain, :password, :host, :port, :download)
18
+
19
+ @permissions, @services, @sessions, @component = {}, {}, {}, nil
20
+ @ready = false
21
+
22
+ jid = Blather::JID.new(fqdn, domain, 'vines')
23
+ @stream = Blather::Client.setup(jid, password, host, port)
24
+
25
+ @stream.register_handler(:disconnected) do
26
+ log.info("Stream disconnected, reconnecting . . .")
27
+ EM::Timer.new(rand(16) + 5) do
28
+ self.class.new(options).start
29
+ end
30
+ true # prevent EM.stop
31
+ end
32
+
33
+ @stream.register_handler(:ready) do
34
+ # prevent handler called twice
35
+ unless @ready
36
+ log.info("Connected #{@stream.jid} agent to #{host}:#{port}")
37
+ @ready = true
38
+ startup
39
+ end
40
+ end
41
+
42
+ @stream.register_handler(:subscription, :request?) do |node|
43
+ # ignore, rather than refuse, requests from users lacking
44
+ # permissions, so agents are invisible to them
45
+ @stream.write(node.approve!) if valid_user?(node.from)
46
+ end
47
+
48
+ @stream.register_handler(:file_transfer) do |iq|
49
+ transfer_file(iq)
50
+ end
51
+
52
+ @stream.register_handler(:disco_info, :get?) do |node|
53
+ disco_info(node)
54
+ end
55
+
56
+ @stream.register_handler(:iq, '/iq[@type="set"]/ns:query', :ns => SYSTEMS) do |node|
57
+ update_permissions(node)
58
+ end
59
+
60
+ @stream.register_handler(:iq, '/iq[@type="get"]/ns:query', :ns => 'jabber:iq:version') do |node|
61
+ version(node)
62
+ end
63
+
64
+ @stream.register_handler(:message, :chat?, :body) do |msg|
65
+ process_message(msg)
66
+ end
67
+ end
68
+
69
+ def start
70
+ @stream.run
71
+ end
72
+
73
+ private
74
+
75
+ # After the bot connects to the chat server, discover the component, send
76
+ # our ohai system description data, and initialize permissions.
77
+ def startup
78
+ cb = proc do |component|
79
+ if component
80
+ log.info("Found vines component at #{component}")
81
+ @component = component
82
+ send_system_info
83
+ request_permissions
84
+ else
85
+ log.info("Vines component not found, rediscovering . . .")
86
+ EM::Timer.new(10) { discover_component(&cb) }
87
+ end
88
+ end
89
+ discover_component(&cb)
90
+ end
91
+
92
+ def version(node)
93
+ return unless from_service?(node) || valid_user?(node.from)
94
+ iq = Blather::Stanza::Iq::Query.new(:result)
95
+ iq.id, iq.to = node.id, node.from
96
+ iq.query.add_child("<name>Vines Agent</name>")
97
+ iq.query.add_child("<version>#{VERSION}</version>")
98
+ @stream.write(iq)
99
+ end
100
+
101
+ def disco_info(node)
102
+ return unless from_service?(node) || valid_user?(node.from)
103
+ disco = Blather::Stanza::DiscoInfo.new(:result)
104
+ disco.id = node.id
105
+ disco.to = node.from
106
+ disco.identities = {
107
+ name: 'Vines Agent',
108
+ type: 'bot',
109
+ category: 'client'
110
+ }
111
+ disco.features = %w[
112
+ http://jabber.org/protocol/bytestreams
113
+ http://jabber.org/protocol/disco#info
114
+ http://jabber.org/protocol/si
115
+ http://jabber.org/protocol/si/profile/file-transfer
116
+ http://jabber.org/protocol/xhtml-im
117
+ jabber:iq:version
118
+ ]
119
+ @stream.write(disco)
120
+ end
121
+
122
+ def transfer_file(iq)
123
+ return unless from_service?(iq) || valid_user?(iq.from)
124
+ name, size = iq.si.file['name'], iq.si.file['size'].to_i
125
+ log.info("Receiving file: #{name}")
126
+ transfer = Blather::FileTransfer.new(@stream, iq)
127
+ file = absolute_path(download, name) rescue nil
128
+ if file
129
+ transfer.accept(Blather::FileTransfer::SimpleFileReceiver, file, size)
130
+ else
131
+ transfer.decline
132
+ end
133
+ end
134
+
135
+ def absolute_path(dir, name)
136
+ File.expand_path(name, dir).tap do |absolute|
137
+ raise 'path traversal' unless File.dirname(absolute) == dir
138
+ end
139
+ end
140
+
141
+ # Collect and send ohai system data for this machine back to the
142
+ # component.
143
+ def send_system_info
144
+ system = Ohai::System.new.tap do |sys|
145
+ sys.all_plugins
146
+ end
147
+ iq = Blather::Stanza::Iq::Query.new(:set).tap do |node|
148
+ node.to = @component
149
+ node.query.content = system.to_json
150
+ node.query.namespace = SYSTEMS
151
+ end
152
+ @stream.write(iq)
153
+ end
154
+
155
+ # Return the fully qualified domain name for this machine. This is used
156
+ # to determine the agent's JID.
157
+ def fqdn
158
+ system = Ohai::System.new
159
+ system.require_plugin('os')
160
+ system.require_plugin('hostname')
161
+ system.fqdn.downcase
162
+ end
163
+
164
+ # Use service discovery to find the JID of our Vines component. We ask the
165
+ # server for its list of components, then ask each component for it's info.
166
+ # The component broadcasting the http://getvines.com/protocol feature is our
167
+ # Vines service.
168
+ def discover_component
169
+ disco = Blather::Stanza::DiscoItems.new
170
+ disco.to = @stream.jid.domain
171
+ @stream.write_with_handler(disco) do |result|
172
+ items = result.error? ? [] : result.items
173
+ Fiber.new do
174
+ # use fiber instead of EM::Iterator until EM 1.0.0 release
175
+ found = items.find {|item| component?(item.jid) }
176
+ yield found ? found.jid : nil
177
+ end.resume
178
+ end
179
+ end
180
+
181
+ # Return true if this JID is the Vines component with which we need to
182
+ # communicate. This method suspends the Fiber that calls it in order to
183
+ # turn the disco#info requests synchronous.
184
+ def component?(jid)
185
+ fiber = Fiber.current
186
+ info = Blather::Stanza::DiscoInfo.new
187
+ info.to = jid
188
+ @stream.write_with_handler(info) do |reply|
189
+ features = reply.error? ? [] : reply.features
190
+ found = !!features.find {|f| f.var == NS }
191
+ fiber.resume(found)
192
+ end
193
+ Fiber.yield
194
+ end
195
+
196
+ # Download the list of unix user accounts and the JID's that are allowed
197
+ # to use them. This is used to determine if a change user command like
198
+ # +v user root+ is allowed.
199
+ def request_permissions
200
+ iq = Blather::Stanza::Iq::Query.new(:get).tap do |node|
201
+ node.to = @component
202
+ node.query['name'] = @stream.jid.node
203
+ node.query.namespace = SYSTEMS
204
+ end
205
+ @stream.write_with_handler(iq) do |reply|
206
+ update_permissions(reply) unless reply.error?
207
+ end
208
+ end
209
+
210
+ def update_permissions(node)
211
+ return unless node.from == @component
212
+ obj = JSON.parse(node.content) rescue {}
213
+ @permissions = obj['permissions'] || {}
214
+ @services = (obj['services'] || {}).map {|s| s['jid'] }
215
+ @sessions.values.each {|shell| shell.permissions = @permissions }
216
+ end
217
+
218
+ # Execute the incoming XMPP message as a shell command if the sender is
219
+ # allowed to execute commands on this agent as the requested user name.
220
+ def process_message(message)
221
+ bare, full = message.from.stripped.to_s, message.from.to_s
222
+ forward_to = nil
223
+
224
+ if from_service?(message)
225
+ jid = message.xpath('/message/ns:jid', 'ns' => NS).first
226
+ jid = Blather::JID.new(jid.content) rescue nil
227
+ return unless jid
228
+ bare, full = jid.stripped.to_s, jid.to_s
229
+ forward_to = full
230
+ end
231
+
232
+ return unless valid_user?(bare)
233
+ session = @sessions[full] ||= Shell.new(bare, @permissions)
234
+ session.run(message.body.strip) do |output|
235
+ @stream.write(reply(message, output, forward_to))
236
+ end
237
+ end
238
+
239
+ # Reply to the sender's message with the command's output. The component
240
+ # uses the thread attribute to pair command messages with their output
241
+ # replies.
242
+ def reply(message, body, forward_to)
243
+ Blather::Stanza::Message.new(message.from, body).tap do |node|
244
+ node << node.document.create_element('jid', forward_to, xmlns: NS) if forward_to
245
+ node.thread = message.thread if message.thread
246
+ node.xhtml = '<span style="font-family:Menlo,Courier,monospace;"></span>'
247
+ span = node.xhtml_node.elements.first
248
+ body.each_line do |line|
249
+ span.add_child(Nokogiri::XML::Text.new(line.chomp, span.document))
250
+ span.add_child(span.document.create_element('br'))
251
+ end
252
+ end
253
+ end
254
+
255
+ # Return true if the JID is allowed to run commands as a unix user
256
+ # account on the system. Return false if the agent doesn't have a
257
+ # permissions entry for this JID.
258
+ def valid_user?(jid)
259
+ jid = jid.stripped.to_s if jid.respond_to?(:stripped)
260
+ valid = !!@permissions.find {|unix, jids| jids.include?(jid) }
261
+ log.warn("Denied access to #{jid}") unless valid
262
+ valid
263
+ end
264
+
265
+ # Return true if the stanza was sent to the agent by a service JID to
266
+ # which the agent belongs. The agent will only reply to stanzas sent from
267
+ # users it trusts or from services of which it's a member. Stanzas
268
+ # received from untrusted JID's are ignored.
269
+ def from_service?(node)
270
+ @services.include?(node.from.stripped.to_s.downcase)
271
+ end
272
+ end
273
+ end
274
+ end