diff options
| author | luke <luke@980ebf18-57e1-0310-9a29-db15c13687c0> | 2007-02-08 01:39:39 +0000 |
|---|---|---|
| committer | luke <luke@980ebf18-57e1-0310-9a29-db15c13687c0> | 2007-02-08 01:39:39 +0000 |
| commit | 7e07e3dc843798bdbc7a03428ca054adaff2fb72 (patch) | |
| tree | 34d0f9f8c2ee11bdc281e6e4d18cad444253fe36 /lib/puppet/network | |
| parent | 6d8068eddd0d29ec53f62557eb53f6ebb8e40591 (diff) | |
| download | puppet-7e07e3dc843798bdbc7a03428ca054adaff2fb72.tar.gz puppet-7e07e3dc843798bdbc7a03428ca054adaff2fb72.tar.xz puppet-7e07e3dc843798bdbc7a03428ca054adaff2fb72.zip | |
Moving all of the client and server code into a single network/ directory. In other words, more code structure cleanup.
git-svn-id: https://reductivelabs.com/svn/puppet/trunk@2179 980ebf18-57e1-0310-9a29-db15c13687c0
Diffstat (limited to 'lib/puppet/network')
25 files changed, 3853 insertions, 0 deletions
diff --git a/lib/puppet/network/client.rb b/lib/puppet/network/client.rb new file mode 100644 index 000000000..604912025 --- /dev/null +++ b/lib/puppet/network/client.rb @@ -0,0 +1,190 @@ +# the available clients + +require 'puppet' +require 'puppet/network/networkclient' + +# FIXME this still isn't a good design, because none of the handlers overlap +# so i could just as easily include them all in the main module +# but at least it's better organized for now +class Puppet::Network::Client + include Puppet::Daemon + include Puppet::Util + + # FIXME The cert stuff should only come up with networking, so it + # should be in the network client, not the normal client. But if i do + # that, it's hard to tell whether the certs have been initialized. + include Puppet::Daemon + attr_reader :secureinit + attr_accessor :schedule, :lastrun, :local, :stopping + + class << self + attr_reader :drivername, :handler + attr_accessor :netclient + end + + def initcerts + unless self.readcert + #if self.is_a? Puppet::Network::Client::CA + unless self.requestcert + return nil + end + #else + # return nil + #end + #unless self.requestcert + #end + end + + # unless we have a driver, we're a local client and we can't add + # certs anyway, so it doesn't matter + unless @driver + return true + end + + self.setcerts + end + + def initialize(hash) + # to whom do we connect? + @server = nil + @nil = nil + @secureinit = hash[:NoSecureInit] || true + + if hash.include?(:FQDN) + @fqdn = hash[:FQDN] + else + self.fqdn + end + + if hash.include?(:Cache) + @cache = hash[:Cache] + else + @cache = true + end + + driverparam = self.class.drivername + if hash.include?(:Server) + if $noclientnetworking + raise NetworkClientError.new("Networking not available: %s" % + $nonetworking) + end + + args = {:Server => hash[:Server]} + args[:Port] = hash[:Port] || Puppet[:masterport] + + if self.readcert + args[:Certificate] = @cert + args[:Key] = @key + args[:CAFile] = @cacertfile + end + + netclient = nil + unless netclient = self.class.netclient + unless handler = self.class.handler + raise Puppet::DevError, + "Class %s has no handler defined" % self.class + end + namespace = self.class.handler.interface.prefix + netclient = Puppet::Network::NetworkClient.netclient(namespace) + self.class.netclient = netclient + end + @driver = netclient.new(args) + @local = false + elsif hash.include?(driverparam) + @driver = hash[driverparam] + @local = true + else + raise ClientError, "%s must be passed a Server or %s" % + [self.class, driverparam] + end + end + + # Are we a local client? + def local? + if defined? @local and @local + true + else + false + end + end + + # A wrapper method to run and then store the last run time + def runnow + if self.stopping + Puppet.notice "In shutdown progress; skipping run" + return + end + begin + self.run + self.lastrun = Time.now.to_i + rescue => detail + if Puppet[:trace] + puts detail.backtrace + end + Puppet.err "Could not run %s: %s" % [self.class, detail] + end + end + + def run + raise Puppet::DevError, "Client type %s did not override run" % + self.class + end + + def scheduled? + if sched = self.schedule + return sched.match?(self.lastrun) + else + return true + end + end + + def setcerts + @driver.cert = @cert + @driver.key = @key + @driver.ca_file = @cacertfile + end + + def shutdown + if self.stopping + Puppet.notice "Already in shutdown" + else + self.stopping = true + if self.respond_to? :running? and self.running? + Puppet::Util::Storage.store + end + rmpidfile() + end + end + + # Start listening for events. We're pretty much just listening for + # timer events here. + def start + setpidfile() + # Create our timer. Puppet will handle observing it and such. + timer = Puppet.newtimer( + :interval => Puppet[:runinterval], + :tolerance => 1, + :start? => true + ) do + if self.scheduled? + self.runnow + end + end + + # Run once before we start following the timer + self.runnow + end + + require 'puppet/network/client/proxy' + require 'puppet/network/client/ca' + require 'puppet/network/client/dipper' + require 'puppet/network/client/file' + require 'puppet/network/client/log' + require 'puppet/network/client/master' + require 'puppet/network/client/runner' + require 'puppet/network/client/status' + require 'puppet/network/client/reporter' + require 'puppet/network/client/resource' +end + +# $Id$ diff --git a/lib/puppet/network/client/ca.rb b/lib/puppet/network/client/ca.rb new file mode 100644 index 000000000..9a99c1145 --- /dev/null +++ b/lib/puppet/network/client/ca.rb @@ -0,0 +1,23 @@ +require 'puppet/network/client/proxy' + +class Puppet::Network::Client::CA < Puppet::Network::Client::ProxyClient + @drivername = :CA + + # set up the appropriate interface methods + @handler = Puppet::Network::Server::CA + self.mkmethods + + def initialize(hash = {}) + if hash.include?(:CA) + if hash[:CA].is_a? Hash + hash[:CA] = Puppet::Network::Server::CA.new(hash[:CA]) + else + hash[:CA] = Puppet::Network::Server::CA.new() + end + end + + super(hash) + end +end + +# $Id$ diff --git a/lib/puppet/network/client/dipper.rb b/lib/puppet/network/client/dipper.rb new file mode 100644 index 000000000..8eaffc1a0 --- /dev/null +++ b/lib/puppet/network/client/dipper.rb @@ -0,0 +1,76 @@ +# The client class for filebuckets. +class Puppet::Network::Client::Dipper < Puppet::Network::Client + @drivername = :Bucket + + @handler = Puppet::Network::Server::FileBucket + + attr_accessor :name + + # Create our bucket client + def initialize(hash = {}) + if hash.include?(:Path) + bucket = Puppet::Network::Server::FileBucket.new( + :Path => hash[:Path] + ) + hash.delete(:Path) + hash[:Bucket] = bucket + end + + super(hash) + end + + # Back up a file to our bucket + def backup(file) + unless FileTest.exists?(file) + raise(BucketError, "File %s does not exist" % file) + end + contents = File.read(file) + unless local? + contents = Base64.encode64(contents) + end + return @driver.addfile(contents,file) + end + + # Restore the file + def restore(file,sum) + restore = true + if FileTest.exists?(file) + cursum = Digest::MD5.hexdigest(File.read(file)) + + # if the checksum has changed... + # this might be extra effort + if cursum == sum + restore = false + end + end + + if restore + if newcontents = @driver.getfile(sum) + unless local? + newcontents = Base64.decode64(newcontents) + end + tmp = "" + newsum = Digest::MD5.hexdigest(newcontents) + changed = nil + unless FileTest.writable?(file) + changed = File.stat(file).mode + File.chmod(changed | 0200, file) + end + File.open(file,File::WRONLY|File::TRUNC) { |of| + of.print(newcontents) + } + if changed + File.chmod(changed, file) + end + else + Puppet.err "Could not find file with checksum %s" % sum + return nil + end + return newsum + else + return nil + end + end +end + +# $Id$ diff --git a/lib/puppet/network/client/file.rb b/lib/puppet/network/client/file.rb new file mode 100644 index 000000000..7596aec1f --- /dev/null +++ b/lib/puppet/network/client/file.rb @@ -0,0 +1,20 @@ +class Puppet::Network::Client::FileClient < Puppet::Network::Client::ProxyClient + @drivername = :FileServer + + # set up the appropriate interface methods + @handler = Puppet::Network::Server::FileServer + + self.mkmethods + + def initialize(hash = {}) + if hash.include?(:FileServer) + unless hash[:FileServer].is_a?(Puppet::Network::Server::FileServer) + raise Puppet::DevError, "Must pass an actual FS object" + end + end + + super(hash) + end +end + +# $Id$ diff --git a/lib/puppet/network/client/log.rb b/lib/puppet/network/client/log.rb new file mode 100644 index 000000000..eddb8e0ca --- /dev/null +++ b/lib/puppet/network/client/log.rb @@ -0,0 +1,17 @@ +class Puppet::Network::Client::LogClient < Puppet::Network::Client::ProxyClient + @drivername = :Logger + + # set up the appropriate interface methods + @handler = Puppet::Network::Server::Logger + self.mkmethods + + def initialize(hash = {}) + if hash.include?(:Logger) + hash[:Logger] = Puppet::Network::Server::Logger.new() + end + + super(hash) + end +end + +# $Id$ diff --git a/lib/puppet/network/client/master.rb b/lib/puppet/network/client/master.rb new file mode 100644 index 000000000..9f07f48ef --- /dev/null +++ b/lib/puppet/network/client/master.rb @@ -0,0 +1,654 @@ +# The client for interacting with the puppetmaster config server. +require 'sync' +require 'timeout' + +class Puppet::Network::Client::MasterClient < Puppet::Network::Client + unless defined? @@sync + @@sync = Sync.new + end + + @handler = Puppet::Network::Server::Master + + Puppet.setdefaults("puppetd", + :puppetdlockfile => [ "$statedir/puppetdlock", + "A lock file to temporarily stop puppetd from doing anything."], + :usecacheonfailure => [true, + "Whether to use the cached configuration when the remote + configuration will not compile. This option is useful for testing + new configurations, where you want to fix the broken configuration + rather than reverting to a known-good one." + ], + :downcasefacts => [false, + "Whether facts should be made all lowercase when sent to the server."] + ) + + Puppet.setdefaults(:puppetd, + :configtimeout => [30, + "How long the client should wait for the configuration to be retrieved + before considering it a failure. This can help reduce flapping if too + many clients contact the server at one time." + ], + :reportserver => ["$server", + "The server to which to send transaction reports." + ], + :report => [false, + "Whether to send reports after every transaction." + ] + ) + + # Plugin information. + Puppet.setdefaults("puppet", + :pluginpath => ["$vardir/plugins", + "Where Puppet should look for plugins. Multiple directories should + be colon-separated, like normal PATH variables."], + :plugindest => ["$vardir/plugins", + "Where Puppet should store plugins that it pulls down from the central + server."], + :pluginsource => ["puppet://$server/plugins", + "From where to retrieve plugins. The standard Puppet ``file`` type + is used for retrieval, so anything that is a valid file source can + be used here."], + :pluginsync => [false, + "Whether plugins should be synced with the central server."], + :pluginsignore => [".svn CVS", + "What files to ignore when pulling down plugins."] + ) + + # Central fact information. + Puppet.setdefaults("puppet", + :factpath => ["$vardir/facts", + "Where Puppet should look for facts. Multiple directories should + be colon-separated, like normal PATH variables."], + :factdest => ["$vardir/facts", + "Where Puppet should store facts that it pulls down from the central + server."], + :factsource => ["puppet://$server/facts", + "From where to retrieve facts. The standard Puppet ``file`` type + is used for retrieval, so anything that is a valid file source can + be used here."], + :factsync => [false, + "Whether facts should be synced with the central server."], + :factsignore => [".svn CVS", + "What files to ignore when pulling down facts."] + ) + + @drivername = :Master + + attr_accessor :objects + attr_reader :compile_time + + class << self + # Puppetd should only have one instance running, and we need a way + # to retrieve it. + attr_accessor :instance + include Puppet::Util + end + + def self.facts + # Retrieve the facts from the central server. + if Puppet[:factsync] + self.getfacts() + end + + down = Puppet[:downcasefacts] + + facts = {} + Facter.each { |name,fact| + if down + facts[name] = fact.to_s.downcase + else + facts[name] = fact.to_s + end + } + + # Add our client version to the list of facts, so people can use it + # in their manifests + facts["clientversion"] = Puppet.version.to_s + + facts + end + + # This method actually applies the configuration. + def apply(tags = nil, ignoreschedules = false) + unless defined? @objects + raise Puppet::Error, "Cannot apply; objects not defined" + end + + transaction = @objects.evaluate + + if tags + transaction.tags = tags + end + + if ignoreschedules + transaction.ignoreschedules = true + end + + transaction.addtimes :config_retrieval => @configtime + + begin + transaction.evaluate + rescue Puppet::Error => detail + Puppet.err "Could not apply complete configuration: %s" % + detail + rescue => detail + Puppet.err "Found a bug: %s" % detail + if Puppet[:trace] + puts detail.backtrace + end + ensure + Puppet::Util::Storage.store + end + + if Puppet[:report] + report(transaction) + end + + return transaction + ensure + if defined? transaction and transaction + transaction.cleanup + end + end + + # Cache the config + def cache(text) + Puppet.info "Caching configuration at %s" % self.cachefile + confdir = File.dirname(Puppet[:localconfig]) + File.open(self.cachefile + ".tmp", "w", 0660) { |f| + f.print text + } + File.rename(self.cachefile + ".tmp", self.cachefile) + end + + def cachefile + unless defined? @cachefile + @cachefile = Puppet[:localconfig] + ".yaml" + end + @cachefile + end + + def clear + #@objects = nil + @objects.remove(true) + Puppet::Type.allclear + end + + # Initialize and load storage + def dostorage + begin + Puppet::Util::Storage.load + @compile_time ||= Puppet::Util::Storage.cache(:configuration)[:compile_time] + rescue => detail + if Puppet[:trace] + puts detail.backtrace + end + Puppet.err "Corrupt state file %s: %s" % [Puppet[:statefile], detail] + begin + File.unlink(Puppet[:statefile]) + retry + rescue => detail + raise Puppet::Error.new("Cannot remove %s: %s" % + [Puppet[:statefile], detail]) + end + end + end + + # Check whether our configuration is up to date + def fresh? + unless self.compile_time + return false + end + + # We're willing to give a 2 second drift + if @driver.freshness - @compile_time.to_i < 1 + return true + else + return false + end + end + + # Let the daemon run again, freely in the filesystem. Frolick, little + # daemon! + def enable + Puppet::Util::Pidlock.new(Puppet[:puppetdlockfile]).unlock(:anonymous => true) + end + + # Stop the daemon from making any configuration runs. + def disable + Puppet::Util::Pidlock.new(Puppet[:puppetdlockfile]).lock(:anonymous => true) + end + + # Retrieve the config from a remote server. If this fails, then + # use the cached copy. + def getconfig + dostorage() + if self.fresh? + Puppet.info "Config is up to date" + unless defined? @objects + begin + @objects = YAML.load(self.retrievecache).to_type + rescue => detail + Puppet.warning "Could not load cached configuration: %s" % detail + end + end + return + end + Puppet.debug("getting config") + + # Retrieve the plugins. + if Puppet[:pluginsync] + getplugins() + end + + facts = self.class.facts + + unless facts.length > 0 + raise Puppet::Network::ClientError.new( + "Could not retrieve any facts" + ) + end + + unless objects = get_actual_config(facts) + @objects = nil + return + end + + unless objects.is_a?(Puppet::TransBucket) + raise NetworkClientError, + "Invalid returned objects of type %s" % objects.class + end + + self.setclasses(objects.classes) + + # Clear all existing objects, so we can recreate our stack. + if defined? @objects + Puppet::Type.allclear + + # Make sure all of the objects are really gone. + @objects.remove(true) + end + @objects = nil + + # Now convert the objects to real Puppet objects + @objects = objects.to_type + + if @objects.nil? + raise Puppet::Error, "Configuration could not be processed" + end + + # and perform any necessary final actions before we evaluate. + @objects.finalize + + return @objects + end + + # A simple proxy method, so it's easy to test. + def getplugins + self.class.getplugins + end + + # Just so we can specify that we are "the" instance. + def initialize(*args) + Puppet.config.use(:puppet, :sslcertificates, :puppetd) + super + + # This might be nil + @configtime = 0 + + self.class.instance = self + @running = false + + mkdefault_objects + end + + # Make the default objects necessary for function. + def mkdefault_objects + # First create the default scheduling objects + Puppet::Type.type(:schedule).mkdefaultschedules + + # And filebuckets + Puppet::Type.type(:filebucket).mkdefaultbucket + end + + # Mark that we should restart. The Puppet module checks whether we're running, + # so this only gets called if we're in the middle of a run. + def restart + # If we're currently running, then just mark for later + Puppet.notice "Received signal to restart; waiting until run is complete" + @restart = true + end + + # Should we restart? + def restart? + if defined? @restart + @restart + else + false + end + end + + # Retrieve the cached config + def retrievecache + if FileTest.exists?(self.cachefile) + return File.read(self.cachefile) + else + return "" + end + end + + # The code that actually runs the configuration. + def run(tags = nil, ignoreschedules = false) + lockfile = Puppet::Util::Pidlock.new(Puppet[:puppetdlockfile]) + + Puppet::Util.sync(:puppetrun).synchronize(Sync::EX) do + if !lockfile.lock + Puppet.notice "Lock file %s exists; skipping configuration run" % + lockfile.lockfile + else + @running = true + @configtime = thinmark do + self.getconfig + end + + if defined? @objects and @objects + unless @local + Puppet.notice "Starting configuration run" + end + benchmark(:notice, "Finished configuration run") do + self.apply(tags, ignoreschedules) + end + end + @running = false + end + + lockfile.unlock + + # Did we get HUPped during the run? If so, then restart now that we're + # done with the run. + if self.restart? + Process.kill(:HUP, $$) + end + end + end + + def running? + @running + end + + # Store the classes in the classfile, but only if we're not local. + def setclasses(ary) + if @local + return + end + unless ary and ary.length > 0 + Puppet.info "No classes to store" + return + end + begin + File.open(Puppet[:classfile], "w") { |f| + f.puts ary.join("\n") + } + rescue => detail + Puppet.err "Could not create class file %s: %s" % + [Puppet[:classfile], detail] + end + end + + private + + # Download files from the remote server, returning a list of all + # changed files. + def self.download(args) + objects = Puppet::Type.type(:component).create( + :name => "#{args[:name]}_collector" + ) + hash = { + :path => args[:dest], + :recurse => true, + :source => args[:source], + :tag => "#{args[:name]}s", + :owner => Process.uid, + :group => Process.gid, + :backup => false + } + + if args[:ignore] + hash[:ignore] = args[:ignore].split(/\s+/) + end + objects.push Puppet::Type.type(:file).create(hash) + + Puppet.info "Retrieving #{args[:name]}s" + + begin + trans = objects.evaluate + trans.ignoretags = true + Timeout::timeout(self.timeout) do + trans.evaluate + end + rescue Puppet::Error, Timeout::Error => detail + if Puppet[:debug] + puts detail.backtrace + end + Puppet.err "Could not retrieve #{args[:name]}s: %s" % detail + end + + # Now source all of the changed objects, but only source those + # that are top-level. + files = [] + trans.changed?.find_all do |object| + yield object if block_given? + files << object[:path] + end + trans.cleanup + + # Now clean up after ourselves + objects.remove + files + end + + # Retrieve facts from the central server. + def self.getfacts + # Clear all existing definitions. + Facter.clear + + # Download the new facts + path = Puppet[:factpath].split(":") + files = [] + download(:dest => Puppet[:factdest], :source => Puppet[:factsource], + :ignore => Puppet[:factsignore], :name => "fact") do |object| + + next unless path.include?(File.dirname(object[:path])) + + files << object[:path] + + end + ensure + # Reload everything. + if Facter.respond_to? :loadfacts + Facter.loadfacts + elsif Facter.respond_to? :load + Facter.load + else + raise Puppet::Error, + "You must upgrade your version of Facter to use centralized facts" + end + + # This loads all existing facts and any new ones. We have to remove and + # reload because there's no way to unload specific facts. + loadfacts() + end + + # Retrieve the plugins from the central server. We only have to load the + # changed plugins, because Puppet::Type loads plugins on demand. + def self.getplugins + path = Puppet[:pluginpath].split(":") + download(:dest => Puppet[:plugindest], :source => Puppet[:pluginsource], + :ignore => Puppet[:pluginsignore], :name => "plugin") do |object| + + next unless path.include?(File.dirname(object[:path])) + + begin + Puppet.info "Reloading plugin %s" % + File.basename(File.basename(object[:path])).sub(".rb",'') + load object[:path] + rescue => detail + Puppet.warning "Could not reload plugin %s: %s" % + [object[:path], detail] + end + end + end + + def self.loaddir(dir, type) + return unless FileTest.directory?(dir) + + Dir.entries(dir).find_all { |e| e =~ /\.rb$/ }.each do |file| + fqfile = File.join(dir, file) + begin + Puppet.info "Loading #{type} %s" % File.basename(file.sub(".rb",'')) + Timeout::timeout(self.timeout) do + load fqfile + end + rescue => detail + Puppet.warning "Could not load #{type} %s: %s" % [fqfile, detail] + end + end + end + + def self.loadfacts + Puppet[:factpath].split(":").each do |dir| + loaddir(dir, "fact") + end + end + + def self.timeout + @timeout = Puppet[:configtimeout] + case @timeout + when String: + if @timeout =~ /^\d+$/ + @timeout = Integer(@timeout) + else + raise ArgumentError, "Configuration timeout must be an integer" + end + when Integer: # nothing + else + raise ArgumentError, "Configuration timeout must be an integer" + end + end + + # Send off the transaction report. + def report(transaction) + begin + report = transaction.report() + if Puppet[:rrdgraph] == true + report.graph() + end + reportclient().report(report) + rescue => detail + Puppet.err "Reporting failed: %s" % detail + end + end + + def reportclient + unless defined? @reportclient + @reportclient = Puppet::Network::Client::Reporter.new( + :Server => Puppet[:reportserver] + ) + end + + @reportclient + end + + loadfacts() + + private + + # Actually retrieve the configuration, either from the server or from a local master. + def get_actual_config(facts) + if @local + return get_local_config(facts) + else + begin + Timeout::timeout(self.class.timeout) do + return get_remote_config(facts) + end + rescue Timeout::Error + Puppet.err "Configuration retrieval timed out" + return nil + end + end + end + + # Retrieve a configuration from a local master. + def get_local_config(facts) + # If we're local, we don't have to do any of the conversion + # stuff. + objects = @driver.getconfig(facts, "yaml") + @compile_time = Time.now + + if objects == "" + raise Puppet::Error, "Could not retrieve configuration" + end + + return objects + end + + # Retrieve a config from a remote master. + def get_remote_config(facts) + textobjects = "" + + textfacts = CGI.escape(YAML.dump(facts)) + + benchmark(:debug, "Retrieved configuration") do + # error handling for this is done in the network client + begin + textobjects = @driver.getconfig(textfacts, "yaml") + rescue => detail + Puppet.err "Could not retrieve configuration: %s" % detail + + unless Puppet[:usecacheonfailure] + @objects = nil + Puppet.warning "Not using cache on failed configuration" + return + end + end + end + + fromcache = false + if textobjects == "" + textobjects = self.retrievecache + if textobjects == "" + raise Puppet::Error.new( + "Cannot connect to server and there is no cached configuration" + ) + end + Puppet.warning "Could not get config; using cached copy" + fromcache = true + else + @compile_time = Time.now + Puppet::Util::Storage.cache(:configuration)[:compile_time] = @compile_time + end + + begin + textobjects = CGI.unescape(textobjects) + rescue => detail + raise Puppet::Error, "Could not CGI.unescape configuration" + end + + if @cache and ! fromcache + self.cache(textobjects) + end + + begin + objects = YAML.load(textobjects) + rescue => detail + raise Puppet::Error, + "Could not understand configuration: %s" % + detail.to_s + end + + return objects + end +end + +# $Id$ diff --git a/lib/puppet/network/client/proxy.rb b/lib/puppet/network/client/proxy.rb new file mode 100644 index 000000000..e1295a96f --- /dev/null +++ b/lib/puppet/network/client/proxy.rb @@ -0,0 +1,28 @@ +# unlike the other client classes (again, this design sucks) this class +# is basically just a proxy class -- it calls its methods on the driver +# and that's about it +class Puppet::Network::Client::ProxyClient < Puppet::Network::Client + def self.mkmethods + interface = @handler.interface + namespace = interface.prefix + + + interface.methods.each { |ary| + method = ary[0] + Puppet.debug "%s: defining %s.%s" % [self, namespace, method] + define_method(method) { |*args| + begin + @driver.send(method, *args) + rescue XMLRPC::FaultException => detail + #Puppet.err "Could not call %s.%s: %s" % + # [namespace, method, detail.faultString] + #raise NetworkClientError, + # "XMLRPC Error: %s" % detail.faultString + raise NetworkClientError, detail.faultString + end + } + } + end +end + +# $Id$ diff --git a/lib/puppet/network/client/reporter.rb b/lib/puppet/network/client/reporter.rb new file mode 100644 index 000000000..dd340da02 --- /dev/null +++ b/lib/puppet/network/client/reporter.rb @@ -0,0 +1,34 @@ +class Puppet::Network::Client::Reporter < Puppet::Network::Client + @drivername = :Report + + # set up the appropriate interface methods + @handler = Puppet::Network::Server::Report + + def initialize(hash = {}) + if hash.include?(:Report) + hash[:Report] = Puppet::Network::Server::Report.new() + end + + super(hash) + end + + # Send our report. We get the transaction report and convert it to YAML + # as appropriate. + def report(transreport) + report = YAML.dump(transreport) + + unless self.local + report = CGI.escape(report) + end + + # Now send the report + file = nil + benchmark(:info, "Sent transaction report") do + file = @driver.report(report) + end + + file + end +end + +# $Id$ diff --git a/lib/puppet/network/client/resource.rb b/lib/puppet/network/client/resource.rb new file mode 100644 index 000000000..71a19bf91 --- /dev/null +++ b/lib/puppet/network/client/resource.rb @@ -0,0 +1,65 @@ +class Puppet::Network::Client::Resource < Puppet::Network::Client + @drivername = :ResourceServer + + @handler = Puppet::Network::Server::Resource + + def apply(bucket) + + case bucket + when Puppet::TransObject + tmp = Puppet::TransBucket.new + tmp.push bucket + bucket = tmp + bucket.name = Facter["hostname"].value + bucket.type = "resource" + when Puppet::TransBucket + # nothing + else + raise Puppet::DevError, "You must pass a transportable object, not a %s" % + bucket.class + end + + unless @local + bucket = Base64.encode64(YAML::dump(bucket)) + end + report = @driver.apply(bucket, "yaml") + + return report + end + + def describe(type, name, retrieve = false, ignore = false) + Puppet.info "Describing %s[%s]" % [type.to_s.capitalize, name] + text = @driver.describe(type, name, retrieve, ignore, "yaml") + + object = nil + if @local + object = text + else + object = YAML::load(Base64.decode64(text)) + end + + return object + end + + def initialize(hash = {}) + if hash.include?(:ResourceServer) + unless hash[:ResourceServer].is_a?(Puppet::Network::Server::Resource) + raise Puppet::DevError, "Must pass an actual PElement server object" + end + end + + super(hash) + end + + def list(type, ignore = false, base = false) + bucket = @driver.list(type, ignore, base, "yaml") + + unless @local + bucket = YAML::load(Base64.decode64(bucket)) + end + + return bucket + end +end + +# $Id$ diff --git a/lib/puppet/network/client/runner.rb b/lib/puppet/network/client/runner.rb new file mode 100644 index 000000000..40d13ac86 --- /dev/null +++ b/lib/puppet/network/client/runner.rb @@ -0,0 +1,17 @@ +class Puppet::Network::Client::Runner < Puppet::Network::Client::ProxyClient + @drivername = :Runner + + # set up the appropriate interface methods + @handler = Puppet::Network::Server::Runner + self.mkmethods + + def initialize(hash = {}) + if hash.include?(:Runner) + hash[:Runner] = Puppet::Network::Server::Runner.new() + end + + super(hash) + end +end + +# $Id$ diff --git a/lib/puppet/network/client/status.rb b/lib/puppet/network/client/status.rb new file mode 100644 index 000000000..6c1a96e85 --- /dev/null +++ b/lib/puppet/network/client/status.rb @@ -0,0 +1,7 @@ +class Puppet::Network::Client::StatusClient < Puppet::Network::Client::ProxyClient + # set up the appropriate interface methods + @handler = Puppet::Network::Server::ServerStatus + self.mkmethods +end + +# $Id$ diff --git a/lib/puppet/network/networkclient.rb b/lib/puppet/network/networkclient.rb new file mode 100644 index 000000000..62d8906e0 --- /dev/null +++ b/lib/puppet/network/networkclient.rb @@ -0,0 +1,167 @@ +require 'puppet/sslcertificates' +require 'openssl' +require 'puppet/daemon' +require 'puppet/network/server' +require 'puppet/external/base64' + +require 'webrick' +require 'cgi' +require 'xmlrpc/client' +require 'xmlrpc/server' +require 'yaml' + +module Puppet + module Network + class ClientError < Puppet::Error; end + class NetworkClientError < Puppet::Error; end + class NetworkClient < XMLRPC::Client + attr_accessor :puppet_server, :puppet_port + @clients = {} + + class << self + include Puppet::Util + include Puppet::Util::ClassGen + end + + # Create a netclient for each handler + def self.mkclients + # add the methods associated with each namespace + Puppet::Network::Server::Handler.each { |handler| + interface = handler.interface + namespace = interface.prefix + + # Create a subclass for every client type. This is + # so that all of the methods are on their own class, + # so that they namespaces can define the same methods if + # they want. + constant = handler.to_s.sub(/^.+::/, '') + name = namespace.downcase + newclient = genclass(name, :hash => @clients, + :constant => constant) + + interface.methods.each { |ary| + method = ary[0] + if public_method_defined?(method) + raise Puppet::DevError, "Method %s is already defined" % + method + end + newclient.send(:define_method,method) { |*args| + Puppet.debug "Calling %s.%s" % [namespace, method] + #Puppet.info "peer cert is %s" % @http.peer_cert + #Puppet.info "cert is %s" % @http.cert + begin + call("%s.%s" % [namespace, method.to_s],*args) + rescue OpenSSL::SSL::SSLError => detail + raise NetworkClientError, + "Certificates were not trusted: %s" % detail + rescue XMLRPC::FaultException => detail + #Puppet.err "Could not call %s.%s: %s" % + # [namespace, method, detail.faultString] + #raise NetworkClientError, + # "XMLRPC Error: %s" % detail.faultString + raise NetworkClientError, detail.faultString + rescue Errno::ECONNREFUSED => detail + msg = "Could not connect to %s on port %s" % + [@host, @port] + raise NetworkClientError, msg + rescue SocketError => detail + error = NetworkClientError.new( + "Could not find server %s" % @puppetserver + ) + error.set_backtrace detail.backtrace + raise error + rescue => detail + Puppet.err "Could not call %s.%s: %s" % + [namespace, method, detail.inspect] + error = NetworkClientError.new(detail.to_s) + error.set_backtrace detail.backtrace + raise error + end + } + } + } + end + + def self.netclient(namespace) + if @clients.empty? + self.mkclients() + end + + namespace = symbolize(namespace) + + @clients[namespace] + end + + def ca_file=(cafile) + @http.ca_file = cafile + store = OpenSSL::X509::Store.new + store.add_file(cafile) + store.purpose = OpenSSL::X509::PURPOSE_SSL_CLIENT + @http.cert_store = store + end + + def cert=(cert) + #Puppet.debug "Adding certificate" + @http.cert = cert + @http.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + + def key=(key) + @http.key = key + end + + def initialize(hash) + hash[:Path] ||= "/RPC2" + hash[:Server] ||= "localhost" + hash[:Port] ||= Puppet[:masterport] + + @puppet_server = hash[:Server] + @puppet_port = hash[:Port] + + @puppetserver = hash[:Server] + + super( + hash[:Server], + hash[:Path], + hash[:Port], + nil, # proxy_host + nil, # proxy_port + nil, # user + nil, # password + true, # use_ssl + 120 # a two minute timeout, instead of 30 seconds + ) + + if hash[:Certificate] + self.cert = hash[:Certificate] + else + unless defined? $nocertwarned + Puppet.err "No certificate; running with reduced functionality." + $nocertwarned = true + end + end + + if hash[:Key] + self.key = hash[:Key] + end + + if hash[:CAFile] + self.ca_file = hash[:CAFile] + end + + # from here, i need to add the key, cert, and ca cert + # and reorgize how i start the client + end + + def local + false + end + + def local? + false + end + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server.rb b/lib/puppet/network/server.rb new file mode 100644 index 000000000..e9205d48b --- /dev/null +++ b/lib/puppet/network/server.rb @@ -0,0 +1,207 @@ +# the server +# +# allow things to connect to us and communicate, and stuff + +require 'puppet' +require 'puppet/daemon' +require 'webrick' +require 'webrick/https' +require 'cgi' +require 'xmlrpc/server' +require 'xmlrpc/client' + +module Puppet + class ServerError < RuntimeError; end + module Network + class Server < WEBrick::HTTPServer + include Puppet::Daemon + + Puppet.config.setdefaults(:puppetd, + :listen => [false, "Whether puppetd should listen for + connections. If this is true, then by default only the + ``runner`` server is started, which allows remote authorized + and authenticated nodes to connect and trigger ``puppetd`` + runs."] + ) + + # Create our config object if necessary. This works even if + # there's no configuration file. + def authconfig + unless defined? @authconfig + @authconfig = Puppet::Network::AuthConfig.new() + end + + @authconfig + end + + # Read the CA cert and CRL and populate an OpenSSL::X509::Store + # with them, with flags appropriate for checking client + # certificates for revocation + def x509store + if Puppet[:cacrl] == 'none' + # No CRL, no store needed + return nil + end + unless File.exist?(Puppet[:cacrl]) + raise Puppet::Error, "Could not find CRL" + end + crl = OpenSSL::X509::CRL.new(File.read(Puppet[:cacrl])) + store = OpenSSL::X509::Store.new + store.purpose = OpenSSL::X509::PURPOSE_ANY + store.flags = OpenSSL::X509::V_FLAG_CRL_CHECK_ALL|OpenSSL::X509::V_FLAG_CRL_CHECK + store.add_file(@cacertfile) + store.add_crl(crl) + return store + end + + def initialize(hash = {}) + Puppet.info "Starting server for Puppet version %s" % Puppet.version + daemonize = nil + if hash.include?(:Daemonize) + daemonize = hash[:Daemonize] + end + + # FIXME we should have some kind of access control here, using + # :RequestHandler + hash[:Port] ||= Puppet[:masterport] + hash[:Logger] ||= self.httplog + hash[:AccessLog] ||= [ + [ self.httplog, WEBrick::AccessLog::COMMON_LOG_FORMAT ], + [ self.httplog, WEBrick::AccessLog::REFERER_LOG_FORMAT ] + ] + + if hash.include?(:Handlers) + unless hash[:Handlers].is_a?(Hash) + raise ServerError, "Handlers must have arguments" + end + + @handlers = hash[:Handlers].collect { |handler, args| + hclass = nil + unless hclass = Handler.handler(handler) + raise ServerError, "Invalid handler %s" % handler + end + hclass.new(args) + } + else + raise ServerError, "A server must have handlers" + end + + # okay, i need to retrieve my cert and set it up, somehow + # the default case will be that i'm also the ca + if ca = @handlers.find { |handler| handler.is_a?(Puppet::Network::Server::CA) } + @driver = ca + @secureinit = true + self.fqdn + else + if hash.include?(:NoSecureInit) + @secureinit = false + else + @secureinit = true + end + end + + unless self.readcert + unless self.requestcert + raise Puppet::Error, "Cannot start without certificates" + end + end + + hash[:SSLCertificateStore] = x509store + hash[:SSLCertificate] = @cert + hash[:SSLPrivateKey] = @key + hash[:SSLStartImmediately] = true + hash[:SSLEnable] = true + hash[:SSLCACertificateFile] = @cacertfile + hash[:SSLVerifyClient] = OpenSSL::SSL::VERIFY_PEER + hash[:SSLCertName] = nil + + super(hash) + + Puppet.info "Listening on port %s" % hash[:Port] + + # this creates a new servlet for every connection, + # but all servlets have the same list of handlers + # thus, the servlets can have their own state -- passing + # around the requests and such -- but the handlers + # have a global state + + # mount has to be called after the server is initialized + self.mount("/RPC2", Puppet::Network::Server::Servlet, @handlers) + end + + # the base class for the different handlers + class Handler + attr_accessor :server + class << self + include Puppet::Util + end + + @subclasses = [] + + def self.each + @subclasses.each { |c| yield c } + end + + def self.handler(name) + name = name.to_s.downcase + @subclasses.find { |h| + h.name.to_s.downcase == name + } + end + + def self.inherited(sub) + @subclasses << sub + end + + def self.interface + if defined? @interface + return @interface + else + raise Puppet::DevError, "Handler %s has no defined interface" % + self + end + end + + def self.name + unless defined? @name + @name = self.to_s.sub(/.+::/, '').intern + end + + return @name + end + + def initialize(hash = {}) + end + end + + + class ServerStatus < Handler + + @interface = XMLRPC::Service::Interface.new("status") { |iface| + iface.add_method("int status()") + } + + @name = :Status + + def status(status = nil, client = nil, clientip = nil) + return 1 + end + end + end + end +end + +require 'puppet/network/server/authstore' +require 'puppet/network/server/authconfig' +require 'puppet/network/server/servlet' +require 'puppet/network/server/master' +require 'puppet/network/server/ca' +require 'puppet/network/server/fileserver' +require 'puppet/network/server/filebucket' +require 'puppet/network/server/resource' +require 'puppet/network/server/runner' +require 'puppet/network/server/logger' +require 'puppet/network/server/report' +require 'puppet/network/client' + +# $Id$ diff --git a/lib/puppet/network/server/authconfig.rb b/lib/puppet/network/server/authconfig.rb new file mode 100644 index 000000000..e4d31d8d8 --- /dev/null +++ b/lib/puppet/network/server/authconfig.rb @@ -0,0 +1,173 @@ +require 'puppet/util/loadedfile' +require 'puppet/network/server/rights' + +module Puppet + class ConfigurationError < Puppet::Error; end + class Network::AuthConfig < Puppet::Util::LoadedFile + Puppet.config.setdefaults(:puppet, + :authconfig => [ "$confdir/namespaceauth.conf", + "The configuration file that defines the rights to the different + namespaces and methods. This can be used as a coarse-grained + authorization system for both ``puppetd`` and ``puppetmasterd``." + ] + ) + + # Just proxy the setting methods to our rights stuff + [:allow, :deny].each do |method| + define_method(method) do |*args| + @rights.send(method, *args) + end + end + + # Here we add a little bit of semantics. They can set auth on a whole namespace + # or on just a single method in the namespace. + def allowed?(name, host, ip) + namespace, method = name.to_s.split(".") + unless namespace and method + raise ArgumentError, "Invalid method name %s" % name + end + + name = name.intern if name.is_a? String + namespace = namespace.intern + method = method.intern + + read() + + if @rights.include?(name) + return @rights[name].allowed?(host, ip) + elsif @rights.include?(namespace) + return @rights[namespace].allowed?(host, ip) + else + return false + end + end + + # Does the file exist? Puppetmasterd does not require it, but + # puppetd does. + def exists? + FileTest.exists?(@file) + end + + def initialize(file = nil, parsenow = true) + @file ||= Puppet[:authconfig] + + unless @file + raise Puppet::DevError, "No authconfig file defined" + end + return unless self.exists? + super(@file) + @rights = Puppet::Network::Rights.new + @configstamp = @configstatted = nil + @configtimeout = 60 + + if parsenow + read() + end + end + + # Read the configuration file. + def read + return unless FileTest.exists?(@file) + + if @configstamp + if @configtimeout and @configstatted + if Time.now - @configstatted > @configtimeout + @configstatted = Time.now + tmp = File.stat(@file).ctime + + if tmp == @configstamp + return + else + Puppet.notice "%s vs %s" % [tmp, @configstamp] + end + else + return + end + else + Puppet.notice "%s and %s" % [@configtimeout, @configstatted] + end + end + + parse() + + @configstamp = File.stat(@file).ctime + @configstatted = Time.now + end + + private + + def parse + newrights = Puppet::Network::Rights.new + begin + File.open(@file) { |f| + right = nil + count = 1 + f.each { |line| + case line + when /^\s*#/: next # skip comments + when /^\s*$/: next # skip blank lines + when /\[([\w.]+)\]/: # "namespace" or "namespace.method" + name = $1 + if newrights.include?(name) + raise FileServerError, "%s is already set at %s" % + [newrights[name], name] + end + newrights.newright(name) + right = newrights[name] + when /^\s*(\w+)\s+(.+)$/: + var = $1 + value = $2 + case var + when "allow": + value.split(/\s*,\s*/).each { |val| + begin + right.info "allowing %s access" % val + right.allow(val) + rescue AuthStoreError => detail + raise ConfigurationError, "%s at line %s of %s" % + [detail.to_s, count, @config] + end + } + when "deny": + value.split(/\s*,\s*/).each { |val| + begin + right.info "denying %s access" % val + right.deny(val) + rescue AuthStoreError => detail + raise ConfigurationError, "%s at line %s of %s" % + [detail.to_s, count, @config] + end + } + else + raise ConfigurationError, + "Invalid argument '%s' at line %s" % [var, count] + end + else + raise ConfigurationError, "Invalid line %s: %s" % [count, line] + end + count += 1 + } + } + rescue Errno::EACCES => detail + Puppet.err "Configuration error: Cannot read %s; cannot serve" % @file + #raise Puppet::Error, "Cannot read %s" % @config + rescue Errno::ENOENT => detail + Puppet.err "Configuration error: '%s' does not exit; cannot serve" % + @file + #raise Puppet::Error, "%s does not exit" % @config + #rescue FileServerError => detail + # Puppet.err "FileServer error: %s" % detail + end + + # Verify each of the rights are valid. + # We let the check raise an error, so that it can raise an error + # pointing to the specific problem. + newrights.each { |name, right| + right.valid? + } + @rights = newrights + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/authstore.rb b/lib/puppet/network/server/authstore.rb new file mode 100755 index 000000000..51ce93d46 --- /dev/null +++ b/lib/puppet/network/server/authstore.rb @@ -0,0 +1,227 @@ +# standard module for determining whether a given hostname or IP has access to +# the requested resource + +require 'ipaddr' + +module Puppet + class AuthStoreError < Puppet::Error; end + class AuthorizationError < Puppet::Error; end + + class Network::AuthStore + # This has to be an array, not a hash, else it loses its ordering. + ORDER = [ + [:ip, [:ip]], + [:name, [:hostname, :domain]] + ] + + Puppet::Util.logmethods(self, true) + + def allow(pattern) + # a simple way to allow anyone at all to connect + if pattern == "*" + @globalallow = true + else + store(pattern, @allow) + end + end + + def allowed?(name, ip) + if name or ip + # This is probably unnecessary, and can cause some weirdnesses in + # cases where we're operating over localhost but don't have a real + # IP defined. + unless name and ip + raise Puppet::DevError, "Name and IP must be passed to 'allowed?'" + end + # else, we're networked and such + else + # we're local + return true + end + + # yay insecure overrides + if @globalallow + return true + end + + value = nil + ORDER.each { |nametype, array| + if nametype == :ip + value = IPAddr.new(ip) + else + value = name.split(".").reverse + end + + + array.each { |type| + [[@deny, false], [@allow, true]].each { |ary| + hash, retval = ary + if hash.include?(type) + hash[type].each { |pattern| + if match?(nametype, value, pattern) + return retval + end + } + end + } + } + } + + self.info "defaulting to no access for %s" % name + # default to false + return false + end + + def deny(pattern) + store(pattern, @deny) + end + + def initialize + @globalallow = nil + @allow = Hash.new { |hash, key| + hash[key] = [] + } + @deny = Hash.new { |hash, key| + hash[key] = [] + } + end + + private + + def match?(nametype, value, pattern) + if value == pattern # simplest shortcut + return true + end + + case nametype + when :ip: matchip?(value, pattern) + when :name: matchname?(value, pattern) + else + raise Puppet::DevError, "Invalid match type %s" % nametype + end + end + + def matchip?(value, pattern) + # we're just using builtin stuff for this, thankfully + if pattern.include?(value) + return true + else + return false + end + end + + def matchname?(value, pattern) + # yay, horribly inefficient + if pattern[-1] != '*' # the pattern has no metachars and is not equal + # thus, no match + #Puppet.info "%s is not equal with no * in %s" % [value, pattern] + return false + else + # we know the last field of the pattern is '*' + # if everything up to that doesn't match, we're definitely false + if pattern[0..-2] != value[0..pattern.length-2] + #Puppet.notice "subpatterns didn't match; %s vs %s" % + # [pattern[0..-2], value[0..pattern.length-2]] + return false + end + + case value.length <=> pattern.length + when -1: # value is shorter than pattern + if pattern.length - value.length == 1 + # only ever allowed when the value is the domain of a + # splatted pattern + #Puppet.info "allowing splatted domain %s" % [value] + return true + else + return false + end + when 0: # value is the same length as pattern + if pattern[-1] == "*" + #Puppet.notice "same length with *" + return true + else + return false + end + when 1: # value is longer than pattern + # at this point we've already verified that everything up to + # the '*' in the pattern matches, so we are true + return true + end + end + end + + def store(pattern, hash) + type, value = type(pattern) + + if type and value + # this won't work once we get beyond simple stuff... + hash[type] << value + else + raise AuthStoreError, "Invalid pattern %s" % pattern + end + end + + def type(pattern) + type = value = nil + case pattern + when /^(\d+\.){3}\d+$/: + type = :ip + begin + value = IPAddr.new(pattern) + rescue ArgumentError => detail + raise AuthStoreError, "Invalid IP address pattern %s" % pattern + end + when /^(\d+\.){3}\d+\/(\d+)$/: + mask = Integer($2) + if mask < 1 or mask > 32 + raise AuthStoreError, "Invalid IP mask %s" % mask + end + type = :ip + begin + value = IPAddr.new(pattern) + rescue ArgumentError => detail + raise AuthStoreError, "Invalid IP address pattern %s" % pattern + end + when /^(\d+\.){1,3}\*$/: # an ip address with a '*' at the end + type = :ip + match = $1 + match.sub!(".", '') + ary = pattern.split(".") + + mask = case ary.index(match) + when 0: 8 + when 1: 16 + when 2: 24 + else + raise AuthStoreError, "Invalid IP pattern %s" % pattern + end + + ary.pop + while ary.length < 4 + ary.push("0") + end + + begin + value = IPAddr.new(ary.join(".") + "/" + mask.to_s) + rescue ArgumentError => detail + raise AuthStoreError, "Invalid IP address pattern %s" % pattern + end + when /^[\d.]+$/: # necessary so incomplete IP addresses can't look + # like hostnames + raise AuthStoreError, "Invalid IP address pattern %s" % pattern + when /^([a-zA-Z][-\w]*\.)+[-\w]+$/: # a full hostname + type = :hostname + value = pattern.split(".").reverse + when /^\*(\.([a-zA-Z][-\w]*)){1,}$/: + type = :domain + value = pattern.split(".").reverse + else + raise AuthStoreError, "Invalid pattern %s" % pattern + end + + return [type, value] + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/ca.rb b/lib/puppet/network/server/ca.rb new file mode 100644 index 000000000..8a61399ba --- /dev/null +++ b/lib/puppet/network/server/ca.rb @@ -0,0 +1,152 @@ +require 'openssl' +require 'puppet' +require 'puppet/sslcertificates' +require 'xmlrpc/server' + +# Much of this was taken from QuickCert: +# http://segment7.net/projects/ruby/QuickCert/ + +class Puppet::Network::Server + class CA < Handler + attr_reader :ca + + @interface = XMLRPC::Service::Interface.new("puppetca") { |iface| + iface.add_method("array getcert(csr)") + } + + def autosign + if defined? @autosign + @autosign + else + Puppet[:autosign] + end + end + + # FIXME autosign? should probably accept both hostnames and IP addresses + def autosign?(hostname) + # simple values are easy + if autosign == true or autosign == false + return autosign + end + + # we only otherwise know how to handle files + unless autosign =~ /^\// + raise Puppet::Error, "Invalid autosign value %s" % + autosign.inspect + end + + unless FileTest.exists?(autosign) + unless defined? @@warnedonautosign + @@warnedonautosign = true + Puppet.info "Autosign is enabled but %s is missing" % autosign + end + return false + end + auth = Puppet::Network::AuthStore.new + File.open(autosign) { |f| + f.each { |line| + next if line =~ /^\s*#/ + next if line =~ /^\s*$/ + auth.allow(line.chomp) + } + } + + # for now, just cheat and pass a fake IP address to allowed? + return auth.allowed?(hostname, "127.1.1.1") + end + + def initialize(hash = {}) + Puppet.config.use(:puppet, :certificates, :ca) + if hash.include? :autosign + @autosign = hash[:autosign] + end + + @ca = Puppet::SSLCertificates::CA.new(hash) + end + + # our client sends us a csr, and we either store it for later signing, + # or we sign it right away + def getcert(csrtext, client = nil, clientip = nil) + csr = OpenSSL::X509::Request.new(csrtext) + + # Use the hostname from the CSR, not from the network. + subject = csr.subject + + nameary = subject.to_a.find { |ary| + ary[0] == "CN" + } + + if nameary.nil? + Puppet.err( + "Invalid certificate request: could not retrieve server name" + ) + return "invalid" + end + + hostname = nameary[1] + + unless @ca + Puppet.notice "Host %s asked for signing from non-CA master" % hostname + return "" + end + + # We used to save the public key, but it's basically unnecessary + # and it mucks with the permissions requirements. + # save_pk(hostname, csr.public_key) + + certfile = File.join(Puppet[:certdir], [hostname, "pem"].join(".")) + + # first check to see if we already have a signed cert for the host + cert, cacert = ca.getclientcert(hostname) + if cert and cacert + Puppet.info "Retrieving existing certificate for %s" % hostname + #Puppet.info "Cert: %s; Cacert: %s" % [cert.class, cacert.class] + return [cert.to_pem, cacert.to_pem] + elsif @ca + if self.autosign?(hostname) or client.nil? + if client.nil? + Puppet.info "Signing certificate for CA server" + end + # okay, we don't have a signed cert + # if we're a CA and autosign is turned on, then go ahead and sign + # the csr and return the results + Puppet.info "Signing certificate for %s" % hostname + cert, cacert = @ca.sign(csr) + #Puppet.info "Cert: %s; Cacert: %s" % [cert.class, cacert.class] + return [cert.to_pem, cacert.to_pem] + else # just write out the csr for later signing + if @ca.getclientcsr(hostname) + Puppet.info "Not replacing existing request from %s" % hostname + else + Puppet.notice "Host %s has a waiting certificate request" % + hostname + @ca.storeclientcsr(csr) + end + return ["", ""] + end + else + raise "huh?" + end + end + + private + + # Save the public key. + def save_pk(hostname, public_key) + pkeyfile = File.join(Puppet[:publickeydir], [hostname, "pem"].join('.')) + + if FileTest.exists?(pkeyfile) + currentkey = File.open(pkeyfile) { |k| k.read } + unless currentkey == public_key.to_s + raise Puppet::Error, "public keys for %s differ" % hostname + end + else + File.open(pkeyfile, "w", 0644) { |f| + f.print public_key.to_s + } + end + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/filebucket.rb b/lib/puppet/network/server/filebucket.rb new file mode 100755 index 000000000..77dbbde5e --- /dev/null +++ b/lib/puppet/network/server/filebucket.rb @@ -0,0 +1,167 @@ +#-------------------- +# accept and serve files + + +require 'webrick' +require 'xmlrpc/server' +require 'xmlrpc/client' +require 'facter' +require 'digest/md5' +require 'puppet/external/base64' + +class Puppet::Network::Server + class BucketError < RuntimeError; end + class FileBucket < Handler + Puppet.config.setdefaults("puppetmasterd", + :bucketdir => { + :default => "$vardir/bucket", + :mode => 0750, + :owner => "$user", + :group => "$group", + :desc => "Where FileBucket files are stored." + } + ) + + Puppet.config.setdefaults("filebucket", + :clientbucketdir => { + :default => "$vardir/clientbucket", + :mode => 0750, + :desc => "Where FileBucket files are stored locally." + } + ) + @interface = XMLRPC::Service::Interface.new("puppetbucket") { |iface| + iface.add_method("string addfile(string, string)") + iface.add_method("string getfile(string)") + } + + Puppet::Util.logmethods(self, true) + attr_reader :name, :path + + # this doesn't work for relative paths + def FileBucket.paths(base,md5) + return [ + File.join(base, md5), + File.join(base, md5, "contents"), + File.join(base, md5, "paths") + ] + end + + def initialize(hash) + if hash.include?(:ConflictCheck) + @conflictchk = hash[:ConflictCheck] + hash.delete(:ConflictCheck) + else + @conflictchk = true + end + + if hash.include?(:Path) + @path = hash[:Path] + hash.delete(:Path) + else + if defined? Puppet + @path = Puppet[:bucketdir] + else + @path = File.expand_path("~/.filebucket") + end + end + + Puppet.config.use(:filebucket) + + @name = "Filebucket[#{@path}]" + end + + # accept a file from a client + def addfile(contents, path, client = nil, clientip = nil) + if client + contents = Base64.decode64(contents) + end + md5 = Digest::MD5.hexdigest(contents) + + bpath, bfile, pathpath = FileBucket.paths(@path,md5) + + # if it's a new directory... + if Puppet.recmkdir(bpath) + msg = "Adding %s(%s)" % [path, md5] + msg += " from #{client}" if client + self.info msg + # ...then just create the file + File.open(bfile, File::WRONLY|File::CREAT, 0440) { |of| + of.print contents + } + else # if the dir already existed... + # ...we need to verify that the contents match the existing file + if @conflictchk + unless FileTest.exists?(bfile) + raise(BucketError, + "No file at %s for sum %s" % [bfile,md5], caller) + end + + curfile = File.read(bfile) + + # If the contents don't match, then we've found a conflict. + # Unlikely, but quite bad. + if curfile != contents + raise(BucketError, + "Got passed new contents for sum %s" % md5, caller) + else + msg = "Got duplicate %s(%s)" % [path, md5] + msg += " from #{client}" if client + self.info msg + end + end + end + + contents = "" + + # in either case, add the passed path to the list of paths + paths = nil + addpath = false + if FileTest.exists?(pathpath) + File.open(pathpath) { |of| + paths = of.readlines.collect { |l| l.chomp } + } + + # unless our path is already there... + unless paths.include?(path) + addpath = true + end + else + addpath = true + end + + # if it's a new file, or if our path isn't in the file yet, add it + if addpath + File.open(pathpath, File::WRONLY|File::CREAT|File::APPEND) { |of| + of.puts path + } + end + + return md5 + end + + def getfile(md5, client = nil, clientip = nil) + bpath, bfile, bpaths = FileBucket.paths(@path,md5) + + unless FileTest.exists?(bfile) + return false + end + + contents = nil + File.open(bfile) { |of| + contents = of.read + } + + if client + return Base64.encode64(contents) + else + return contents + end + end + + def to_s + self.name + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/fileserver.rb b/lib/puppet/network/server/fileserver.rb new file mode 100755 index 000000000..904d497ca --- /dev/null +++ b/lib/puppet/network/server/fileserver.rb @@ -0,0 +1,589 @@ +require 'puppet' +require 'webrick/httpstatus' +require 'cgi' +require 'delegate' + +class Puppet::Network::Server + class FileServerError < Puppet::Error; end + class FileServer < Handler + attr_accessor :local + + Puppet.setdefaults("fileserver", + :fileserverconfig => ["$confdir/fileserver.conf", + "Where the fileserver configuration is stored."]) + + CHECKPARAMS = [:mode, :type, :owner, :group, :checksum] + + @interface = XMLRPC::Service::Interface.new("fileserver") { |iface| + iface.add_method("string describe(string, string)") + iface.add_method("string list(string, string, boolean, array)") + iface.add_method("string retrieve(string, string)") + } + + # Describe a given file. This returns all of the manageable aspects + # of that file. + def describe(url, links = :ignore, client = nil, clientip = nil) + links = links.intern if links.is_a? String + + if links == :manage + raise Puppet::Network::Server::FileServerError, "Cannot currently copy links" + end + + mount, path = convert(url, client, clientip) + + if client + mount.debug "Describing %s for %s" % [url, client] + end + + obj = nil + unless obj = mount.check(path, links) + return "" + end + + desc = [] + CHECKPARAMS.each { |check| + if property = obj.property(check) + unless property.is + mount.debug "Manually retrieving info for %s" % check + property.retrieve + end + desc << property.is + else + if check == "checksum" and obj.property(:type).is == "file" + mount.notice "File %s does not have data for %s" % + [obj.name, check] + end + desc << nil + end + } + + return desc.join("\t") + end + + # Create a new fileserving module. + def initialize(hash = {}) + @mounts = {} + @files = {} + + if hash[:Local] + @local = hash[:Local] + else + @local = false + end + + if hash[:Config] == false + @noreadconfig = true + else + @config = Puppet::Util::LoadedFile.new( + hash[:Config] || Puppet[:fileserverconfig] + ) + @noreadconfig = false + end + + if hash.include?(:Mount) + @passedconfig = true + unless hash[:Mount].is_a?(Hash) + raise Puppet::DevError, "Invalid mount hash %s" % + hash[:Mount].inspect + end + + hash[:Mount].each { |dir, name| + if FileTest.exists?(dir) + self.mount(dir, name) + end + } + else + @passedconfig = false + readconfig(false) # don't check the file the first time. + end + end + + # List a specific directory's contents. + def list(url, links = :ignore, recurse = false, ignore = false, client = nil, clientip = nil) + mount, path = convert(url, client, clientip) + + if client + mount.debug "Listing %s for %s" % [url, client] + end + + obj = nil + unless FileTest.exists?(path) + return "" + end + + # We pass two paths here, but reclist internally changes one + # of the arguments when called internally. + desc = reclist(mount, path, path, recurse, ignore) + + if desc.length == 0 + mount.notice "Got no information on //%s/%s" % + [mount, path] + return "" + end + + desc.collect { |sub| + sub.join("\t") + }.join("\n") + end + + def local? + self.local + end + + # Mount a new directory with a name. + def mount(path, name) + if @mounts.include?(name) + if @mounts[name] != path + raise FileServerError, "%s is already mounted at %s" % + [@mounts[name].path, name] + else + # it's already mounted; no problem + return + end + end + + # Let the mounts do their own error-checking. + @mounts[name] = Mount.new(name, path) + @mounts[name].info "Mounted %s" % path + + return @mounts[name] + end + + # Retrieve a file from the local disk and pass it to the remote + # client. + def retrieve(url, links = :ignore, client = nil, clientip = nil) + links = links.intern if links.is_a? String + + mount, path = convert(url, client, clientip) + + if client + mount.info "Sending %s to %s" % [url, client] + end + + unless FileTest.exists?(path) + return "" + end + + links = links.intern if links.is_a? String + + if links == :ignore and FileTest.symlink?(path) + return "" + end + + str = nil + if links == :manage + raise Puppet::Error, "Cannot copy links yet." + else + str = File.read(path) + end + + if @local + return str + else + return CGI.escape(str) + end + end + + def umount(name) + @mounts.delete(name) if @mounts.include? name + end + + private + + def authcheck(file, mount, client, clientip) + # If we're local, don't bother passing in information. + if local? + client = nil + clientip = nil + end + unless mount.allowed?(client, clientip) + mount.warning "%s cannot access %s" % + [client, file] + raise Puppet::AuthorizationError, "Cannot access %s" % mount + end + end + + def convert(url, client, clientip) + readconfig + + url = URI.unescape(url) + + mount, stub = splitpath(url, client) + + authcheck(url, mount, client, clientip) + + path = nil + unless path = mount.subdir(stub, client) + mount.notice "Could not find subdirectory %s" % + "//%s/%s" % [mount, stub] + return "" + end + + return mount, path + end + + # Deal with ignore parameters. + def handleignore(children, path, ignore) + ignore.each { |ignore| + Dir.glob(File.join(path,ignore), File::FNM_DOTMATCH) { |match| + children.delete(File.basename(match)) + } + } + return children + end + + # Read the configuration file. + def readconfig(check = true) + return if @noreadconfig + + if check and ! @config.changed? + return + end + + newmounts = {} + begin + File.open(@config.file) { |f| + mount = nil + count = 1 + f.each { |line| + case line + when /^\s*#/: next # skip comments + when /^\s*$/: next # skip blank lines + when /\[(\w+)\]/: + name = $1 + if newmounts.include?(name) + raise FileServerError, "%s is already mounted at %s" % + [newmounts[name], name], count, @config.file + end + mount = Mount.new(name) + newmounts[name] = mount + when /^\s*(\w+)\s+(.+)$/: + var = $1 + value = $2 + case var + when "path": + begin + mount.path = value + rescue FileServerError => detail + Puppet.err "Removing mount %s: %s" % + [mount.name, detail] + newmounts.delete(mount.name) + end + when "allow": + value.split(/\s*,\s*/).each { |val| + begin + mount.info "allowing %s access" % val + mount.allow(val) + rescue AuthStoreError => detail + raise FileServerError.new(detail.to_s, + count, @config.file) + end + } + when "deny": + value.split(/\s*,\s*/).each { |val| + begin + mount.info "denying %s access" % val + mount.deny(val) + rescue AuthStoreError => detail + raise FileServerError.new(detail.to_s, + count, @config.file) + end + } + else + raise FileServerError.new("Invalid argument '%s'" % var, + count, @config.file) + end + else + raise FileServerError.new("Invalid line '%s'" % line.chomp, + count, @config.file) + end + count += 1 + } + } + rescue Errno::EACCES => detail + Puppet.err "FileServer error: Cannot read %s; cannot serve" % @config + #raise Puppet::Error, "Cannot read %s" % @config + rescue Errno::ENOENT => detail + Puppet.err "FileServer error: '%s' does not exist; cannot serve" % + @config + #raise Puppet::Error, "%s does not exit" % @config + #rescue FileServerError => detail + # Puppet.err "FileServer error: %s" % detail + end + + # Verify each of the mounts are valid. + # We let the check raise an error, so that it can raise an error + # pointing to the specific problem. + newmounts.each { |name, mount| + unless mount.valid? + raise FileServerError, "No path specified for mount %s" % + name + end + } + @mounts = newmounts + end + + # Recursively list the directory. FIXME This should be using + # puppet objects, not directly listing. + def reclist(mount, root, path, recurse, ignore) + # Take out the root of the path. + name = path.sub(root, '') + if name == "" + name = "/" + end + + if name == path + raise FileServerError, "Could not match %s in %s" % + [root, path] + end + + desc = [name] + ftype = File.stat(path).ftype + + desc << ftype + if recurse.is_a?(Integer) + recurse -= 1 + end + + ary = [desc] + if recurse == true or (recurse.is_a?(Integer) and recurse > -1) + if ftype == "directory" + children = Dir.entries(path) + if ignore + children = handleignore(children, path, ignore) + end + children.each { |child| + next if child =~ /^\.\.?$/ + reclist(mount, root, File.join(path, child), recurse, ignore).each { |cobj| + ary << cobj + } + } + end + end + + return ary.reject { |c| c.nil? } + end + + # Split the path into the separate mount point and path. + def splitpath(dir, client) + # the dir is based on one of the mounts + # so first retrieve the mount path + mount = nil + path = nil + if dir =~ %r{/(\w+)/?} + tmp = $1 + path = dir.sub(%r{/#{tmp}/?}, '') + + unless mount = @mounts[tmp] + raise FileServerError, "Fileserver module '%s' not mounted" % tmp + end + else + raise FileServerError, "Fileserver error: Invalid path '%s'" % dir + end + + if path == "" + path = nil + else + # Remove any double slashes that might have occurred + path = URI.unescape(path.gsub(/\/\//, "/")) + end + + return mount, path + end + + def to_s + "fileserver" + end + + # A simple class for wrapping mount points. Instances of this class + # don't know about the enclosing object; they're mainly just used for + # authorization. + class Mount < Puppet::Network::AuthStore + attr_reader :name + + Puppet::Util.logmethods(self, true) + + # Run 'retrieve' on a file. This gets the actual parameters, so + # we can pass them to the client. + def check(dir, links) + unless FileTest.exists?(dir) + self.notice "File source %s does not exist" % dir + return nil + end + + obj = fileobj(dir, links) + + # FIXME we should really have a timeout here -- we don't + # want to actually check on every connection, maybe no more + # than every 60 seconds or something. It'd be nice if we + # could use the builtin scheduling to do this. + + # Retrieval is enough here, because we don't want to cache + # any information in the state file, and we don't want to generate + # any state changes or anything. We don't even need to sync + # the checksum, because we're always going to hit the disk + # directly. + obj.retrieve + + return obj + end + + # Create a map for a specific client. + def clientmap(client) + { + "h" => client.sub(/\..*$/, ""), + "H" => client, + "d" => client.sub(/[^.]+\./, "") # domain name + } + end + + # Replace % patterns as appropriate. + def expand(path, client = nil) + # This map should probably be moved into a method. + map = nil + + if client + map = clientmap(client) + else + Puppet.notice "No client; expanding '%s' with local host" % + path + # Else, use the local information + map = localmap() + end + path.gsub(/%(.)/) do |v| + key = $1 + if key == "%" + "%" + else + map[key] || v + end + end + end + + # Do we have any patterns in our path, yo? + def expandable? + if defined? @expandable + @expandable + else + false + end + end + + # Create out object. It must have a name. + def initialize(name, path = nil) + unless name =~ %r{^\w+$} + raise FileServerError, "Invalid name format '%s'" % name + end + @name = name + + if path + self.path = path + else + @path = nil + end + + super() + end + + def fileobj(path, links) + obj = nil + if obj = Puppet.type(:file)[path] + # This can only happen in local fileserving, but it's an + # important one. It'd be nice if we didn't just set + # the check params every time, but I'm not sure it's worth + # the effort. + obj[:check] = CHECKPARAMS + else + obj = Puppet.type(:file).create( + :name => path, + :check => CHECKPARAMS + ) + end + + if links == :manage + links = :follow + end + + # This, ah, might be completely redundant + unless obj[:links] == links + obj[:links] = links + end + + return obj + end + + # Cache this manufactured map, since if it's used it's likely + # to get used a lot. + def localmap + unless defined? @@localmap + @@localmap = { + "h" => Facter.value("hostname"), + "H" => [Facter.value("hostname"), + Facter.value("domain")].join("."), + "d" => Facter.value("domain") + } + end + @@localmap + end + + # Return the path as appropriate, expanding as necessary. + def path(client = nil) + if expandable? + return expand(@path, client) + else + return @path + end + end + + # Set the path. + def path=(path) + # FIXME: For now, just don't validate paths with replacement + # patterns in them. + if path =~ /%./ + # Mark that we're expandable. + @expandable = true + else + unless FileTest.exists?(path) + raise FileServerError, "%s does not exist" % path + end + unless FileTest.directory?(path) + raise FileServerError, "%s is not a directory" % path + end + unless FileTest.readable?(path) + raise FileServerError, "%s is not readable" % path + end + @expandable = false + end + @path = path + end + + # Retrieve a specific directory relative to a mount point. + # If they pass in a client, then expand as necessary. + def subdir(dir = nil, client = nil) + basedir = self.path(client) + + dirname = if dir + File.join(basedir, dir.split("/").join(File::SEPARATOR)) + else + basedir + end + + dirname + end + + def to_s + "mount[#{@name}]" + end + + # Verify our configuration is valid. This should really check to + # make sure at least someone will be allowed, but, eh. + def valid? + return false unless @path + + return true + end + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/logger.rb b/lib/puppet/network/server/logger.rb new file mode 100755 index 000000000..f6bf9ba88 --- /dev/null +++ b/lib/puppet/network/server/logger.rb @@ -0,0 +1,52 @@ +require 'yaml' + +class Puppet::Network::Server + class LoggerError < RuntimeError; end + + # Receive logs from remote hosts. + class Logger < Handler + @interface = XMLRPC::Service::Interface.new("puppetlogger") { |iface| + iface.add_method("void addlog(string)") + } + + # accept a log message from a client, and route it accordingly + def addlog(message, client = nil, clientip = nil) + unless message + raise Puppet::DevError, "Did not receive message" + end + + Puppet.info message.inspect + # if the client is set, then we're not local + if client + begin + message = YAML.load(CGI.unescape(message)) + #message = message + rescue => detail + raise XMLRPC::FaultException.new( + 1, "Could not unYAML log message from %s" % client + ) + end + end + + unless message + raise Puppet::DevError, "Could not resurrect message" + end + + # Mark it as remote, so it's not sent to syslog + message.remote = true + + if client + if ! message.source or message.source == "Puppet" + message.source = client + end + end + + Puppet::Util::Log.newmessage(message) + + # This is necessary or XMLRPC gets all pukey + return "" + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/master.rb b/lib/puppet/network/server/master.rb new file mode 100644 index 000000000..b7096cd6d --- /dev/null +++ b/lib/puppet/network/server/master.rb @@ -0,0 +1,212 @@ +require 'openssl' +require 'puppet' +require 'puppet/parser/interpreter' +require 'puppet/sslcertificates' +require 'xmlrpc/server' +require 'yaml' + +class Puppet::Network::Server + class MasterError < Puppet::Error; end + class Master < Handler + include Puppet::Util + + attr_accessor :ast, :local + attr_reader :ca + + @interface = XMLRPC::Service::Interface.new("puppetmaster") { |iface| + iface.add_method("string getconfig(string)") + iface.add_method("int freshness()") + } + + # FIXME At some point, this should be autodocumenting. + def addfacts(facts) + # Add our server version to the fact list + facts["serverversion"] = Puppet.version.to_s + + # And then add the server name and IP + {"servername" => "hostname", + "serverip" => "ipaddress" + }.each do |var, fact| + if obj = Facter[fact] + facts[var] = obj.value + else + Puppet.warning "Could not retrieve fact %s" % fact + end + end + end + + # Manipulate the client name as appropriate. + def clientname(name, ip, facts) + # Always use the hostname from Facter. + client = facts["hostname"] + clientip = facts["ipaddress"] + if Puppet[:node_name] == 'cert' + if name + client = name + end + if ip + clientip = ip + end + end + + return client, clientip + end + + # Tell a client whether there's a fresh config for it + def freshness(client = nil, clientip = nil) + if Puppet.features.rails? and Puppet[:storeconfigs] + host = Puppet::Rails::Host.find_or_create_by_name(client) + host.last_freshcheck = Time.now + if clientip and (! host.ip or host.ip == "") + host.ip = clientip + end + host.save + end + if defined? @interpreter + return @interpreter.parsedate + else + return 0 + end + end + + def initialize(hash = {}) + args = {} + + # Allow specification of a code snippet or of a file + if code = hash[:Code] + args[:Code] = code + else + args[:Manifest] = hash[:Manifest] || Puppet[:manifest] + end + + if hash[:Local] + @local = hash[:Local] + else + @local = false + end + + args[:Local] = @local + + if hash.include?(:CA) and hash[:CA] + @ca = Puppet::SSLCertificates::CA.new() + else + @ca = nil + end + + Puppet.debug("Creating interpreter") + + if hash.include?(:UseNodes) + args[:UseNodes] = hash[:UseNodes] + elsif @local + args[:UseNodes] = false + end + + # This is only used by the cfengine module, or if --loadclasses was + # specified in +puppet+. + if hash.include?(:Classes) + args[:Classes] = hash[:Classes] + end + + @interpreter = Puppet::Parser::Interpreter.new(args) + end + + def getconfig(facts, format = "marshal", client = nil, clientip = nil) + if @local + # we don't need to do anything, since we should already + # have raw objects + Puppet.debug "Our client is local" + else + Puppet.debug "Our client is remote" + + # XXX this should definitely be done in the protocol, somehow + case format + when "marshal": + Puppet.warning "You should upgrade your client. 'Marshal' will not be supported much longer." + begin + facts = Marshal::load(CGI.unescape(facts)) + rescue => detail + raise XMLRPC::FaultException.new( + 1, "Could not rebuild facts" + ) + end + when "yaml": + begin + facts = YAML.load(CGI.unescape(facts)) + rescue => detail + raise XMLRPC::FaultException.new( + 1, "Could not rebuild facts" + ) + end + else + raise XMLRPC::FaultException.new( + 1, "Unavailable config format %s" % format + ) + end + end + + client, clientip = clientname(client, clientip, facts) + + # Add any server-side facts to our server. + addfacts(facts) + + retobjects = nil + + # This is hackish, but there's no "silence" option for benchmarks + # right now + if @local + #begin + retobjects = @interpreter.run(client, facts) + #rescue Puppet::Error => detail + # Puppet.err detail + # raise XMLRPC::FaultException.new( + # 1, detail.to_s + # ) + #rescue => detail + # Puppet.err detail.to_s + # return "" + #end + else + benchmark(:notice, "Compiled configuration for %s" % client) do + begin + retobjects = @interpreter.run(client, facts) + rescue Puppet::Error => detail + Puppet.err detail + raise XMLRPC::FaultException.new( + 1, detail.to_s + ) + rescue => detail + Puppet.err detail.to_s + return "" + end + end + end + + if @local + return retobjects + else + str = nil + case format + when "marshal": + str = Marshal::dump(retobjects) + when "yaml": + str = YAML.dump(retobjects) + else + raise XMLRPC::FaultException.new( + 1, "Unavailable config format %s" % format + ) + end + return CGI.escape(str) + end + end + + def local? + if defined? @local and @local + return true + else + return false + end + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/report.rb b/lib/puppet/network/server/report.rb new file mode 100755 index 000000000..cd0214e30 --- /dev/null +++ b/lib/puppet/network/server/report.rb @@ -0,0 +1,174 @@ +# A simple server for triggering a new run on a Puppet client. +class Puppet::Network::Server + class Report < Handler + class << self + include Puppet::Util::ClassGen + end + + module ReportBase + include Puppet::Util::Docs + attr_writer :useyaml + + def useyaml? + if defined? @useyaml + @useyaml + else + false + end + end + end + + @interface = XMLRPC::Service::Interface.new("puppetreports") { |iface| + iface.add_method("string report(array)") + } + + Puppet.setdefaults(:reporting, + :reports => ["store", + "The list of reports to generate. All reports are looked for + in puppet/reports/<name>.rb, and multiple report names should be + comma-separated (whitespace is okay)." + ], + :reportdir => {:default => "$vardir/reports", + :mode => 0750, + :owner => "$user", + :group => "$group", + :desc => "The directory in which to store reports + received from the client. Each client gets a separate + subdirectory."} + ) + + @reports = {} + @reportloader = Puppet::Util::Autoload.new(self, "puppet/reports") + + class << self + attr_reader :hooks + end + + # Add a new report type. + def self.newreport(name, options = {}, &block) + name = symbolize(name) + + mod = genmodule(name, :extend => ReportBase, :hash => @reports, :block => block) + + if options[:useyaml] + mod.useyaml = true + end + + mod.send(:define_method, :report_name) do + name + end + end + + # Load a report. + def self.report(name) + name = name.intern if name.is_a? String + unless @reports.include? name + if @reportloader.load(name) + unless @reports.include? name + Puppet.warning( + "Loaded report file for %s but report was not defined" % + name + ) + return nil + end + else + return nil + end + end + @reports[symbolize(name)] + end + + def self.reportdocs + docs = "" + + # Use this method so they all get loaded + reports.sort { |a,b| a.to_s <=> b.to_s }.each do |name| + mod = self.report(name) + docs += "## %s\n\n" % name + + docs += Puppet::Util::Docs.scrub(mod.doc) + "\n\n" + end + + docs + end + + def self.reports + @reportloader.loadall + @reports.keys + end + + def initialize(*args) + super + Puppet.config.use(:reporting) + Puppet.config.use(:metrics) + end + + # Accept a report from a client. + def report(report, client = nil, clientip = nil) + # Unescape the report + unless @local + report = CGI.unescape(report) + end + + begin + process(report) + rescue => detail + Puppet.err "Could not process report %s: %s" % [$1, detail] + if Puppet[:trace] + puts detail.backtrace + end + end + end + + private + + # Process the report using all of the existing hooks. + def process(yaml) + return if Puppet[:reports] == "none" + + # First convert the report to real objects + begin + report = YAML.load(yaml) + rescue => detail + Puppet.warning "Could not load report: %s" % detail + return + end + + # Used for those reports that accept yaml + client = report.host + + reports().each do |name| + if mod = self.class.report(name) + Puppet.info "Processing report %s for %s" % [name, client] + + # We have to use a dup because we're including a module in the + # report. + newrep = report.dup + begin + newrep.extend(mod) + if mod.useyaml? + newrep.process(yaml) + else + newrep.process + end + rescue => detail + if Puppet[:trace] + puts detail.backtrace + end + Puppet.err "Report %s failed: %s" % + [name, detail] + end + else + Puppet.warning "No report named '%s'" % name + end + end + end + + # Handle the parsing of the reports attribute. + def reports + Puppet[:reports].gsub(/(^\s+)|(\s+$)/, '').split(/\s*,\s*/) + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/resource.rb b/lib/puppet/network/server/resource.rb new file mode 100755 index 000000000..37e331a13 --- /dev/null +++ b/lib/puppet/network/server/resource.rb @@ -0,0 +1,190 @@ +require 'puppet' +require 'puppet/network/server' + +# Serve Puppet elements. Useful for querying, copying, and, um, other stuff. +class Puppet::Network::Server + class Resource < Handler + attr_accessor :local + + @interface = XMLRPC::Service::Interface.new("resource") { |iface| + iface.add_method("string apply(string, string)") + iface.add_method("string describe(string, string, array, array)") + iface.add_method("string list(string, array, string)") + } + + # Apply a TransBucket as a transaction. + def apply(bucket, format = "yaml", client = nil, clientip = nil) + unless @local + begin + case format + when "yaml": + bucket = YAML::load(Base64.decode64(bucket)) + else + raise Puppet::Error, "Unsupported format '%s'" % format + end + rescue => detail + raise Puppet::Error, "Could not load YAML TransBucket: %s" % detail + end + end + + component = bucket.to_type + + # Create a client, but specify the remote machine as the server + # because the class requires it, even though it's unused + client = Puppet::Network::Client::MasterClient.new(:Server => client||"localhost") + + # Set the objects + client.objects = component + + # And then apply the configuration. This way we're reusing all + # the code in there. It should probably just be separated out, though. + transaction = client.apply + + # And then clean up + component.remove + + # It'd be nice to return some kind of report, but... at this point + # we have no such facility. + return "success" + end + + # Describe a given object. This returns the 'is' values for every property + # available on the object type. + def describe(type, name, retrieve = nil, ignore = [], format = "yaml", client = nil, clientip = nil) + Puppet.info "Describing %s[%s]" % [type.to_s.capitalize, name] + @local = true unless client + typeklass = nil + unless typeklass = Puppet.type(type) + raise Puppet::Error, "Puppet type %s is unsupported" % type + end + + obj = nil + + retrieve ||= :all + ignore ||= [] + + if obj = typeklass[name] + obj[:check] = retrieve + else + begin + obj = typeklass.create(:name => name, :check => retrieve) + rescue Puppet::Error => detail + raise Puppet::Error, "%s[%s] could not be created: %s" % + [type, name, detail] + end + end + + unless obj + raise XMLRPC::FaultException.new( + 1, "Could not create %s[%s]" % [type, name] + ) + end + + trans = obj.to_trans + + # Now get rid of any attributes they specifically don't want + ignore.each do |st| + if trans.include? st + trans.delete(st) + end + end + + # And get rid of any attributes that are nil + trans.each do |attr, value| + if value.nil? + trans.delete(attr) + end + end + + unless @local + case format + when "yaml": + trans = Base64.encode64(YAML::dump(trans)) + else + raise XMLRPC::FaultException.new( + 1, "Unavailable config format %s" % format + ) + end + end + + return trans + end + + # Create a new fileserving module. + def initialize(hash = {}) + if hash[:Local] + @local = hash[:Local] + else + @local = false + end + end + + # List all of the elements of a given type. + def list(type, ignore = [], base = nil, format = "yaml", client = nil, clientip = nil) + @local = true unless client + typeklass = nil + unless typeklass = Puppet.type(type) + raise Puppet::Error, "Puppet type %s is unsupported" % type + end + + # They can pass in false + ignore ||= [] + ignore = [ignore] unless ignore.is_a? Array + bucket = Puppet::TransBucket.new + bucket.type = typeklass.name + + typeklass.list.each do |obj| + next if ignore.include? obj.name + + object = Puppet::TransObject.new(obj.name, typeklass.name) + bucket << object + end + + unless @local + case format + when "yaml": + begin + bucket = Base64.encode64(YAML::dump(bucket)) + rescue => detail + Puppet.err detail + raise XMLRPC::FaultException.new( + 1, detail.to_s + ) + end + else + raise XMLRPC::FaultException.new( + 1, "Unavailable config format %s" % format + ) + end + end + + return bucket + end + + private + + def authcheck(file, mount, client, clientip) + unless mount.allowed?(client, clientip) + mount.warning "%s cannot access %s" % + [client, file] + raise Puppet::AuthorizationError, "Cannot access %s" % mount + end + end + + # Deal with ignore parameters. + def handleignore(children, path, ignore) + ignore.each { |ignore| + Dir.glob(File.join(path,ignore), File::FNM_DOTMATCH) { |match| + children.delete(File.basename(match)) + } + } + return children + end + + def to_s + "resource" + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/rights.rb b/lib/puppet/network/server/rights.rb new file mode 100755 index 000000000..11da3b705 --- /dev/null +++ b/lib/puppet/network/server/rights.rb @@ -0,0 +1,74 @@ +require 'ipaddr' +require 'puppet/network/server/authstore' + +# Define a set of rights and who has access to them. +class Puppet::Network::Rights < Hash + # We basically just proxy directly to our rights. Each Right stores + # its own auth abilities. + [:allow, :allowed?, :deny].each do |method| + define_method(method) do |name, *args| + name = name.intern if name.is_a? String + + if obj = right(name) + obj.send(method, *args) + else + raise ArgumentError, "Unknown right '%s'" % name + end + end + end + + def [](name) + name = name.intern if name.is_a? String + super(name) + end + + # Define a new right to which access can be provided. + def newright(name) + name = name.intern if name.is_a? String + shortname = Right.shortname(name) + if self.include? name + raise ArgumentError, "Right '%s' is already defined" % name + else + self[name] = Right.new(name, shortname) + end + end + + private + + # Retrieve a right by name. + def right(name) + name = name.intern if name.is_a? String + self[name] + end + + # A right. + class Right < Puppet::Network::AuthStore + attr_accessor :name, :shortname + + Puppet::Util.logmethods(self, true) + + def self.shortname(name) + name.to_s[0..0] + end + + def initialize(name, shortname = nil) + @name = name + @shortname = shortname + unless @shortname + @shortname = Right.shortname(name) + end + super() + end + + def to_s + "access[%s]" % @name + end + + # There's no real check to do at this point + def valid? + true + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/runner.rb b/lib/puppet/network/server/runner.rb new file mode 100755 index 000000000..c0ec8fb9d --- /dev/null +++ b/lib/puppet/network/server/runner.rb @@ -0,0 +1,61 @@ +class Puppet::Network::Server + class MissingMasterError < RuntimeError; end # Cannot find the master client + # A simple server for triggering a new run on a Puppet client. + class Runner < Handler + @interface = XMLRPC::Service::Interface.new("puppetrunner") { |iface| + iface.add_method("string run(string, string)") + } + + # Run the client configuration right now, optionally specifying + # tags and whether to ignore schedules + def run(tags = nil, ignoreschedules = false, fg = true, client = nil, clientip = nil) + # We need to retrieve the client + master = Puppet::Network::Client::MasterClient.instance + + unless master + raise MissingMasterError, "Could not find the master client" + end + + if Puppet::Util::Pidlock.new(Puppet[:puppetdlockfile]).locked? + Puppet.notice "Could not trigger run; already running" + return "running" + end + + if tags == "" + tags = nil + end + + if ignoreschedules == "" + ignoreschedules == nil + end + + msg = "" + if client + msg = "%s(%s) " % [client, clientip] + end + msg += "triggered run" % + if tags + msg += " with tags %s" % tags + end + + if ignoreschedules + msg += " without schedules" + end + + Puppet.notice msg + + # And then we need to tell it to run, with this extra info. + if fg + master.run(tags, ignoreschedules) + else + Puppet.newthread do + master.run(tags, ignoreschedules) + end + end + + return "success" + end + end +end + +# $Id$ diff --git a/lib/puppet/network/server/servlet.rb b/lib/puppet/network/server/servlet.rb new file mode 100644 index 000000000..0a7253eff --- /dev/null +++ b/lib/puppet/network/server/servlet.rb @@ -0,0 +1,277 @@ +require 'xmlrpc/server' + +class Puppet::Network::Server + class ServletError < RuntimeError; end + class Servlet < XMLRPC::WEBrickServlet + ERR_UNAUTHORIZED = 30 + + attr_accessor :request + + # this is just a duplicate of the normal method; it's here for + # debugging when i need it + def self.get_instance(server, *options) + self.new(server, *options) + end + + # This is a hackish way to avoid an auth message every time we have a + # normal operation + def self.log(msg) + unless defined? @logs + @logs = {} + end + if @logs.include?(msg) + @logs[msg] += 1 + else + Puppet.info msg + @logs[msg] = 1 + end + end + + def add_handler(interface, handler) + @loadedhandlers << interface.prefix + super + end + + # Verify that our client has access. We allow untrusted access to + # puppetca methods but no others. + def authorize(request, method) + namespace = method.sub(/\..+/, '') + client = request.peeraddr[2] + if defined? @client and @client + client = @client + end + ip = request.peeraddr[3] + if request.client_cert + if @puppetserver.authconfig.exists? + allowed = @puppetserver.authconfig.allowed?(method, client, ip) + + if allowed + Puppet.debug "Allowing %s(%s) trusted access to %s" % + [client, ip, method] + return true + else + Puppet.debug "Denying %s(%s) trusted access to %s" % + [client, ip, method] + return false + end + else + # This is pretty hackish, but... + # This means we can't actually test this method at this point. + # The next release of Puppet will almost definitely require + # this file to exist or will default to denying all access. + if Puppet.name == "puppetmasterd" or defined? Test::Unit::TestCase + Puppet.debug "Allowing %s(%s) trusted access to %s" % + [client, ip, method] + return true + else + Puppet.debug "Denying %s(%s) trusted access to %s on %s" % + [client, ip, method, Puppet.name] + return false + end + end + else + if method =~ /^puppetca\./ + Puppet.notice "Allowing %s(%s) untrusted access to CA methods" % + [client, ip] + else + Puppet.err "Unauthenticated client %s(%s) cannot call %s" % + [client, ip, method] + return false + end + end + end + + def available?(method) + namespace = method.sub(/\..+/, '') + client = request.peeraddr[2] + ip = request.peeraddr[3] + if @loadedhandlers.include?(namespace) + return true + else + Puppet.warning "Client %s(%s) requested unavailable functionality %s" % + [client, ip, namespace] + return false + end + end + + def initialize(server, handlers) + @puppetserver = server + @notified = {} + # the servlet base class does not consume any arguments + # and its BasicServer base class only accepts a 'class_delim' + # option which won't change in Puppet at all + # thus, we don't need to pass any args to our base class, + # and we can consume them all ourselves + super() + + @loadedhandlers = [] + handlers.each { |handler| + #Puppet.debug "adding handler for %s" % handler.class + self.add_handler(handler.class.interface, handler) + } + + # Initialize these to nil, but they will get set to values + # by the 'service' method. These have to instance variables + # because I don't have a clear line from the service method to + # the service hook. + @request = nil + @client = nil + @clientip = nil + + self.set_service_hook { |obj, *args| + if @client and @clientip + args.push(@client, @clientip) + end + begin + obj.call(*args) + rescue XMLRPC::FaultException + raise + rescue Puppet::AuthorizationError => detail + #Puppet.warning obj.inspect + #Puppet.warning args.inspect + Puppet.err "Permission denied: %s" % detail.to_s + raise XMLRPC::FaultException.new( + 1, detail.to_s + ) + rescue Puppet::Error => detail + #Puppet.warning obj.inspect + #Puppet.warning args.inspect + if Puppet[:trace] + puts detail.backtrace + end + Puppet.err detail.to_s + error = XMLRPC::FaultException.new( + 1, detail.to_s + ) + error.set_backtrace detail.backtrace + raise error + rescue => detail + #Puppet.warning obj.inspect + #Puppet.warning args.inspect + if Puppet[:trace] + puts detail.backtrace + end + Puppet.err "Could not call: %s" % detail.to_s + error = XMLRPC::FaultException.new(1, detail.to_s) + error.set_backtrace detail.backtrace + raise error + end + } + end + + # Handle the actual request. This does some basic collection of + # data, and then just calls the parent method. + def service(request, response) + @request = request + + # The only way that @client can be nil is if the request is local. + if peer = request.peeraddr + @client = peer[2] + @clientip = peer[3] + else + raise XMLRPC::FaultException.new( + ERR_UNCAUGHT_EXCEPTION, + "Could not retrieve client information" + ) + end + + # If they have a certificate (which will almost always be true) + # then we get the hostname from the cert, instead of via IP + # info + if cert = request.client_cert + nameary = cert.subject.to_a.find { |ary| + ary[0] == "CN" + } + + if nameary.nil? + Puppet.warning "Could not retrieve server name from cert" + else + unless @client == nameary[1] + Puppet.debug "Overriding %s with cert name %s" % + [@client, nameary[1]] + @client = nameary[1] + end + end + end + begin + super + rescue => detail + Puppet.err "Could not service request: %s: %s" % + [detail.class, detail] + end + @client = nil + @clientip = nil + @request = nil + end + + private + + # this is pretty much just a copy of the original method but with more + # feedback + # here's where we have our authorization hooks + def dispatch(methodname, *args) + + if defined? @request and @request + unless self.available?(methodname) + raise XMLRPC::FaultException.new( + ERR_UNAUTHORIZED, + "Functionality %s not available" % + methodname.sub(/\..+/, '') + ) + end + unless self.authorize(@request, methodname) + raise XMLRPC::FaultException.new( + ERR_UNAUTHORIZED, + "Host %s not authorized to call %s" % + [@request.host, methodname] + ) + end + else + raise Puppet::DevError, "Did not get request in dispatch" + end + + #Puppet.warning "dispatch on %s called with %s" % + # [methodname, args.inspect] + for name, obj in @handler + if obj.kind_of? Proc + unless methodname == name + #Puppet.debug "obj is proc but %s != %s" % + # [methodname, name] + next + end + else + unless methodname =~ /^#{name}(.+)$/ + #Puppet.debug "methodname did not match" + next + end + unless obj.respond_to? $1 + #Puppet.debug "methodname does not respond to %s" % $1 + next + end + obj = obj.method($1) + end + + if check_arity(obj, args.size) + if @service_hook.nil? + return obj.call(*args) + else + return @service_hook.call(obj, *args) + end + else + Puppet.debug "arity is incorrect" + end + end + + if @default_handler.nil? + raise XMLRPC::FaultException.new( + ERR_METHOD_MISSING, + "Method #{methodname} missing or wrong number of parameters!" + ) + else + @default_handler.call(methodname, *args) + end + end + end +end + +# $Id$ |
