From 4fd46b4f0e16c42ca7eb9e815e18b89a4b0ca7d8 Mon Sep 17 00:00:00 2001 From: Denis Knauf Date: Sat, 20 Jun 2020 22:30:05 +0200 Subject: [PATCH] init --- .gitignore | 8 ++ Gemfile | 6 ++ README.md | 36 ++++++++ Rakefile | 2 + knot-ruby.gemspec | 31 +++++++ lib/knot.rb | 4 + lib/knot/errors.rb | 166 +++++++++++++++++++++++++++++++++++++ lib/knot/interface.rb | 159 +++++++++++++++++++++++++++++++++++ lib/knot/protocol.rb | 188 ++++++++++++++++++++++++++++++++++++++++++ lib/knot/version.rb | 3 + 10 files changed, 603 insertions(+) create mode 100644 .gitignore create mode 100644 Gemfile create mode 100644 README.md create mode 100644 Rakefile create mode 100644 knot-ruby.gemspec create mode 100644 lib/knot.rb create mode 100644 lib/knot/errors.rb create mode 100644 lib/knot/interface.rb create mode 100644 lib/knot/protocol.rb create mode 100644 lib/knot/version.rb diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9106b2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/.bundle/ +/.yardoc +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..548b363 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +# Specify your gem's dependencies in knot.gemspec +gemspec + +gem "rake", "~> 12.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..da624b9 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# Knot + +Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/knot`. To experiment with that code, run `bin/console` for an interactive prompt. + +TODO: Delete this and the text above, and describe your gem + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'knot' +``` + +And then execute: + + $ bundle install + +Or install it yourself as: + + $ gem install knot + +## Usage + +TODO: Write usage instructions here + +## Development + +After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. + +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/knot. + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..43022f7 --- /dev/null +++ b/Rakefile @@ -0,0 +1,2 @@ +require "bundler/gem_tasks" +task :default => :spec diff --git a/knot-ruby.gemspec b/knot-ruby.gemspec new file mode 100644 index 0000000..9076bd2 --- /dev/null +++ b/knot-ruby.gemspec @@ -0,0 +1,31 @@ +require_relative 'lib/knot/version' + +Gem::Specification.new do |spec| + spec.name = "knot" + spec.version = Knot::VERSION + spec.authors = ['Denis Knauf'] + spec.email = ['gems+knot@denkn.at'] + spec.licenses = ["LGPL-3.0"] + + spec.summary = %q{Provides interface to knot-server.} + spec.description = %q{Implements knot-protocol to provide an interface to knot-DNS-server} + spec.homepage = 'https://git.denkn.at/deac/knot' + spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") + + #spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = spec.homepage + spec.metadata["changelog_uri"] = spec.homepage + + spec.add_dependency 'iounpack', '~> 0' + + # Specify which files should be added to the gem when it is released. + # The `git ls-files -z` loads the files in the RubyGem that have been added into git. + spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do + `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + end + spec.bindir = "bin" + spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] +end diff --git a/lib/knot.rb b/lib/knot.rb new file mode 100644 index 0000000..4231108 --- /dev/null +++ b/lib/knot.rb @@ -0,0 +1,4 @@ +require 'knot/version' +require 'knot/errors' +require 'knot/protocol' +require 'knot/interface' diff --git a/lib/knot/errors.rb b/lib/knot/errors.rb new file mode 100644 index 0000000..bffb208 --- /dev/null +++ b/lib/knot/errors.rb @@ -0,0 +1,166 @@ +module Knot + class Error < ::Exception + attr_reader :Errno + attr_reader :Key + attr_reader :Errstr + end +end + +module Knot::Errors + @num2exc = {} + @key2exc = {} + @err2exc = {} + class << self + attr_reader :num2exc, :key2exc, :err2exc + + def array_arguments_with_typecheck *args + l = args.length + lambda do |a| + l == a.length and + args.zip(a).all? {|t,v| t === v } + end + end + end + + def method_missing key + self[key] + end + + def [] key_or_errno + case key_or_errno + when Integer + @num2exc[key_or_errno] + when Exception + key_or_errno + when Symbol + @key2exc[key_or_errno] + else + raise ArgumentError, "Invalid type. Expect Integer/Knot::Error/Symbol" + end + end + + i = 0 + [ + [:EOK, 0, "OK"], + # Directly mapped error codes. + [:ENOMEM, -Errno::ENOMEM::Errno, "not enough memory" ], + [:EINVAL, -Errno::EINVAL::Errno, "invalid parameter" ], + [:ENOTSUP, -Errno::ENOTSUP::Errno, "operation not supported" ], + [:EBUSY, -Errno::EBUSY::Errno, "requested resource is busy" ], + [:EAGAIN, -Errno::EAGAIN::Errno, "OS lacked necessary resources" ], + [:EACCES, -Errno::EACCES::Errno, "operation not permitted" ], + [:ECONNREFUSED, -Errno::ECONNREFUSED::Errno, "connection refused" ], + [:EISCONN, -Errno::EISCONN::Errno, "already connected" ], + [:EADDRINUSE, -Errno::EADDRINUSE::Errno, "address already in use" ], + [:ENOENT, -Errno::ENOENT::Errno, "not exists" ], + [:EEXIST, -Errno::EEXIST::Errno, "already exists" ], + [:ERANGE, -Errno::ERANGE::Errno, "value is out of range" ], + [:EADDRNOTAVAIL, -Errno::EADDRNOTAVAIL::Errno, "address is not available" ], + + # General errors. + [:ERROR, -1000, "failed"], + [:EPARSEFAIL, "parser failed"], + [:ESEMCHECK, "semantic check"], + [:EUPTODATE, "zone is up-to-date"], + [:EFEWDATA, "not enough data to parse"], + [:ESPACE, "not enough space provided"], + [:EMALF, "malformed data"], + [:ENSEC3PAR, "missing or wrong NSEC3PARAM record"], + [:ENSEC3CHAIN, "missing or wrong NSEC3 chain in the zone"], + [:EOUTOFZONE, "name does not belong to the zone"], + [:EZONEINVAL, "invalid zone file"], + [:ENOZONE, "no such zone found"], + [:ENONODE, "no such node in zone found"], + [:ENORECORD, "no such record in zone found"], + [:EISRECORD, "such record already exists in zone"], + [:ENOMASTER, "no usable master"], + [:EPREREQ, "UPDATE prerequisity not met"], + [:ETTL, "TTL mismatch"], + [:ENOXFR, "transfer was not sent"], + [:EDENIED, "not allowed"], + [:ECONN, "connection reset"], + [:ETIMEOUT, "connection timeout"], + [:ENODIFF, "cannot create zone diff"], + [:ENOTSIG, "expected a TSIG or SIG(0)"], + [:ELIMIT, "exceeded response rate limit"], + [:EZONESIZE, "zone size exceeded"], + [:EOF, "end of file"], + [:ESYSTEM, "system error"], + [:EFILE, "file error"], + [:ESOAINVAL, "SOA mismatch"], + [:ETRAIL, "trailing data"], + [:EPROCESSING, "processing error"], + + # Control states. + [:CTL_ESTOP, "stopping server"], + # Network errors. + [:NET_EADDR, "bad address or host name"], + [:NET_ESOCKET, "can't create socket"], + [:NET_ECONNECT, "can't connect"], + [:NET_ESEND, "can't send data"], + [:NET_ERECV, "can't receive data"], + [:NET_ETIMEOUT, "network timeout"], + # Encoding errors. + [:BASE64_ESIZE, "invalid base64 string length"], + [:BASE64_ECHAR, "invalid base64 character"], + [:BASE32HEX_ESIZE, "invalid base32hex string length"], + [:BASE32HEX_ECHAR, "invalid base32hex character"], + # TSIG errors. + [:KNOT_TSIG_EBADSIG, "failed to verify TSIG"], + [:KNOT_TSIG_EBADKEY, "TSIG key not recognized or invalid"], + [:KNOT_TSIG_EBADTIME, "TSIG out of time window"], + [:KNOT_TSIG_EBADTRUNC, "TSIG bad truncation"], + # DNSSEC errors. + [:DNSSEC_ENOKEY, "no keys for signing"], + [:DNSSEC_EMISSINGKEYTYPE, "missing active KSK or ZSK"], + # Yparser errors. + [:YP_ECHAR_TAB, "tabulator character is not allowed"], + [:YP_EINVAL_ITEM, "invalid item"], + [:YP_EINVAL_ID, "invalid identifier"], + [:YP_EINVAL_DATA, "invalid value"], + [:YP_EINVAL_INDENT, "invalid indentation"], + [:YP_ENOTSUP_DATA, "value not supported"], + [:YP_ENOTSUP_ID, "identifier not supported"], + [:YP_ENODATA, "missing value"], + [:YP_ENOID, "missing identifier"], + # Configuration errors. + [:CONF_ENOTINIT, "config DB not initialized"], + [:CONF_EVERSION, "invalid config DB version"], + [:CONF_EREDEFINE, "duplicate identifier"], + # Transaction errors. + [:TXN_EEXISTS, "too many transactions"], + [:TXN_ENOTEXISTS, "no active transaction"], + # DNSSEC errors. + [:INVALID_PUBLIC_KEY, "invalid public key"], + [:INVALID_PRIVATE_KEY, "invalid private key"], + [:INVALID_KEY_ALGORITHM, "invalid key algorithm"], + [:INVALID_KEY_SIZE, "invalid key size"], + [:INVALID_KEY_ID, "invalid key ID"], + [:INVALID_KEY_NAME, "invalid key name"], + [:NO_PUBLIC_KEY, "no public key"], + [:NO_PRIVATE_KEY, "no private key"], + ].each do |v| + e = nil + case v + when array_arguments_with_typecheck( Symbol, String) + v, e = v + when array_arguments_with_typecheck( String, String) + v, e = v + v = v.to_sym + when array_arguments_with_typecheck( Symbol, Integer, String) + v, i, e = v + when array_arguments_with_typecheck( String, Integer, String) + v, i, e = v + v = v.to_sym + else + raise ArgumentError, "[Symbol, String] | [Symbol, Int, String] expected, not #{v}" + end + cl = Class.new Exception + cl.const_set :Key, v + cl.const_set :Errno, i + cl.const_set :Errstr, e + const_set v, cl + @num2exc[i] = @key2exc[v] = @err2exc[e] = cl + i += 1 + end +end diff --git a/lib/knot/interface.rb b/lib/knot/interface.rb new file mode 100644 index 0000000..5130950 --- /dev/null +++ b/lib/knot/interface.rb @@ -0,0 +1,159 @@ +require_relative 'protocol' +require_relative 'errors' + +module Knot +end + +class Knot::Zone + attr_reader :protocol, :zone + def initialize zone, protocol = nil + @protocol = protocol || Protocol.new + @zone, @transaction_opened = zone, 0 + end + + def begin + @transaction_opened += 1 + @protocol.call command: 'zone-begin', zone: @zone if 1 == @transaction_opened + end + + def commit + @protocol.call command: 'zone-commit', zone: @zone if 1 == @transaction_opened + @transaction_opened -= 1 if 0 < @transaction_opened + end + + def abort + @protocol.call command: 'zone-abort', zone: @zone + @transaction_opened = 0 + end + + def transaction + self.begin + yield self + rescue Object + self.abort + raise + ensure + self.commit unless $! + end + + # zone operation + + def check() @protocol.call command: 'zone-check', zone: @zone end + def reload() @protocol.call command: 'zone-reload', zone: @zone end + def refresh() @protocol.call command: 'zone-refresh', zone: @zone end + def notify() @protocol.call command: 'zone-notify', zone: @zone end + def retransfer() @protocol.call command: 'zone-retransfer', zone: @zone end + def sign() @protocol.call command: 'zone-sign', zone: @zone end + def freeze() @protocol.call command: 'zone-freeze', zone: @zone end + def thaw() @protocol.call command: 'zone-thaw', zone: @zone end + def status( filter = nil) @protocol.call command: 'zone-status', zone: @zone, filter: filter end + + def stats( modul = nil, counter = nil) + @protocol.call command: 'zone-stats', zone: @zone, module: modul, counter: counter + end + + # zone manipulation + + def read() @protocol.call command: 'zone-read', zone: @zone end + def diff() @protocol.call command: 'zone-diff', zone: @zone end + + # setting record + # if data is nil, it will be unset. + def set owner, ttl = nil, type, data + @protocol.call command: data.nil? ? 'zone-unset' : 'zone-set', + zone: @zone, owner: owner, ttl: ttl, type: type, data: data + rescue Knot::Errors::EISRECORD, Knot::Errors::ENONODE, Knot::Errors::ENOENT + end + alias []= set + + def unset owner, type = nil, data = nil + @protocol.call command: 'zone-unset', + zone: @zone, owner: owner, type: type, data: data + rescue Knot::Errors::ENONODE, Knot::Errors::ENOENT + end + alias delete unset + + def get owner = nil, type = nil + @protocol.call command: 'zone-get', + zone: @zone, owner: owner, type: type + rescue Knot::Errors::ENONODE, Knot::Errors::ENOENT + nil + end + alias [] get +end + +class Knot::Conf + def initialize protocol = nil + @protocol = protocol || Protocol.new + @transaction_opened = 0 + end + + def begin + @transaction_opened += 1 + @protocol.call command: 'conf-begin' if 1 == @transaction_opened + end + + def commit + @protocol.call command: 'conf-commit' if 1 == @transaction_opened + @transaction_opened -= 1 if 0 < @transaction_opened + end + + def abort + @protocol.call command: 'conf-abort' + @transaction_opened = 0 + end + + def transaction + self.begin + yield self + rescue Object + self.abort + raise + ensure + self.commit + end + + def parse_item k + case k + when k + case k.keys.sort + when %w[section], %w[id section], %w[item section], %w[id item section] then k + else raise ArgumentError, "Invalid Item-format" + end + + when Array + case k.length + when 1 then {section: k[0]} + when 2 then {section: k[0], item: k[1]} + when 3 then {section: k[0], id: k[1], item: k[2]} + else raise ArgumentError, "Invalid Item-format" + end + + when /\A + (?
[a-z0-9_-]+ ) + (?: \[ (? [a-z0-9_.-]+) \] )? + (?: \. (?[a-z0-9_-]+) )? + \z/xi + $~.named_captures.delete_if {|_,v| v.nil? } + else raise ArgumentError, "Invalid Item-format" + end + end + + def set item, value + @protocol.call parse_item( item).update( command: 'conf-set', data: value) + end + alias [] set + + def unset item, value = nil + @protocol.call parse_item( item).update( command: 'conf-unset', data: value) + end + alias delete unset + + def list item = nil + @protocol.call (item ? parse_item( item) : {}).update( command: 'conf-list') + end + + def read item = nil + @protocol.call (item ? parse_item( item) : {}).update( command: 'conf-read') + end +end diff --git a/lib/knot/protocol.rb b/lib/knot/protocol.rb new file mode 100644 index 0000000..903697b --- /dev/null +++ b/lib/knot/protocol.rb @@ -0,0 +1,188 @@ +require 'iounpack' +require_relative 'errors' + +module Knot + class Protocol + end +end + +module Knot::Protocol::Type + def [] code + case code + when 0, End then return End + when 1, Data then return Data + when 2, Extra then return Extra + when 3, Block then return Block + end + end + + class Base + def self.expect_data? + false + end + def expect_data?() self.class.expect_data? end + def code() self.class.code end + end + class End < Base + def self.code() 0 end + end + class Data < Base + def code() 1 end + def expect_data? + true + end + attr_reader :data + def initialize data + @data = data + end + end + class Extra < Base + def code() 2 end + def expect_data? + true + end + attr_reader :data + def initialize data + @data = data + end + end + class Block < Base + def code() 3 end + end +end + +module Knot::Protocol::Idx + Idx = [ + :command, # 10, :CMD, # Control command name. + :flags, # 11, :FLAGS, # Control command flags. + :error, # 12, :ERROR, # Error message. + :section, # 13, :SECTION, # Configuration section name. + :item, # 14, :ITEM, # Configuration item name. + :id, # 15, :ID, # Congiguration item identifier. + :zone, # 16, :ZONE, # Zone name. + :owner, # 17, :OWNER, # Zone record owner + :ttl, # 18, :TTL, # Zone record TTL. + :type, # 19, :TYPE, # Zone record type name. + :data, # 1a, :DATA, # Configuration item/zone record data. + :filter, # 1b, :FILTER, # An option or a filter for output data processing. + ] + Name = {} + Code = {} + Idx.each_with_index do |v, i| + Code[0x10+i] = v + Name[v] = i + end + def self.[] k + case k + when Symbol + Name[k] or raise Knot::Errors::EINVAL, "Unknown Idx: #{k}" + when Integer + Idx[k] or raise Knot::Errors::EINVAL, "Unknown Idx: #{k}" + else + raise ArgumentError, "Unknown Idx-Type: #{k}" + end + end +end + +class Knot::Protocol + attr_reader :sock, :conf, :zones + attr_accessor :debug + + def initialize path_or_sock = nil + case path_or_sock + when String, Pathname + @sock = UNIXSocket.new path_or_sock.to_s + when Socket + @sock = path_or_sock + when nil + @sock = UNIXSocket.new '/run/knot/knot.sock' + end + @debug = false + @conf = Knot::Conf.new self + @zones = Hash.new {|h, zone| h[zone] = Knot::Zone.new zone, self } + end + + def snd sock: nil, **data + rsock = sock || @sock + s = '' + sock = StringIO.new s + sock.write [1].pack( 'c') + data[:flags] ||= '' + Idx::Idx.each_with_index do |n, i| + v = data[n]&.to_s + sock.write [0x10+i, v.size, v].pack( 'c na*') if v + end + sock.write [3].pack( 'c') + sock.flush + STDERR.puts( {data: data, _: s}.inspect) if @debug + rsock.write s + rsock.flush + end + + class RecordIO + attr_reader :str + + def initialize sock, str = nil + @str, @sock = str || '', sock + end + + def unpack pattern + IOUnpack.new(pattern).unpack self + end + + def unpack1 pattern + IOUnpack.new(pattern).unpack1 self + end + + def read n + s = @sock.read n + @str.insert -1, s + s + end + end + + def rcv sock: nil + ret, r = [], nil + sock = sock || @sock + sock = RecordIO.new sock if @debug + loop do + t = sock.unpack1 'c' + case t + when 0, 3 + return ret + when 1, 2 + type = t + ret.push( r = {}) + else + raise Knot::Errors::EINVAL, "Missing Type before: #{t}" if ret.empty? + i = Idx::Idx[t - 0x10] or raise Knot::Errors::EINVAL, "Unknown index: #{t-0x10}" + l = sock.unpack1 'n' + r[i] = sock.read( l) + end + end + ensure + STDERR.puts( {rcvd: ret, read: sock.str}.inspect) if @debug + ret + end + + def call sock: nil, **data + snd sock: sock, **data + rcv( sock: sock).each do |r| + if r[:error] + if e = Knot::Errors.err2exc[r[:error]] + raise e, r[:error] + end + raise Knot::Error, r[:error] + end + end + end + + def zone( zone) @zones[zone.to_s.to_sym] end + + def conf_set( **opts) call opts.update( command: 'conf-set') end + def conf_unset( **opts) call opts.update( command: 'conf-set') end + + def zone_set( **opts) call opts.update( command: 'zone-set') end + def zone_unset( **opts) call opts.update( command: 'zone-unset') end + def zone_get( **opts) call opts.update( command: 'zone-get') end +end diff --git a/lib/knot/version.rb b/lib/knot/version.rb new file mode 100644 index 0000000..4db3f7f --- /dev/null +++ b/lib/knot/version.rb @@ -0,0 +1,3 @@ +module Knot + VERSION = "0.1.0" +end