dynette/dynette.rb
2018-01-03 20:51:12 +01:00

360 lines
11 KiB
Ruby
Executable file
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/ruby
require 'rubygems'
require 'sinatra'
require 'data_mapper'
require 'json'
require 'base64'
require 'bcrypt'
######################
###  Configuration ###
######################
DataMapper.setup(:default, ENV['DATABASE_URL'] || "postgres://dynette:myPassword@localhost/dynette")
DOMAINS = ["nohost.me", "noho.st"]
ALLOWED_IP = ["127.0.0.1"]
###############
### Classes ###
###############
# Dynette Entry class
class Entry
include DataMapper::Resource
include BCrypt
property :id, Serial
# we need at least 90 chars for hmac-sha512 keys
property :public_key, String, :length => 100
# for historical reasons, tsig algo was md5, so we assume that every
# entry is using md5 while we provide automatic upgrade code inside
# yunohost to move to sha512 instead (and register new domains using sha512)
# it would be good to depreciate md5 in the futur but that migh be complicated
property :key_algo, String, :default => "hmac-md5"
property :subdomain, String
property :current_ip, String
property :created_at, DateTime
property :recovery_password, Text
has n, :ips
end
# IP class
class Ip
include DataMapper::Resource
property :id, Serial
property :ip_addr, String
belongs_to :entry
end
# IP Log class
class Iplog
include DataMapper::Resource
property :ip_addr, String, :key => true
property :visited_at, DateTime
end
# IP ban class
class Ipban
include DataMapper::Resource
property :ip_addr, String, :key => true
end
################
### Handlers ###
################
# 404 Error handler
not_found do
content_type :json
halt 404, { :error => "Not found" }.to_json
end
##############
### Routes ###
##############
# Common tasks and settings for every route
before do
# Always return json
content_type :json
# Allow CORS
headers['Access-Control-Allow-Origin'] = '*'
# Ban IP on flood
if Ipban.first(:ip_addr => request.ip)
halt 410, { :error => "Your ip is banned from the service" }.to_json
end
unless %w[domains test all ban unban].include? request.path_info.split('/')[1]
if iplog = Iplog.last(:ip_addr => request.ip)
if iplog.visited_at.to_time > Time.now - 30
halt 410, { :error => "Please wait 30sec" }.to_json
else
iplog.update(:visited_at => Time.now)
end
else
Iplog.create(:ip_addr => request.ip, :visited_at => Time.now)
end
end
end
# Check params
['/test/:subdomain', '/key/:public_key', '/ips/:public_key', '/ban/:ip', '/unban/:ip' ].each do |path|
before path do
if params.has_key?("public_key")
public_key = Base64.decode64(params[:public_key].encode('ascii-8bit'))
unless public_key.length == 24 or public_key.length == 89
halt 400, { :error => "Key is invalid: #{public_key.to_s.encode('UTF-8', {:invalid => :replace, :undef => :replace, :replace => '?'})}" }.to_json
end
end
if params.has_key?("key_algo") and not ["hmac-md5", "hmac-sha512"].include? params[:key_algo]
halt 400, { :error => "key_algo value is invalid: #{public_key}, it should be either 'hmac-sha512' or 'hmac-md5' (but you should **really** use 'hmac-sha512')" }.to_json
end
if params.has_key?("subdomain")
unless params[:subdomain].match /^([a-z0-9]{1}([a-z0-9\-]*[a-z0-9])*)(\.[a-z0-9]{1}([a-z0-9\-]*[a-z0-9])*)*(\.[a-z]{1}([a-z0-9\-]*[a-z0-9])*)$/
halt 400, { :error => "Subdomain is invalid: #{params[:subdomain]}" }.to_json
end
unless DOMAINS.include? params[:subdomain].gsub(params[:subdomain].split('.')[0]+'.', '')
halt 400, { :error => "Subdomain #{params[:subdomain]} is not part of available domains: #{DOMAINS.join(', ')}" }.to_json
end
end
if params.has_key?("ip")
unless params[:ip].match /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
halt 400, { :error => "IP is invalid: #{params[:ip]}" }.to_json
end
end
end
end
# Main page, return some basic text
get '/' do
content_type 'text/html'
"Wanna play the dynette ?"
end
# Delete interface for user with recovery password
get '/delete' do
f = File.open("delete.html", "r")
content_type 'text/html'
f.read
end
# Get availables DynDNS domains
get '/domains' do
DOMAINS.to_json
end
# Check for sub-domain vailability
get '/test/:subdomain' do
if entry = Entry.first(:subdomain => params[:subdomain])
halt 409, { :error => "Subdomain already taken: #{entry.subdomain}" }.to_json
else
halt 200, "Domain #{params[:subdomain]} is available".to_json
end
end
# Register a sub-domain
post '/key/:public_key' do
params[:public_key] = Base64.decode64(params[:public_key].encode('ascii-8bit'))
# Check params
halt 400, { :error => "Please indicate a subdomain" }.to_json unless params.has_key?("subdomain")
# If already exists
if entry = Entry.first(:subdomain => params[:subdomain])
halt 409, { :error => "Subdomain already taken: #{entry.subdomain}" }.to_json
end
if entry = Entry.first(:public_key => params[:public_key])
halt 409, { :error => "Key already exists for domain #{entry.subdomain}" }.to_json
end
# If user provided a recovery password, hash and salt it before storing it
if params.has_key?("recovery_password")
recovery_password = BCrypt::Password.create(params[:recovery_password])
else
recovery_password = ""
end
if params.has_key?("key_algo")
key_algo = params[:key_algo]
else # default until we'll one day kill it
key_algo = "hmac-md5"
end
# Process
entry = Entry.new(:public_key => params[:public_key], :subdomain => params[:subdomain], :current_ip => request.ip, :created_at => Time.now, :recovery_password => recovery_password, :key_algo => key_algo)
entry.ips << Ip.create(:ip_addr => request.ip)
if entry.save
halt 201, { :public_key => entry.public_key, :subdomain => entry.subdomain, :current_ip => entry.current_ip }.to_json
else
halt 412, { :error => "A problem occured during DNS registration" }.to_json
end
end
# Migrate a key from hmac-md5 to hmac-sha512 because it's 2017
put '/migrate_key_to_sha512/' do
# TODO check parameters
params[:public_key_md5] = Base64.decode64(params[:public_key_md5].encode('ascii-8bit'))
params[:public_key_sha512] = Base64.decode64(params[:public_key_sha512].encode('ascii-8bit'))
# TODO check entry exists
entry = Entry.first(:public_key => params[:public_key_md5],
:key_algo => "hmac-md5")
unless request.ip == entry.current_ip
entry.ips << Ip.create(:ip_addr => request.ip)
end
entry.current_ip = request.ip
entry.public_key = params[:public_key_sha512]
entry.key_algo = "hmac-sha512"
# we probably want to remove this once this algo is stable to avoid having
# an entry point in our api where some could possibly hammer our db to try
# to find if a key is registered or not
if entry == nil
if Entry.first(:public_key => params[:public_key_sha512])
halt 400, { :error => "This domain has already been migrated to hmac-sha512." }.to_json
else
halt 404, { :error => "There is not domain registered with this key." }.to_json
end
end
unless entry.save
halt 412, { :error => "A problem occured during key algo migration" }.to_json
end
# I don't have any other way of communicating with this dynette.cron.py
# this is awful
File.open("/tmp/dynette_flush_bind_cache", "w").close
# let's try flusing here, hope that could help ... (this design is so awful)
`/usr/sbin/rndc flush`
halt 201, { :public_key => entry.public_key, :subdomain => entry.subdomain, :current_ip => entry.current_ip }.to_json
end
# Update a sub-domain
put '/key/:public_key' do
params[:public_key] = Base64.decode64(params[:public_key].encode('ascii-8bit'))
entry = Entry.first(:public_key => params[:public_key])
unless request.ip == entry.current_ip
entry.ips << Ip.create(:ip_addr => request.ip)
end
entry.current_ip = request.ip
if entry.save
halt 201, { :public_key => entry.public_key, :subdomain => entry.subdomain, :current_ip => entry.current_ip }.to_json
else
halt 412, { :error => "A problem occured during DNS update" }.to_json
end
end
# Delete a sub-domain from key
delete '/key/:public_key' do
unless ALLOWED_IP.include? request.ip
halt 403, { :error => "Access denied"}.to_json
end
params[:public_key] = Base64.decode64(params[:public_key].encode('ascii-8bit'))
if entry = Entry.first(:public_key => params[:public_key])
Ip.first(:entry_id => entry.id).destroy
if entry.destroy
halt 200, "OK".to_json
else
halt 412, { :error => "A problem occured during DNS deletion" }.to_json
end
end
end
# Delete a sub-domain
delete '/domains/:subdomain' do
unless (ALLOWED_IP.include? request.ip) || (params.has_key?("recovery_password"))
halt 403, { :error => "Access denied"}.to_json
end
if entry = Entry.first(:subdomain => params[:subdomain])
# For non-admin
unless (ALLOWED_IP.include? request.ip)
# If no recovery password was provided when registering domain,
# or if wrong password is provided, deny access
if (entry.recovery_password == "") || (BCrypt::Password.new(entry.recovery_password) != params[:recovery_password])
halt 403, { :error => "Access denied" }.to_json
end
end
Ip.first(:entry_id => entry.id).destroy
if entry.destroy
halt 200, "OK".to_json
else
halt 412, { :error => "A problem occured during DNS deletion" }.to_json
end
end
halt 404
end
# Get all registered sub-domains
get '/all' do
unless ALLOWED_IP.include? request.ip
halt 403, { :error => "Access denied"}.to_json
end
Entry.all.to_json
end
# Get all registered sub-domains for a specific DynDNS domain
get '/all/:domain' do
unless ALLOWED_IP.include? request.ip
halt 403, { :error => "Access denied"}.to_json
end
result = []
Entry.all.each do |entry|
result.push(entry) if params[:domain] == entry.subdomain.gsub(entry.subdomain.split('.')[0]+'.', '')
end
halt 200, result.to_json
end
# ?
get '/ips/:public_key' do
params[:public_key] = Base64.decode64(params[:public_key].encode('ascii-8bit'))
unless ALLOWED_IP.include? request.ip
halt 403, { :error => "Access denied"}.to_json
end
ips = []
Entry.first(:public_key => params[:public_key]).ips.all.each do |ip|
ips.push(ip.ip_addr)
end
ips.to_json
end
# Ban an IP address for 30 seconds
get '/ban/:ip' do
unless ALLOWED_IP.include? request.ip
halt 403, { :error => "Access denied"}.to_json
end
Ipban.create(:ip_addr => params[:ip])
Ipban.all.to_json
end
# Unban an IP address
get '/unban/:ip' do
unless ALLOWED_IP.include? request.ip
halt 403, { :error => "Access denied"}.to_json
end
Ipban.first(:ip_addr => params[:ip]).destroy
Ipban.all.to_json
end
#DataMapper.auto_migrate! # Destroy db content
DataMapper.auto_upgrade!