Skip to content

Commit 4e15e9c

Browse files
Bastian Schmidtekohl
andcommitted
Fixes #35270 - Support system image download
* Implement fetch and extract system image * Implement class for file extraction with isoinfo * Add capability for archive extraction * Separate logging and file writing tasks * Add additional API endpoint /tftp/system_image/ Co-Authored-By: Ewoud Kohl van Wijngaarden <[email protected]>
1 parent d8b56bf commit 4e15e9c

13 files changed

Lines changed: 276 additions & 5 deletions

config/settings.d/tftp.yml.example

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
# Can be true, false, or http/https to enable just one of the protocols
2+
# Can be true or false
33
:enabled: false
44

55
#:tftproot: /var/lib/tftpboot
@@ -13,3 +13,12 @@
1313
# Defines the default certificate action for certificate checking.
1414
# When false, the argument --no-check-certificate will be used.
1515
#:verify_server_cert: true
16+
17+
# Enable/Disable Smart Proxy support for managing boot images. Allows the Smart
18+
# Proxy to download, extract, and store system images. It becomes important when
19+
# automating the extraction process as it is done for the Ubuntu Autoinstall
20+
# procedure.
21+
#:enable_system_image: true
22+
23+
# Defines the default folder to provide system images.
24+
#:system_image_root: /var/lib/foreman-proxy/tftp/system_images

lib/proxy/archive_extract.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
module Proxy
2+
class ArchiveExtract < Proxy::Util::CommandTask
3+
include Util
4+
5+
SHELL_COMMAND = 'isoinfo'
6+
7+
def initialize(image_path, file_in_image, dst_path)
8+
args = [
9+
which(SHELL_COMMAND),
10+
# Print information from Rock Ridge extensions
11+
'-R',
12+
# Filename to read ISO-9660 image from
13+
'-i', image_path.to_s,
14+
# Extract specified file to stdout
15+
'-x', file_in_image.to_s
16+
]
17+
18+
super(args, nil, dst_path)
19+
end
20+
21+
def start
22+
lock = Proxy::FileLock.try_locking(File.join(File.dirname(@output), ".#{File.basename(@output)}.lock"))
23+
if lock.nil?
24+
false
25+
else
26+
super do
27+
Proxy::FileLock.unlock(lock)
28+
File.unlink(lock)
29+
end
30+
end
31+
end
32+
end
33+
end

lib/proxy/util.rb

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,19 @@ class CommandTask
1313
# stderr is redirected to proxy error log, stdout to proxy debug log
1414
# command can be either string or array (command + arguments)
1515
# input is passed into STDIN and must be string
16-
def initialize(command, input = nil)
16+
# output can be a string containing a file path. If this is the case,
17+
# output is not logged but written to this file.
18+
def initialize(command, input = nil, output = nil)
1719
@command = command
1820
@input = input
21+
@output = output
1922
end
2023

2124
def start(&ensured_block)
25+
@output.nil? ? spawn_logging_thread(&ensured_block) : spawn_output_thread(&ensured_block)
26+
end
27+
28+
def spawn_logging_thread(&ensured_block)
2229
# run the task in its own thread
2330
@task = Thread.new(@command, @input) do |cmd, input|
2431
status = nil
@@ -43,6 +50,26 @@ def start(&ensured_block)
4350
self
4451
end
4552

53+
def spawn_output_thread(&ensured_block)
54+
# run the task in its own thread
55+
@task = Thread.new(@command, @input, @output) do |cmd, input, file|
56+
status = nil
57+
Open3.pipeline_w(cmd, :out => file.to_s) do |stdin, thr|
58+
cmdline_string = Shellwords.escape(cmd.is_a?(Array) ? cmd.join(' ') : cmd)
59+
last_thr = thr[-1]
60+
logger.info "[#{last_thr.pid}] Started task #{cmdline_string}"
61+
stdin.write(input) if input
62+
stdin.close
63+
# call thr.value to wait for a Process::Status object.
64+
status = last_thr.value
65+
end
66+
status ? status.exitstatus : $CHILD_STATUS
67+
ensure
68+
yield if block_given?
69+
end
70+
self
71+
end
72+
4673
# wait for the task to finish and get the subprocess return code
4774
def join
4875
@task.value

lib/smart_proxy_for_testing.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
require 'proxy/dependency_injection'
1111
require 'proxy/util'
1212
require 'proxy/http_download'
13+
require 'proxy/archive_extract'
1314
require 'proxy/helpers'
1415
require 'proxy/memory_store'
1516
require 'proxy/plugin_validators'

lib/smart_proxy_main.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
require 'proxy/dependency_injection'
1515
require 'proxy/util'
1616
require 'proxy/http_download'
17+
require 'proxy/archive_extract'
1718
require 'proxy/helpers'
1819
require 'proxy/memory_store'
1920
require 'proxy/plugin_validators'

modules/tftp/http_config.ru

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
require 'tftp/tftp_api'
2+
require 'tftp/tftp_system_image_api'
23

34
map "/tftp" do
45
run Proxy::TFTP::Api
56
end
7+
8+
map "/tftp/system_image" do
9+
run Proxy::TFTP::SystemImageApi
10+
end

modules/tftp/server.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,64 @@ def pxeconfig_file(mac)
150150
end
151151
end
152152

153+
def self.fetch_system_image(image_dst, url, files, tftp_path)
154+
# Build paths, verify parameter do not contain ".." (switch folder), and check existing files
155+
image_root = Pathname.new(Proxy::TFTP::Plugin.settings.system_image_root).cleanpath
156+
image_path = Pathname.new(File.expand_path(image_dst, image_root)).cleanpath
157+
tftproot = Pathname.new(Proxy::TFTP::Plugin.settings.tftproot).cleanpath
158+
raise_error_on_prohibited_path(image_root, image_path, image_dst)
159+
file_exists = File.exist? image_path
160+
extr_file_map = {}
161+
files.each do |file|
162+
extr_filename = boot_filename(tftp_path, file)
163+
extr_file_path = Pathname.new(File.expand_path(extr_filename, tftproot)).cleanpath
164+
raise_error_on_prohibited_path(tftproot, extr_file_path, file)
165+
file_exists = false unless File.exist? extr_file_path
166+
extr_file_map[file] = extr_file_path
167+
end
168+
169+
if file_exists
170+
200 # Return 200 if all files exist already
171+
else
172+
fetch_system_image_worker(url, image_path, extr_file_map)
173+
202 # Return 202 if download process was triggered
174+
end
175+
end
176+
177+
def self.fetch_system_image_worker(url, image_path, extr_file_map)
178+
lock_file = ".#{File.basename(image_path.sub_ext(''))}.lock"
179+
# Lock
180+
image_path.parent.mkpath
181+
lock = Proxy::FileLock.try_locking(File.join(File.dirname(image_path), lock_file))
182+
if lock.nil?
183+
raise IOError.new, "System image download and extraction is still in progress"
184+
end
185+
186+
Thread.new(lock, url, image_path, extr_file_map) do |t_lock, t_url, t_image_path, t_extr_file_map|
187+
# Wait for download completion
188+
download_task = choose_protocol_and_fetch(t_url, t_image_path)
189+
if download_task.is_a?(FalseClass)
190+
logger.error "TFTP image download error: Is another process downloading it already?"
191+
Thread.stop
192+
end
193+
unless download_task.join == 0
194+
logger.error "TFTP image download error: Task did not complete"
195+
Thread.stop
196+
end
197+
198+
t_extr_file_map.each do |file_in_image, extr_file|
199+
# Create destination directory and extract file from iso
200+
extr_file.parent.mkpath
201+
extract_task = ::Proxy::ArchiveExtract.new(t_image_path, file_in_image, extr_file).start
202+
logger.error "TFTP image file extraction error: #{file_in_image} => #{extr_file}" unless extract_task.join == 0
203+
end
204+
ensure
205+
# Unlock
206+
Proxy::FileLock.unlock(t_lock)
207+
File.unlink(t_lock)
208+
end
209+
end
210+
153211
def self.fetch_boot_file(dst, src)
154212
filename = boot_filename(dst, src)
155213
destination = Pathname.new(File.expand_path(filename, Proxy::TFTP::Plugin.settings.tftproot)).cleanpath
@@ -180,4 +238,10 @@ def self.boot_filename(dst, src)
180238
# Do not append a '-' if the dst is a directory path
181239
dst.end_with?('/') ? dst + src.split("/")[-1] : dst + '-' + src.split("/")[-1]
182240
end
241+
242+
def self.raise_error_on_prohibited_path(base_path, relative_path, error_parameter)
243+
if relative_path.expand_path.relative_path_from(base_path).to_s.start_with?('..')
244+
raise "File to extract from image contains up-directory: #{error_parameter}"
245+
end
246+
end
183247
end

modules/tftp/tftp_api.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ def create_default(variant)
3434
end
3535
end
3636

37+
post "/fetch_system_image" do
38+
log_halt(400, "TFTP: Wrong input parameters given.") unless [params[:path], params[:url], params[:files], params[:tftp_path]].all?
39+
40+
begin
41+
Proxy::TFTP.fetch_system_image(params[:path], params[:url], params[:files], params[:tftp_path]) # differentiates between 200 and 202
42+
rescue IOError
43+
log_halt(423, "TFTP: Image download process is running")
44+
rescue => error
45+
log_halt(500, "TFTP: Failed to fetch system file: #{error.message}")
46+
end
47+
end
48+
3749
post "/fetch_boot_file" do
3850
log_halt(400, "TFTP: Failed to fetch boot file: ") { Proxy::TFTP.fetch_boot_file(params[:prefix], params[:path]) }
3951
end

modules/tftp/tftp_plugin.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@ class Plugin < ::Proxy::Plugin
44

55
rackup_path File.expand_path("http_config.ru", __dir__)
66

7+
load_programmable_settings do |settings|
8+
settings[:http_port] = ::Proxy::Settings::Plugin.http_enabled?(settings[:enabled]) ? Proxy::SETTINGS.http_port : nil
9+
settings
10+
end
11+
712
default_settings :tftproot => '/var/lib/tftpboot',
813
:tftp_connect_timeout => 10,
9-
:verify_server_cert => true
14+
:verify_server_cert => true,
15+
:enable_system_image => true,
16+
:system_image_root => '/var/lib/foreman-proxy/tftp/system_images'
1017
validate :verify_server_cert, boolean: true
1118

19+
# Expose automatic iso handling capability
20+
capability -> { settings[:enable_system_image] ? 'system_image' : nil }
21+
1222
expose_setting :tftp_servername
23+
expose_setting :http_port
1324
end
1425
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
module Proxy::TFTP
2+
class SystemImageApi < ::Sinatra::Base
3+
helpers ::Proxy::Helpers
4+
5+
get "/*" do
6+
file = Pathname.new(params[:splat].first).cleanpath
7+
root = Pathname.new(Proxy::TFTP::Plugin.settings.system_image_root).expand_path.cleanpath
8+
joined_path = File.join(root, file)
9+
log_halt(404, "Not found") unless File.exist?(joined_path)
10+
real_file = Pathname.new(joined_path).realpath
11+
log_halt(403, "Invalid or empty path") unless real_file.fnmatch?("#{root}/**")
12+
log_halt(403, "Directory listing not allowed") if File.directory?(real_file)
13+
log_halt(503, "Not a regular file") unless File.file?(real_file)
14+
send_file real_file
15+
end
16+
end
17+
end

0 commit comments

Comments
 (0)