vines-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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