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

Compare commits

...

37 Commits

Author SHA1 Message Date
cn
6604b98a84 release: 2.0.0.rc1 2018-03-26 19:51:57 +02:00
cn
3a426f75a2 docs: add link to older version 1.x 2018-03-26 19:40:11 +02:00
cn
ff127ce49f travis: fix yaml 2018-03-26 19:32:18 +02:00
cn
147071da9e daemon: add host offlining by deleting the associated DNS records 2018-02-23 16:37:10 +01:00
cn
8d4e96a1dd tracing: add opentracing for rack and dyndnsd with configurable jaeger-client support and spanmanager 2018-02-23 14:37:16 +01:00
cn
21857959b5 gem: fix applicable rubocop todos 2018-02-23 12:54:43 +01:00
cn
9a7c20babb gem: add rubocop 2018-02-23 11:13:28 +01:00
cn
fc7a1cabd3 gem: fix license name to standard, explicitly set required ruby version 2018-02-23 11:09:06 +01:00
cn
092f012cb5 travis: add ruby 2.5 2018-02-17 21:32:58 +01:00
cn
750f51d911 docs: update README and add CHANGELOG 2018-02-15 16:02:33 +01:00
cn
599fa2b718 daemon: move authorization check to daemon making it testable 2018-02-05 15:29:12 +01:00
cn
26bf1ee3fd gem: update to Rack 2.0 2018-02-05 15:23:27 +01:00
cn
acf3d7ed74 helpers: refactor for simple functions 2018-02-03 21:40:44 +01:00
cn
9195d40344 daemon: break down setup into smaller parts 2018-02-02 16:45:53 +01:00
cn
bd3c5d3234 responder: fix bug 2018-02-02 16:44:50 +01:00
cn
08eaacb6ab daemon: break extracting myip down into smaller parts 2018-02-02 16:14:28 +01:00
cn
6080e14356 daemon: break down request handling into smaller parts 2018-02-01 21:50:56 +01:00
cn
f1b58f5167 responder: refactor into Rack middleware and improve API conformance 2018-02-01 21:46:22 +01:00
cn
3f56070ed5 travis: update rubies 2018-02-01 20:54:59 +01:00
cn
16af27ae52 docs: fix API docs link at dyn.com 2018-01-26 13:50:25 +01:00
cn
16518dca9b gem: loosen version constraints of dependencies since we dropped Ruby 1.8 2018-01-26 13:49:16 +01:00
cn
b31ccafe9c travis: update rubies 2018-01-26 13:05:33 +01:00
cn
2a5140fcf0 Bump version 2017-10-31 11:18:08 +01:00
2edb9522f1 auth: fix broken password check
Guessing an existing user's name was enough to successfully authenticate.
2017-10-20 16:20:38 +00:00
cn
13613643cc Bump version 2016-12-07 14:20:52 +01:00
cn
4894015325 Add "myip6" URL parameter to provide an IPv6 address in addition to an IPv4 one as "myip" 2016-12-07 14:20:01 +01:00
cn
ae095c22b7 Force bundler update on travis-ci to prevent bug pre bundler 1.9.3 2016-12-01 19:05:38 +01:00
cn
3e1a391281 Bump version 2016-12-01 00:24:50 +01:00
cn
cf40e167d1 travis: disable ruby 1.8.7 2016-11-30 22:20:49 +01:00
cn
af97e162a0 Change dependency to json version that does not require ruby >= 2 2016-11-30 22:16:54 +01:00
cn
7ce1c1f480 Fix tests to use rspec expect syntax 2016-11-30 22:07:51 +01:00
cn
f76c5933d7 Allow dyndnsd to use address from X-Real-IP 2016-11-30 21:59:12 +01:00
cn
a9083e916e Add coverage of IPv6 addresses in tests 2016-11-30 21:24:56 +01:00
cn
cfce5be361 Bump version 2016-11-27 22:21:16 +01:00
cn
9ae2a63af2 Pin rack gem in Gemfile to ~> 1.6 for ruby < 2.2.2 support 2016-11-27 22:21:08 +01:00
Christian Nicolai
567f252cad Merge pull request #2 from haasn/ipv6
Support IPv6 addresses
2015-06-18 14:11:37 +02:00
Niklas Haas
d2747549fe Support IPv6 addresses
This also checks for IPv6-mapped IPv4 addresses.

Closes #1.
2015-06-18 06:09:46 +02:00
17 changed files with 802 additions and 264 deletions

62
.rubocop.yml Normal file
View File

@@ -0,0 +1,62 @@
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

7
.rubocop_todo.yml Normal file
View File

@@ -0,0 +1,7 @@
# 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,9 +1,7 @@
---
sudo: false
language: ruby
rvm:
- 2.0.0
- 1.9.3
- 1.8.7
gemfile:
- Gemfile
- 2.5
- 2.4
- 2.3

78
CHANGELOG.md Normal file
View File

@@ -0,0 +1,78 @@
# 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,16 +1,18 @@
# dyndnsd.rb
[![Build Status](https://travis-ci.org/cmur2/dyndnsd.png)](https://travis-ci.org/cmur2/dyndnsd)
[![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)
A small, lightweight and extensible DynDNS server written with Ruby and Rack.
## Description
dyndnsd.rb is aimed to implement a small [DynDNS-compliant](http://dyn.com/support/developers/api/) server in Ruby. 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.
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.
The 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
@@ -101,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
http[s]://[USER]:[PASSWORD]@[DOMAIN]:[PORT]/nic/update?hostname=[HOSTNAMES]&myip=[MYIP]
http[s]://[USER]:[PASSWORD]@[DOMAIN]:[PORT]/nic/update?hostname=[HOSTNAMES]&myip=[MYIP]&myip6=[MYIP6]
where:
@@ -110,7 +112,18 @@ where:
* 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
* 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 address will be used if missing
* MYIP is optional and the HTTP client's IP address will be used if missing
* MYIP6 is optional but if present also requires presence of MYIP
### IP address determination
The following rules apply:
* use any IP address provided via the myip parameter when present, or
* use any IP address provided via the X-Real-IP header e.g. when used behind HTTP reverse proxy such as nginx, or
* use any IP address used by the connecting HTTP client
If you want to provide an additional IPv6 address as myip6 parameter the myip parameter containing an IPv4 address has to be present, too! No automatism is applied then.
### SSL, multiple listen ports
@@ -120,6 +133,79 @@ 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.
### 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
dyndnsd.rb is licensed under the Apache License, Version 2.0. See LICENSE for more information.

View File

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

View File

@@ -1,31 +1,36 @@
$:.push File.expand_path("../lib", __FILE__)
$LOAD_PATH.push File.expand_path('../lib', __FILE__)
require 'dyndnsd/version'
Gem::Specification.new do |s|
s.name = 'dyndnsd'
s.name = 'dyndnsd'
s.version = Dyndnsd::VERSION
s.summary = 'dyndnsd.rb'
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.license = 'Apache License Version 2.0'
s.homepage = 'https://github.com/cmur2/dyndnsd'
s.homepage = 'https://github.com/cmur2/dyndnsd'
s.license = 'Apache-2.0'
s.files = `git ls-files`.split($/)
s.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR)
s.test_files = s.files.grep(%r{^(test|spec|features)/})
s.require_paths = ['lib']
s.executables = ['dyndnsd']
s.add_runtime_dependency 'rack'
s.required_ruby_version = '>= 2.3'
s.add_runtime_dependency 'rack', '~> 2.0'
s.add_runtime_dependency 'json'
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', '~> 1.3'
s.add_development_dependency 'bundler'
s.add_development_dependency 'rake'
s.add_development_dependency 'rspec'
s.add_development_dependency 'rack-test'
s.add_development_dependency 'rubocop', '~> 0.52.1'
end

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

@@ -8,12 +8,16 @@ require 'yaml'
require 'rack'
require 'metriks'
require 'metriks/reporter/graphite'
require 'opentracing'
require 'rack/tracer'
require 'spanmanager'
require 'dyndnsd/generator/bind'
require 'dyndnsd/updater/command_with_bind_zone'
require 'dyndnsd/responder/dyndns_style'
require 'dyndnsd/responder/rest_style'
require 'dyndnsd/database'
require 'dyndnsd/helper'
require 'dyndnsd/version'
module Dyndnsd
@@ -26,131 +30,203 @@ module Dyndnsd
end
class LogFormatter
def call(lvl, time, progname, msg)
"[%s] %-5s %s\n" % [Time.now.strftime('%Y-%m-%d %H:%M:%S'), lvl, msg.to_s]
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
def initialize(config, db, updater, responder)
def initialize(config, db, updater)
@users = config['users']
@domain = config['domain']
@db = db
@updater = updater
@responder = responder
@db.load
@db['serial'] ||= 1
@db['hosts'] ||= {}
(@db.save; update) if @db.changed?
end
def update
@updater.update(@db)
end
def is_fqdn_valid?(hostname)
return false if hostname.length < @domain.length + 2
return false if not hostname.end_with?(@domain)
name = hostname.chomp(@domain)
return false if not name.match(/^[a-zA-Z0-9_-]+\.$/)
true
end
def call(env)
return @responder.response_for_error(:method_forbidden) if env["REQUEST_METHOD"] != "GET"
return @responder.response_for_error(:not_found) if env["PATH_INFO"] != "/nic/update"
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
# no myip?
if not params["myip"]
params["myip"] = env["REMOTE_ADDR"]
end
# malformed myip?
begin
IPAddr.new(params["myip"], Socket::AF_INET)
rescue ArgumentError
params["myip"] = env["REMOTE_ADDR"]
end
myip = params["myip"]
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
@updater.update(@db)
end
@responder.response_for_changes(changes, myip)
end
def authorized?(username, password)
Helper.span('check_authorized') do |span|
span.set_tag('dyndnsd.user', username)
allow = Helper.user_allowed?(username, password, @users)
if !allow
Dyndnsd.logger.warn "Login failed for #{username}"
Metriks.meter('requests.auth_failed').mark
end
allow
end
end
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'
handle_dyndns_request(env)
end
def self.run!
if ARGV.length != 1
puts "Usage: dyndnsd config_file"
puts 'Usage: dyndnsd config_file'
exit 1
end
config_file = ARGV[0]
if not File.file?(config_file)
puts "Config file not found!"
if !File.file?(config_file)
puts 'Config file not found!'
exit 1
end
puts "DynDNSd version #{Dyndnsd::VERSION}"
puts "Using config file #{config_file}"
config = YAML::load(File.open(config_file, 'r') { |f| f.read })
config = YAML.safe_load(File.open(config_file, 'r', &: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']
Dyndnsd.logger = Logger.new(config['logfile'])
else
Dyndnsd.logger = Logger.new(STDOUT)
end
Dyndnsd.logger.progname = "dyndnsd"
Dyndnsd.logger.progname = 'dyndnsd'
Dyndnsd.logger.formatter = LogFormatter.new
end
Dyndnsd.logger.info "Starting..."
# drop privs (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']
private_class_method def self.setup_traps
Signal.trap('INT') do
Dyndnsd.logger.info 'Quitting...'
Rack::Handler::WEBrick.shutdown
end
Signal.trap('TERM') do
Dyndnsd.logger.info 'Quitting...'
Rack::Handler::WEBrick.shutdown
end
end
private_class_method def self.setup_monitoring(config)
# configure metriks
if config['graphite']
host = config['graphite']['host'] || 'localhost'
@@ -169,33 +245,43 @@ module Dyndnsd
end
reporter.start
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
db = Database.new(config['db'])
updater = Updater::CommandWithBindZone.new(config['domain'], config['updater']['params']) if config['updater']['name'] == 'command_with_bind_zone'
responder = Responder::DynDNSStyle.new
daemon = Daemon.new(config, db, updater)
# configure rack
app = Daemon.new(config, db, updater, responder)
app = Rack::Auth::Basic.new(app, "DynDNS") do |user,pass|
allow = (config['users'].has_key? user) and (config['users'][user]['password'] == pass)
if not allow
Dyndnsd.logger.warn "Login failed for #{user}"
Metriks.meter('requests.auth_failed').mark
end
allow
app = Rack::Auth::Basic.new(daemon, 'DynDNS', &daemon.method(:authorized?))
if config['responder'] == 'RestStyle'
app = Responder::RestStyle.new(app)
else
app = Responder::DynDNSStyle.new(app)
end
Signal.trap('INT') do
Dyndnsd.logger.info "Quitting..."
Rack::Handler::WEBrick.shutdown
end
Signal.trap('TERM') do
Dyndnsd.logger.info "Quitting..."
Rack::Handler::WEBrick.shutdown
end
trust_incoming_span = config.dig('tracing', 'trust_incoming_span') || false
app = Rack::Tracer.new(app, trust_incoming_span: trust_incoming_span)
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

View File

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

View File

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

41
lib/dyndnsd/helper.rb Normal file
View File

@@ -0,0 +1,41 @@
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,19 +2,52 @@
module Dyndnsd
module Responder
class DynDNSStyle
def response_for_error(state)
# 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
def initialize(app)
@app = app
end
def response_for_changes(states, ip)
body = states.map { |state| "#{state} #{ip}" }.join("\n")
return [200, {"Content-Type" => "text/plain"}, [body]]
def call(env)
@app.call(env).tap do |status_code, headers, body|
if headers.key?('X-DynDNS-Response')
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

View File

@@ -2,19 +2,52 @@
module Dyndnsd
module Responder
class RestStyle
def response_for_error(state)
# 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
def initialize(app)
@app = app
end
def response_for_changes(states, ip)
body = states.map { |state| state == :good ? "Changed to #{ip}" : "No change needed for #{ip}" }.join("\n")
return [200, {"Content-Type" => "text/plain"}, [body]]
def call(env)
@app.call(env).tap do |status_code, headers, body|
if headers.key?('X-DynDNS-Response')
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

View File

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

View File

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

View File

@@ -2,11 +2,11 @@ require 'spec_helper'
describe Dyndnsd::Daemon do
include Rack::Test::Methods
def app
Dyndnsd.logger = Logger.new(STDOUT)
Dyndnsd.logger.level = Logger::UNKNOWN
config = {
'domain' => 'example.org',
'users' => {
@@ -18,125 +18,223 @@ describe Dyndnsd::Daemon do
}
db = Dyndnsd::DummyDatabase.new({})
updater = Dyndnsd::Updater::Dummy.new
responder = Dyndnsd::Responder::DynDNSStyle.new
app = Dyndnsd::Daemon.new(config, db, updater, responder)
Rack::Auth::Basic.new(app, "DynDNS") do |user,pass|
(config['users'].has_key? user) and (config['users'][user]['password'] == pass)
end
daemon = Dyndnsd::Daemon.new(config, db, updater)
app = Rack::Auth::Basic.new(daemon, 'DynDNS', &daemon.method(:authorized?))
app = Dyndnsd::Responder::DynDNSStyle.new(app)
Rack::Tracer.new(app, trust_incoming_span: false)
end
it 'requires authentication' do
get '/'
last_response.status.should == 401
pending 'Need to find a way to add custom body on 401 responses'
last_response.should be_ok 'badauth'
expect(last_response.status).to eq(401)
expect(last_response.body).to eq('badauth')
end
it 'requires configured correct credentials' do
authorize 'test', 'wrongsecret'
get '/'
expect(last_response.status).to eq(401)
expect(last_response.body).to eq('badauth')
end
it 'only supports GET requests' do
authorize 'test', 'secret'
post '/nic/update'
last_response.status.should == 405
expect(last_response.status).to eq(405)
end
it 'provides only the /nic/update URL' do
authorize 'test', 'secret'
get '/other/url'
last_response.status.should == 404
expect(last_response.status).to eq(404)
end
it 'requires the hostname query parameter' do
authorize 'test', 'secret'
get '/nic/update'
last_response.should be_ok
last_response.body.should == 'notfqdn'
expect(last_response).to be_ok
expect(last_response.body).to eq('notfqdn')
end
it 'supports multiple hostnames in request' do
authorize 'test', 'secret'
get '/nic/update?hostname=foo.example.org,bar.example.org&myip=1.2.3.4'
last_response.should be_ok
last_response.body.should == "good 1.2.3.4\ngood 1.2.3.4"
expect(last_response).to be_ok
expect(last_response.body).to eq("good 1.2.3.4\ngood 1.2.3.4")
get '/nic/update?hostname=foo.example.org,bar.example.org&myip=2001:db8::1'
expect(last_response).to be_ok
expect(last_response.body).to eq("good 2001:db8::1\ngood 2001:db8::1")
end
it 'rejects request if one hostname is invalid' do
authorize 'test', 'secret'
get '/nic/update?hostname=test'
last_response.should be_ok
last_response.body.should == 'notfqdn'
expect(last_response).to be_ok
expect(last_response.body).to eq('notfqdn')
get '/nic/update?hostname=test.example.com'
last_response.should be_ok
last_response.body.should == 'notfqdn'
expect(last_response).to be_ok
expect(last_response.body).to eq('notfqdn')
get '/nic/update?hostname=test.example.org.me'
last_response.should be_ok
last_response.body.should == 'notfqdn'
expect(last_response).to be_ok
expect(last_response.body).to eq('notfqdn')
get '/nic/update?hostname=foo.test.example.org'
last_response.should be_ok
last_response.body.should == 'notfqdn'
expect(last_response).to be_ok
expect(last_response.body).to eq('notfqdn')
get '/nic/update?hostname=in%20valid.example.org'
last_response.should be_ok
last_response.body.should == 'notfqdn'
expect(last_response).to be_ok
expect(last_response.body).to eq('notfqdn')
get '/nic/update?hostname=valid.example.org,in.valid.example.org'
last_response.should be_ok
last_response.body.should == 'notfqdn'
expect(last_response).to be_ok
expect(last_response.body).to eq('notfqdn')
end
it 'rejects request if user does not own one hostname' do
authorize 'test', 'secret'
get '/nic/update?hostname=notmyhost.example.org'
last_response.should be_ok
last_response.body.should == 'nohost'
expect(last_response).to be_ok
expect(last_response.body).to eq('nohost')
get '/nic/update?hostname=foo.example.org,notmyhost.example.org'
last_response.should be_ok
last_response.body.should == 'nohost'
expect(last_response).to be_ok
expect(last_response.body).to eq('nohost')
end
it 'updates a host on IPv4 change' do
authorize 'test', 'secret'
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4'
last_response.should be_ok
get '/nic/update?hostname=foo.example.org&myip=1.2.3.40'
last_response.should be_ok
last_response.body.should == 'good 1.2.3.40'
end
it 'returns IPv4 no change' do
authorize 'test', 'secret'
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4'
last_response.should be_ok
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4'
last_response.should be_ok
last_response.body.should == 'nochg 1.2.3.4'
end
it 'outputs IPv4 status per hostname' do
it 'updates a host on IP change' do
authorize 'test', 'secret'
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4'
last_response.should be_ok
last_response.body.should == 'good 1.2.3.4'
get '/nic/update?hostname=foo.example.org,bar.example.org&myip=1.2.3.4'
last_response.should be_ok
last_response.body.should == "nochg 1.2.3.4\ngood 1.2.3.4"
expect(last_response).to be_ok
get '/nic/update?hostname=foo.example.org&myip=1.2.3.40'
expect(last_response).to be_ok
expect(last_response.body).to eq('good 1.2.3.40')
get '/nic/update?hostname=foo.example.org&myip=2001:db8::1'
expect(last_response).to be_ok
get '/nic/update?hostname=foo.example.org&myip=2001:db8::10'
expect(last_response).to be_ok
expect(last_response.body).to eq('good 2001:db8::10')
end
it 'uses clients remote IPv4 address if myip not specified' do
it 'returns IP no change' do
authorize 'test', 'secret'
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4'
expect(last_response).to be_ok
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4'
expect(last_response).to be_ok
expect(last_response.body).to eq('nochg 1.2.3.4')
get '/nic/update?hostname=foo.example.org&myip=2001:db8::1'
expect(last_response).to be_ok
get '/nic/update?hostname=foo.example.org&myip=2001:db8::1'
expect(last_response).to be_ok
expect(last_response.body).to eq('nochg 2001:db8::1')
end
it 'outputs IP status per hostname' 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,bar.example.org&myip=1.2.3.4'
expect(last_response).to be_ok
expect(last_response.body).to eq("nochg 1.2.3.4\ngood 1.2.3.4")
get '/nic/update?hostname=foo.example.org&myip=2001:db8::1'
expect(last_response).to be_ok
expect(last_response.body).to eq('good 2001:db8::1')
get '/nic/update?hostname=foo.example.org,bar.example.org&myip=2001:db8::1'
expect(last_response).to be_ok
expect(last_response.body).to eq("nochg 2001:db8::1\ngood 2001:db8::1")
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
authorize 'test', 'secret'
get '/nic/update?hostname=foo.example.org'
last_response.should be_ok
last_response.body.should == 'good 127.0.0.1'
expect(last_response).to be_ok
expect(last_response.body).to eq('good 127.0.0.1')
end
it 'uses clients remote IP address from X-Real-IP header if behind proxy' do
authorize 'test', 'secret'
get '/nic/update?hostname=foo.example.org', '', 'HTTP_X_REAL_IP' => '10.0.0.1'
expect(last_response).to be_ok
expect(last_response.body).to eq('good 10.0.0.1')
get '/nic/update?hostname=foo.example.org', '', 'HTTP_X_REAL_IP' => '2001:db8::1'
expect(last_response).to be_ok
expect(last_response.body).to eq('good 2001:db8::1')
end
it 'supports an IPv4 and an IPv6 address in one request' do
authorize 'test', 'secret'
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.body).to eq('good 1.2.3.4 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.body).to eq('nohost')
get '/nic/update?hostname=foo.example.org&myip=1.2.3.4&myip6=BROKENIP'
expect(last_response).to be_ok
expect(last_response.body).to eq('nohost')
get '/nic/update?hostname=foo.example.org&myip6=2001:db8::10'
expect(last_response).to be_ok
expect(last_response.body).to eq('nohost')
get '/nic/update?hostname=foo.example.org&myip=1.2.3.40'
expect(last_response).to be_ok
expect(last_response.body).to eq('good 1.2.3.40')
end
end

View File

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