Свой простой DynDNS сервер

    Недавно ко мне обратились с вопросом «А какой внутренний IP адрес нужно указать в модеме для проброса порта на сервер?». Ответить на вопрос я не смог, так как давно не был на этом сервере, а квалификация человека на том конце не позволяла залогиниться на сервер и выполнить ip address show. Тогда я задумался над созданием своего простого аналога DynDNS сервера с возможностью хранения IP адресов всех интерфейсов клиента.

    Серверная и клиентская часть реализованы на Python. ОС сервера — Debian 7, клиент — любой Linux с Python. Предполагается, что у вас уже есть свой домен и свои DNS сервера.

    Серверная часть

    Bind

    Для начала сгенерируем необходимые ключи и настроим DNS-сервер:
    dnssec-keygen -r /dev/urandom -a hmac-md5 -b 512 -n HOST dyndns.example.com.
    

    Из лобого из полученных файлов вида Kdyndns.example.com.+x+y.key или Kdyndns.example.com.+x+y.private запоминаем ключ зоны и добавляем зону (/etc/bind/named.conf.local):

    key "dyndns.example.com." {
        algorithm hmac-md5;
        secret "вот тут запомненный ранее ключ";
    };
    
    zone "dyndns.example.com" {
        type master;
        file "/etc/bind/db.dyndns.example.com";
        allow-query { any; };
        allow-transfer { 127.0.0.1; };
        allow-update { key dyndns.example.com.; };
    };
    


    Apache

    Будем использовать Apache с mod-wsgi. Если у вас уже есть установленный и настроенный Apache, то просто устанавливаем один нужный пакет:
    sudo aptitude install libapache2-mod-wsgi
    

    Включаем модуль wsgi:
    sudo a2enmod wsgi
    sudo service apache2 reload
    

    Создаем новый VirtualHost с SSL:
    /etc/apache2/sites-available/dyndns-ssl
    <VirtualHost *:443>
    	ServerName dyndns.example.com
    	ServerAdmin admin@example.com
    	DocumentRoot /var/www/tmp
    	<Directory />
    		Options -FollowSymLinks
    		AllowOverride None
    		Order allow,deny
    		Allow from all
    	</Directory>
    	<Directory /var/www/tmp>
    		Options -Indexes -FollowSymLinks -MultiViews
    		AllowOverride None
    		deny from all
    	</Directory>
    	Alias /wsgi-scripts/ /var/www/dyndns/wsgi-scripts/
    	<Location /wsgi-scripts>
    		SetHandler wsgi-script
    		Options +ExecCGI
    	</Location>
    	SSLEngine on
    	SSLCertificateFile /etc/ssl/localcerts/dyndns.example.com
    	LogLevel info
    	ErrorLog ${APACHE_LOG_DIR}/error_dyndns-ssl.example.com.log
    	CustomLog ${APACHE_LOG_DIR}/access_dyndns-ssl.example.com.log combined
    </VirtualHost>
    


    И кладем главный скрипт update-dyndns.wsgi в /var/www/dyndns/wsgi-scripts/:
    update-dyndns.wsgi
    import dns.query
    import dns.tsigkeyring
    import dns.update
    import sys
    import datetime
    from IPy import IP
    from cgi import parse_qs, escape
    import hashlib
     
    def application(environ, start_response):
    	status = '200 OK'
    	output = 'example.com DynDNS: '
    	ttl = 300
    	domain = 'dyndns.example.com'
    	salt = 'YourSalt'
     
    	d = parse_qs(environ['QUERY_STRING'])
     
    	hostname = escape(d.get('hostname',[''])[0])
    	main_address = escape(environ['REMOTE_ADDR'])
    	interfacesRaw = [i.split('_') for i in [escape(interface) for interface in d.get('interface',[])]]
    	checkRemote = escape(d.get('checkstring',[''])[0])
    	checkString = hashlib.md5(salt + hostname).hexdigest()
     
    	interfaces=[]
    	for interface in interfacesRaw:
    		try:
    			IP(interface[1])
    			interfaces.append(interface)
    		except:
    			output += 'Following addresses are not valid: ' + ' '.join(interface)
    	timestampStr = "Last_update_at_" + str(datetime.datetime.now().strftime("%Y-%m-%d_%H:%M"))
     
    	output += timestampStr + '; Hostname: ' + hostname + '; External address: ' + main_address + '; Check string: ' + checkRemote + '; Interfaces: ' + str(interfaces)
    	if hostname != '' and main_address != '' and checkRemote == checkString:
    		try:
    			keyring = dns.tsigkeyring.from_text({domain+'.' : 'YourKeyring'})
    			update = dns.update.Update(domain, keyring=keyring)
    			update.replace(hostname, ttl, 'a', main_address)
    			update.replace(hostname, ttl, 'txt', timestampStr)
    			if interfaces != []:
    				for interface in interfaces:
    					str1 = interface[0] + '.' + hostname + '.' + domain + '.'
    					str2 = interface[0] + '.' + hostname + '.' + domain + '.'
    					update.replace(str1, ttl, 'a', interface[1])
    					update.replace(str2, ttl, 'txt', timestampStr)
    			nsResponse = dns.query.tcp(update, '127.0.0.1')
    			output += '; update OK'
    		except:
    			output += '; Error inserting records!\n\n'
    	else:
    		print 'Error in query ' + escape(environ['QUERY_STRING'])
    		output += '; Error in input'
    	print output
     
    	output = ''
    	response_headers = [('Content-type', 'text/plain'),('Content-Length', str(len(output)))]
    	start_response(status, response_headers)
    	return [output]
    


    Общий принцип работы скрипта таков: клиент дергает на сервере определенный URL вида dyndns.example.com/wsgi-scripts/update-dyndns.wsgi?hostname=&interface=_&checkstring=, где checkstring - некая строка, получаемая комбинацией соли (заданной в переменной salt), известной серверу и всем клиентам и имени хоста. interface может быть сколько угодно - они все попадут в DNS-зону. При получении правильного запроса от клиента, сервер добавляет или изменяет существующие A и TXT записи зоны вида interface.hostname.example.com и hostname.example.com.

    Клиентская часть

    Тут всё просто - по cron каждые, например, пол часа выполняем скрипт, предварительно задав переменные domain, myhostname и salt:
    update_mydyndns.py
    #!/usr/bin/python
    ##################################################################
    #
    #Configuration:
    domain='dyndns.example.com'
    myhostname='hostname-placeholder'
    ##################################################################
    
    import socket
    import fcntl
    import struct
    import sys
    import array
    import hashlib
    import httplib
    import urllib
    
    def all_interfaces():
    	is_64bits = sys.maxsize > 2**32
    	struct_size = 40 if is_64bits else 32
    	s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    	max_possible = 8 # initial value
    	while True:
    		bytes = max_possible * struct_size
    		names = array.array('B', '\0' * bytes)
    		outbytes = struct.unpack('iL', fcntl.ioctl(s.fileno(),0x8912,struct.pack('iL', bytes, names.buffer_info()[0])))[0]
    		if outbytes == bytes:
    			max_possible *= 2
    		else:
    			break
    	namestr = names.tostring()
    	lst = []
    	for i in range(0, outbytes, struct_size):
    		name = namestr[i:i+16].split('\0', 1)[0]
    		ip = socket.inet_ntoa(namestr[i+20:i+24])
    		if name != 'lo':
    			lst.append((name,ip))
    	return lst
    
    salt = 'YourSalt'
    checkString = hashlib.md5(salt + myhostname).hexdigest()
    requestData = {}
    requestData['hostname'] = myhostname
    requestData['checkstring'] = checkString
    requestData['interface'] = [j for j in [i[0]+'_'+i[1] for i in all_interfaces()]]
    requestString = urllib.urlencode(requestData,True)
    
    conn = httplib.HTTPSConnection("dyndns.example.com", 443)
    conn.request("GET", "/wsgi-scripts/update-dyndns.wsgi?"+requestString)
    r1 = conn.getresponse()
    print r1.status, r1.reason
    


    Для удобства ещё скрипт получения всех записей зоны (положить на сервер в /var/www/dyndns/wsgi-scripts/):
    get-whole-zone.wsgi
    import dns.query
    import dns.zone
     
    def application(environ, start_response):
    	status = '200 OK'
    	output = ''
    	domain = 'dyndns.example.com'
     
    	z = dns.zone.from_xfr(dns.query.xfr('127.0.0.1', 'dyndns.example.com'))
    	names = z.nodes.keys()
    	names.sort()
    	for n in names:
    		output += '\n' + z[n].to_text(n)
     
    	response_headers = [('Content-type', 'text/plain'),('Content-Length', str(len(output)))]
    	start_response(status, response_headers)
    	return [output]
    



    P.S.

    Я понимаю, что Python-код не претендует на красивость и в целом решение не очень секьюрно (был бы рад узнать, как можно обойтись без проверочной строки и соли), но у меня оно работает и решает кучу проблем. Надеюсь, кому-нибудь все это пригодится.
    Поделиться публикацией

    Комментарии 16

      0
      еще нужно в SOA Serial менять.
        +3
        Он автоматически меняется при обновлении зоны. За полтора месяца использования и 4 хостах с 3-4 интерфейсами Serial зоны у меня уже 16136.
        0
        Было бы еще интересно посмотреть на пример расширения этого до поддержки WAN DNS-SD.
          0
          Зачем делать велосипед если можно просто включить обновление DNS через DHCP?
            0
            а вы видимо не в теме, это не для сервисов в локалке, а внешних сервисов.
              0
              Если этот вопрос адресован мне, то отмечу, что все сервера территориально разнесены и ни в коем случае не находятся в рамках одной локальной сети. Кроме того, почти все они имеют динамический внешний IP адрес, получаемый от провайдера.
                0
                Тогда вам стоит в статье как-то это указать, из текста складывается впечатление что это в рамках одной сети.
                  0
                  Не хотелось бы спорить, но городить всё описанное в топике в рамках локальной сети, да ещё и на 4-5 серверов было бы по меньшей мере нелогично, поэтому я и не стал указывать расположение клиентов.
                    –1
                    Мало ли какая у вас задача, я говорю о том что при написании статьи стоит все-таки описать решаемую задачу если вы хотите чтобы не было таких вопросов.
                    Кстати раз уж вы диспользуете wsgi то стоит, наверно, поднять это все не на Apache, а на том же Lighttpd или Nginx.
              0
              >по cron каждые, например, пол часа выполняем скрипт
              Если клиент сам устанавливает соединение с провайдером посредством PPP, то в первую очередь нужно поставить скрипт обновления адреса при установлении соединения (т.е., к примеру в /etc/ppp/ip-up.d/10dyndns). Думаю, не нужно объяснять зачем.
                0
                Вы правы. Спасибо за уточнение. Я этот момент упустил, так как все сервера (так исторически сложилось) ходят через роутеры. Конечно, самый правильный вариант — дергать сервер только после перезагрузки клиента, либо после изменения состояния любого сетевого подключения (если сервер сам поднимает соединения с Интернетом и имеет «белый» IP адрес).
                0
                А еще есть такой милый, ничего не стоящий хак на случай поломки DynDNS, если на клиенте есть Dropbox:
                /bin/echo "`date`: host.dyndns.example.com new ip is $MYIP" >> /home/user/Dropbox/ddns.log
                Сорри, что не Питон, но тут главное — идея.
                  0
                  Но это только если сервера «личные», а у меня везде разные хозяева (включая иногда и рутовый доступ). А для себя — да, самый простой и действенный вариант.
                    0
                    надо только определить $MYIP и не забыть следить за размером этого лога, а вообще идея неплохая.
                    также, если есть какой-нибудь свой веб-сервер в интернете, можно кроном делать wget my-site/i-am-here, адрес он определит сам и логи не будут доступны окружающим, можно даже состояние сервера туда слать и настроить мониторинг (хотя бы скрипт на коленке) на анализ таких записей в логе, если лень делать нормальный мониторинг :)
                      0
                      У меня так (IPv4 only):
                      MYIP=`/sbin/ifconfig ppp0 | /bin/grep "inet addr" | /bin/sed 's/.*addr:\([0-9.]*\) .*/\1/'`
                      Работает много дольше года, но нужно будет принимать меры, если интернет не всегда поднимается на ppp0 — уже не помню откуда я это помню ))

                      Судя по man pppd, если скрипт запускается из /etc/ppp/ip-up.d/, а номер будущего интерфейса с интернетом неизвестен, то можно еще так:
                      MYIP=`/sbin/ifconfig $1 | /bin/grep "inet addr" | /bin/sed 's/.*addr:\([0-9.]*\) .*/\1/'`
                      А можно и вовсе вот так:
                      MYIP=$4

                      Только в последних 2-х случаях скрипт также будет срабатывать при подключении PPTP VPN, что меня не устраивало, а придумывать логику различения VPN было не за чем.
                    0
                    в целом решение не очень секьюрно
                    Почему же, если дергаем по https с нормальным паролем, то вполне себе ничего решение, даже без дополнительных ухищрений с проверкой строки/соли (масло-масленное). Увести https пароль или угнать https токен (ключ шифрования сессии) — та еще задача.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое