xolo-admin 1.0.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.
@@ -0,0 +1,191 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+ #
7
+
8
+ # frozen_string_literal: true
9
+
10
+ # main module
11
+ module Xolo
12
+
13
+ module Admin
14
+
15
+ # connection to the xolo server from xadm
16
+ module Connection
17
+
18
+ # Constants
19
+ ##############################
20
+ ##############################
21
+
22
+ TIMEOUT = 300
23
+ OPEN_TIMEOUT = 10
24
+
25
+ PING_ROUTE = '/ping'
26
+ PING_RESPONSE = 'pong'
27
+
28
+ LOGIN_ROUTE = '/auth/login'
29
+
30
+ # Module methods
31
+ ##############################
32
+ ##############################
33
+
34
+ # when this module is included
35
+ def self.included(includer)
36
+ Xolo.verbose_include includer, self
37
+ end
38
+
39
+ # Instance Methods
40
+ ##############################
41
+ ##############################
42
+
43
+ # Authenticate to the Xolo server and get a session token
44
+ # (maintained by Faraday via the Xolo::Admin::CookieJar middleware)
45
+ #
46
+ ##############
47
+ def login(test: false)
48
+ return if !test && cmd_details[:no_login]
49
+
50
+ hostname = config.hostname
51
+ admin = config.admin
52
+ pw = fetch_pw
53
+ xadm = Xolo::Admin::EXECUTABLE_FILENAME
54
+
55
+ raise Xolo::MissingDataError, "No xolo server hostname. Please run '#{xadm} config'" unless hostname
56
+ raise Xolo::MissingDataError, "No xolo admin username. Please run '#{xadm} config'" unless admin
57
+
58
+ payload = { admin: admin, password: pw }
59
+
60
+ payload[:proxy_admin] = global_opts[:proxy_admin] if global_opts[:proxy_admin]
61
+
62
+ # provide the hostname to make a persistent Faraday connection object
63
+ # so in the future we just call server_cnx with no hostname to get the same
64
+ # connection object
65
+ resp = server_cnx.post Xolo::Admin::Connection::LOGIN_ROUTE, payload
66
+
67
+ if resp.success?
68
+ @logged_in = true
69
+ return
70
+ end
71
+
72
+ case resp.status
73
+ when 401
74
+ raise Xolo::AuthenticationError, resp.body[:error]
75
+ else
76
+ raise Xolo::ServerError, "#{resp.status}: #{resp.body}"
77
+ end
78
+ rescue Faraday::UnauthorizedError
79
+ raise Xolo::AuthenticationError, 'Invalid username or password'
80
+ end
81
+
82
+ # @pararm host [String] an alternate hostname to use, defaults to config.hostname
83
+ #
84
+ # @return [URI] The server base URL
85
+ #################
86
+ def server_url(host: nil)
87
+ @server_url = nil if host
88
+ return @server_url if @server_url
89
+
90
+ @server_url = URI.parse "https://#{host || config.hostname}"
91
+ end
92
+
93
+ # A connection for requests without any file uploads
94
+ #
95
+ # None of our GET routes expected any request body, so it doesn't matter if
96
+ # its set to be JSON.
97
+ #
98
+ # For our POST routes that dont upload files (e.g. setloglevel), the request
99
+ # body, if any, will be JSON.
100
+ #
101
+ # @param host [String] The hostname of the Xolo server. Must be provided the
102
+ # first time this is called (usually for logging in)
103
+ #
104
+ # @return [Faraday::Connection]
105
+ ##################################
106
+ def server_cnx(host: nil)
107
+ @server_cnx = nil if host
108
+ return @server_cnx if @server_cnx
109
+
110
+ @server_cnx = Faraday.new(server_url(host: host)) do |cnx|
111
+ cnx.options[:timeout] = TIMEOUT
112
+ cnx.options[:open_timeout] = OPEN_TIMEOUT
113
+
114
+ cnx.request :json
115
+
116
+ cnx.use Xolo::Admin::CookieJar
117
+
118
+ cnx.response :json, parser_options: { symbolize_names: true }
119
+ cnx.response :raise_error
120
+
121
+ cnx.adapter :net_http
122
+ end
123
+
124
+ # @server_cnx.headers['X-Proxy-Admin'] = global_opts[:proxy_admin] if global_opts[:proxy_admin]
125
+ end
126
+
127
+ # A connection for responses that stream the progress of a long server
128
+ # process.
129
+ #
130
+ # @param host [String] The hostname of the Xolo server. Must be provided the
131
+ # first time this is called (usually for logging in)
132
+ #
133
+ # @return [Faraday::Connection]
134
+ ##################################
135
+ def streaming_cnx(host: nil)
136
+ @streaming_cnx = nil if host
137
+ return @streaming_cnx if @streaming_cnx
138
+
139
+ # this proc every time we get a chunk, just print it to stdout
140
+ # and make note if any of them conain an error
141
+ streaming_proc = proc do |chunk, _size, _env|
142
+ puts chunk
143
+ @streaming_error ||= chunk.include? STREAMING_OUTPUT_ERROR
144
+ end
145
+
146
+ req_opts = { on_data: streaming_proc }
147
+
148
+ @streaming_cnx = Faraday.new(server_url(host: host), request: req_opts) do |cnx|
149
+ cnx.options[:timeout] = TIMEOUT
150
+ cnx.options[:open_timeout] = OPEN_TIMEOUT
151
+ cnx.use Xolo::Admin::CookieJar
152
+ cnx.response :raise_error
153
+ cnx.adapter :net_http
154
+ end
155
+ end
156
+
157
+ # A connection for POST requests with file uploads
158
+ #
159
+ # The request body will be multipart/url-encoded
160
+ # and authentication is required
161
+ #
162
+ # @param host [String] The hostname of the Xolo server. Must be provided the
163
+ # first time this is called (usually for logging in)
164
+ #
165
+ # @return [Faraday::Connection]
166
+ ##################################
167
+ def upload_cnx(host: nil)
168
+ @upload_cnx = nil if host
169
+ return @upload_cnx if @upload_cnx
170
+
171
+ @upload_cnx = Faraday.new(server_url(host: host)) do |cnx|
172
+ cnx.options[:timeout] = TIMEOUT
173
+ cnx.options[:open_timeout] = OPEN_TIMEOUT
174
+
175
+ cnx.request :multipart
176
+ cnx.request :url_encoded
177
+
178
+ cnx.use Xolo::Admin::CookieJar
179
+
180
+ cnx.response :json, parser_options: { symbolize_names: true }
181
+ cnx.response :raise_error
182
+
183
+ cnx.adapter :net_http
184
+ end
185
+ end
186
+
187
+ end # module Connection
188
+
189
+ end # module Admin
190
+
191
+ end # module Xolo
@@ -0,0 +1,81 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+ #
7
+
8
+ # frozen_string_literal: true
9
+
10
+ # main module
11
+ module Xolo
12
+
13
+ module Admin
14
+
15
+ # Faraday Middleware for storing and sending the only cookie we
16
+ # use with the Xolo server.
17
+ #
18
+ # See https://lostisland.github.io/faraday/#/middleware/custom-middleware
19
+ #
20
+ class CookieJar < Faraday::Middleware
21
+
22
+ # Constants
23
+ ##########################
24
+ ##########################
25
+
26
+ COOKIE_HEADER = 'Cookie'
27
+ SET_COOKIE_HEADER = 'set-cookie'
28
+ SESSION_COOKIE_NAME = 'rack.session'
29
+ SESSION_COOKIE_EXPIRES_NAME = 'expires'
30
+
31
+ # Class Methods
32
+ ##########################
33
+ ##########################
34
+
35
+ class << self
36
+
37
+ # runtime storage for our single cookie
38
+ attr_accessor :session_cookie, :session_expires
39
+
40
+ end
41
+
42
+ # Instance Methods
43
+ ##########################
44
+ ##########################
45
+
46
+ # we only send back the rack.session cookie, as long as it
47
+ # exists and hasn't expired (it lasts an hour)
48
+ #####################
49
+ def on_request(env)
50
+ return unless self.class.session_cookie && self.class.session_expires
51
+
52
+ raise Xolo::InvalidTokenError, 'Server Session Expired' if Time.now > self.class.session_expires
53
+
54
+ env[:request_headers][COOKIE_HEADER] = "#{SESSION_COOKIE_NAME}=#{self.class.session_cookie}"
55
+ end
56
+
57
+ # The server only ever sends one cookie,
58
+ # and we only care about 2 values:
59
+ # rack.session, and expires
60
+ ####################
61
+ def on_complete(env)
62
+ raw_cookie = env[:response_headers][SET_COOKIE_HEADER]
63
+ return unless raw_cookie
64
+
65
+ tepid_cookie = raw_cookie.split(Xolo::SEMICOLON_SEP_RE)
66
+ tepid_cookie.each do |part|
67
+ name, value = part.split('=')
68
+ case name
69
+ when SESSION_COOKIE_NAME
70
+ self.class.session_cookie = value
71
+ when SESSION_COOKIE_EXPIRES_NAME
72
+ self.class.session_expires = Time.parse(value).localtime
73
+ end
74
+ end
75
+ end
76
+
77
+ end # module Converters
78
+
79
+ end # module Admin
80
+
81
+ end # module Xolo
@@ -0,0 +1,212 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+ #
7
+
8
+ # frozen_string_literal: true
9
+
10
+ # main module
11
+ module Xolo
12
+
13
+ module Admin
14
+
15
+ # Personal credentials for users of 'xadm', stored in the login keychain
16
+ #
17
+ module Credentials
18
+
19
+ # Constants
20
+ ##############################
21
+ ##############################
22
+
23
+ # The security command
24
+ SEC_COMMAND = '/usr/bin/security'
25
+
26
+ # exit status when the login keychain can't be accessed because we aren't in a GUI session
27
+ SEC_STATUS_NO_GUI_ERROR = 36
28
+
29
+ # exit status when the keychain password provided is incorrect
30
+ SEC_STATUS_AUTH_ERROR = 51
31
+
32
+ # exit status when the desired item isn't found in the keychain
33
+ SEC_STATUS_NOT_FOUND_ERROR = 44
34
+
35
+ # The 'kind' of item in the keychain
36
+ XOLO_CREDS_KIND = 'Xolo::Admin::Password'
37
+
38
+ # the Service for the generic 'Xolo::Admin::Credentials' keychain entry
39
+ XOLO_CREDS_SVC = 'com.pixar.xolo.password'
40
+
41
+ # the Label for the generic 'Xolo::Admin::Credentials' keychain entry
42
+ XOLO_CREDS_LBL = '"Xolo Admin Password"'
43
+
44
+ # Module methods
45
+ ##############################
46
+ ##############################
47
+
48
+ # when this module is included
49
+ def self.included(includer)
50
+ Xolo.verbose_include includer, self
51
+ end
52
+
53
+ # Instance Methods
54
+ ##########################
55
+ ##########################
56
+
57
+ # If the keychain is not accessible, prompt for the password
58
+ #
59
+ # @return [String] Get the admin's password from the login keychain
60
+ #
61
+ ##############################################
62
+ def fetch_pw
63
+ return config.data_from_command_file_or_string(config.pw, enforce_secure_mode: true) if config.no_gui
64
+
65
+ cmd = ['find-generic-password']
66
+ cmd << '-s'
67
+ cmd << XOLO_CREDS_SVC
68
+ cmd << '-l'
69
+ cmd << XOLO_CREDS_LBL
70
+ cmd << '-w'
71
+ run_security(cmd.map { |i| security_escape i }.join(' '))
72
+
73
+ # If we can't access the keychain, prompt for the password. This is usually
74
+ # when we're running in a non-GUI session, e.g. via ssh.
75
+ rescue Xolo::KeychainError
76
+ raise unless @security_exit_status.exitstatus == SEC_STATUS_NO_GUI_ERROR
77
+ raise unless STDOUT.isatty
78
+
79
+ question = "Keychain not accessible.\nPlease enter the xolo admin password for #{config.admin}: "
80
+ highline_cli.ask(question) do |q|
81
+ q.echo = false
82
+ end
83
+ end
84
+
85
+ # Store an item in the default keychain
86
+ #
87
+ # @param acct [String] The username for the password.
88
+ # xadm doesn't use this, it uses the admin name from the
89
+ # configuration. But the keychain item requires a value here.
90
+ #
91
+ # @param pw [String] The password to store
92
+ #
93
+ # @return [String] the location where the password is stored
94
+ ##############################################
95
+ def store_pw(acct, pw)
96
+ # delete the item first if its there
97
+ delete_pw
98
+
99
+ cmd = ['add-generic-password']
100
+ cmd << '-a'
101
+ cmd << acct
102
+ cmd << '-s'
103
+ cmd << XOLO_CREDS_SVC
104
+ cmd << '-w'
105
+ cmd << pw
106
+ cmd << '-l'
107
+ cmd << XOLO_CREDS_LBL
108
+ cmd << '-D'
109
+ cmd << XOLO_CREDS_KIND
110
+
111
+ run_security(cmd.map { |i| security_escape i }.join(' '))
112
+ end
113
+
114
+ # delete the xolo admin password from the login keychain
115
+ # @return [void]
116
+ ##############################################
117
+ def delete_pw
118
+ cmd = ['delete-generic-password']
119
+ cmd << '-s'
120
+ cmd << XOLO_CREDS_SVC
121
+ cmd << '-l'
122
+ cmd << XOLO_CREDS_LBL
123
+
124
+ run_security(cmd.map { |i| security_escape i }.join(' '))
125
+ rescue Xolo::NoSuchItemError
126
+ nil
127
+ rescue RuntimeError => e
128
+ raise e unless e.to_s == 'No matching keychain item was found'
129
+
130
+ nil
131
+ end
132
+
133
+ # Run the security command in interactive mode on a given keychain,
134
+ # passing in a subcommand and its arguments. so that they don't appear in the
135
+ # `ps` output
136
+ #
137
+ # @param cmd [String] the subcommand being passed to 'security' with
138
+ # all needed options. It will not be visible outide this process, so
139
+ # its OK to put passwords into the options.
140
+ #
141
+ # @return [String] the stdout of the 'security' command.
142
+ #
143
+ ######
144
+ def run_security(cmd)
145
+ output = Xolo::BLANK
146
+ errs = Xolo::BLANK
147
+
148
+ Open3.popen3("#{SEC_COMMAND} -i") do |stdin, stdout, stderr, wait_thr|
149
+ # pid = wait_thr.pid # pid of the started process.
150
+ stdin.puts cmd
151
+ stdin.close
152
+
153
+ output = stdout.read
154
+ errs = stderr.read
155
+
156
+ @security_exit_status = wait_thr.value # Process::Status object returned.
157
+ end
158
+ # exit 44 is 'The specified item could not be found in the keychain'
159
+ return output.chomp if @security_exit_status.success?
160
+
161
+ case @security_exit_status.exitstatus
162
+ when SEC_STATUS_AUTH_ERROR
163
+ raise Xolo::KeychainError, 'Problem accessing login keychain. Is it locked?'
164
+
165
+ when SEC_STATUS_NOT_FOUND_ERROR
166
+ raise Xolo::NoSuchItemError, "No xolo admin password. Please run 'xadm config'"
167
+
168
+ else
169
+ errs.chomp!
170
+ errs =~ /: returned\s+(-?\d+)$/
171
+ errnum = Regexp.last_match(1)
172
+ desc = errnum ? security_error_desc(errnum) : errs
173
+ desc ||= errs
174
+ raise Xolo::KeychainError, "#{desc.gsub("\n", '; ')}; exit status #{@security_exit_status.exitstatus}"
175
+ end # case
176
+ end # run_security
177
+
178
+ # use `security error` to get a description of an error number
179
+ ##############
180
+ def security_error_desc(num)
181
+ desc = `#{SEC_COMMAND} error #{num}`
182
+ return if desc.include?('unknown error')
183
+
184
+ desc.chomp.split(num).last
185
+ rescue StandardError
186
+ nil
187
+ end
188
+
189
+ # given a string, wrap it in single quotes and escape internal single quotes
190
+ # and backslashes so it can be used in the interactive 'security' command
191
+ #
192
+ # @param str[String] the string to escape
193
+ #
194
+ # @return [String] the escaped string
195
+ ###################
196
+ def security_escape(str)
197
+ # first escape backslashes
198
+ str = str.to_s.gsub '\\', '\\\\\\'
199
+
200
+ # then single quotes
201
+ str.gsub! "'", "\\\\'"
202
+
203
+ # if other things need escaping, add them here
204
+
205
+ "'#{str}'"
206
+ end # security_escape
207
+
208
+ end # module Prefs
209
+
210
+ end # module Admin
211
+
212
+ end # module Xolo
@@ -0,0 +1,81 @@
1
+ # Copyright 2025 Pixar
2
+ #
3
+ # Licensed under the terms set forth in the LICENSE.txt file available at
4
+ # at the root of this project.
5
+ #
6
+ #
7
+
8
+ # frozen_string_literal: true
9
+
10
+ # MonkeyPatch HighLine::Terminal#readline_read so that Readline
11
+ # lines can be case-insensitive, and have a prompt.
12
+ #
13
+ # To use a prompt, put it in the environtment variable 'XADM_HIGHLINE_READLINE_PROMPT'
14
+ #
15
+ # To make the readline completion case-insensitive, set the environment
16
+ # variable XADM_HIGHLINE_READLINE_CASE_INSENSITIVE to anything.
17
+ #
18
+ # This really only modifies the Regexp used for the completion_proc to make it
19
+ # case insensitive if desired (adding an 'i' to the end)
20
+ # and sets the prompt when calling Readline.readline
21
+ #
22
+ # TODO: Do this 'smartly' as with the monkeypatches in pixar-ruby-extensions
23
+ #
24
+ class HighLine
25
+
26
+ class Terminal
27
+
28
+ # Use readline to read one line
29
+ # @param question [HighLine::Question] question from where to get
30
+ # autocomplete candidate strings
31
+ #################################
32
+ def readline_read(question)
33
+ # prep auto-completion
34
+ unless question.selection.empty?
35
+ Readline.completion_proc = lambda do |str|
36
+ regex = ENV['XADM_HIGHLINE_READLINE_CASE_INSENSITIVE'] ? /\A#{Regexp.escape(str)}/i : /\A#{Regexp.escape(str)}/
37
+ question.selection.grep(regex)
38
+ end
39
+ end
40
+
41
+ # work-around ugly readline() warnings
42
+ old_verbose = $VERBOSE
43
+ $VERBOSE = nil
44
+
45
+ raw_answer = run_preserving_stty do
46
+ Readline.readline(ENV['XADM_HIGHLINE_READLINE_PROMPT'].to_s, true)
47
+ end
48
+
49
+ $VERBOSE = old_verbose
50
+
51
+ raw_answer
52
+ end
53
+
54
+ # Get one line from terminal using default #gets method.
55
+ ##############################
56
+ # def get_line_default(highline)
57
+ # raise EOFError, 'The input stream is exhausted.' if highline.track_eof? && highline.input.eof?
58
+
59
+ # highline.output.print "#{ENV['XADM_HIGHLINE_LINE_PROMPT']}" if ENV['XADM_HIGHLINE_LINE_PROMPT']
60
+
61
+ # highline.input.gets
62
+ # end
63
+
64
+ end # terminal
65
+
66
+ # # Deals with the task of "asking" a question
67
+ # class QuestionAsker
68
+
69
+ # alias ask_once_real ask_once
70
+
71
+ # # Gets just one answer, as opposed to #gather_answers
72
+ # #
73
+ # # @return [String] answer
74
+ # def ask_once
75
+ # @highline.output.print "#{ENV['XADM_HIGHLINE_LINE_PROMPT']}" if ENV['XADM_HIGHLINE_LINE_PROMPT']
76
+ # ask_once_real
77
+ # end
78
+
79
+ # end # question asker
80
+
81
+ end