xolo-server 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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +177 -0
  3. data/README.md +7 -0
  4. data/bin/xoloserver +106 -0
  5. data/data/com.pixar.xoloserver.plist +29 -0
  6. data/data/uninstall-pkgs-by-id.zsh +103 -0
  7. data/lib/xolo/server/app.rb +133 -0
  8. data/lib/xolo/server/command_line.rb +216 -0
  9. data/lib/xolo/server/configuration.rb +739 -0
  10. data/lib/xolo/server/constants.rb +70 -0
  11. data/lib/xolo/server/helpers/auth.rb +257 -0
  12. data/lib/xolo/server/helpers/client_data.rb +415 -0
  13. data/lib/xolo/server/helpers/file_transfers.rb +265 -0
  14. data/lib/xolo/server/helpers/jamf_pro.rb +156 -0
  15. data/lib/xolo/server/helpers/log.rb +97 -0
  16. data/lib/xolo/server/helpers/maintenance.rb +401 -0
  17. data/lib/xolo/server/helpers/notification.rb +145 -0
  18. data/lib/xolo/server/helpers/pkg_signing.rb +141 -0
  19. data/lib/xolo/server/helpers/progress_streaming.rb +252 -0
  20. data/lib/xolo/server/helpers/title_editor.rb +92 -0
  21. data/lib/xolo/server/helpers/titles.rb +145 -0
  22. data/lib/xolo/server/helpers/versions.rb +160 -0
  23. data/lib/xolo/server/log.rb +286 -0
  24. data/lib/xolo/server/mixins/changelog.rb +315 -0
  25. data/lib/xolo/server/mixins/title_jamf_access.rb +1668 -0
  26. data/lib/xolo/server/mixins/title_ted_access.rb +519 -0
  27. data/lib/xolo/server/mixins/version_jamf_access.rb +1541 -0
  28. data/lib/xolo/server/mixins/version_ted_access.rb +373 -0
  29. data/lib/xolo/server/object_locks.rb +102 -0
  30. data/lib/xolo/server/routes/auth.rb +89 -0
  31. data/lib/xolo/server/routes/jamf_pro.rb +89 -0
  32. data/lib/xolo/server/routes/maint.rb +174 -0
  33. data/lib/xolo/server/routes/title_editor.rb +71 -0
  34. data/lib/xolo/server/routes/titles.rb +285 -0
  35. data/lib/xolo/server/routes/uploads.rb +93 -0
  36. data/lib/xolo/server/routes/versions.rb +261 -0
  37. data/lib/xolo/server/routes.rb +168 -0
  38. data/lib/xolo/server/title.rb +1143 -0
  39. data/lib/xolo/server/version.rb +902 -0
  40. data/lib/xolo/server.rb +205 -0
  41. data/lib/xolo-server.rb +8 -0
  42. metadata +243 -0
@@ -0,0 +1,145 @@
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 Server
14
+
15
+ module Helpers
16
+
17
+ # constants and methods for working with Xolo Titles on the server
18
+ module Titles
19
+
20
+ # Module Methods
21
+ #######################
22
+ #######################
23
+
24
+ # when this module is included
25
+ def self.included(includer)
26
+ Xolo.verbose_include includer, self
27
+ end
28
+
29
+ # Instance Methods
30
+ #######################
31
+ ######################
32
+
33
+ # A list of all known titles
34
+ # @return [Array<String>]
35
+ ############
36
+ def all_titles
37
+ Xolo::Server::Title.all_titles
38
+ end
39
+
40
+ # A an array of all server titles as Title objects
41
+ # @return [Array<Xolo::Server::Title>]
42
+ ############
43
+ def all_title_objects
44
+ all_titles.map { |t| instantiate_title t }
45
+ end
46
+
47
+ # Instantiate a Server::Title with access to the Sinatra app instance,
48
+ #
49
+ # If given a string, use it with .load to read the title from disk
50
+ #
51
+ # If given a Hash, use it with .new to instantiate fresh
52
+ #
53
+ # In all cases, set the session, to use for logging
54
+ # (the reason this method exists)
55
+ #
56
+ # @param data [Hash, String] hash to use with .new or name to use with .load
57
+ #
58
+ # @return [Xolo::Server::Title]
59
+ #################
60
+ def instantiate_title(data)
61
+ title =
62
+ case data
63
+ when Hash
64
+ Xolo::Server::Title.new data
65
+
66
+ when String
67
+ halt_on_missing_title data
68
+ Xolo::Server::Title.load data
69
+
70
+ else
71
+ msg = 'Invalid data to instantiate a Xolo::Server::Title'
72
+ log_error msg
73
+
74
+ halt 400, { status: 400, error: msg }
75
+ end
76
+
77
+ title.server_app_instance = self
78
+ title
79
+ end
80
+
81
+ # Halt 404 if a title doesn't exist
82
+ # @pararm [String] The title of a Title
83
+ # @return [void]
84
+ ##################
85
+ def halt_on_missing_title(title)
86
+ return if all_titles.include? title
87
+
88
+ msg = "Title '#{title}' does not exist."
89
+ log_debug "ERROR: #{msg}"
90
+ halt 404, { status: 404, error: msg }
91
+ end
92
+
93
+ # Halt 409 if a title already exists
94
+ # @pararm [String] The title of a Title
95
+ # @return [void]
96
+ ##################
97
+ def halt_on_existing_title(title)
98
+ return unless all_titles.include? title
99
+
100
+ msg = "Title '#{title}' already exists."
101
+ log_debug "ERROR: #{msg}"
102
+ halt 409, { status: 409, error: msg }
103
+ end
104
+
105
+ # Halt 409 if a title is locked
106
+ # @pararm [String] The title of a Title
107
+ # @return [void]
108
+ ##################
109
+ def halt_on_locked_title(title)
110
+ return unless Xolo::Server::Title.locked? title
111
+
112
+ msg = "Title '#{title}' is being modified by another admin. Try again later."
113
+ log_debug "ERROR: #{msg}"
114
+ halt 409, { status: 409, error: msg }
115
+ end
116
+
117
+ # when freezing or thawing, are we dealing with a list of computers
118
+ # or a list of users, for whom we need to get all their assigned computers
119
+ # @param targets [Array<String>] a list of computers or usernames
120
+ # @param users [Boolean] is the list usernames? if not, its computers
121
+ # @return [Array<String>] a list of computers to freeze or thaw
122
+ ########################
123
+ def expand_freeze_thaw_targets(targets:, users:)
124
+ return targets unless users
125
+
126
+ log_debug "Expanding user list to freeze or thaw: #{targets}"
127
+
128
+ expanded_targets = []
129
+ all_users = Jamf::User.all_names(cnx: jamf_cnx)
130
+ targets.each do |user|
131
+ next unless all_users.include? user
132
+
133
+ expanded_targets += Jamf::User.fetch(name: user, cnx: jamf_cnx).computers.map { |c| c[:name] }
134
+ end
135
+
136
+ expanded_targets.uniq
137
+ end
138
+
139
+ end # TitleEditor
140
+
141
+ end # Helpers
142
+
143
+ end # Server
144
+
145
+ end # module Xolo
@@ -0,0 +1,160 @@
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 Server
14
+
15
+ module Helpers
16
+
17
+ # constants and methods for working with Xolo Versions on the server
18
+ # As a helper, these are available in the App instance context, for all
19
+ # routes and views
20
+ module Versions
21
+
22
+ # Module Methods
23
+ #######################
24
+ #######################
25
+
26
+ # when this module is included
27
+ def self.included(includer)
28
+ Xolo.verbose_include includer, self
29
+ end
30
+
31
+ # Instance Methods
32
+ #######################
33
+ ######################
34
+
35
+ # The default minimum OS for versions
36
+ # # @return [String] the default minimum OS for versions
37
+ #############
38
+ def default_min_os
39
+ if Xolo::Server.config.default_min_os.pix_empty?
40
+ Xolo::Core::BaseClasses::Version::DEFAULT_MIN_OS.to_s
41
+ else
42
+ Xolo::Server.config.default_min_os.to_s
43
+ end
44
+ end
45
+
46
+ # A list of all known versions of a title
47
+ # @return [Array<String>]
48
+ ############
49
+ def all_versions(title)
50
+ Xolo::Server::Version.all_versions(title)
51
+ end
52
+
53
+ # A list of all known versions of a title
54
+ # @return [Array<Xolo::Server::Version>]
55
+ ############
56
+ def all_version_instances(title)
57
+ all_versions(title).map { |v| instantiate_version title: title, version: v }
58
+ end
59
+
60
+ # Instantiate a Server::Version, with access to the Sinata App instance
61
+ #
62
+ # If given a Hash, use it with .new to instantiate fresh
63
+ #
64
+ # If given a title and version, the title may be a String, the title's
65
+ # title, or a Xolo::Server::Title object. If it's a Xolo::Server::Title
66
+ # that object will be used as the title_object for the version object.
67
+ #
68
+ # In all cases, set the server_app_instance in the new version onject
69
+ # to use for access from the version object to the Sinatra App instance
70
+ # for the session and api connection objects
71
+ #
72
+ # @param data [Hash] hash to use with .new
73
+ # @param title [String, Xolo::Server::Title] title to use with .load
74
+ # @param version [String] version to use with .load
75
+ #
76
+ # @return [Xolo::Server::Version]
77
+ #################
78
+ def instantiate_version(data = nil, title: nil, version: nil)
79
+ title_obj = nil
80
+
81
+ if data
82
+ title = data[:title]
83
+ elsif title.is_a?(Xolo::Server::Title)
84
+ title_obj = title
85
+ title = title_obj.title
86
+ end
87
+
88
+ vers =
89
+ if data.is_a? Hash
90
+ Xolo::Server::Version.new data
91
+
92
+ elsif title && version
93
+ halt_on_missing_title title
94
+ halt_on_missing_version title, version
95
+
96
+ Xolo::Server::Version.load title, version
97
+ else
98
+ msg = 'Invalid data to instantiate a Xolo::Server::Version'
99
+ log_error msg
100
+ halt 400, { status: 400, error: msg }
101
+ end
102
+
103
+ vers.title_object = title_obj || instantiate_title(title)
104
+ vers.server_app_instance = self
105
+ vers
106
+ end
107
+
108
+ # Halt 404 if a version doesn't exist
109
+ # @pararm [String] The title of a Title
110
+ # @return [void]
111
+ ##################
112
+ def halt_on_missing_version(title, version)
113
+ return if all_versions(title).include? version
114
+
115
+ msg = "No version '#{version}' for title '#{title}'."
116
+ log_debug "ERROR: #{msg}"
117
+ resp_body = @streaming_now ? msg : { status: 404, error: msg }
118
+
119
+ # don't halt if we're streaming, just error out
120
+ raise Xolo::NoSuchItemError, msg if @streaming_now
121
+
122
+ halt 404, resp_body
123
+ end
124
+
125
+ # Halt 409 if a title already exists
126
+ # @pararm [String] The title of a Title
127
+ # @return [void]
128
+ ##################
129
+ def halt_on_existing_version(title, version)
130
+ return unless all_versions(title).include? version
131
+
132
+ msg = "Version '#{version}' of title '#{title}' already exists."
133
+ log_debug "ERROR: #{msg}"
134
+ resp_body = @streaming_now ? msg : { status: 409, error: msg }
135
+
136
+ # don't halt if we're streaming, just error out
137
+ raise Xolo::NoSuchItemError, msg if @streaming_now
138
+
139
+ halt 409, resp_body
140
+ end
141
+
142
+ # Halt 409 if a version is locked
143
+ # @pararm [String] The title of a Title
144
+ # @return [void]
145
+ ##################
146
+ def halt_on_locked_version(title, version)
147
+ return unless Xolo::Server::Version.locked? title, version
148
+
149
+ msg = "Version '#{version}' of title '#{title}' is being modified by another admin. Try again later."
150
+ log_debug "ERROR: #{msg}"
151
+ halt 409, { status: 409, error: msg }
152
+ end
153
+
154
+ end # TitleEditor
155
+
156
+ end # Helpers
157
+
158
+ end # Server
159
+
160
+ end # module Xolo
@@ -0,0 +1,286 @@
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 Server
14
+
15
+ # constants and methods for writing to the log
16
+ module Log
17
+
18
+ # when this module is included
19
+ def self.included(includer)
20
+ Xolo.verbose_include includer, self
21
+ end
22
+
23
+ DATETIME_FORMAT = '%F %T'
24
+
25
+ # Easier for reporting level changes - the index is the severity number
26
+ LEVELS = %w[DEBUG INFO WARN ERROR FATAL UNKNOWN].freeze
27
+
28
+ # THe log format - we use 'progname' to hole the
29
+ # session object, if there is one.
30
+ #
31
+ LOG_FORMATTER = proc do |severity, datetime, progname, msg|
32
+ progname &&= " #{progname}"
33
+ "#{datetime.strftime DATETIME_FORMAT} #{severity}#{progname}: #{msg}\n"
34
+ end
35
+
36
+ LOG_DIR = Xolo::Server::Constants::DATA_DIR + 'logs'
37
+ LOG_FILE_NAME = 'xoloserver.log'
38
+ LOG_FILE = LOG_DIR + LOG_FILE_NAME
39
+
40
+ # log rotation, compression, pruning
41
+
42
+ # keep this many days of logs total
43
+ DFT_LOG_DAYS_TO_KEEP = 30
44
+
45
+ # compress the log files when they are this many days old
46
+ DFT_LOG_COMPRESS_AFTER_DAYS = 7
47
+
48
+ # shell out to bzip2 - no need for another gem requirement.
49
+ BZIP2 = '/usr/bin/bzip2'
50
+
51
+ # compressed files have this extension
52
+ BZIPPED_EXTNAME = '.bz2'
53
+
54
+ # This file is touched after each log rotation run.
55
+ # Its mtime is used to decide if we should to it again
56
+ LAST_ROTATION_FILE = LOG_DIR + 'last_log_rotation'
57
+
58
+ # When we rotate the main log to .0 we repoint the logger to this
59
+ # temp filename, then rename the main log to .0, then rename this
60
+ # temp file to the main log. This way the logger never has to be
61
+ # reinitialized.
62
+ TEMP_LOG_FILE = LOG_DIR + 'temp_xoloserver.log'
63
+
64
+ # top-level logger for the server as a whole
65
+ #############################################
66
+ def self.logger
67
+ return @logger if @logger
68
+
69
+ LOG_DIR.mkpath
70
+ LOG_FILE.pix_touch
71
+
72
+ @logger = Logger.new(
73
+ LOG_FILE,
74
+ datetime_format: DATETIME_FORMAT,
75
+ formatter: LOG_FORMATTER
76
+ )
77
+
78
+ @logger
79
+ end
80
+
81
+ # A mutex for the log rotation process
82
+ #
83
+ # TODO: use Concrrent Ruby instead of Mutex
84
+ #
85
+ # @return [Mutex] the mutex
86
+ #####################
87
+ def self.rotation_mutex
88
+ @rotation_mutex ||= Mutex.new
89
+ end
90
+
91
+ # change log level of the server logger, new requests should inherit it
92
+ #############################################
93
+ # def self.set_level(level, user: :unknown)
94
+ # lvl_const = level.to_s.upcase.to_sym
95
+ # if Logger.constants.include? lvl_const
96
+ # lvl = Logger.const_get lvl_const
97
+ # logger.debug "changing log level to #{lvl_const} (#{lvl}) by #{user}"
98
+ # logger.level = lvl
99
+ # logger.info "log level changed to #{lvl_const} by #{user}"
100
+
101
+ # { loglevel: lvl_const }
102
+ # else
103
+ # { error: "Unknown level '#{level}', use one of: debug, info, warn, error, fatal, unknown" }
104
+ # end
105
+ # end
106
+
107
+ # Log rotation is done by a Concurrent::TimerTask, which checks every
108
+ # 5 minutes to see if it should do anything.
109
+ # It will only do a rotation if the current time is in the midnight hour
110
+ # (00:00 - 00:59) AND if the last rotation was more than 23 hours ago.
111
+ #
112
+ # @return [Concurrent::TimerTask] the timed task to do log rotation
113
+ def self.log_rotation_timer_task
114
+ return @log_rotation_timer_task if @log_rotation_timer_task
115
+
116
+ @log_rotation_timer_task =
117
+ Concurrent::TimerTask.new(execution_interval: 300) { rotate_logs }
118
+
119
+ logger.info 'Created Concurrent::TimerTask for nightly log rotation.'
120
+ @log_rotation_timer_task
121
+ end
122
+
123
+ # rotate the log file, keeping some number of old ones and possibly
124
+ # compressing them after some time.
125
+ # The log rotation built into ruby's Logger class doesn't allow this kind of
126
+ # behavior, And I don't want to require yet another 3rd party gem.
127
+ #
128
+ # @param force [Boolean] force rotation even if not midnight
129
+ #
130
+ # @return [void]
131
+ ###############################
132
+ def self.rotate_logs(force: false)
133
+ return unless rotate_logs_now?(force: force)
134
+
135
+ # TODO: Use Concurrent ruby rather than this instance variable
136
+ mutex = Xolo::Server::Log.rotation_mutex
137
+
138
+ if mutex.locked?
139
+ log_warn 'Log rotation already running, skipping this run'
140
+ return
141
+ end
142
+
143
+ mutex.lock
144
+
145
+ logger.info 'Starting Log Rotation'
146
+
147
+ # how many to keep?
148
+ days_to_keep = Xolo::Server.config.log_days_to_keep || Xolo::Server::Log::DFT_LOG_DAYS_TO_KEEP
149
+
150
+ # when to compress?
151
+ compress_after = Xolo::Server.config.log_compress_after_days || Xolo::Server::Log::DFT_LOG_COMPRESS_AFTER_DAYS
152
+
153
+ # no compression if compress_after is less than zero or greater/equal to days to keeps
154
+ compress_after = nil if compress_after.negative? || compress_after >= days_to_keep
155
+
156
+ # work down thru the number to keep
157
+ days_to_keep.downto(1) do |age|
158
+ # the previous file to the newly aged file
159
+ # if we're moving to a newly aged .3, this is .2
160
+ prev_age = age - 1
161
+
162
+ # rename a previous file to n+1, e.g. file.2 becomes file.3
163
+ # This will overwrite an existing new_file, which is how we
164
+ # delete the oldest
165
+ prev_file = LOG_DIR + "#{LOG_FILE_NAME}.#{prev_age}"
166
+ new_file = LOG_DIR + "#{LOG_FILE_NAME}.#{age}"
167
+ if prev_file.file?
168
+ logger.info "Moving log file #{prev_file.basename} => #{new_file.basename}"
169
+ prev_file.rename new_file
170
+ end
171
+
172
+ # Do the same for any already compressed files
173
+ prev_compressed_file = LOG_DIR + "#{LOG_FILE_NAME}.#{prev_age}#{BZIPPED_EXTNAME}"
174
+ new_compressed_file = LOG_DIR + "#{LOG_FILE_NAME}.#{age}#{BZIPPED_EXTNAME}"
175
+ if prev_compressed_file.file?
176
+ logger.info "Moving log file #{prev_compressed_file.basename} => #{new_compressed_file.basename}"
177
+ prev_compressed_file.rename new_compressed_file
178
+ end
179
+
180
+ next unless compress_after
181
+
182
+ # compress the one we just moved if we should
183
+ compress_log(new_file) if age >= compress_after && new_file.file?
184
+ end # downto
185
+
186
+ # now for the current logfile...
187
+ rotate_live_log compress_after&.zero?
188
+
189
+ # touch the last rotation file
190
+ LAST_ROTATION_FILE.pix_touch
191
+ rescue => e
192
+ logger.error "Error rotating logs: #{e}"
193
+ e.backtrace.each { |l| logger.error "..#{l}" }
194
+ ensure
195
+ Xolo::Server::Log.rotation_mutex.unlock if Xolo::Server::Log.rotation_mutex.owned?
196
+ end
197
+
198
+ # Rotate the current log file without losing any log entries
199
+ #
200
+ # @return [void]
201
+ ###############################
202
+ def self.rotate_live_log(compress_zero)
203
+ # first, delete any old tmp file
204
+ TEMP_LOG_FILE.delete if TEMP_LOG_FILE.file?
205
+
206
+ # then repoint the logger to the temp file
207
+ logger.reopen TEMP_LOG_FILE
208
+
209
+ # make sure it has data in it
210
+ logger.info 'Starting New Log'
211
+
212
+ # then rename the main log to .0
213
+ zero_file = LOG_DIR + "#{LOG_FILE_NAME}.0"
214
+ logger.info "Moving log file #{LOG_FILE_NAME} => #{zero_file.basename}"
215
+
216
+ LOG_FILE.rename zero_file
217
+
218
+ # then rename the temp file to the main log.
219
+ # The logger will still log to it because it holds a
220
+ # filehandle to it, and doesn't care about its name.
221
+ TEMP_LOG_FILE.rename LOG_FILE
222
+
223
+ # compress the zero file if we should
224
+ compress_log(zero_file) if compress_zero
225
+ end
226
+
227
+ # should we rotate the logs right now?
228
+ #
229
+ # @return [Boolean] true if we should rotate the logs
230
+ ###############################
231
+ def self.rotate_logs_now?(force: false)
232
+ return true if force
233
+
234
+ now = Time.now
235
+ # only during the midnight hour
236
+ return false unless now.hour.zero?
237
+
238
+ # only if the last rotation was more than 23 hrs ago
239
+ # if no rotation_file, assume 24 hrs ago.
240
+ rotation_file = Xolo::Server::Log::LAST_ROTATION_FILE
241
+ last_rotation = rotation_file.file? ? rotation_file.mtime : (now - (24 * 3600))
242
+
243
+ twenty_three_hrs_ago = now - (23 * 3600)
244
+
245
+ # less than (<) means its been more than 23 hrs
246
+ # since the last rotation was before 23 hrs ago.
247
+ last_rotation < twenty_three_hrs_ago
248
+ end
249
+
250
+ #########################
251
+ def self.compress_log(file)
252
+ zipout = `/usr/bin/bzip2 #{file.to_s.shellescape}`
253
+
254
+ if $CHILD_STATUS.success?
255
+ logger.info "Compressed log file: #{file}"
256
+ else
257
+ logger.error "Failed to compress log file: #{file}"
258
+ zipout.lines.each { |l| logger.error ".. #{l.chomp}" }
259
+ end # if success?
260
+ end
261
+
262
+ end # module Log
263
+
264
+ # Wrapper for Xolo::Server::Log.logger,
265
+ # also available as Xolo::Server.logger from anywhere.
266
+ # Within Sinatra routes and views, its available via the #logger instance method.
267
+ #########################################
268
+ def self.logger
269
+ Log.logger
270
+ end
271
+
272
+ # set the log level of the server logger
273
+ #########################################
274
+ def self.set_log_level(level, admin:)
275
+ # make sure the level is valid
276
+ raise ArgumentError, "Unknown log level '#{level}'" unless Log::LEVELS.include? level.to_s.upcase
277
+
278
+ # unknonwn always gets logged
279
+ logger.unknown "Setting log level to #{level} by #{admin}"
280
+
281
+ logger.level = level
282
+ end
283
+
284
+ end # Server
285
+
286
+ end # module Xolo