first commit

This commit is contained in:
2026-02-16 17:41:03 +00:00
commit 3f15490a0d
1055 changed files with 194272 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
class RobotError(Exception):
def __init__(self, message, status=None):
if status is not None:
message = "{0} ({1})".format(message, status)
super(RobotError, self).__init__(message)
self.status = status
class ManualReboot(Exception):
pass
class ConnectError(Exception):
pass
class WebRobotError(RobotError):
pass

View File

@@ -0,0 +1,60 @@
from hetzner import RobotError
__all__ = ['Failover', 'FailoverManager']
class Failover(object):
ip = None
server_ip = None
server_number = None
active_server_ip = None
def __repr__(self):
return "%s (destination: %s, booked on %s (%s))" % (
self.ip, self.active_server_ip, self.server_number, self.server_ip)
def __init__(self, data):
for attr, value in data.items():
if hasattr(self, attr):
setattr(self, attr, value)
class FailoverManager(object):
def __init__(self, conn, servers):
self.conn = conn
self.servers = servers
def list(self):
failovers = {}
try:
ips = self.conn.get('/failover')
except RobotError as err:
if err.status == 404:
return failovers
else:
raise
for ip in ips:
failover = Failover(ip.get('failover'))
failovers[failover.ip] = failover
return failovers
def set(self, ip, new_destination):
failovers = self.list()
if ip not in failovers.keys():
raise RobotError(
"Invalid IP address '%s'. Failover IP addresses are %s"
% (ip, failovers.keys()))
failover = failovers.get(ip)
if new_destination == failover.active_server_ip:
raise RobotError(
"%s is already the active destination of failover IP %s"
% (new_destination, ip))
available_dests = [s.ip for s in list(self.servers)]
if new_destination not in available_dests:
raise RobotError(
"Invalid destination '%s'. "
"The destination is not in your server list: %s"
% (new_destination, available_dests))
result = self.conn.post('/failover/%s'
% ip, {'active_server_ip': new_destination})
return Failover(result.get('failover'))

View File

@@ -0,0 +1,65 @@
try:
from urllib import urlencode
except ImportError:
from urllib.parse import urlencode
from hetzner import RobotError
__all__ = ['ReverseDNS', 'ReverseDNSManager']
class ReverseDNS(object):
def __init__(self, conn, ip=None, result=None):
self.conn = conn
self.ip = ip
self.update_info(result)
def update_info(self, result=None):
if result is None:
try:
result = self.conn.get('/rdns/{0}'.format(self.ip))
except RobotError as err:
if err.status == 404:
result = None
else:
raise
if result is not None:
data = result['rdns']
self.ip = data['ip']
self.ptr = data['ptr']
else:
self.ptr = None
def set(self, value):
self.conn.post('/rdns/{0}'.format(self.ip), {'ptr': value})
def remove(self):
self.conn.delete('/rdns/{0}'.format(self.ip))
def __repr__(self):
return "<ReverseDNS PTR: {0}>".format(self.ptr)
class ReverseDNSManager(object):
def __init__(self, conn, main_ip=None):
self.conn = conn
self.main_ip = main_ip
def get(self, ip):
return ReverseDNS(self.conn, ip)
def __iter__(self):
if self.main_ip is None:
url = '/rdns'
else:
data = urlencode({'server_ip': self.main_ip})
url = '/rdns?{0}'.format(data)
try:
result = self.conn.get(url)
except RobotError as err:
if err.status == 404:
result = []
else:
raise
return iter([ReverseDNS(self.conn, result=rdns) for rdns in result])

View File

@@ -0,0 +1,110 @@
import socket
import time
from hetzner import ConnectError, ManualReboot
class Reset(object):
def __init__(self, server):
self.server = server
self.conn = server.conn
def check_ssh(self, port=22, timeout=5):
"""
Check if the current server has an open SSH port. Return True if port
is reachable, otherwise false. Time out after 'timeout' seconds.
"""
success = True
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(5)
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((self.server.ip, port))
s.close()
except socket.error:
success = False
socket.setdefaulttimeout(old_timeout)
return success
def observed_reboot(self, patience=300, tries=None, manual=False):
"""
Reboot and wait patience seconds until the system comes back.
If not, retry with the next step in tries and wait another patience
seconds. Repeat until there are no more tries left.
If manual is true, do a manual reboot in case the server doesn't come
up again. Raises a ManualReboot exception if that is the case.
Return True on success and False if the system didn't come up.
"""
is_down = False
if tries is None:
if self.server.is_vserver:
tries = ['hard']
else:
tries = ['soft', 'hard']
for mode in tries:
self.server.logger.info("Tring to reboot using the %r method.",
mode)
self.reboot(mode)
start_time = time.time()
self.server.logger.info("Waiting for machine to become available.")
while True:
current_time = time.time()
if current_time > start_time + patience:
self.server.logger.info(
"Machine didn't come up after %d seconds.",
patience
)
break
is_up = self.check_ssh()
time.sleep(1)
if is_up and is_down:
self.server.logger.info("Machine just became available.")
return
elif not is_down:
is_down = not is_up
if manual:
self.reboot('manual')
raise ManualReboot("Issued a manual reboot because the server"
" did not come back to life.")
else:
raise ConnectError("Server keeps playing dead after reboot :-(")
def reboot(self, mode='soft'):
"""
Reboot the server, modes are "soft" for reboot by triggering Ctrl-Alt-
Del, "hard" for triggering a hardware reset and "manual" for requesting
a poor devil from the data center to go to your server and press the
power button.
On a vServer, rebooting with mode="soft" is a no-op, any other value
results in a hard reset.
"""
if self.server.is_vserver:
if mode == 'soft':
return
self.conn.scraper.login(force=True)
baseurl = '/server/vserverCommand/id/{0}/command/reset'
url = baseurl.format(self.server.number)
response = self.conn.scraper.request(url, method='POST')
assert "msgbox_success" in response.read().decode('utf-8')
return response
modes = {
'manual': 'man',
'hard': 'hw',
'soft': 'sw',
}
modekey = modes.get(mode, modes['soft'])
return self.conn.post('/reset/{0}'.format(self.server.ip),
{'type': modekey})

View File

@@ -0,0 +1,399 @@
import re
import json
import logging
from base64 import b64encode
try:
from httplib import BadStatusLine, ResponseNotReady
except ImportError:
from http.client import BadStatusLine, ResponseNotReady
try:
from urllib import urlencode
except ImportError:
from urllib.parse import urlencode
from hetzner import WebRobotError, RobotError
from hetzner.server import Server
from hetzner.rdns import ReverseDNSManager
from hetzner.failover import FailoverManager
from hetzner.util.http import ValidatedHTTPSConnection
ROBOT_HOST = "robot-ws.your-server.de"
ROBOT_WEBHOST = "robot.your-server.de"
ROBOT_LOGINHOST = "accounts.hetzner.com"
RE_CSRF_TOKEN = re.compile(
r'<input[^>]*?name="_csrf_token"[^>]*?value="([^">]+)"'
)
__all__ = ['Robot', 'RobotConnection', 'RobotWebInterface', 'ServerManager']
class RobotWebInterface(object):
"""
This is for scraping the web interface and can be used to implement
features that are not yet available in the official API.
"""
def __init__(self, user=None, passwd=None):
self.conn = None
self.session_cookie = None
self.user = user
self.passwd = passwd
self.logged_in = False
self.logger = logging.getLogger("Robot scraper for {0}".format(user))
def _parse_cookies(self, response):
"""
Return a dictionary consisting of the cookies from the given response.
"""
result = {}
cookies = response.getheader('set-cookie')
if cookies is None:
return result
# Not very accurate but sufficent enough for our use case.
for cookieval in cookies.split(','):
cookieattrs = cookieval.strip().split(';')
if len(cookieattrs) <= 1:
continue
cookie = cookieattrs[0].strip().split('=', 1)
if len(cookie) != 2:
continue
result[cookie[0]] = cookie[1]
return result
def update_session(self, response):
"""
Parses the session cookie from the given response instance and updates
self.session_cookie accordingly if a session cookie was recognized.
"""
session = self._parse_cookies(response).get('robot')
if session is not None:
self.session_cookie = "robot=" + session
def connect(self, force=False):
"""
Establish a connection to the robot web interface if we're not yet
connected. If 'force' is set to True, throw away the old connection and
establish a new one, regardless of whether we are connected or not.
"""
if force and self.conn is not None:
self.conn.close()
self.conn = None
if self.conn is None:
self.conn = ValidatedHTTPSConnection(ROBOT_WEBHOST)
def login(self, user=None, passwd=None, force=False):
"""
Log into the robot web interface using self.user and self.passwd. If
user/passwd is provided as arguments, those are used instead and
self.user/self.passwd are updated accordingly.
"""
if self.logged_in and not force:
return
self.connect(force=force)
# Update self.user and self.passwd in case we need to re-establish the
# connection.
if user is not None:
self.user = user
if passwd is not None:
self.passwd = passwd
if self.user is None or self.passwd is None:
raise WebRobotError("Login credentials for the web user interface "
"are missing.")
if self.user.startswith("#ws+"):
raise WebRobotError("The user {0} is a dedicated web service user "
"and cannot be used for scraping the web user "
"interface.".format(self.user))
# We need to first visit the Robot so that we later get an OAuth token
# for the Robot from the authentication site.
self.logger.debug("Visiting Robot web frontend for the first time.")
auth_url = self.request('/', xhr=False).getheader('location')
if not auth_url.startswith('https://' + ROBOT_LOGINHOST + '/'):
msg = "https://{0}/ does not redirect to https://{1}/ " \
"but instead redirects to: {2}"
raise WebRobotError(msg.format(ROBOT_WEBHOST, ROBOT_LOGINHOST,
auth_url))
self.logger.debug("Following authentication redirect to %r.", auth_url)
# This is primarily for getting a first session cookie.
login_conn = ValidatedHTTPSConnection(ROBOT_LOGINHOST)
login_conn.request('GET', auth_url[len(ROBOT_LOGINHOST) + 8:], None)
response = login_conn.getresponse()
if response.status != 302:
raise WebRobotError("Invalid status code {0} while visiting auth"
" URL".format(response.status))
cookies = self._parse_cookies(response)
if "PHPSESSID" not in cookies:
msg = "Auth site didn't respond with a session cookie."
raise WebRobotError(msg)
self.logger.debug("Session ID for auth site is %r.",
cookies['PHPSESSID'])
# Make sure that we always send the auth site's session ID in
# subsequent requests.
cookieval = '; '.join([k + '=' + v for k, v in cookies.items()])
headers = {'Cookie': cookieval}
self.logger.debug("Visiting login page at https://%s/login.",
ROBOT_LOGINHOST)
# Note that the auth site doesn't seem to support keep-alives, so we
# need to reconnect here.
login_conn = ValidatedHTTPSConnection(ROBOT_LOGINHOST)
login_conn.request('GET', "/login", None, headers)
response = login_conn.getresponse()
if response.status != 200:
raise WebRobotError("Invalid status code {0} while visiting login"
" page".format(response.status))
# Find the CSRF token
haystack = response.read()
token = RE_CSRF_TOKEN.search(str(haystack))
if token is None:
raise WebRobotError("Unable to find CSRF token for login form")
data = urlencode({'_username': self.user, '_password': self.passwd,
'_csrf_token': token.group(1)})
self.logger.debug("Logging in to auth site with user %s.", self.user)
# Again, we need to reconnect here.
login_conn = ValidatedHTTPSConnection(ROBOT_LOGINHOST)
post_headers = headers.copy()
post_headers['Content-Type'] = 'application/x-www-form-urlencoded'
login_conn.request('POST', '/login_check', data, post_headers)
response = login_conn.getresponse()
# Here, if the authentication is successful another session is started
# and we get a new session ID.
cookies = self._parse_cookies(response)
if "PHPSESSID" not in cookies:
raise WebRobotError("Login to robot web interface failed.")
self.logger.debug("New session ID for auth site after login is %r.",
cookies['PHPSESSID'])
cookieval = '; '.join([k + '=' + v for k, v in cookies.items()])
headers['Cookie'] = cookieval
# This should be the actual OAuth authorization URL.
location = response.getheader('Location')
if response.status != 302 or location is None:
raise WebRobotError("Unable to get OAuth authorization URL.")
if not location.startswith('https://' + ROBOT_LOGINHOST + '/'):
msg = "https://{0}/ does not redirect to https://{1}/ " \
"but instead redirects to: {2}"
raise WebRobotError(msg.format(ROBOT_LOGINHOST, ROBOT_LOGINHOST,
location))
self.logger.debug("Got redirected, visiting %r.", location)
login_conn = ValidatedHTTPSConnection(ROBOT_LOGINHOST)
login_conn.request('GET', location[len(ROBOT_LOGINHOST) + 8:], None,
headers)
response = login_conn.getresponse()
# We now should get an URL back to the Robot web interface.
location = response.getheader('Location')
if response.status != 302 or location is None:
raise WebRobotError("Failed to get OAuth URL for Robot.")
if not location.startswith('https://' + ROBOT_WEBHOST + '/'):
msg = "https://{0}/ does not redirect to https://{1}/ " \
"but instead redirects to: {2}"
raise WebRobotError(msg.format(ROBOT_LOGINHOST, ROBOT_WEBHOST,
auth_url))
self.logger.debug("Going back to Robot web interface via %r.",
location)
# Reconnect to Robot with the OAuth token.
self.connect(force=True)
response = self.request(location[len(ROBOT_WEBHOST) + 8:], xhr=False)
if response.status != 302:
raise WebRobotError("Status after providing OAuth token should be"
" 302 and not {0}".format(response.status))
if response.getheader('location') != 'https://' + ROBOT_WEBHOST + '/':
raise WebRobotError("Robot login with OAuth token has failed.")
self.logged_in = True
def request(self, path, data=None, xhr=True, method=None, log=True):
"""
Send a request to the web interface, using 'data' for urlencoded POST
data. If 'data' is None (which it is by default), a GET request is sent
instead. A httplib.HTTPResponse is returned on success.
By default this method uses headers for XMLHttpRequests, so if the
request should be an ordinary HTTP request, set 'xhr' to False.
If 'log' is set to False, don't log anything containing data. This is
useful to prevent logging sensible information such as passwords.
"""
self.connect()
headers = {'Connection': 'keep-alive'}
if self.session_cookie is not None:
headers['Cookie'] = self.session_cookie
if xhr:
headers['X-Requested-With'] = 'XMLHttpRequest'
if data is None:
if method is None:
method = 'GET'
encoded = None
else:
if method is None:
method = 'POST'
encoded = urlencode(data)
headers['Content-Type'] = 'application/x-www-form-urlencoded'
if log:
self.logger.debug("Sending %s request to Robot web frontend "
"at %s with data %r.",
("XHR " if xhr else "") + method, path, encoded)
self.conn.request(method, path, encoded, headers)
try:
response = self.conn.getresponse()
except ResponseNotReady:
self.logger.debug("Connection closed by Robot web frontend,"
" retrying.")
# Connection closed, so we need to reconnect.
# FIXME: Try to avoid endless loops here!
self.connect(force=True)
return self.request(path, data=data, xhr=xhr, log=log)
if log:
self.logger.debug("Got response from web frontend with status %d.",
response.status)
self.update_session(response)
return response
class RobotConnection(object):
def __init__(self, user, passwd):
self.user = user
self.passwd = passwd
self.conn = ValidatedHTTPSConnection(ROBOT_HOST)
self.logger = logging.getLogger("Robot of {0}".format(user))
# Provide this as a way to easily add unsupported API features.
self.scraper = RobotWebInterface(user, passwd)
def _request(self, method, path, data, headers, retry=1):
self.conn.request(method.upper(), path, data, headers)
try:
return self.conn.getresponse()
except BadStatusLine:
# XXX: Sometimes, the API server seems to have a problem with
# keepalives.
if retry <= 0:
raise
self.conn.close()
self.conn.connect()
return self._request(method, path, data, headers, retry - 1)
def request(self, method, path, data=None, allow_empty=False):
if data is not None:
data = urlencode(data)
auth = 'Basic {0}'.format(b64encode(
"{0}:{1}".format(self.user, self.passwd).encode('ascii')
).decode('ascii'))
headers = {'Authorization': auth}
if data is not None:
headers['Content-Type'] = 'application/x-www-form-urlencoded'
self.logger.debug("Sending %s request to Robot at %s with data %r.",
method, path, data)
response = self._request(method, path, data, headers)
raw_data = response.read().decode('utf-8')
if len(raw_data) == 0 and not allow_empty:
msg = "Empty response, status {0}."
raise RobotError(msg.format(response.status), response.status)
elif not allow_empty:
try:
data = json.loads(raw_data)
except ValueError:
msg = "Response is not JSON (status {0}): {1}"
raise RobotError(msg.format(response.status, repr(raw_data)))
else:
data = None
self.logger.debug(
"Got response from Robot with status %d and data %r.",
response.status, data
)
if 200 <= response.status < 300:
return data
else:
error = data.get('error', None)
if error is None:
raise RobotError("Unknown error: {0}".format(data),
response.status)
else:
err = "{0} - {1}".format(error['status'], error['message'])
missing = error.get('missing', [])
invalid = error.get('invalid', [])
fields = []
if missing is not None:
fields += missing
if invalid is not None:
fields += invalid
if len(fields) > 0:
err += ", fields: {0}".format(', '.join(fields))
raise RobotError(err, response.status)
def get(self, path):
return self.request('GET', path)
def post(self, path, data):
return self.request('POST', path, data)
def put(self, path, data):
return self.request('PUT', path, data)
def delete(self, path, data=None):
return self.request('DELETE', path, data, allow_empty=True)
class ServerManager(object):
def __init__(self, conn):
self.conn = conn
def get(self, ip):
"""
Get server by providing its main IP address.
"""
return Server(self.conn, self.conn.get('/server/{0}'.format(ip)))
def __iter__(self):
return iter([Server(self.conn, s) for s in self.conn.get('/server')])
class Robot(object):
def __init__(self, user, passwd):
self.conn = RobotConnection(user, passwd)
self.servers = ServerManager(self.conn)
self.rdns = ReverseDNSManager(self.conn)
self.failover = FailoverManager(self.conn, self.servers)

View File

@@ -0,0 +1,467 @@
import os
import re
import random
import string
import subprocess
import warnings
import logging
from tempfile import mkdtemp
from datetime import datetime
from functools import reduce
try:
from urllib import urlencode
except ImportError:
from urllib.parse import urlencode
from hetzner import RobotError, WebRobotError
from hetzner.rdns import ReverseDNS, ReverseDNSManager
from hetzner.reset import Reset
from hetzner.util import addr, scraping
__all__ = ['AdminAccount', 'IpAddress', 'RescueSystem', 'Server', 'Subnet',
'IpManager', 'SubnetManager']
class SSHAskPassHelper(object):
"""
This creates a temporary SSH askpass helper script, which just passes the
provided password.
"""
def __init__(self, passwd):
self.passwd = passwd
self.tempdir = None
self.script = None
def __enter__(self):
self.tempdir = mkdtemp()
script = os.path.join(self.tempdir, "askpass")
fd = os.open(script, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o700)
self.script = script
esc_passwd = self.passwd.replace("'", r"'\''")
askpass = "#!/bin/sh\necho -n '{0}'".format(esc_passwd).encode('ascii')
os.write(fd, askpass)
os.close(fd)
return script
def __exit__(self, type, value, traceback):
if self.script is not None:
os.unlink(self.script)
if self.tempdir is not None:
os.rmdir(self.tempdir)
class RescueSystem(object):
def __init__(self, server):
self.server = server
self.conn = server.conn
self._active = None
self._password = None
def _fetch_status(self):
reply = self.conn.get('/boot/{0}/rescue'.format(self.server.ip))
data = reply['rescue']
self._active = data['active']
self._password = data['password']
@property
def active(self):
if self._active is not None:
return self._active
self._fetch_status()
return self._active
@property
def password(self):
if self._password is not None:
return self._password
self._fetch_status()
return self._password
def _rescue_action(self, method, opts=None):
reply = self.conn.request(
method,
'/boot/{0}/rescue'.format(self.server.ip),
opts
)
data = reply['rescue']
self._active = data['active']
self._password = data['password']
def activate(self, bits=64, os='linux'):
"""
Activate the rescue system if necessary.
"""
if not self.active:
opts = {'os': os, 'arch': bits}
return self._rescue_action('post', opts)
def deactivate(self):
"""
Deactivate the rescue system if necessary.
"""
if self.active:
return self._rescue_action('delete')
def observed_activate(self, *args, **kwargs):
"""
Activate the rescue system and reboot into it.
Look at Server.observed_reboot() for options.
"""
self.activate()
self.server.observed_reboot(*args, **kwargs)
def observed_deactivate(self, *args, **kwargs):
"""
Deactivate the rescue system and reboot into normal system.
Look at Server.observed_reboot() for options.
"""
self.deactivate()
self.server.observed_reboot(*args, **kwargs)
def shell(self, *args, **kwargs):
"""
Reboot into rescue system, spawn a shell and after the shell is
closed, reboot back into the normal system.
Look at Server.observed_reboot() for further options.
"""
msg = ("The RescueSystem.shell() method will be removed from the API"
" in version 1.0.0, please do not use it! See"
" https://github.com/aszlig/hetzner/issues/13"
" for details.")
warnings.warn(msg, FutureWarning)
self.observed_activate(*args, **kwargs)
with SSHAskPassHelper(self.password) as askpass:
ssh_options = [
'CheckHostIP=no',
'GlobalKnownHostsFile=/dev/null',
'UserKnownHostsFile=/dev/null',
'StrictHostKeyChecking=no',
'LogLevel=quiet',
]
ssh_args = reduce(lambda acc, opt: acc + ['-o', opt],
ssh_options, [])
cmd = ['ssh'] + ssh_args + ["root@{0}".format(self.server.ip)]
env = dict(os.environ)
env['DISPLAY'] = ":666"
env['SSH_ASKPASS'] = askpass
subprocess.check_call(cmd, env=env, preexec_fn=os.setsid)
self.observed_deactivate(*args, **kwargs)
class AdminAccount(object):
def __init__(self, server):
# XXX: This is preliminary, because we don't have such functionality in
# the official API yet.
self._scraper = server.conn.scraper
self._serverid = server.number
self.exists = False
self.login = None
self.passwd = None
self.update_info()
def update_info(self):
"""
Get information about currently active admin login.
"""
self._scraper.login()
login_re = re.compile(r'"label_req">Login.*?"element">([^<]+)',
re.DOTALL)
path = '/server/admin/id/{0}'.format(self._serverid)
response = self._scraper.request(path)
assert response.status == 200
match = login_re.search(response.read().decode('utf-8'))
if match is None:
self.exists = False
else:
self.exists = True
self.login = match.group(1)
def _genpasswd(self):
random.seed(os.urandom(512))
chars = string.ascii_letters + string.digits + "/()-=+_,;.^~#*@"
length = random.randint(20, 40)
return ''.join(random.choice(chars) for i in range(length))
def create(self, passwd=None):
"""
Create a new admin account if missing. If passwd is supplied, use it
instead of generating a random one.
"""
if passwd is None:
passwd = self._genpasswd()
form_path = '/server/admin/id/{0}'.format(self._serverid)
form_response = self._scraper.request(form_path, method='POST')
parser = scraping.CSRFParser('password[_csrf_token]')
parser.feed(form_response.read().decode('utf-8'))
assert parser.csrf_token is not None
data = {
'password[new_password]': passwd,
'password[new_password_repeat]': passwd,
'password[_csrf_token]': parser.csrf_token,
}
if not self.exists:
failmsg = "Unable to create admin account"
path = '/server/adminCreate/id/{0}'.format(self._serverid)
else:
failmsg = "Unable to update admin account password"
path = '/server/adminUpdate'
data['id'] = self._serverid
response = self._scraper.request(path, data)
data = response.read().decode('utf-8')
if "msgbox_success" not in data:
ul_re = re.compile(r'<ul\s+class="error_list">(.*?)</ul>',
re.DOTALL)
li_re = re.compile(r'<li>\s*([^<]*?)\s*</li>')
ul_match = ul_re.search(data)
if ul_match is not None:
errors = [error.group(1)
for error in li_re.finditer(ul_match.group(0))]
msg = failmsg + ': ' + ', '.join(errors)
raise WebRobotError(msg)
raise WebRobotError(failmsg)
self.update_info()
self.passwd = passwd
return self.login, self.passwd
def delete(self):
"""
Remove the admin account.
"""
if not self.exists:
return
path = '/server/adminDelete/id/{0}'.format(self._serverid)
assert "msgbox_success" in \
self._scraper.request(path).read().decode('utf-8')
self.update_info()
def __repr__(self):
if self.exists:
return "<AdminAccount login: {0}>".format(self.login)
else:
return "<AdminAccount missing>"
class IpAddress(object):
def __init__(self, conn, result, subnet_ip=None):
self.conn = conn
self.subnet_ip = subnet_ip
self.update_info(result)
self._rdns = None
@property
def rdns(self):
"""
Get or set reverse DNS PTRs.
"""
if self._rdns is None:
self._rdns = ReverseDNS(self.conn, self.ip)
return self._rdns
def update_info(self, result=None):
"""
Update the information of the current IP address and all related
information such as traffic warnings. If result is omitted, a new
request is sent to the robot to gather the information.
"""
if self.subnet_ip is not None:
if result is None:
result = self.conn.get('/subnet/{0}'.format(self._subnet_addr))
data = result['subnet']
self._subnet_addr = data['ip']
data['ip'] = self.subnet_ip
# Does not exist in subnets
data['separate_mac'] = None
else:
if result is None:
result = self.conn.get('/ip/{0}'.format(self.ip))
data = result['ip']
self.ip = data['ip']
self.server_ip = data['server_ip']
self.locked = data['locked']
self.separate_mac = data['separate_mac']
self.traffic_warnings = data['traffic_warnings']
self.traffic_hourly = data['traffic_hourly']
self.traffic_daily = data['traffic_daily']
self.traffic_monthly = data['traffic_monthly']
def __repr__(self):
return "<IpAddress {0}>".format(self.ip)
class IpManager(object):
def __init__(self, conn, main_ip):
self.conn = conn
self.main_ip = main_ip
def get(self, ip):
"""
Get a specific IP address of a server.
"""
return IpAddress(self.conn, self.conn.get('/ip/{0}'.format(ip)))
def __iter__(self):
data = urlencode({'server_ip': self.main_ip})
result = self.conn.get('/ip?{0}'.format(data))
return iter([IpAddress(self.conn, ip) for ip in result])
class Subnet(object):
def __init__(self, conn, result):
self.conn = conn
self.update_info(result)
def update_info(self, result=None):
"""
Update the information of the subnet. If result is omitted, a new
request is sent to the robot to gather the information.
"""
if result is None:
result = self.conn.get('/subnet/{0}'.format(self.net_ip))
data = result['subnet']
self.net_ip = data['ip']
self.mask = data['mask']
self.gateway = data['gateway']
self.server_ip = data['server_ip']
self.failover = data['failover']
self.locked = data['locked']
self.traffic_warnings = data['traffic_warnings']
self.traffic_hourly = data['traffic_hourly']
self.traffic_daily = data['traffic_daily']
self.traffic_monthly = data['traffic_monthly']
self.is_ipv6, self.numeric_net_ip = addr.parse_ipaddr(self.net_ip)
self.numeric_gateway = addr.parse_ipaddr(self.gateway, self.is_ipv6)
getrange = addr.get_ipv6_range if self.is_ipv6 else addr.get_ipv4_range
self.numeric_range = getrange(self.numeric_net_ip, self.mask)
def get_ip_range(self):
"""
Return the smallest and biggest possible IP address of the current
subnet.
"""
convert = addr.ipv6_bin2addr if self.is_ipv6 else addr.ipv4_bin2addr
return convert(self.numeric_range[0]), convert(self.numeric_range[1])
def __contains__(self, addr):
"""
Check whether a specific IP address is within the current subnet.
"""
numeric_addr = addr.parse_ipaddr(addr, self.is_ipv6)
return self.numeric_range[0] <= numeric_addr <= self.numeric_range[1]
def get_ip(self, addr):
"""
Return an IpAddress object for the specified IPv4 or IPv6 address or
None if the IP address doesn't exist in the current subnet.
"""
if addr in self:
result = self.conn.get('/subnet/{0}'.format(self.net_ip))
return IpAddress(self.conn, result, addr)
else:
return None
def __repr__(self):
return "<Subnet {0}/{1} (Gateway: {2})>".format(self.net_ip, self.mask,
self.gateway)
class SubnetManager(object):
def __init__(self, conn, main_ip):
self.conn = conn
self.main_ip = main_ip
def get(self, net_ip):
"""
Get a specific subnet of a server.
"""
return Subnet(self.conn, self.conn.get('/subnet/{0}'.format(net_ip)))
def __iter__(self):
data = urlencode({'server_ip': self.main_ip})
try:
result = self.conn.get('/subnet?{0}'.format(data))
except RobotError as err:
# If there are no subnets a 404 is returned rather than just an
# empty list.
if err.status == 404:
result = []
return iter([Subnet(self.conn, net) for net in result])
class Server(object):
def __init__(self, conn, result):
self.conn = conn
self.update_info(result)
self.rescue = RescueSystem(self)
self.reset = Reset(self)
self.ips = IpManager(self.conn, self.ip)
self.subnets = SubnetManager(self.conn, self.ip)
self.rdns = ReverseDNSManager(self.conn, self.ip)
self._admin_account = None
self.logger = logging.getLogger("Server #{0}".format(self.number))
@property
def admin(self):
"""
Update, create and delete admin accounts.
"""
if self._admin_account is None:
self._admin_account = AdminAccount(self)
return self._admin_account
def update_info(self, result=None):
"""
Updates the information of the current Server instance either by
sending a new GET request or by parsing the response given by result.
"""
if result is None:
result = self.conn.get('/server/{0}'.format(self.ip))
data = result['server']
self.ip = data['server_ip']
self.number = data['server_number']
self.name = data['server_name']
self.product = data['product']
self.datacenter = data['dc']
self.traffic = data['traffic']
self.status = data['status']
self.cancelled = data['cancelled']
self.paid_until = datetime.strptime(data['paid_until'], '%Y-%m-%d')
self.is_vserver = self.product.startswith('VQ')
def observed_reboot(self, *args, **kwargs):
msg = ("Server.observed_reboot() is deprecated. Please use"
" Server.reset.observed_reboot() instead.")
warnings.warn(msg, DeprecationWarning)
return self.reset.observed_reboot(*args, **kwargs)
def reboot(self, *args, **kwargs):
msg = ("Server.reboot() is deprecated. Please use"
" Server.reset.reboot() instead.")
warnings.warn(msg, DeprecationWarning)
return self.reset.reboot(*args, **kwargs)
def set_name(self, name):
result = self.conn.post('/server/{0}'.format(self.ip),
{'server_name': name})
self.update_info(result)
def __repr__(self):
return "<{0} (#{1} {2})>".format(self.ip, self.number, self.product)

View File

@@ -0,0 +1 @@
from hetzner.tests.test_util_addr import * # NOQA

View File

@@ -0,0 +1,85 @@
import unittest
import struct
import socket
from hetzner.util.addr import (parse_ipv4, parse_ipv6, parse_ipaddr,
get_ipv4_range, get_ipv6_range,
ipv4_bin2addr, ipv6_bin2addr)
class UtilAddrTestCase(unittest.TestCase):
def test_parse_ipv4(self):
self.assertEqual(parse_ipv4('174.26.72.88'), 2920958040)
self.assertEqual(parse_ipv4('0.0.0.0'), 0)
self.assertEqual(parse_ipv4('255.255.255.255'), 4294967295)
self.assertRaises(socket.error, parse_ipv4, '999.999.999.999')
self.assertRaises(socket.error, parse_ipv4, '::ffff:192.168.0.1')
def test_parse_ipv6(self):
self.assertEqual(parse_ipv6('::ffff:192.168.0.1'), 281473913978881)
self.assertEqual(parse_ipv6('fe80::fbd6:7860'),
338288524927261089654018896845572831328)
self.assertEqual(parse_ipv6('ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff'),
340282366920938463463374607431768211455)
self.assertEqual(parse_ipv6('::'), 0)
self.assertRaises(socket.error, parse_ipv6, '174.26.72.88')
def test_parse_ipaddr(self):
self.assertEqual(parse_ipaddr('1.2.3.4'), (False, 16909060))
self.assertEqual(parse_ipaddr('255.255.0.0', False), 4294901760)
self.assertEqual(parse_ipaddr('dead::beef'),
(True, 295986882420777848964380943247191621359))
self.assertEqual(parse_ipaddr('ffff::ffff', True),
340277174624079928635746076935439056895)
self.assertRaises(socket.error, parse_ipaddr, '1.2.3.4', True)
self.assertRaises(socket.error, parse_ipaddr, 'dead::beef', False)
self.assertRaises(socket.error, parse_ipaddr, 'invalid')
def test_get_ipv4_range(self):
self.assertEqual(get_ipv4_range(0xac100000, 12),
(2886729728, 2887778303))
self.assertEqual(get_ipv4_range(0xa1b2c3d4, 16),
(2712797184, 2712862719))
self.assertEqual(get_ipv4_range(0xa1b2c3d4, 32),
(2712847316, 2712847316))
self.assertEqual(get_ipv4_range(0xa1b2c3d4, 0),
(0, 4294967295))
self.assertRaises(ValueError, get_ipv4_range, 0x01, 64)
def test_get_ipv6_range(self):
self.assertEqual(
get_ipv6_range(0x00010203ff05060708091a1b1c1d1e1f, 36),
(5233173638632030885207665411096576,
5233178590392188026728765007593471)
)
self.assertEqual(
get_ipv6_range(0x000102030405060708091a1b1c1d1e1f, 64),
(5233100606242805471950326074441728,
5233100606242823918694399783993343)
)
self.assertEqual(
get_ipv6_range(0x000102030405060708091a1b1c1d1e1f, 128),
(5233100606242806050973056906370591,
5233100606242806050973056906370591)
)
self.assertEqual(
get_ipv6_range(0x000102030405060708091a1b1c1d1e1f, 0),
(0, 340282366920938463463374607431768211455)
)
self.assertRaises(ValueError, get_ipv6_range, 0x01, 256)
def test_ipv4_bin2addr(self):
self.assertEqual(ipv4_bin2addr(0x01020304), '1.2.3.4')
self.assertEqual(ipv4_bin2addr(0x0000ffff), '0.0.255.255')
self.assertEqual(ipv4_bin2addr(0xffff0000), '255.255.0.0')
self.assertRaises(struct.error, ipv4_bin2addr, 0xa1ffff0000)
def test_ipv6_bin2addr(self):
self.assertEqual(ipv6_bin2addr(0x01020304050607080910111213141516),
'102:304:506:708:910:1112:1314:1516')
self.assertEqual(ipv6_bin2addr(0xffff000000000dead00000beef000000),
'ffff::dea:d000:be:ef00:0')
self.assertEqual(ipv6_bin2addr(0x123400000000000000000000000000ff),
'1234::ff')
self.assertRaises(struct.error, ipv6_bin2addr,
0xa1ffff0000000000000000000000000000)

View File

@@ -0,0 +1,84 @@
import socket
import struct
def parse_ipv4(addr):
"""
Return a numeric representation of the given IPv4 address.
"""
binary_ip = socket.inet_pton(socket.AF_INET, addr)
return struct.unpack('!L', binary_ip)[0]
def parse_ipv6(addr):
"""
Return a numeric representation of the given IPv6 address.
"""
binary_ip = socket.inet_pton(socket.AF_INET6, addr)
high, low = struct.unpack('!QQ', binary_ip)
return high << 64 | low
def parse_ipaddr(addr, is_ipv6=None):
"""
Parse IP address and return a tuple consisting of a boolean indicating
whether the given address is an IPv6 address and the numeric representation
of the address.
If is_ipv6 is either True or False, the specific address type is enforced
and only the parsed address is returned instead of a tuple.
"""
if is_ipv6 is None:
try:
return False, parse_ipv4(addr)
except socket.error:
return True, parse_ipv6(addr)
elif is_ipv6:
return parse_ipv6(addr)
else:
return parse_ipv4(addr)
def get_ipv4_range(numeric_netaddr, prefix_len):
"""
Return the smallest and biggest possible IPv4 address of the specified
network address (in numeric representation) and prefix length.
"""
mask_inverted = 32 - prefix_len
mask_bin = 0xffffffff >> mask_inverted << mask_inverted
range_start = numeric_netaddr & mask_bin
range_end = range_start | (1 << mask_inverted) - 1
return range_start, range_end
def get_ipv6_range(numeric_netaddr, prefix_len):
"""
Return the smallest and biggest possible IPv6 address of the specified
network address (in numeric representation) and prefix length.
"""
mask_bin_full = 0xffffffffffffffffffffffffffffffff
mask_inverted = 128 - prefix_len
mask_bin = mask_bin_full >> mask_inverted << mask_inverted
range_start = numeric_netaddr & mask_bin
range_end = range_start | (1 << mask_inverted) - 1
return range_start, range_end
def ipv4_bin2addr(numeric_addr):
"""
Convert a numeric representation of the given IPv4 address into quad-dotted
notation.
"""
packed = struct.pack('!L', numeric_addr)
return socket.inet_ntop(socket.AF_INET, packed)
def ipv6_bin2addr(numeric_addr):
"""
Convert a numeric representation of the given IPv6 address into a shortened
hexadecimal notiation separated by colons.
"""
high = numeric_addr >> 64
low = numeric_addr & 0xffffffffffffffff
packed = struct.pack('!QQ', high, low)
return socket.inet_ntop(socket.AF_INET6, packed)

View File

@@ -0,0 +1,70 @@
import os
import ssl
import socket
from tempfile import NamedTemporaryFile
try:
from httplib import HTTPSConnection
except ImportError:
from http.client import HTTPSConnection
class ValidatedHTTPSConnection(HTTPSConnection):
CA_ROOT_CERT_FALLBACK = '''
DigiCert Global Root G2
-----BEGIN CERTIFICATE-----
MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH
MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT
MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI
2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx
1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ
q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz
tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ
vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP
BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV
5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY
1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4
NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG
Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91
8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe
pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl
MrY=
-----END CERTIFICATE-----
'''
def get_ca_cert_bundle(self):
via_env = os.getenv('SSL_CERT_FILE')
if via_env is not None and os.path.exists(via_env):
return via_env
probe_paths = [
"/etc/ssl/certs/ca-certificates.crt",
"/etc/ssl/certs/ca-bundle.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
]
for path in probe_paths:
if os.path.exists(path):
return path
return None
def connect(self):
sock = socket.create_connection((self.host, self.port),
self.timeout,
self.source_address)
bundle = cafile = self.get_ca_cert_bundle()
if bundle is None:
ca_certs = NamedTemporaryFile()
ca_certs.write('\n'.join(
map(str.strip, self.CA_ROOT_CERT_FALLBACK.splitlines())
).encode('ascii'))
ca_certs.flush()
cafile = ca_certs.name
self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
cert_reqs=ssl.CERT_REQUIRED,
ca_certs=cafile)
if bundle is None:
ca_certs.close()

View File

@@ -0,0 +1,19 @@
try:
from HTMLParser import HTMLParser
except ImportError:
from html.parser import HTMLParser
class CSRFParser(HTMLParser):
def __init__(self, field_name):
HTMLParser.__init__(self)
self.field_name = field_name
self.csrf_token = None
def handle_starttag(self, tag, attrs):
if tag != 'input':
return
attrdict = dict(attrs)
if attrdict.get('name', '') == self.field_name:
self.csrf_token = attrdict.get('value', None)
handle_startendtag = handle_starttag