From 5eb0849abd2bab4ac15efbfb530725b7859c97e0 Mon Sep 17 00:00:00 2001 From: Luka Vandervelden Date: Sun, 9 Jun 2019 18:08:59 +0200 Subject: [PATCH] Project organisation for WIP separate binaries. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The idea would be to allow running specific commands from SUID binaries when unpriviledged users should be able to run them. Such commands include getting services status, which need root priviledges to be implemented but shouldn’t be required by users. - Because the repository now builds several binaries, src/main.cr is now src/service.cr and a WIP src/status.cr has been added. - The `status` binary will likely be installed in libexec in the future, with the SUID bit set. --- shard.yml | 4 +- src/config.cr | 8 + src/main.cr | 141 -------- src/service.cr | 435 ++++++------------------ src/{ => service}/environment.cr | 0 src/{ => service}/libc.cr | 0 src/service/service.cr | 349 +++++++++++++++++++ src/{ => service}/service_definition.cr | 0 src/status.cr | 16 + 9 files changed, 487 insertions(+), 466 deletions(-) create mode 100644 src/config.cr delete mode 100644 src/main.cr rename src/{ => service}/environment.cr (100%) rename src/{ => service}/libc.cr (100%) create mode 100644 src/service/service.cr rename src/{ => service}/service_definition.cr (100%) create mode 100644 src/status.cr diff --git a/shard.yml b/shard.yml index e5b413a..1746b59 100644 --- a/shard.yml +++ b/shard.yml @@ -9,6 +9,8 @@ description: | targets: service: - main: src/main.cr + main: src/service.cr + status: + main: src/status.cr license: MIT diff --git a/src/config.cr b/src/config.cr new file mode 100644 index 0000000..ddd014a --- /dev/null +++ b/src/config.cr @@ -0,0 +1,8 @@ + +# Those are set to local to make testing easier. Will change at some point. +PID_DIRECTORY = "./pid" +RC_DIRECTORY = "./rc" +LOG_DIRECTORY = "./log" +SERVICES_DIRECTORY = "./services" +ENVIRONMENTS_DIRECTORY = "./environments" + diff --git a/src/main.cr b/src/main.cr deleted file mode 100644 index 7828d28..0000000 --- a/src/main.cr +++ /dev/null @@ -1,141 +0,0 @@ -require "option_parser" -require "yaml" -require "colorize" - -# Those are set to local to make testing easier. -PID_DIRECTORY = "./pid" -RC_DIRECTORY = "./rc" -LOG_DIRECTORY = "./log" -SERVICES_DIRECTORY = "./services" -ENVIRONMENTS_DIRECTORY = "./environments" - -# TODO: -# - Be more declarative about the definition of commands. - -require "./service_definition.cr" -require "./environment.cr" -require "./service.cr" - -args = [] of String - -parser = OptionParser.parse! do |parser| - parser.banner = "usage: service [options]\n" + - "\n" + - "commands:\n" + - " start Starts a stopped or dead service.\n" + - " stop Stops a running service.\n" + - " status Shows the current state of a service.\n" + - " show Describe a service in detail.\n" + - " add Add a service to an environment.\n" + - " del Remove a service from an environment.\n" + - " list Lists registered services.\n" + - " list-environments Lists registered environments.\n" + - "\n" + - "options:\n" - - parser.on "-h", "--help", "Prints this help message." do - puts parser - exit 0 - end - parser.unknown_args do |x| - args = x - end -end - -command = args[0]? -if command.nil? - STDERR << parser << "\n" - exit 1 -end - -ServiceDefinition.load SERVICES_DIRECTORY -Environment.load ENVIRONMENTS_DIRECTORY -Service.load RC_DIRECTORY - -begin - if args[0] == "help" - puts parser - elsif args[0] == "add" - Service.new(args[1], args[2]?).write RC_DIRECTORY - elsif args[0] == "del" - Service.new(args[1], args[2]?).remove RC_DIRECTORY - elsif args[0] == "start" - services = args[1..args.size].map do |arg| - service = Service.get_by_id(arg) - - unless service - raise Service::Exception.new "Service '#{arg}' does not exist." - end - - service - end - - services.each do |service| - service.dependency_tree.flatten.reverse.each do |service| - next if service.running? PID_DIRECTORY - - puts "starting #{service.to_s}" - service.start PID_DIRECTORY, LOG_DIRECTORY - end - end - elsif args[0] == "stop" - services = args[1..args.size].map do |arg| - service = Service.get_by_id(arg) - - unless service - raise Service::Exception.new "Service '#{arg}' does not exist." - end - - service - end - - services.each do |service| - # FIXME: Build revdep tree and stop services started as dependencies? - next if ! service.running? PID_DIRECTORY - # FIXME: Should we remove duplicate services from the - # tree once flattened? - service.reverse_dependency_tree.flatten.reverse.each do |service| - next if ! service.running? PID_DIRECTORY - - puts "stopping #{service.to_s}" - service.stop PID_DIRECTORY - end - end - elsif args[0] == "status" - puts Service.new(args[1], args[2]?).status PID_DIRECTORY - elsif args[0] == "show" - service = Service.all.find do |service| - unless service.name == args[1] - next false - end - - env = args[2]? || "root" - if service.environment.name != env - next false - end - - true - end - if service - puts service.summary - else - STDERR << "No such service is registered.\n" - exit 2 - end - elsif args[0] == "list" - Service.all.map do |service| - puts service.to_s - end - elsif args[0] == "list-environments" - Environment.all.map do |env| - puts env.to_s - end - else - STDERR << parser << "\n" - exit 1 - end -rescue e : Service::Exception - STDERR << e.message << "\n" - exit 2 -end - diff --git a/src/service.cr b/src/service.cr index f77516e..5b71e52 100644 --- a/src/service.cr +++ b/src/service.cr @@ -1,347 +1,134 @@ +require "option_parser" require "yaml" require "colorize" -require "./libc.cr" +require "./config.cr" -def split_command(string) - args = string.split /\ (?=([^\"]*\"[^\"]*\")*[^\"]*$)/ +# TODO: +# - Be more declarative about the definition of commands. - command = args[0] - args.delete_at 0 +require "./service/*" - return command, args +args = [] of String + +parser = OptionParser.parse! do |parser| + parser.banner = "usage: service [options]\n" + + "\n" + + "commands:\n" + + " start Starts a stopped or dead service.\n" + + " stop Stops a running service.\n" + + " status Shows the current state of a service.\n" + + " show Describe a service in detail.\n" + + " add Add a service to an environment.\n" + + " del Remove a service from an environment.\n" + + " list Lists registered services.\n" + + " list-environments Lists registered environments.\n" + + "\n" + + "options:\n" + + parser.on "-h", "--help", "Prints this help message." do + puts parser + exit 0 + end + parser.unknown_args do |x| + args = x + end end -class Service - getter environment : Environment - getter providers = ProvidersList.new +command = args[0]? +if command.nil? + STDERR << parser << "\n" + exit 1 +end - class Exception < ::Exception - end +ServiceDefinition.load SERVICES_DIRECTORY +Environment.load ENVIRONMENTS_DIRECTORY +Service.load RC_DIRECTORY - class ProvidersList < Hash(String, String) - def []?(name) - super(name).try { |x| Service.get_by_id x} - end - end - - struct AsYAML - YAML.mapping({ - name: String, - environment: String, - consumes: { - type: Array(YAMLConsumes), - default: [] of YAMLConsumes - } - }) - end - struct YAMLConsumes - YAML.mapping({ - token: String, - from: String - }) - end - - def initialize(name, environment_name : String?, @consumes = [] of YAMLConsumes) - @reference = ServiceDefinition.get name - @environment = if environment_name.nil? || environment_name == "" - Environment.root - else - Environment.get environment_name - end - - @consumes.each do |consume| - @providers[consume.token] = consume.from - end - end - - def self.from_yaml(yaml) - yaml = AsYAML.from_yaml yaml - - - self.new yaml.name, yaml.environment, yaml.consumes - end - def to_yaml - { - name: name, - environment: @environment.name - }.to_yaml - end - - def id - if @environment.name == "root" - name - else - "#{@environment.name}/#{name}" - end - end - - # FIXME: At this point, macros would be both more readable and shorter. - def name - @reference.name - end - def type - @reference.type - end - def command - @reference.command - end - def stop_command - @reference.stop_command - end - def provides - @reference.provides - end - - def export_environment_variables - ENV["SERVICE_ENVIRONMENT"] = @environment.name - ENV["SERVICE_ENVIRONMENT_TYPE"] = @environment.type.to_s - - # FIXME: Parsing should probably be done… when parsing the file. - # FIXME: Parsing is probably a bit primitive. Maybe this isn’t the right way of defining this. - @reference.environment_variables.each do |string| - # FIXME: Should probably deserve a warning. - variable = string.match(/^[^=]*=/).not_nil![0] - value = string[variable.size..string.size] - variable = variable[0..variable.size-2] - - ENV[variable] = value - end - end - private def evaluate(string) - string.gsub /%{[a-zA-Z]+}/ do |match| - match = match[2..match.size-2] - - if match.downcase == "environment" - @environment.name - else - "" - end - end - end - - def start(pid_dir : String, log_dir : String) - export_environment_variables - - (@environment.checks + @reference.checks).each do |check| - run_check = false - - check.file.try do |file| - run_check = true if ! File.exists? evaluate file - end - - check.directory.try do |directory| - run_check = true if ! Dir.exists? evaluate directory - end - - unless run_check - next - end - - puts " - #{check.name}" - - child = Process.fork do - @reference.user.try do |user| - unless System.become_user user - STDERR << "service: child could not setuid() to user '#{user}'.\n" - exit 1 - end - end - - Process.exec "sh", ["-c", evaluate check.command], output: Process::Redirect::Inherit, error: Process::Redirect::Inherit - end.wait - - if child.exit_status != 0 - raise Service::Exception.new "Child process exited with status “#{child.exit_status}”." - break - end - end - - # FIXME: Should evaluate be used in split_command? What namespace should split_command use? - command, args = split_command command - args.map! do |arg| evaluate arg end - - process = Process.fork do - base_log_name = "#{log_dir}/#{name}.#{@environment.name}" - stdout_file = File.open "#{base_log_name}.out", "w" - stderr_file = File.open "#{base_log_name}.err", "w" - - LibC.dup2 stdout_file.fd, 1 - LibC.dup2 stderr_file.fd, 2 - - Process.exec command, args, chdir: @reference.directory - end - - self.save_pid pid_dir, process.pid - end - - # TODO: - # - Custom shutdown commands. - # - Should we wait for the process to die? - # - Shouldn’t we remove the pid file? - def stop(pid_dir : String) - _pid = pid pid_dir - - if _pid - command = stop_command - if command - command, args = split_command command - - Process.run(command, args) - else - Process.kill Signal::TERM, _pid - end - else - # Already stopped or dead, nothing to be done here. - end - end - - def get_pid_file(pid_dir) - "#{pid_dir}/#{name}.pid" - end - - def pid(pid_dir) - File.read(get_pid_file pid_dir).to_i - rescue e # pid file missing, corrupted or otherwise not readable - nil - end - - def save_pid(pid_dir, new_pid) - File.write get_pid_file(pid_dir), new_pid - end - - enum Status - Running - Dead - Stopped - end - - def status(pid_dir) - _pid = pid pid_dir - - if _pid - if Process.exists? _pid - Status::Running - else - Status::Dead - end - else - Status::Stopped - end - end - def running?(pid_dir) - status(pid_dir) == Service::Status::Running - end - - def to_s - "#{name} (in #{@environment.name})" - end - - def summary - "Name: #{name}\n" + - "Environment: #{environment.name} (#{environment.type.to_s.downcase})\n" + - ( - if provides.size > 0 - "Provides:\n" + - (provides.map { |x| " - " + x.token + "\n" }).join - else - "" - end - ) + - ( - if @reference.consumes.size > 0 - "Consumes:\n" + - (@reference.consumes.map { |x| - provider = @providers[x.token]? - if provider - " - #{x.token} (from #{provider.id})\n" - else - " - #{x.token} " + - "(NOT CURRENTLY PROVIDED)".colorize(:red).to_s + - "\n" - end - }).join - else - "" - end - ) - end - - class_getter all = [] of Service - def self.load(path) - Dir.each_child path do |child| - unless child.match /\.yaml$/ - next - end - - @@all << Service.from_yaml File.read "#{path}/#{child}" - end - end - - def write(path) - File.write "#{path}/#{name}.#{@environment.name}.yaml", to_yaml - end - - def remove(path) - File.delete "#{path}/#{name}.#{@environment.name}.yaml" - end - - def self.get_by_id(id) - matches = id.match /[^\/]*/ - unless matches # Should not happen, above regex would always match. - raise Exception.new "FIXME" - end - environment_name = matches[0] - service_name = if environment_name == id - environment_name = "root" - id - else - id.sub 0..matches[0].size, "" - end - - @@all.find do |service| - service.name == service_name - end - end - def is_id?(id) - id == self.id || (@environment.name == "root" && id == "root/#{name}") - end - - alias ServiceTree = Array(ServiceTree) | Service - - # Returns a dependency tree. - # You’ll probably want to flatten and reverse it afterwards. - def dependency_tree - tree = [self] of ServiceTree - - @consumes.each do |token| - service = Service.get_by_id token.from +begin + if args[0] == "help" + puts parser + elsif args[0] == "add" + Service.new(args[1], args[2]?).write RC_DIRECTORY + elsif args[0] == "del" + Service.new(args[1], args[2]?).remove RC_DIRECTORY + elsif args[0] == "start" + services = args[1..args.size].map do |arg| + service = Service.get_by_id(arg) unless service - # FIXME: Does it make the dep tree invalid? - # FIXME: Only optional deps should be fine. - next + raise Service::Exception.new "Service '#{arg}' does not exist." end - tree << service.dependency_tree + service end - tree - end + services.each do |service| + service.dependency_tree.flatten.reverse.each do |service| + next if service.running? PID_DIRECTORY - def reverse_dependency_tree - tree = [self] of ServiceTree - - @@all.each do |service| - service.providers.any? do |token, id| - if self.is_id?(id) && ! tree.any? service - tree << service - end + puts "starting #{service.to_s}" + service.start PID_DIRECTORY, LOG_DIRECTORY end end + elsif args[0] == "stop" + services = args[1..args.size].map do |arg| + service = Service.get_by_id(arg) - tree + unless service + raise Service::Exception.new "Service '#{arg}' does not exist." + end + + service + end + + services.each do |service| + # FIXME: Build revdep tree and stop services started as dependencies? + next if ! service.running? PID_DIRECTORY + # FIXME: Should we remove duplicate services from the + # tree once flattened? + service.reverse_dependency_tree.flatten.reverse.each do |service| + next if ! service.running? PID_DIRECTORY + + puts "stopping #{service.to_s}" + service.stop PID_DIRECTORY + end + end + elsif args[0] == "status" + puts Service.new(args[1], args[2]?).status PID_DIRECTORY + elsif args[0] == "show" + service = Service.all.find do |service| + unless service.name == args[1] + next false + end + + env = args[2]? || "root" + if service.environment.name != env + next false + end + + true + end + if service + puts service.summary + else + STDERR << "No such service is registered.\n" + exit 2 + end + elsif args[0] == "list" + Service.all.map do |service| + puts service.to_s + end + elsif args[0] == "list-environments" + Environment.all.map do |env| + puts env.to_s + end + else + STDERR << parser << "\n" + exit 1 end +rescue e : Service::Exception + STDERR << e.message << "\n" + exit 2 end - diff --git a/src/environment.cr b/src/service/environment.cr similarity index 100% rename from src/environment.cr rename to src/service/environment.cr diff --git a/src/libc.cr b/src/service/libc.cr similarity index 100% rename from src/libc.cr rename to src/service/libc.cr diff --git a/src/service/service.cr b/src/service/service.cr new file mode 100644 index 0000000..a83ed07 --- /dev/null +++ b/src/service/service.cr @@ -0,0 +1,349 @@ +require "yaml" +require "colorize" + +require "./service_definition.cr" +require "./environment.cr" +require "./libc.cr" + +def split_command(string) + args = string.split /\ (?=([^\"]*\"[^\"]*\")*[^\"]*$)/ + + command = args[0] + args.delete_at 0 + + return command, args +end + +class Service + getter environment : Environment + getter providers = ProvidersList.new + + class Exception < ::Exception + end + + class ProvidersList < Hash(String, String) + def []?(name) + super(name).try { |x| Service.get_by_id x} + end + end + + struct AsYAML + YAML.mapping({ + name: String, + environment: String, + consumes: { + type: Array(YAMLConsumes), + default: [] of YAMLConsumes + } + }) + end + struct YAMLConsumes + YAML.mapping({ + token: String, + from: String + }) + end + + def initialize(name, environment_name : String?, @consumes = [] of YAMLConsumes) + @reference = ServiceDefinition.get name + @environment = if environment_name.nil? || environment_name == "" + Environment.root + else + Environment.get environment_name + end + + @consumes.each do |consume| + @providers[consume.token] = consume.from + end + end + + def self.from_yaml(yaml) + yaml = AsYAML.from_yaml yaml + + + self.new yaml.name, yaml.environment, yaml.consumes + end + def to_yaml + { + name: name, + environment: @environment.name + }.to_yaml + end + + def id + if @environment.name == "root" + name + else + "#{@environment.name}/#{name}" + end + end + + # FIXME: At this point, macros would be both more readable and shorter. + def name + @reference.name + end + def type + @reference.type + end + def command + @reference.command + end + def stop_command + @reference.stop_command + end + def provides + @reference.provides + end + + def export_environment_variables + ENV["SERVICE_ENVIRONMENT"] = @environment.name + ENV["SERVICE_ENVIRONMENT_TYPE"] = @environment.type.to_s + + # FIXME: Parsing should probably be done… when parsing the file. + # FIXME: Parsing is probably a bit primitive. Maybe this isn’t the right way of defining this. + @reference.environment_variables.each do |string| + # FIXME: Should probably deserve a warning. + variable = string.match(/^[^=]*=/).not_nil![0] + value = string[variable.size..string.size] + variable = variable[0..variable.size-2] + + ENV[variable] = value + end + end + private def evaluate(string) + string.gsub /%{[a-zA-Z]+}/ do |match| + match = match[2..match.size-2] + + if match.downcase == "environment" + @environment.name + else + "" + end + end + end + + def start(pid_dir : String, log_dir : String) + export_environment_variables + + (@environment.checks + @reference.checks).each do |check| + run_check = false + + check.file.try do |file| + run_check = true if ! File.exists? evaluate file + end + + check.directory.try do |directory| + run_check = true if ! Dir.exists? evaluate directory + end + + unless run_check + next + end + + puts " - #{check.name}" + + child = Process.fork do + @reference.user.try do |user| + unless System.become_user user + STDERR << "service: child could not setuid() to user '#{user}'.\n" + exit 1 + end + end + + Process.exec "sh", ["-c", evaluate check.command], output: Process::Redirect::Inherit, error: Process::Redirect::Inherit + end.wait + + if child.exit_status != 0 + raise Service::Exception.new "Child process exited with status “#{child.exit_status}”." + break + end + end + + # FIXME: Should evaluate be used in split_command? What namespace should split_command use? + command, args = split_command command + args.map! do |arg| evaluate arg end + + process = Process.fork do + base_log_name = "#{log_dir}/#{name}.#{@environment.name}" + stdout_file = File.open "#{base_log_name}.out", "w" + stderr_file = File.open "#{base_log_name}.err", "w" + + LibC.dup2 stdout_file.fd, 1 + LibC.dup2 stderr_file.fd, 2 + + Process.exec command, args, chdir: @reference.directory + end + + self.save_pid pid_dir, process.pid + end + + # TODO: + # - Custom shutdown commands. + # - Should we wait for the process to die? + # - Shouldn’t we remove the pid file? + def stop(pid_dir : String) + _pid = pid pid_dir + + if _pid + command = stop_command + if command + command, args = split_command command + + Process.run(command, args) + else + Process.kill Signal::TERM, _pid + end + else + # Already stopped or dead, nothing to be done here. + end + end + + def get_pid_file(pid_dir) + "#{pid_dir}/#{name}.pid" + end + + def pid(pid_dir) + File.read(get_pid_file pid_dir).to_i + rescue e # pid file missing, corrupted or otherwise not readable + nil + end + + def save_pid(pid_dir, new_pid) + File.write get_pid_file(pid_dir), new_pid + end + + enum Status + Running + Dead + Stopped + end + + def status(pid_dir) + _pid = pid pid_dir + + if _pid + if Process.exists? _pid + Status::Running + else + Status::Dead + end + else + Status::Stopped + end + end + def running?(pid_dir) + status(pid_dir) == Service::Status::Running + end + + def to_s + "#{name} (in #{@environment.name})" + end + + def summary + "Name: #{name}\n" + + "Environment: #{environment.name} (#{environment.type.to_s.downcase})\n" + + ( + if provides.size > 0 + "Provides:\n" + + (provides.map { |x| " - " + x.token + "\n" }).join + else + "" + end + ) + + ( + if @reference.consumes.size > 0 + "Consumes:\n" + + (@reference.consumes.map { |x| + provider = @providers[x.token]? + if provider + " - #{x.token} (from #{provider.id})\n" + else + " - #{x.token} " + + "(NOT CURRENTLY PROVIDED)".colorize(:red).to_s + + "\n" + end + }).join + else + "" + end + ) + end + + class_getter all = [] of Service + def self.load(path) + Dir.each_child path do |child| + unless child.match /\.yaml$/ + next + end + + @@all << Service.from_yaml File.read "#{path}/#{child}" + end + end + + def write(path) + File.write "#{path}/#{name}.#{@environment.name}.yaml", to_yaml + end + + def remove(path) + File.delete "#{path}/#{name}.#{@environment.name}.yaml" + end + + def self.get_by_id(id) + matches = id.match /[^\/]*/ + unless matches # Should not happen, above regex would always match. + raise Exception.new "FIXME" + end + environment_name = matches[0] + service_name = if environment_name == id + environment_name = "root" + id + else + id.sub 0..matches[0].size, "" + end + + @@all.find do |service| + service.name == service_name + end + end + def is_id?(id) + id == self.id || (@environment.name == "root" && id == "root/#{name}") + end + + alias ServiceTree = Array(ServiceTree) | Service + + # Returns a dependency tree. + # You’ll probably want to flatten and reverse it afterwards. + def dependency_tree + tree = [self] of ServiceTree + + @consumes.each do |token| + service = Service.get_by_id token.from + + unless service + # FIXME: Does it make the dep tree invalid? + # FIXME: Only optional deps should be fine. + next + end + + tree << service.dependency_tree + end + + tree + end + + def reverse_dependency_tree + tree = [self] of ServiceTree + + @@all.each do |service| + service.providers.any? do |token, id| + if self.is_id?(id) && ! tree.any? service + tree << service + end + end + end + + tree + end +end + + diff --git a/src/service_definition.cr b/src/service/service_definition.cr similarity index 100% rename from src/service_definition.cr rename to src/service/service_definition.cr diff --git a/src/status.cr b/src/status.cr new file mode 100644 index 0000000..5416ac7 --- /dev/null +++ b/src/status.cr @@ -0,0 +1,16 @@ + +require "./service/service.cr" + +require "./config.cr" + +ServiceDefinition.load SERVICES_DIRECTORY +Environment.load ENVIRONMENTS_DIRECTORY +Service.load RC_DIRECTORY + +Service.get_by_id(ARGV[0]).try do |service| + puts service.status PID_DIRECTORY + exit 0 +end + +exit 1 +