radd 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a1b144c324d733fdabbee7eada3e800e3e9c1cba8aee8e2de1a02c67fd6e5a34
4
+ data.tar.gz: 27a536a5b377f7762a6c8bf26c9978e27ac08218745cca493af8f45680fc9f10
5
+ SHA512:
6
+ metadata.gz: f46802e9687b2cd65da92e5866dce85e14a2a8de8fb04b1b1bf29431b485c4979c50f71292d850af787d7e7163a13c5b3282e5ac45548482d66f70ea8d1be5cf
7
+ data.tar.gz: d2a5de1d3da7ad395f77eda95bd72f39796bdb7b64c072ca1df336207c064ca405624f0f09142bac1c44ee937436696d0afbfbbb094000f24d4444e5b3d5b825
data/CHANGELOG ADDED
@@ -0,0 +1,7 @@
1
+ * 0.2.0
2
+
3
+ Use RubyDNS
4
+
5
+ * 0.1.0
6
+
7
+ Use knot DNS
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014-2026 Matthias Grosser
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,12 @@
1
+ radd
2
+ ====
3
+
4
+ Minimal dynamic DNS service
5
+
6
+ rake radd:db:create
7
+
8
+ rake radd:add
9
+
10
+ rake radd:list
11
+
12
+ rake radd:delete
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'radd'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require 'irb'
14
+ IRB.start
data/bin/radd ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ Warning[:experimental] = false
4
+
5
+ require 'bundler/setup'
6
+ require 'radd'
7
+ require 'radd/cli'
8
+
9
+ Radd::Cli.start
data/lib/radd/app.rb ADDED
@@ -0,0 +1,12 @@
1
+ Radd::App = Rack::Builder.app do
2
+ map '/ip' do
3
+ run Radd::IP
4
+ end
5
+
6
+ map '/update' do
7
+ use Rack::Auth::Basic, 'Authorization required' do |user, password|
8
+ Radd.authorized?(user, password)
9
+ end
10
+ run Radd::Update
11
+ end
12
+ end
data/lib/radd/cli.rb ADDED
@@ -0,0 +1,43 @@
1
+ require 'optparse'
2
+
3
+ module Radd::Cli
4
+
5
+ class << self
6
+
7
+ def start
8
+ config = {}
9
+ parser = OptionParser.new
10
+ parser.banner = 'Usage: radd -i IP -d DOMAIN [options]'
11
+ parser.on('--ip IP', 'Public IP address') do |ip|
12
+ config['ip'] = ip
13
+ end
14
+ parser.on('--domain DOMAIN', 'Root FQDN') do |domain|
15
+ config['domain'] = domain
16
+ end
17
+ parser.on('--http-port [PORT]', 'HTTP port') do |port|
18
+ config['http_port'] = port
19
+ end
20
+ parser.on('--dns-port [PORT]', 'DNS port') do |port|
21
+ config['dns_port'] = port
22
+ end
23
+ parser.parse!
24
+
25
+ Radd.configure!(config)
26
+ puts "Starting Radd server for #{Radd.domain}"
27
+
28
+ dns, http = Radd::Nameserver.new, Radd::Webserver.new
29
+
30
+ Async do
31
+ dns_task, http_task = dns.run, http.run
32
+
33
+ watchdog = Async do
34
+ sleep(1) while !http_task.failed? && !dns_task.failed?
35
+ puts "Task failed!"
36
+ exit(1)
37
+ end
38
+ end
39
+ end
40
+
41
+ end
42
+
43
+ end
data/lib/radd/db.rb ADDED
@@ -0,0 +1,3 @@
1
+ require 'sequel'
2
+
3
+ DB = Sequel.connect("sqlite://#{Pathname.new(__FILE__).dirname.join('..', '..', 'db', 'radd.sqlite3')}")
data/lib/radd/dns.rb ADDED
@@ -0,0 +1,18 @@
1
+ class Radd::DNS < Async::DNS::Server
2
+ def initialize
3
+ super(Async::DNS::Endpoint.for(Radd.host, port: Radd.dns_port))
4
+ end
5
+
6
+ def process(name, resource_class, transaction)
7
+ name = name.downcase
8
+ if Resolv::DNS::Resource::IN::A == resource_class
9
+ if Radd.domain == name
10
+ ip = Radd.ip
11
+ else
12
+ ip = Radd.query(name)
13
+ end
14
+ end
15
+ return transaction.respond!(ip, ttl: 300) if ip
16
+ transaction.fail!(:NXDomain)
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ class Radd::DNSService < Async::Service::GenericService
2
+ def setup(container)
3
+ super
4
+
5
+ container.run(count: 1, restart: false) do |instance|
6
+ instance.ready!
7
+ puts "DNS service starting..."
8
+
9
+ Radd::DNS.new.run
10
+ end
11
+ end
12
+ end
data/lib/radd/http.rb ADDED
@@ -0,0 +1,7 @@
1
+ class Radd::HTTP < Async::HTTP::Server
2
+ def initialize
3
+ endpoint = Async::HTTP::Endpoint.parse("http://#{Radd.host}:#{Radd.http_port}")
4
+ middleware = Protocol::Rack::Adapter.new(Radd::App)
5
+ super(middleware, endpoint)
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ class Radd::HTTPService < Async::Service::GenericService
2
+ def setup(container)
3
+ super
4
+
5
+ container.run(count: 1, restart: false) do |instance|
6
+ instance.ready!
7
+ puts "HTTP service starting..."
8
+
9
+ Radd::HTTP.new.run
10
+ end
11
+ end
12
+ end
data/lib/radd/ip.rb ADDED
@@ -0,0 +1,6 @@
1
+ module Radd
2
+ # IP address query responder
3
+ IP = Proc.new do |env|
4
+ [200, {"Content-Type" => "text/plain"}, [env['REMOTE_ADDR']]]
5
+ end
6
+ end
@@ -0,0 +1,18 @@
1
+ class Radd::Nameserver < Async::DNS::Server
2
+ def initialize
3
+ super(Async::DNS::Endpoint.for(Radd.host, port: Radd.dns_port))
4
+ end
5
+
6
+ def process(name, resource_class, transaction)
7
+ name = name.downcase
8
+ if Resolv::DNS::Resource::IN::A == resource_class
9
+ if Radd.domain == name
10
+ ip = Radd.ip
11
+ else
12
+ ip = Radd.query(name)
13
+ end
14
+ end
15
+ return transaction.respond!(ip, ttl: 300) if ip
16
+ transaction.fail!(:NXDomain)
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ class Radd::Record < Sequel::Model
2
+ class << self
3
+ def active
4
+ exclude(ip: nil)
5
+ end
6
+ end
7
+
8
+ def password=(password)
9
+ self.password_hash = BCrypt::Password.create(password)
10
+ end
11
+
12
+ def validate
13
+ super
14
+ errors.add(:name, "is invalid") if !name || !name.match(/\A[a-z0-9]([A-z0-9_\-]*)\z/)
15
+ errors.add(:ip, "is invalid") if ip && !Radd.valid_ip?(ip)
16
+ end
17
+
18
+ def before_save
19
+ super
20
+ self.updated_at = Time.now
21
+ end
22
+ end
@@ -0,0 +1,51 @@
1
+ module Radd
2
+ class Update
3
+ attr_reader :env
4
+
5
+ def self.call(env)
6
+ new(env).call
7
+ end
8
+
9
+ def initialize(env)
10
+ @env = env
11
+ end
12
+
13
+ def record
14
+ @record ||= Record.where(name: name).first
15
+ end
16
+
17
+ def ip
18
+ addr = env['REMOTE_ADDR']
19
+ addr && Radd.valid_ip?(addr) && addr
20
+ end
21
+
22
+ def call
23
+ raise Forbidden unless record
24
+ raise InvalidRequest.new('Invalid IP address') unless ip
25
+ record.ip = ip
26
+ record.save
27
+ [200, {'Content-Type' => 'text/plain'}, ["OK #{ip}"]]
28
+ rescue RaddError => boom
29
+ status = case boom
30
+ when InvalidRequest, Sequel::ValidationFailed then 422
31
+ when Forbidden then 403
32
+ else
33
+ 500
34
+ end
35
+ respond status, "ERROR #{boom.message}"
36
+ rescue Exception => e
37
+ respond 500, "ERROR"
38
+ end
39
+
40
+ private
41
+
42
+ def name
43
+ env['REMOTE_USER']
44
+ end
45
+
46
+ def respond(status, body)
47
+ [status, {'Content-Type' => 'text/plain'}, ["#{status} #{body}\n"]]
48
+ end
49
+
50
+ end
51
+ end
@@ -0,0 +1,3 @@
1
+ module Radd
2
+ VERSION = '1.1.0'
3
+ end
@@ -0,0 +1,7 @@
1
+ class Radd::Webserver < Async::HTTP::Server
2
+ def initialize
3
+ endpoint = Async::HTTP::Endpoint.parse("http://#{Radd.host}:#{Radd.http_port}")
4
+ middleware = Protocol::Rack::Adapter.new(Radd::App)
5
+ super(middleware, endpoint)
6
+ end
7
+ end
data/lib/radd.rb ADDED
@@ -0,0 +1,90 @@
1
+ require 'yaml'
2
+ require 'sequel'
3
+ require 'resolv'
4
+ require 'bcrypt'
5
+
6
+ require 'async'
7
+ require 'async/dns'
8
+ require 'async/http/server'
9
+ require 'async/http/endpoint'
10
+ require 'protocol/rack/adapter'
11
+
12
+ require_relative 'radd/version'
13
+ require_relative 'radd/ip'
14
+ require_relative 'radd/db'
15
+ require_relative 'radd/record'
16
+ require_relative 'radd/update'
17
+ require_relative 'radd/nameserver'
18
+ require_relative 'radd/webserver'
19
+ require_relative 'radd/app'
20
+
21
+ module Radd
22
+ class RaddError < StandardError; end
23
+ class ConfigurationError < StandardError; end
24
+ class Forbidden < RaddError; end
25
+ class InvalidRequest < RaddError; end
26
+ class UpdateError < RaddError; end
27
+
28
+ class << self
29
+ def configure!(config)
30
+ @config = config.slice(*%w[domain ip host dns_port http_port])
31
+ raise Radd::ConfigurationError, 'domain missing' unless Radd.domain
32
+ raise Radd::ConfigurationError, 'invalid IP' unless Radd.valid_ip?(Radd.ip)
33
+ end
34
+
35
+ def domain
36
+ config['domain']
37
+ end
38
+
39
+ def ip
40
+ config['ip']
41
+ end
42
+
43
+ def host
44
+ config['host'] || '127.0.0.1'
45
+ end
46
+
47
+ def dns_port
48
+ config['dns_port'] || 5300
49
+ end
50
+
51
+ def http_port
52
+ config['http_port'] || 3000
53
+ end
54
+
55
+ # Check whether +ip+ is a valid IP address string
56
+ def valid_ip?(ip)
57
+ !!(ip && ip.match(Resolv::IPv4::Regex))
58
+ end
59
+
60
+ # Check whether +name+ is authorized with +password+
61
+ def authorized?(name, password)
62
+ return false unless record = Record.where(name: name).first
63
+ BCrypt::Password.new(record.password_hash) == password
64
+ end
65
+
66
+ # Query the database for +fqdn+
67
+ def query(fqdn)
68
+ return unless fqdn
69
+ return unless name = fqdn2name(fqdn)
70
+ return unless record = Record.active.where(name: name).first
71
+ record.ip
72
+ end
73
+
74
+ def run
75
+
76
+ end
77
+
78
+ private
79
+
80
+ def config
81
+ @config
82
+ end
83
+
84
+ def fqdn2name(fqdn)
85
+ if match = fqdn.downcase.match(/\A([a-z0-9-]{1,63})\.#{Regexp.escape(domain)}\z/)
86
+ match.captures[0]
87
+ end
88
+ end
89
+ end
90
+ end
metadata ADDED
@@ -0,0 +1,199 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: radd
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matthias Grosser
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-02-23 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: optparse
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: async-http
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: protocol-rack
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: async-dns
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: irb
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rack
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: sequel
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ - !ruby/object:Gem::Dependency
125
+ name: sqlite3
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: bcrypt
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ description: Minimal dynamic DNS service
153
+ email: mtgrosser@gmx.net
154
+ executables:
155
+ - radd
156
+ extensions: []
157
+ extra_rdoc_files: []
158
+ files:
159
+ - CHANGELOG
160
+ - LICENSE
161
+ - README.md
162
+ - bin/console
163
+ - bin/radd
164
+ - lib/radd.rb
165
+ - lib/radd/app.rb
166
+ - lib/radd/cli.rb
167
+ - lib/radd/db.rb
168
+ - lib/radd/dns.rb
169
+ - lib/radd/dns_service.rb
170
+ - lib/radd/http.rb
171
+ - lib/radd/http_service.rb
172
+ - lib/radd/ip.rb
173
+ - lib/radd/nameserver.rb
174
+ - lib/radd/record.rb
175
+ - lib/radd/update.rb
176
+ - lib/radd/version.rb
177
+ - lib/radd/webserver.rb
178
+ homepage: https://github.com/mtgrosser/radd
179
+ licenses:
180
+ - MIT
181
+ metadata: {}
182
+ rdoc_options: []
183
+ require_paths:
184
+ - lib
185
+ required_ruby_version: !ruby/object:Gem::Requirement
186
+ requirements:
187
+ - - ">="
188
+ - !ruby/object:Gem::Version
189
+ version: 4.0.0
190
+ required_rubygems_version: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ requirements: []
196
+ rubygems_version: 4.0.3
197
+ specification_version: 4
198
+ summary: Roll your own dynamic DNS
199
+ test_files: []