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

Compare commits

..

1 Commits

Author SHA1 Message Date
cn
efe516276c docs: add link to newer version 2018-03-26 19:43:41 +02:00
17 changed files with 201 additions and 640 deletions

View File

@@ -1,62 +0,0 @@
inherit_from: .rubocop_todo.yml
AllCops:
TargetRubyVersion: '2.3'
Gemspec/OrderedDependencies:
Enabled: false
# allows nicer usage of private_class_method
Layout/EmptyLinesAroundArguments:
Enabled: false
Layout/SpaceInsideHashLiteralBraces:
Enabled: false
Metrics/AbcSize:
Enabled: false
Metrics/BlockLength:
Enabled: false
Metrics/ClassLength:
Enabled: false
Metrics/CyclomaticComplexity:
Enabled: false
Metrics/LineLength:
Max: 200
Metrics/MethodLength:
Enabled: false
Metrics/PerceivedComplexity:
Enabled: false
Style/ConditionalAssignment:
Enabled: false
Style/Documentation:
Enabled: false
Style/FormatStringToken:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Style/GuardClause:
Enabled: false
Style/IdenticalConditionalBranches:
Enabled: false
Style/InverseMethods:
Enabled: false
Style/NegatedIf:
Enabled: false
Style/SymbolArray:
Enabled: false

View File

@@ -1,7 +0,0 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2018-02-23 12:54:10 +0100 using RuboCop version 0.52.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.

View File

@@ -1,7 +1,5 @@
---
sudo: false
language: ruby language: ruby
rvm: rvm:
- 2.5
- 2.4 - 2.4
- 2.3 - 2.1
- 1.9.3

View File

@@ -1,78 +0,0 @@
# Changelog
## 2.0.0
IMPROVEMENTS:
- Drop Ruby 2.2 and lower support
- Better protocol compliance by returning `badauth` in response body on HTTP 401 errors
- Better code maintainability by refactorings
- Update dependencies, mainly `rack` to new major version 2
- Add Ruby 2.5 support
- Add experimental [OpenTracing](http://opentracing.io/) support with [CNCF Jaeger](https://github.com/jaegertracing/jaeger)
- Support host offlining by deleting the associated DNS records
## 1.6.1 (October 31, 2017)
IMPROVEMENTS:
- Fix broken password check affecting all previous releases
## 1.6.0 (December 7, 2016)
IMPROVEMENTS:
- Support providing an IPv6 address in addition to a IPv4 for the same hostname
## 1.5.0 (November 30, 2016)
IMPROVEMENTS:
- Drop Ruby 1.8.7 support
- Pin `json` gem to allow supporting Ruby 1.9.3
- Support determining effective client IP address also from `X-Real-IP` header
## 1.4.0 (November 27, 2016)
IMPROVEMENTS:
- Pin `rack` gem to allow supporting Ruby versions < 2.2.2
- Support IPv6 addresses
## 1.3.0 (October 8, 2013)
IMPROVEMENTS:
- Handle `SIGTERM` \*nix signal properly and shutdown the daemon
## 1.2.2 (June 8, 2013)
IMPROVEMENTS:
- Add proper logging to the provided init script for dyndnsd.rb
## 1.2.1 (June 5, 2013)
IMPROVEMENTS:
- Fix bug in previous release related to metrics preventing startup
## 1.2.0 (May 29, 2013)
IMPROVEMENTS:
- Support sending metrics to graphite via undocumented `graphite:` section in configuration file
## 1.1.0 (April 30, 2013)
IMPROVEMENTS:
- Support dropping priviliges on startup, also affects external commands run
- Add [metriks](https://github.com/eric/metriks) support for basic metrics in the process title
- Detach from child processes running external commands to avoid zombie processes
## 1.0.0 (April 28, 2013)
NEW FEATURES:
- Initial 1.0 release

View File

@@ -1,18 +1,18 @@
# dyndnsd.rb # dyndnsd.rb
[![Build Status](https://travis-ci.org/cmur2/dyndnsd.svg?branch=master)](https://travis-ci.org/cmur2/dyndnsd) [![Dependencies](https://badges.depfu.com/badges/4f25da8493f7a29f652ac892fbf9227b/overview.svg)](https://depfu.com/github/cmur2/dyndnsd) [![Build Status](https://travis-ci.org/cmur2/dyndnsd.svg?branch=dyndnsd-1.x)](https://travis-ci.org/cmur2/dyndnsd)
A small, lightweight and extensible DynDNS server written with Ruby and Rack. A small, lightweight and extensible DynDNS server written with Ruby and Rack.
**Note:** a newer version of dyndnsd.rb is available on [branch master](https://github.com/cmur2/dyndnsd), see also the [changelog](https://github.com/cmur2/dyndnsd/blob/master/CHANGELOG.md).
## Description ## Description
dyndnsd.rb aims to implement a small [DynDNS-compliant](https://help.dyn.com/remote-access-api/) server in Ruby supporting IPv4 and IPv6 addresses. It has an integrated user and hostname database in it's configuration file that is used for authentication and authorization. Besides talking the DynDNS protocol it is able to invoke a so-called *updater*, a small Ruby module that takes care of supplying the current hostname => ip mapping to a DNS server. dyndnsd.rb aims to implement a small [DynDNS-compliant](https://help.dyn.com/remote-access-api/) server in Ruby supporting IPv4 and IPv6 addresses. It has an integrated user and hostname database in it's configuration file that is used for authentication and authorization. Besides talking the DynDNS protocol it is able to invoke an so-called *updater*, a small Ruby module that takes care of supplying the current host => ip mapping to a DNS server.
There is currently one updater shipped with dyndnsd.rb `command_with_bind_zone` that writes out a zone file in BIND syntax onto the current system and invokes a user-supplied command afterwards that is assumed to trigger the DNS server (not necessarily BIND since it's zone files are read by other DNS servers, too) to reload it's zone configuration. There is currently one updater shipped with dyndnsd.rb `command_with_bind_zone` that writes out a zone file in BIND syntax onto the current system and invokes a user-supplied command afterwards that is assumed to trigger the DNS server (not necessarily BIND since it's zone files are read by other DNS servers too) to reload it's zone configuration.
Because of the mechanisms used, dyndnsd.rb is known to work only on \*nix systems. Because of the mechanisms used dyndnsd.rb is known to work only on \*nix systems.
See the [changelog](CHANGELOG.md) before upgrading. The older version 1.x of dyndnsd.rb is still available on [branch dyndnsd-1.x](https://github.com/cmur2/dyndnsd/tree/dyndnsd-1.x).
## General Usage ## General Usage
@@ -103,7 +103,7 @@ Please provide ideas if you are using dyndnsd.rb with other DNS servers :)
The update URL you want to tell your clients (humans or scripts ^^) consists of the following The update URL you want to tell your clients (humans or scripts ^^) consists of the following
http[s]://[USER]:[PASSWORD]@[DOMAIN]:[PORT]/nic/update?hostname=[HOSTNAMES]&myip=[MYIP]&myip6=[MYIP6] http[s]://[USER]:[PASSWORD]@[DOMAIN]:[PORT]/nic/update?hostname=[HOSTNAMES]&myip=[MYIP]
where: where:
@@ -112,8 +112,7 @@ where:
* DOMAIN should match what you defined in your config.yaml as domain but may be anything else when using a webserver as proxy * DOMAIN should match what you defined in your config.yaml as domain but may be anything else when using a webserver as proxy
* PORT depends on your (webserver/proxy) settings * PORT depends on your (webserver/proxy) settings
* HOSTNAMES is a required list of comma separated FQDNs (they all have to end with your config.yaml domain) the user wants to update * HOSTNAMES is a required list of comma separated FQDNs (they all have to end with your config.yaml domain) the user wants to update
* MYIP is optional and the HTTP client's IP address will be used if missing * MYIP is optional and the HTTP client's address will be used if missing
* MYIP6 is optional but if present also requires presence of MYIP
### IP address determination ### IP address determination
@@ -133,79 +132,6 @@ Use a webserver as a proxy to handle SSL and/or multiple listen addresses and po
The [Debian 6 init.d script](init.d/debian-6-dyndnsd) assumes that dyndnsd.rb is installed into the system ruby (no RVM support) and the config.yaml is at /opt/dyndnsd/config.yaml. Modify to your needs. The [Debian 6 init.d script](init.d/debian-6-dyndnsd) assumes that dyndnsd.rb is installed into the system ruby (no RVM support) and the config.yaml is at /opt/dyndnsd/config.yaml. Modify to your needs.
### Monitoring
For monitoring dyndnsd.rb uses the [metriks](https://github.com/eric/metriks) framework and exposes several metrics like the number of unauthenticated requests, requests that did (not) update a hostname, etc. By default the most important metrics are shown in the [proctitle](https://github.com/eric/metriks#proc-title-reporter) but you can also configure a [Graphite](https://graphiteapp.org/) backend for central monitoring.
```yaml
host: "0.0.0.0"
port: "8245" # the DynDNS.com alternative HTTP port
db: "/opt/dyndnsd/db.json"
domain: "dyn.example.org"
# configure the Graphite backend to be used instead of proctitle
graphite:
host: localhost # defaults for host and port of a carbon server
port: 2003
prefix: "my.graphite.metrics.naming.structure.dyndnsd"
# configure the updater, here we use command_with_bind_zone, params are updater-specific
updater:
name: "command_with_bind_zone"
params:
zone_file: "dyn.zone"
command: "echo 'Hello'"
ttl: "5m"
dns: "dns.example.org."
email_addr: "admin.example.org."
# user database with hostnames a user is allowed to update
users:
# 'foo' is username, 'secret' the password
foo:
password: "secret"
hosts:
- foo.example.org
- bar.example.org
test:
password: "ihavenohosts"
```
### Tracing (experimental)
For tracing dyndnsd.rb is instrumented using the [OpenTracing](http://opentracing.io/) framework and will emit span tracing data for the most important operations happening during the request/response cycle. Using a middleware for Rack allows handling incoming OpenTracing span information properly.
Currently only one OpenTracing-compatible tracer implementation named [CNCF Jaeger](https://github.com/jaegertracing/jaeger) can be configured to use with dyndnsd.rb.
```yaml
host: "0.0.0.0"
port: "8245" # the DynDNS.com alternative HTTP port
db: "/opt/dyndnsd/db.json"
domain: "dyn.example.org"
# enable and configure tracing using the (currently only) tracer jaeger
tracing:
trust_incoming_span: false # default value, change to accept incoming OpenTracing spans as parents
jaeger:
host: 127.0.0.1 # defaults for host and port of local jaeger-agent
port: 6831
service_name: "my.dyndnsd.identifier"
# configure the updater, here we use command_with_bind_zone, params are updater-specific
updater:
name: "command_with_bind_zone"
params:
zone_file: "dyn.zone"
command: "echo 'Hello'"
ttl: "5m"
dns: "dns.example.org."
email_addr: "admin.example.org."
# user database with hostnames a user is allowed to update
users:
# 'foo' is username, 'secret' the password
foo:
password: "secret"
hosts:
- foo.example.org
- bar.example.org
test:
password: "ihavenohosts"
```
## License ## License
dyndnsd.rb is licensed under the Apache License, Version 2.0. See LICENSE for more information. dyndnsd.rb is licensed under the Apache License, Version 2.0. See LICENSE for more information.

View File

@@ -1,8 +1,6 @@
require 'bundler/gem_tasks' require 'bundler/gem_tasks'
require 'rspec/core/rake_task' require 'rspec/core/rake_task'
require 'rubocop/rake_task'
RSpec::Core::RakeTask.new(:spec) RSpec::Core::RakeTask.new(:spec)
RuboCop::RakeTask.new
task default: [:rubocop, :spec] task :default => :spec

View File

@@ -1,36 +1,31 @@
$LOAD_PATH.push File.expand_path('../lib', __FILE__) $:.push File.expand_path("../lib", __FILE__)
require 'dyndnsd/version' require 'dyndnsd/version'
Gem::Specification.new do |s| Gem::Specification.new do |s|
s.name = 'dyndnsd' s.name = 'dyndnsd'
s.version = Dyndnsd::VERSION s.version = Dyndnsd::VERSION
s.summary = 'dyndnsd.rb' s.summary = 'dyndnsd.rb'
s.description = 'A small, lightweight and extensible DynDNS server written with Ruby and Rack.' s.description = 'A small, lightweight and extensible DynDNS server written with Ruby and Rack.'
s.author = 'Christian Nicolai' s.author = 'Christian Nicolai'
s.email = 'chrnicolai@gmail.com' s.email = 'chrnicolai@gmail.com'
s.homepage = 'https://github.com/cmur2/dyndnsd' s.license = 'Apache License Version 2.0'
s.license = 'Apache-2.0' s.homepage = 'https://github.com/cmur2/dyndnsd'
s.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) s.files = `git ls-files`.split($/)
s.test_files = s.files.grep(%r{^(test|spec|features)/}) s.test_files = s.files.grep(%r{^(test|spec|features)/})
s.require_paths = ['lib'] s.require_paths = ['lib']
s.executables = ['dyndnsd'] s.executables = ['dyndnsd']
s.required_ruby_version = '>= 2.3' s.add_runtime_dependency 'rack', '~> 1.6'
s.add_runtime_dependency 'rack', '~> 2.0'
s.add_runtime_dependency 'json' s.add_runtime_dependency 'json'
s.add_runtime_dependency 'metriks' s.add_runtime_dependency 'metriks'
s.add_runtime_dependency 'opentracing', '~> 0.3'
s.add_runtime_dependency 'rack-tracer', '~> 0.4'
s.add_runtime_dependency 'spanmanager', '~> 0.3'
s.add_runtime_dependency 'jaeger-client', '~> 0.4'
s.add_development_dependency 'bundler' s.add_development_dependency 'bundler'
s.add_development_dependency 'rake' s.add_development_dependency 'rake'
s.add_development_dependency 'rspec' s.add_development_dependency 'rspec'
s.add_development_dependency 'rack-test' s.add_development_dependency 'rack-test'
s.add_development_dependency 'rubocop', '~> 0.52.1'
end end

307
lib/dyndnsd.rb Executable file → Normal file
View File

@@ -8,16 +8,12 @@ require 'yaml'
require 'rack' require 'rack'
require 'metriks' require 'metriks'
require 'metriks/reporter/graphite' require 'metriks/reporter/graphite'
require 'opentracing'
require 'rack/tracer'
require 'spanmanager'
require 'dyndnsd/generator/bind' require 'dyndnsd/generator/bind'
require 'dyndnsd/updater/command_with_bind_zone' require 'dyndnsd/updater/command_with_bind_zone'
require 'dyndnsd/responder/dyndns_style' require 'dyndnsd/responder/dyndns_style'
require 'dyndnsd/responder/rest_style' require 'dyndnsd/responder/rest_style'
require 'dyndnsd/database' require 'dyndnsd/database'
require 'dyndnsd/helper'
require 'dyndnsd/version' require 'dyndnsd/version'
module Dyndnsd module Dyndnsd
@@ -30,203 +26,154 @@ module Dyndnsd
end end
class LogFormatter class LogFormatter
def call(lvl, _time, _progname, msg) def call(lvl, time, progname, msg)
format("[%s] %-5s %s\n", Time.now.strftime('%Y-%m-%d %H:%M:%S'), lvl, msg.to_s) "[%s] %-5s %s\n" % [Time.now.strftime('%Y-%m-%d %H:%M:%S'), lvl, msg.to_s]
end end
end end
class Daemon class Daemon
def initialize(config, db, updater) def initialize(config, db, updater, responder)
@users = config['users'] @users = config['users']
@domain = config['domain'] @domain = config['domain']
@db = db @db = db
@updater = updater @updater = updater
@responder = responder
@db.load @db.load
@db['serial'] ||= 1 @db['serial'] ||= 1
@db['hosts'] ||= {} @db['hosts'] ||= {}
if @db.changed? (@db.save; update) if @db.changed?
@db.save
@updater.update(@db)
end
end end
def authorized?(username, password) def update
Helper.span('check_authorized') do |span| @updater.update(@db)
span.set_tag('dyndnsd.user', username) end
allow = Helper.user_allowed?(username, password, @users) def is_fqdn_valid?(hostname)
if !allow return false if hostname.length < @domain.length + 2
Dyndnsd.logger.warn "Login failed for #{username}" return false if not hostname.end_with?(@domain)
Metriks.meter('requests.auth_failed').mark name = hostname.chomp(@domain)
end return false if not name.match(/^[a-zA-Z0-9_-]+\.$/)
allow true
end
end end
def call(env) def call(env)
return [422, {'X-DynDNS-Response' => 'method_forbidden'}, []] if env['REQUEST_METHOD'] != 'GET' return @responder.response_for_error(:method_forbidden) if env["REQUEST_METHOD"] != "GET"
return [422, {'X-DynDNS-Response' => 'not_found'}, []] if env['PATH_INFO'] != '/nic/update' return @responder.response_for_error(:not_found) if env["PATH_INFO"] != "/nic/update"
handle_dyndns_request(env) params = Rack::Utils.parse_query(env["QUERY_STRING"])
return @responder.response_for_error(:hostname_missing) if not params["hostname"]
hostnames = params["hostname"].split(',')
# Check if hostname match rules
hostnames.each do |hostname|
return @responder.response_for_error(:hostname_malformed) if not is_fqdn_valid?(hostname)
end
user = env["REMOTE_USER"]
hostnames.each do |hostname|
return @responder.response_for_error(:host_forbidden) if not @users[user]['hosts'].include? hostname
end
myip = nil
if params.has_key?("myip6")
# require presence of myip parameter as valid IPAddr (v4) and valid myip6
return @responder.response_for_error(:host_forbidden) if not params["myip"]
begin
IPAddr.new(params["myip"], Socket::AF_INET)
IPAddr.new(params["myip6"], Socket::AF_INET6)
# myip will be an array
myip = [params["myip"], params["myip6"]]
rescue ArgumentError
return @responder.response_for_error(:host_forbidden)
end
else
# fallback value, always present
myip = env["REMOTE_ADDR"]
# check whether X-Real-IP header has valid IPAddr
if env.has_key?("HTTP_X_REAL_IP")
begin
IPAddr.new(env["HTTP_X_REAL_IP"])
myip = env["HTTP_X_REAL_IP"]
rescue ArgumentError
end
end
# check whether myip parameter has valid IPAddr
if params.has_key?("myip")
begin
IPAddr.new(params["myip"])
myip = params["myip"]
rescue ArgumentError
end
end
end
Metriks.meter('requests.valid').mark
Dyndnsd.logger.info "Request to update #{hostnames} to #{myip} for user #{user}"
changes = []
hostnames.each do |hostname|
if (not @db['hosts'].include? hostname) or (@db['hosts'][hostname] != myip)
changes << :good
@db['hosts'][hostname] = myip
Metriks.meter('requests.good').mark
else
changes << :nochg
Metriks.meter('requests.nochg').mark
end
end
if @db.changed?
@db['serial'] += 1
Dyndnsd.logger.info "Committing update ##{@db['serial']}"
@db.save
update
Metriks.meter('updates.committed').mark
end
@responder.response_for_changes(changes, myip)
end end
def self.run! def self.run!
if ARGV.length != 1 if ARGV.length != 1
puts 'Usage: dyndnsd config_file' puts "Usage: dyndnsd config_file"
exit 1 exit 1
end end
config_file = ARGV[0] config_file = ARGV[0]
if !File.file?(config_file) if not File.file?(config_file)
puts 'Config file not found!' puts "Config file not found!"
exit 1 exit 1
end end
puts "DynDNSd version #{Dyndnsd::VERSION}" puts "DynDNSd version #{Dyndnsd::VERSION}"
puts "Using config file #{config_file}" puts "Using config file #{config_file}"
config = YAML.safe_load(File.open(config_file, 'r', &:read)) config = YAML::load(File.open(config_file, 'r') { |f| f.read })
setup_logger(config)
Dyndnsd.logger.info 'Starting...'
# 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']
setup_traps
setup_monitoring(config)
setup_tracing(config)
setup_rack(config)
end
private
def extract_v4_and_v6_address(params)
return [] if !(params['myip'])
begin
IPAddr.new(params['myip'], Socket::AF_INET)
IPAddr.new(params['myip6'], Socket::AF_INET6)
[params['myip'], params['myip6']]
rescue ArgumentError
[]
end
end
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')
# check whether myip parameter has valid IPAddr
return [params['myip']] if params.key?('myip') && Helper.ip_valid?(params['myip'])
# check whether X-Real-IP header has valid IPAddr
return [env['HTTP_X_REAL_IP']] if env.key?('HTTP_X_REAL_IP') && Helper.ip_valid?(env['HTTP_X_REAL_IP'])
# fallback value, always present
[env['REMOTE_ADDR']]
end
def process_changes(hostnames, myips)
changes = []
Helper.span('process_changes') do |span|
span.set_tag('dyndnsd.hostnames', hostnames.join(','))
hostnames.each do |hostname|
# myips order is always deterministic
if myips.empty? && @db['hosts'].include?(hostname)
@db['hosts'].delete(hostname)
changes << :good
Metriks.meter('requests.good').mark
elsif Helper.changed?(hostname, myips, @db['hosts'])
@db['hosts'][hostname] = myips
changes << :good
Metriks.meter('requests.good').mark
else
changes << :nochg
Metriks.meter('requests.nochg').mark
end
end
end
changes
end
def update_db
@db['serial'] += 1
Dyndnsd.logger.info "Committing update ##{@db['serial']}"
@db.save
@updater.update(@db)
Metriks.meter('updates.committed').mark
end
def handle_dyndns_request(env)
params = Rack::Utils.parse_query(env['QUERY_STRING'])
# require hostname parameter
return [422, {'X-DynDNS-Response' => 'hostname_missing'}, []] if !(params['hostname'])
hostnames = params['hostname'].split(',')
# check for invalid hostnames
invalid_hostnames = hostnames.select { |h| !Helper.fqdn_valid?(h, @domain) }
return [422, {'X-DynDNS-Response' => 'hostname_malformed'}, []] if invalid_hostnames.any?
user = env['REMOTE_USER']
# check for hostnames that the user does not own
forbidden_hostnames = hostnames - @users[user]['hosts']
return [422, {'X-DynDNS-Response' => 'host_forbidden'}, []] if forbidden_hostnames.any?
if params['offline'] == 'YES'
myips = []
else
myips = extract_myips(env, params)
# require at least one IP to update
return [422, {'X-DynDNS-Response' => 'host_forbidden'}, []] if myips.empty?
end
Metriks.meter('requests.valid').mark
Dyndnsd.logger.info "Request to update #{hostnames} to #{myips} for user #{user}"
changes = process_changes(hostnames, myips)
update_db if @db.changed?
[200, {'X-DynDNS-Response' => 'success'}, [changes, myips]]
end
# SETUP
private_class_method def self.setup_logger(config)
if config['logfile'] if config['logfile']
Dyndnsd.logger = Logger.new(config['logfile']) Dyndnsd.logger = Logger.new(config['logfile'])
else else
Dyndnsd.logger = Logger.new(STDOUT) Dyndnsd.logger = Logger.new(STDOUT)
end end
Dyndnsd.logger.progname = 'dyndnsd' Dyndnsd.logger.progname = "dyndnsd"
Dyndnsd.logger.formatter = LogFormatter.new Dyndnsd.logger.formatter = LogFormatter.new
end
private_class_method def self.setup_traps Dyndnsd.logger.info "Starting..."
Signal.trap('INT') do
Dyndnsd.logger.info 'Quitting...' # drop privs (first change group than user)
Rack::Handler::WEBrick.shutdown Process::Sys.setgid(Etc.getgrnam(config['group']).gid) if config['group']
end Process::Sys.setuid(Etc.getpwnam(config['user']).uid) if config['user']
Signal.trap('TERM') do
Dyndnsd.logger.info 'Quitting...'
Rack::Handler::WEBrick.shutdown
end
end
private_class_method def self.setup_monitoring(config)
# configure metriks # configure metriks
if config['graphite'] if config['graphite']
host = config['graphite']['host'] || 'localhost' host = config['graphite']['host'] || 'localhost'
@@ -245,43 +192,33 @@ module Dyndnsd
end end
reporter.start reporter.start
end end
end
private_class_method def self.setup_tracing(config)
# configure OpenTracing
if config.dig('tracing', 'jaeger')
require 'jaeger/client'
host = config['tracing']['jaeger']['host'] || '127.0.0.1'
port = config['tracing']['jaeger']['port'] || 6831
service_name = config['tracing']['jaeger']['service_name'] || 'dyndnsd'
OpenTracing.global_tracer = Jaeger::Client.build(
host: host, port: port, service_name: service_name, flush_interval: 1
)
end
# always use SpanManager
OpenTracing.global_tracer = SpanManager::Tracer.new(OpenTracing.global_tracer)
end
private_class_method def self.setup_rack(config)
# configure daemon # configure daemon
db = Database.new(config['db']) 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['updater']['params']) if config['updater']['name'] == 'command_with_bind_zone'
daemon = Daemon.new(config, db, updater) responder = Responder::DynDNSStyle.new
# configure rack # configure rack
app = Rack::Auth::Basic.new(daemon, 'DynDNS', &daemon.method(:authorized?)) app = Daemon.new(config, db, updater, responder)
app = Rack::Auth::Basic.new(app, "DynDNS") do |user,pass|
if config['responder'] == 'RestStyle' allow = ((config['users'].has_key? user) and (config['users'][user]['password'] == pass))
app = Responder::RestStyle.new(app) if not allow
else Dyndnsd.logger.warn "Login failed for #{user}"
app = Responder::DynDNSStyle.new(app) Metriks.meter('requests.auth_failed').mark
end
allow
end end
trust_incoming_span = config.dig('tracing', 'trust_incoming_span') || false Signal.trap('INT') do
app = Rack::Tracer.new(app, trust_incoming_span: trust_incoming_span) Dyndnsd.logger.info "Quitting..."
Rack::Handler::WEBrick.shutdown
end
Signal.trap('TERM') do
Dyndnsd.logger.info "Quitting..."
Rack::Handler::WEBrick.shutdown
end
Rack::Handler::WEBrick.run app, Host: config['host'], Port: config['port'] Rack::Handler::WEBrick.run app, :Host => config['host'], :Port => config['port']
end end
end end
end end

View File

@@ -4,29 +4,27 @@ require 'forwardable'
module Dyndnsd module Dyndnsd
class Database class Database
extend Forwardable extend Forwardable
def_delegators :@db, :[], :[]=, :each, :has_key? def_delegators :@db, :[], :[]=, :each, :has_key?
def initialize(db_file) def initialize(db_file)
@db_file = db_file @db_file = db_file
end end
def load def load
if File.file?(@db_file) if File.file?(@db_file)
@db = JSON.parse(File.open(@db_file, 'r', &:read)) @db = JSON.load(File.open(@db_file, 'r') { |f| f.read })
else else
@db = {} @db = {}
end end
@db_hash = @db.hash @db_hash = @db.hash
end end
def save def save
Helper.span('database_save') do |_span| File.open(@db_file, 'w') { |f| JSON.dump(@db, f) }
File.open(@db_file, 'w') { |f| JSON.dump(@db, f) } @db_hash = @db.hash
@db_hash = @db.hash
end
end end
def changed? def changed?
@db_hash != @db.hash @db_hash != @db.hash
end end

View File

@@ -10,25 +10,25 @@ module Dyndnsd
@additional_zone_content = config['additional_zone_content'] @additional_zone_content = config['additional_zone_content']
end end
def generate(db) def generate(zone)
out = [] out = []
out << "$TTL #{@ttl}" out << "$TTL #{@ttl}"
out << "$ORIGIN #{@domain}." out << "$ORIGIN #{@domain}."
out << '' out << ""
out << "@ IN SOA #{@dns} #{@email_addr} ( #{db['serial']} 3h 5m 1w 1h )" out << "@ IN SOA #{@dns} #{@email_addr} ( #{zone['serial']} 3h 5m 1w 1h )"
out << "@ IN NS #{@dns}" out << "@ IN NS #{@dns}"
out << '' out << ""
db['hosts'].each do |hostname, ips| zone['hosts'].each do |hostname,ips|
ips.each do |ip| (ips.is_a?(Array) ? ips : [ips]).each do |ip|
ip = IPAddr.new(ip).native ip = IPAddr.new(ip).native
type = ip.ipv6? ? 'AAAA' : 'A' type = ip.ipv6? ? "AAAA" : "A"
name = hostname.chomp('.' + @domain) name = hostname.chomp('.' + @domain)
out << "#{name} IN #{type} #{ip}" out << "#{name} IN #{type} #{ip}"
end end
end end
out << '' out << ""
out << @additional_zone_content out << @additional_zone_content
out << '' out << ""
out.join("\n") out.join("\n")
end end
end end

View File

@@ -1,41 +0,0 @@
require 'ipaddr'
module Dyndnsd
class Helper
def self.fqdn_valid?(hostname, domain)
return false if hostname.length < domain.length + 2
return false if !hostname.end_with?(domain)
name = hostname.chomp(domain)
return false if !name.match(/^[a-zA-Z0-9_-]+\.$/)
true
end
def self.ip_valid?(ip)
IPAddr.new(ip)
return true
rescue ArgumentError
return false
end
def self.user_allowed?(username, password, users)
(users.key? username) && (users[username]['password'] == password)
end
def self.changed?(hostname, myips, hosts)
# myips order is always deterministic
((!hosts.include? hostname) || (hosts[hostname] != myips)) && !myips.empty?
end
def self.span(operation, &block)
span = OpenTracing.start_span(operation)
span.set_tag('component', 'dyndnsd')
span.set_tag('span.kind', 'server')
begin
block.call(span)
ensure
span.finish
end
end
end
end

View File

@@ -2,52 +2,19 @@
module Dyndnsd module Dyndnsd
module Responder module Responder
class DynDNSStyle class DynDNSStyle
def initialize(app) def response_for_error(state)
@app = app # general http errors
return [405, {"Content-Type" => "text/plain"}, ["Method Not Allowed"]] if state == :method_forbidden
return [404, {"Content-Type" => "text/plain"}, ["Not Found"]] if state == :not_found
# specific errors
return [200, {"Content-Type" => "text/plain"}, ["notfqdn"]] if state == :hostname_missing
return [200, {"Content-Type" => "text/plain"}, ["nohost"]] if state == :host_forbidden
return [200, {"Content-Type" => "text/plain"}, ["notfqdn"]] if state == :hostname_malformed
end end
def call(env) def response_for_changes(states, ip)
@app.call(env).tap do |status_code, headers, body| body = states.map { |state| "#{state} #{ip.is_a?(Array) ? ip.join(' ') : ip}" }.join("\n")
if headers.key?('X-DynDNS-Response') return [200, {"Content-Type" => "text/plain"}, [body]]
return decorate_dyndnsd_response(status_code, headers, body)
else
return decorate_other_response(status_code, headers, body)
end
end
end
private
def decorate_dyndnsd_response(status_code, headers, body)
if status_code == 200
[200, {'Content-Type' => 'text/plain'}, [get_success_body(body[0], body[1])]]
elsif status_code == 422
error_response_map[headers['X-DynDNS-Response']]
end
end
def decorate_other_response(status_code, headers, _body)
if status_code == 400
[status_code, headers, ['Bad Request']]
elsif status_code == 401
[status_code, headers, ['badauth']]
end
end
def get_success_body(changes, myips)
changes.map { |change| "#{change} #{myips.join(' ')}" }.join("\n")
end
def error_response_map
{
# general http errors
'method_forbidden' => [405, {'Content-Type' => 'text/plain'}, ['Method Not Allowed']],
'not_found' => [404, {'Content-Type' => 'text/plain'}, ['Not Found']],
# specific errors
'hostname_missing' => [200, {'Content-Type' => 'text/plain'}, ['notfqdn']],
'hostname_malformed' => [200, {'Content-Type' => 'text/plain'}, ['notfqdn']],
'host_forbidden' => [200, {'Content-Type' => 'text/plain'}, ['nohost']]
}
end end
end end
end end

View File

@@ -2,52 +2,19 @@
module Dyndnsd module Dyndnsd
module Responder module Responder
class RestStyle class RestStyle
def initialize(app) def response_for_error(state)
@app = app # general http errors
return [405, {"Content-Type" => "text/plain"}, ["Method Not Allowed"]] if state == :method_forbidden
return [404, {"Content-Type" => "text/plain"}, ["Not Found"]] if state == :not_found
# specific errors
return [422, {"Content-Type" => "text/plain"}, ["Hostname missing"]] if state == :hostname_missing
return [403, {"Content-Type" => "text/plain"}, ["Forbidden"]] if state == :host_forbidden
return [422, {"Content-Type" => "text/plain"}, ["Hostname malformed"]] if state == :hostname_malformed
end end
def call(env) def response_for_changes(states, ip)
@app.call(env).tap do |status_code, headers, body| body = states.map { |state| state == :good ? "Changed to #{ip.is_a?(Array) ? ip.join(' ') : ip}" : "No change needed for #{ip.is_a?(Array) ? ip.join(' ') : ip}" }.join("\n")
if headers.key?('X-DynDNS-Response') return [200, {"Content-Type" => "text/plain"}, [body]]
return decorate_dyndnsd_response(status_code, headers, body)
else
return decorate_other_response(status_code, headers, body)
end
end
end
private
def decorate_dyndnsd_response(status_code, headers, body)
if status_code == 200
[200, {'Content-Type' => 'text/plain'}, [get_success_body(body[0], body[1])]]
elsif status_code == 422
error_response_map[headers['X-DynDNS-Response']]
end
end
def decorate_other_response(status_code, headers, _body)
if status_code == 400
[status_code, headers, ['Bad Request']]
elsif status_code == 401
[status_code, headers, ['Unauthorized']]
end
end
def get_success_body(changes, myips)
changes.map { |change| change == :good ? "Changed to #{myips.join(' ')}" : "No change needed for #{myips.join(' ')}" }.join("\n")
end
def error_response_map
{
# general http errors
'method_forbidden' => [405, {'Content-Type' => 'text/plain'}, ['Method Not Allowed']],
'not_found' => [404, {'Content-Type' => 'text/plain'}, ['Not Found']],
# specific errors
'hostname_missing' => [422, {'Content-Type' => 'text/plain'}, ['Hostname missing']],
'hostname_malformed' => [422, {'Content-Type' => 'text/plain'}, ['Hostname malformed']],
'host_forbidden' => [403, {'Content-Type' => 'text/plain'}, ['Forbidden']]
}
end end
end end
end end

View File

@@ -7,21 +7,16 @@ module Dyndnsd
@command = config['command'] @command = config['command']
@generator = Generator::Bind.new(domain, config) @generator = Generator::Bind.new(domain, config)
end end
def update(zone) def update(zone)
Helper.span('updater_update') do |span| # write zone file in bind syntax
span.set_tag('dyndnsd.updater.name', self.class.name.split('::').last) File.open(@zone_file, 'w') { |f| f.write(@generator.generate(zone)) }
# call user-defined command
# write zone file in bind syntax pid = fork do
File.open(@zone_file, 'w') { |f| f.write(@generator.generate(zone)) } exec @command
# call user-defined command
pid = fork do
exec @command
end
# detach so children don't become zombies
Process.detach(pid)
end end
# detach so children don't become zombies
Process.detach(pid)
end end
end end
end end

View File

@@ -1,4 +1,4 @@
module Dyndnsd module Dyndnsd
VERSION = '2.0.0.rc1'.freeze VERSION = "1.6.1"
end end

View File

@@ -18,25 +18,20 @@ describe Dyndnsd::Daemon do
} }
db = Dyndnsd::DummyDatabase.new({}) db = Dyndnsd::DummyDatabase.new({})
updater = Dyndnsd::Updater::Dummy.new updater = Dyndnsd::Updater::Dummy.new
daemon = Dyndnsd::Daemon.new(config, db, updater) responder = Dyndnsd::Responder::DynDNSStyle.new
app = Dyndnsd::Daemon.new(config, db, updater, responder)
app = Rack::Auth::Basic.new(daemon, 'DynDNS', &daemon.method(:authorized?)) Rack::Auth::Basic.new(app, "DynDNS") do |user,pass|
(config['users'].has_key? user) and (config['users'][user]['password'] == pass)
app = Dyndnsd::Responder::DynDNSStyle.new(app) end
Rack::Tracer.new(app, trust_incoming_span: false)
end end
it 'requires authentication' do it 'requires authentication' do
get '/' get '/'
expect(last_response.status).to eq(401) expect(last_response.status).to eq(401)
expect(last_response.body).to eq('badauth')
end
it 'requires configured correct credentials' do pending 'Need to find a way to add custom body on 401 responses'
authorize 'test', 'wrongsecret' expect(last_response).not_to be_ok
get '/'
expect(last_response.status).to eq(401)
expect(last_response.body).to eq('badauth') expect(last_response.body).to eq('badauth')
end end
@@ -101,7 +96,6 @@ describe Dyndnsd::Daemon do
it 'rejects request if user does not own one hostname' do it 'rejects request if user does not own one hostname' do
authorize 'test', 'secret' authorize 'test', 'secret'
get '/nic/update?hostname=notmyhost.example.org' get '/nic/update?hostname=notmyhost.example.org'
expect(last_response).to be_ok expect(last_response).to be_ok
expect(last_response.body).to eq('nohost') expect(last_response.body).to eq('nohost')
@@ -167,34 +161,6 @@ describe Dyndnsd::Daemon do
expect(last_response.body).to eq("nochg 2001:db8::1\ngood 2001:db8::1") expect(last_response.body).to eq("nochg 2001:db8::1\ngood 2001:db8::1")
end end
it 'offlines a host' do
authorize 'test', 'secret'
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4'
expect(last_response).to be_ok
expect(last_response.body).to eq('good 1.2.3.4')
get '/nic/update?hostname=foo.example.org&offline=YES'
expect(last_response).to be_ok
expect(last_response.body).to eq('good ')
get '/nic/update?hostname=foo.example.org&offline=YES'
expect(last_response).to be_ok
expect(last_response.body).to eq('nochg ')
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4'
expect(last_response).to be_ok
expect(last_response.body).to eq('good 1.2.3.4')
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4&offline=YES'
expect(last_response).to be_ok
expect(last_response.body).to eq('good ')
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4&offline=YES'
expect(last_response).to be_ok
expect(last_response.body).to eq('nochg ')
end
it 'uses clients remote IP address if myip not specified' do it 'uses clients remote IP address if myip not specified' do
authorize 'test', 'secret' authorize 'test', 'secret'
get '/nic/update?hostname=foo.example.org' get '/nic/update?hostname=foo.example.org'
@@ -219,7 +185,7 @@ describe Dyndnsd::Daemon do
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4&myip6=2001:db8::1' get '/nic/update?hostname=foo.example.org&myip=1.2.3.4&myip6=2001:db8::1'
expect(last_response).to be_ok expect(last_response).to be_ok
expect(last_response.body).to eq('good 1.2.3.4 2001:db8::1') expect(last_response.body).to eq("good 1.2.3.4 2001:db8::1")
get '/nic/update?hostname=foo.example.org&myip=BROKENIP&myip6=2001:db8::1' get '/nic/update?hostname=foo.example.org&myip=BROKENIP&myip6=2001:db8::1'
expect(last_response).to be_ok expect(last_response).to be_ok

View File

@@ -4,7 +4,7 @@ require 'forwardable'
module Dyndnsd module Dyndnsd
class DummyDatabase class DummyDatabase
extend Forwardable extend Forwardable
def_delegators :@db, :[], :[]=, :each, :has_key? def_delegators :@db, :[], :[]=, :each, :has_key?
def initialize(db_init) def initialize(db_init)
@@ -25,3 +25,5 @@ module Dyndnsd
end end
end end
end end