1
0
mirror of https://github.com/cmur2/dyndnsd.git synced 2025-08-08 08:33:56 +02:00

Compare commits

...

8 Commits

Author SHA1 Message Date
cn
c112b211a0 release: 2.2.0 2020-03-06 21:55:42 +01:00
cn
b597ecb15e gem: adopt frozen string literals 2020-03-06 21:48:17 +01:00
cn
c73c09f311 gem: refactor gemspec, exclude tests from gem, move binaries to ./exe
- based on recommendations of https://piotrmurach.com/articles/writing-a-ruby-gem-specification/
2020-03-06 21:46:55 +01:00
cn
88133edc1a gems: fix order, drop unnecessary json dependency
- json was added pre Ruby 2.0 in e50430b177
2020-03-02 01:44:39 +01:00
cn
9dfafd7e8d release: 2.1.1 2020-03-01 21:55:35 +01:00
cn
af5e4ca3e0 dyndnsd: handle potential nil cases detected by sorbet
- including review suggestions from @jgraichen
2020-03-01 21:51:28 +01:00
cn
0317189057 release: 2.1.0 2020-03-01 11:43:39 +01:00
cn
73dbf2a5fa gem: add solargraph support 2020-02-29 20:21:50 +01:00
23 changed files with 217 additions and 35 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.DS_Store
*.lock
pkg/*
.yardoc

View File

@@ -1,9 +1,6 @@
AllCops:
TargetRubyVersion: '2.3'
Gemspec/OrderedDependencies:
Enabled: false
Layout/EmptyLineAfterGuardClause:
Enabled: false
@@ -56,9 +53,6 @@ Style/Documentation:
Style/FormatStringToken:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Style/GuardClause:
Enabled: false

16
.solargraph.yml Normal file
View File

@@ -0,0 +1,16 @@
---
include:
- "**/*.rb"
- "bin/dyndnsd"
exclude:
- spec/**/*
- test/**/*
- vendor/**/*
- ".bundle/**/*"
require: []
domains: []
reporters:
- rubocop
- require_not_found
require_paths: []
max_files: 5000

View File

@@ -7,3 +7,6 @@ rvm:
- 2.5
- 2.4
- 2.3
script:
- bundle exec rake travis

View File

@@ -1,10 +1,25 @@
# Changelog
## 2.1.0
## 2.2.0 (March 6, 2020)
IMPROVEMENTS:
- Refactor gemspec based on [recommendations](https://piotrmurach.com/articles/writing-a-ruby-gem-specification/) so tests are now excluded from gem and binaries move to `./exe` directory
- Adopt Ruby 2.3 frozen string literals for source code potentially reducing memory consumption
## 2.1.1 (March 1, 2020)
IMPROVEMENTS:
- Fix potential `nil` cases detected by [Sorbet](https://sorbet.org) including refactorings
## 2.1.0 (March 1, 2020)
IMPROVEMENTS:
- Add Ruby 2.7 support
- Add [solargraph](https://github.com/castwide/solargraph) to dev tooling as Ruby Language Server usable e.g. for IDEs (used solargraph version not compatible with Ruby 2.7 as bundler-audit 0.6.x requires old `thor` gem)
- Document code using YARD tags, e.g. for type information and better code completion
## 2.0.0 (January 25, 2019)
@@ -15,7 +30,7 @@ IMPROVEMENTS:
- Better code maintainability by refactorings
- Update dependencies, mainly `rack` to new major version 2
- Add Ruby 2.5 and Ruby 2.6 support
- Add experimental [OpenTracing](http://opentracing.io/) support with [CNCF Jaeger](https://github.com/jaegertracing/jaeger)
- Add experimental [OpenTracing](https://opentracing.io/) support with [CNCF Jaeger](https://github.com/jaegertracing/jaeger)
- Support host offlining by deleting the associated DNS records
- Add textfile reporter to write Graphite-style metrics (also compatible with [Prometheus](https://prometheus.io/)) into a file

View File

@@ -1,3 +1,5 @@
# frozen_string_literal: true
source 'https://rubygems.org'
gemspec

View File

@@ -1,3 +1,5 @@
# frozen_string_literal: true
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
require 'rubocop/rake_task'
@@ -7,4 +9,16 @@ RSpec::Core::RakeTask.new(:spec)
RuboCop::RakeTask.new
Bundler::Audit::Task.new
desc 'Should be run by developer once to prepare initial solargraph usage (fill caches etc.)'
task :'solargraph:init' do
sh 'solargraph download-core'
end
desc 'Run experimental solargraph type checker'
task :'solargraph:tc' do
sh 'solargraph typecheck'
end
task default: [:rubocop, :spec, 'bundle:audit']
task travis: [:default, :'solargraph:tc']

View File

@@ -1,4 +0,0 @@
require 'dyndnsd'
Dyndnsd::Daemon.run!

View File

@@ -1,7 +1,6 @@
# frozen_string_literal: true
$LOAD_PATH.push File.expand_path('lib', __dir__)
require 'dyndnsd/version'
require_relative 'lib/dyndnsd/version'
Gem::Specification.new do |s|
s.name = 'dyndnsd'
@@ -9,28 +8,36 @@ Gem::Specification.new do |s|
s.summary = 'dyndnsd.rb'
s.description = 'A small, lightweight and extensible DynDNS server written with Ruby and Rack.'
s.author = 'Christian Nicolai'
s.email = 'chrnicolai@gmail.com'
s.homepage = 'https://github.com/cmur2/dyndnsd'
s.license = 'Apache-2.0'
s.metadata = {
'bug_tracker_uri' => "#{s.homepage}/issues",
'changelog_uri' => "#{s.homepage}/blob/master/CHANGELOG.md",
'source_code_uri' => s.homepage
}
s.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
s.test_files = s.files.grep(%r{^(test|spec|features)/})
s.files = `git ls-files -z`.split("\x0").select do |f|
f.match(%r{^(init.d|lib)/})
end
s.require_paths = ['lib']
s.bindir = 'exe'
s.executables = ['dyndnsd']
s.extra_rdoc_files = Dir['README.md', 'CHANGELOG.md', 'LICENSE']
s.required_ruby_version = '>= 2.3'
s.add_runtime_dependency 'rack', '~> 2.0'
s.add_runtime_dependency 'json'
s.add_runtime_dependency 'jaeger-client', '~> 0.10.0'
s.add_runtime_dependency 'metriks'
s.add_runtime_dependency 'opentracing', '~> 0.5.0'
s.add_runtime_dependency 'rack', '~> 2.0'
s.add_runtime_dependency 'rack-tracer', '~> 0.9.0'
s.add_runtime_dependency 'jaeger-client', '~> 0.10.0'
s.add_development_dependency 'bundler'
s.add_development_dependency 'bundler-audit', '~> 0.6.0'
s.add_development_dependency 'rack-test'
s.add_development_dependency 'rake'
s.add_development_dependency 'rspec'
s.add_development_dependency 'rack-test'
s.add_development_dependency 'rubocop', '~> 0.80.0'
s.add_development_dependency 'bundler-audit', '~> 0.6.0'
s.add_development_dependency 'solargraph'
end

6
exe/dyndnsd Executable file
View File

@@ -0,0 +1,6 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'dyndnsd'
Dyndnsd::Daemon.run!

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require 'etc'
require 'logger'
@@ -21,21 +21,32 @@ require 'dyndnsd/textfile_reporter'
require 'dyndnsd/version'
module Dyndnsd
# @return [Logger]
def self.logger
@logger
end
# @param logger [Logger]
# @return [Logger]
def self.logger=(logger)
@logger = logger
end
class LogFormatter
# @param lvl [Object]
# @param _time [DateTime]
# @param _progname [String]
# @param msg [Object]
# @return [String]
def call(lvl, _time, _progname, msg)
format("[%s] %-5s %s\n", Time.now.strftime('%Y-%m-%d %H:%M:%S'), lvl, msg.to_s)
end
end
class Daemon
# @param config [Hash{String => Object}]
# @param db [Dyndnsd::Database]
# @param updater [#update]
def initialize(config, db, updater)
@users = config['users']
@domain = config['domain']
@@ -51,6 +62,9 @@ module Dyndnsd
end
end
# @param username [String]
# @param password [String]
# @return [Boolean]
def authorized?(username, password)
Helper.span('check_authorized') do |span|
span.set_tag('dyndnsd.user', username)
@@ -64,6 +78,8 @@ module Dyndnsd
end
end
# @param env [Hash{String => String}]
# @return [Array{Integer,Hash{String => String},Array{String}}]
def call(env)
return [422, {'X-DynDNS-Response' => 'method_forbidden'}, []] if env['REQUEST_METHOD'] != 'GET'
return [422, {'X-DynDNS-Response' => 'not_found'}, []] if env['PATH_INFO'] != '/nic/update'
@@ -71,6 +87,7 @@ module Dyndnsd
handle_dyndns_request(env)
end
# @return [void]
def self.run!
if ARGV.length != 1
puts 'Usage: dyndnsd config_file'
@@ -95,8 +112,14 @@ module Dyndnsd
# drop priviliges as soon as possible
# NOTE: first change group than user
Process::Sys.setgid(Etc.getgrnam(config['group']).gid) if config['group']
Process::Sys.setuid(Etc.getpwnam(config['user']).uid) if config['user']
if config['group']
group = Etc.getgrnam(config['group'])
Process::Sys.setgid(group.gid) if group
end
if config['user']
user = Etc.getpwnam(config['user'])
Process::Sys.setuid(user.uid) if user
end
setup_traps
@@ -109,6 +132,8 @@ module Dyndnsd
private
# @param params [Hash{String => String}]
# @return [Array{String}]
def extract_v4_and_v6_address(params)
return [] if !(params['myip'])
begin
@@ -120,6 +145,9 @@ module Dyndnsd
end
end
# @param env [Hash{String => String}]
# @param params [Hash{String => String}]
# @return [Array{String}]
def extract_myips(env, params)
# require presence of myip parameter as valid IPAddr (v4) and valid myip6
return extract_v4_and_v6_address(params) if params.key?('myip6')
@@ -134,6 +162,9 @@ module Dyndnsd
[env['REMOTE_ADDR']]
end
# @param hostnames [String]
# @param myips [Array{String}]
# @return [Array{Symbol}]
def process_changes(hostnames, myips)
changes = []
Helper.span('process_changes') do |span|
@@ -158,6 +189,7 @@ module Dyndnsd
changes
end
# @return [void]
def update_db
@db['serial'] += 1
Dyndnsd.logger.info "Committing update ##{@db['serial']}"
@@ -166,6 +198,8 @@ module Dyndnsd
Metriks.meter('updates.committed').mark
end
# @param env [Hash{String => String}]
# @return [Array{Integer,Hash{String => String},Array{String}}]
def handle_dyndns_request(env)
params = Rack::Utils.parse_query(env['QUERY_STRING'])
@@ -204,6 +238,8 @@ module Dyndnsd
# SETUP
# @param config [Hash{String => Object}]
# @return [void]
private_class_method def self.setup_logger(config)
if config['logfile']
Dyndnsd.logger = Logger.new(config['logfile'])
@@ -215,6 +251,7 @@ module Dyndnsd
Dyndnsd.logger.formatter = LogFormatter.new
end
# @return [void]
private_class_method def self.setup_traps
Signal.trap('INT') do
Dyndnsd.logger.info 'Quitting...'
@@ -226,6 +263,8 @@ module Dyndnsd
end
end
# @param config [Hash{String => Object}]
# @return [void]
private_class_method def self.setup_monitoring(config)
# configure metriks
if config['graphite']
@@ -253,6 +292,8 @@ module Dyndnsd
end
end
# @param config [Hash{String => Object}]
# @return [void]
private_class_method def self.setup_tracing(config)
# configure OpenTracing
if config.dig('tracing', 'jaeger')
@@ -267,10 +308,12 @@ module Dyndnsd
end
end
# @param config [Hash{String => Object}]
# @return [void]
private_class_method def self.setup_rack(config)
# configure daemon
db = Database.new(config['db'])
updater = Updater::CommandWithBindZone.new(config['domain'], config['updater']['params']) if config['updater']['name'] == 'command_with_bind_zone'
updater = Updater::CommandWithBindZone.new(config['domain'], config.dig('updater', 'params')) if config.dig('updater', 'name') == 'command_with_bind_zone'
daemon = Daemon.new(config, db, updater)
# configure rack

View File

@@ -1,3 +1,4 @@
# frozen_string_literal: true
require 'forwardable'
@@ -7,19 +8,22 @@ module Dyndnsd
def_delegators :@db, :[], :[]=, :each, :has_key?
# @param db_file [String]
def initialize(db_file)
@db_file = db_file
end
# @return [void]
def load
if File.file?(@db_file)
@db = JSON.parse(File.open(@db_file, 'r', &:read))
@db = JSON.parse(File.read(@db_file, mode: 'r'))
else
@db = {}
end
@db_hash = @db.hash
end
# @return [void]
def save
Helper.span('database_save') do |_span|
File.open(@db_file, 'w') { |f| JSON.dump(@db, f) }
@@ -27,6 +31,7 @@ module Dyndnsd
end
end
# @return [Boolean]
def changed?
@db_hash != @db.hash
end

View File

@@ -1,7 +1,10 @@
# frozen_string_literal: true
module Dyndnsd
module Generator
class Bind
# @param domain [String]
# @param config [Hash{String => Object}]
def initialize(domain, config)
@domain = domain
@ttl = config['ttl']
@@ -10,6 +13,8 @@ module Dyndnsd
@additional_zone_content = config['additional_zone_content']
end
# @param db [Dyndnsd::Database]
# @return [String]
def generate(db)
out = []
out << "$TTL #{@ttl}"

View File

@@ -1,8 +1,12 @@
# frozen_string_literal: true
require 'ipaddr'
module Dyndnsd
class Helper
# @param hostname [String]
# @param domain [String]
# @return [Boolean]
def self.fqdn_valid?(hostname, domain)
return false if hostname.length < domain.length + 2
return false if !hostname.end_with?(domain)
@@ -11,6 +15,8 @@ module Dyndnsd
true
end
# @param ip [String]
# @return [Boolean]
def self.ip_valid?(ip)
IPAddr.new(ip)
true
@@ -18,15 +24,26 @@ module Dyndnsd
false
end
# @param username [String]
# @param password [String]
# @param users [Hash]
# @return [Boolean]
def self.user_allowed?(username, password, users)
(users.key? username) && (users[username]['password'] == password)
end
# @param hostname [String]
# @param myips [Array]
# @param hosts [Hash]
# @return [Boolean]
def self.changed?(hostname, myips, hosts)
# myips order is always deterministic
((!hosts.include? hostname) || (hosts[hostname] != myips)) && !myips.empty?
end
# @param operation [String]
# @param block [Proc]
# @return [void]
def self.span(operation, &block)
scope = OpenTracing.start_active_span(operation)
span = scope.span
@@ -41,7 +58,7 @@ module Dyndnsd
'error.kind': e.class.to_s,
'error.object': e,
message: e.message,
stack: e.backtrace.join("\n")
stack: e.backtrace&.join("\n") || ''
)
raise
ensure

View File

@@ -1,11 +1,15 @@
# frozen_string_literal: true
module Dyndnsd
module Responder
class DynDNSStyle
# @param app [#call]
def initialize(app)
@app = app
end
# @param env [Hash{String => String}]
# @return [Array{Integer,Hash{String => String},Array{String}}]
def call(env)
@app.call(env).tap do |status_code, headers, body|
if headers.key?('X-DynDNS-Response')
@@ -18,6 +22,10 @@ module Dyndnsd
private
# @param status_code [Integer]
# @param headers [Hash{String => String}]
# @param body [Array{String}]
# @return [Array{Integer,Hash{String => String},Array{String}}]
def decorate_dyndnsd_response(status_code, headers, body)
if status_code == 200
[200, {'Content-Type' => 'text/plain'}, [get_success_body(body[0], body[1])]]
@@ -26,6 +34,10 @@ module Dyndnsd
end
end
# @param status_code [Integer]
# @param headers [Hash{String => String}]
# @param _body [Array{String}]
# @return [Array{Integer,Hash{String => String},Array{String}}]
def decorate_other_response(status_code, headers, _body)
if status_code == 400
[status_code, headers, ['Bad Request']]
@@ -34,10 +46,14 @@ module Dyndnsd
end
end
# @param changes [Array{Symbol}]
# @param myips [Array{String}]
# @return [String]
def get_success_body(changes, myips)
changes.map { |change| "#{change} #{myips.join(' ')}" }.join("\n")
end
# @return [Hash{String => Object}]
def error_response_map
{
# general http errors

View File

@@ -1,11 +1,15 @@
# frozen_string_literal: true
module Dyndnsd
module Responder
class RestStyle
# @param app [#call]
def initialize(app)
@app = app
end
# @param env [Hash{String => String}]
# @return [Array{Integer,Hash{String => String},Array{String}}]
def call(env)
@app.call(env).tap do |status_code, headers, body|
if headers.key?('X-DynDNS-Response')
@@ -18,6 +22,10 @@ module Dyndnsd
private
# @param status_code [Integer]
# @param headers [Hash{String => String}]
# @param body [Array{String}]
# @return [Array{Integer,Hash{String => String},Array{String}}]
def decorate_dyndnsd_response(status_code, headers, body)
if status_code == 200
[200, {'Content-Type' => 'text/plain'}, [get_success_body(body[0], body[1])]]
@@ -26,6 +34,10 @@ module Dyndnsd
end
end
# @param status_code [Integer]
# @param headers [Hash{String => String}]
# @param _body [Array{String}]
# @return [Array{Integer,Hash{String => String},Array{String}}]
def decorate_other_response(status_code, headers, _body)
if status_code == 400
[status_code, headers, ['Bad Request']]
@@ -34,10 +46,14 @@ module Dyndnsd
end
end
# @param changes [Array{Symbol}]
# @param myips [Array{String}]
# @return [String]
def get_success_body(changes, myips)
changes.map { |change| change == :good ? "Changed to #{myips.join(' ')}" : "No change needed for #{myips.join(' ')}" }.join("\n")
end
# @return [Hash{String => Object}]
def error_response_map
{
# general http errors

View File

@@ -1,3 +1,4 @@
# frozen_string_literal: true
# Adapted from https://github.com/eric/metriks-graphite/blob/master/lib/metriks/reporter/graphite.rb
@@ -5,8 +6,11 @@ require 'metriks'
module Dyndnsd
class TextfileReporter
# @return [String]
attr_reader :file
# @param file [String]
# @param options [Hash{Symbol => Object}]
def initialize(file, options = {})
@file = file
@@ -17,6 +21,7 @@ module Dyndnsd
@on_error = options[:on_error] || proc { |ex| }
end
# @return [void]
def start
@thread ||= Thread.new do
loop do
@@ -33,16 +38,19 @@ module Dyndnsd
end
end
# @return [void]
def stop
@thread&.kill
@thread = nil
end
# @return [void]
def restart
stop
start
end
# @return [void]
def write
File.open(@file, 'w') do |f|
@registry.each do |name, metric|
@@ -85,6 +93,12 @@ module Dyndnsd
end
end
# @param file [String]
# @param base_name [String]
# @param metric [Object]
# @param keys [Array{Symbol}]
# @param snapshot_keys [Array{Symbol}]
# @return [void]
def write_metric(file, base_name, metric, keys, snapshot_keys = [])
time = Time.now.to_i

View File

@@ -1,16 +1,21 @@
# frozen_string_literal: true
module Dyndnsd
module Updater
class CommandWithBindZone
# @param domain [String]
# @param config [Hash{String => Object}]
def initialize(domain, config)
@zone_file = config['zone_file']
@command = config['command']
@generator = Generator::Bind.new(domain, config)
end
# @param db [Dyndnsd::Database]
# @return [void]
def update(db)
Helper.span('updater_update') do |span|
span.set_tag('dyndnsd.updater.name', self.class.name.split('::').last)
span.set_tag('dyndnsd.updater.name', self.class.name&.split('::')&.last || 'None')
# write zone file in bind syntax
File.open(@zone_file, 'w') { |f| f.write(@generator.generate(db)) }
@@ -20,7 +25,7 @@ module Dyndnsd
end
# detach so children don't become zombies
Process.detach(pid)
Process.detach(pid) if pid
end
end
end

View File

@@ -1,4 +1,5 @@
# frozen_string_literal: true
module Dyndnsd
VERSION = '2.0.0'.freeze
VERSION = '2.2.0'
end

View File

@@ -1,4 +1,6 @@
require 'spec_helper'
# frozen_string_literal: true
require_relative 'spec_helper'
describe Dyndnsd::Daemon do
include Rack::Test::Methods

View File

@@ -1,8 +1,10 @@
# frozen_string_literal: true
require 'rubygems'
require 'bundler/setup'
require 'rack/test'
require 'dyndnsd'
require 'support/dummy_database'
require 'support/dummy_updater'
require_relative 'support/dummy_database'
require_relative 'support/dummy_updater'

View File

@@ -1,3 +1,4 @@
# frozen_string_literal: true
require 'forwardable'

View File

@@ -1,3 +1,4 @@
# frozen_string_literal: true
module Dyndnsd
module Updater