dschanoeh/hover-ddns

Authentication Issue

monkeyworknet opened this issue Β· 20 comments

ERRO[0000] Could not log in: Received status code 401

Looks like Hover has forced 2 factor, any work arounds for the app?

I'm interested in this as well. Thx!

I can confirm that I'm having the same issue and don't see a workaround right now.

I spoke with Hover Support about this yesterday. The API broke because they enabled MFA for all accounts with no notification. They said that they are not hearing from enough people needing API interfaces to Hover for them to invest in making it work in this new security regime. I mentioned that both Microsoft and Google have app passwords which they could implement as well, but no positive response on this from them yet.

If this is important to you, PLEASE contact Hover support and let them know that you use the API, and that them disabling access to it is a serious problem for you.

as an alternative option I've been experimenting with Cloudflare tunnels - if this can't be resolved may be something those who are doing homelab type work might want to investigate

I just emailed support about this, too.

In the mean time, I discovered that you can edit and re-send API calls to hover to create records that bypass their input form restrictions. If you create a new CNAME record called * pointing to say, .duckdns.org, you can then "Edit and resend" the response (using browser dev tools) and change the * to an @ and resend the response. This will effectively create a CNAME record for your root domain, too. (Which apparently isn't supposed to work according to IANA, and yet?) Just keep in mind:

What’s important here is that this is a contractual limitation, not a technical one. It is possible to use a CNAME at the root, but it can result in unexpected errors, as it is breaking the expected contract of behavior.

Edit: * also will work as a wildcard CNAME redirect, in case you can get away with just using subdomains.

I called them to ask what's up, made it clear I depend on the API. They apologized. She linked me this github as I'd forgotten how I even wrote my script years ago. They might be starting to get the motivation to work on this, I hope.

Does not look like they intend to support this anytime soon. I e-mailed them and the response I got was:


Thank you for reaching out to Hover Support and for your sharing this candid feedback with us.

While we do not support API at this time, a possible workaround is to use a 3rd party DNS provider such as Cloudflare. We certainly value you as a Hover customer and would love to see our platform continue to meet your needs where possible. Please don't hesitate to reach out if we can help with anything else.

I should not say this in case someone from Hover is watching and is actively trying to sabotage use of the API, but it turns out this is still very workable.

In order for the browser to authenticate against the API, 3 cookies are needed (__zlcmid, hover_session, and hoverauth). The latter two have an expiry of "Session" (meaning the API itself does not seem to associate an expiry with them). The former has an expiry of almost a year.

So... if you just manually craft cookies in place of the username/password authentication formerly used, you should only have to login to the browser and go through the 2FA cycle once per year. This is very doable, at least for me!

I've adjusted my scripts to account for this and it works without any other changes. Time will tell if the usability of the session persists for up to a year or not.

Just checking in -- still working a couple days later with the same cookies! Looks like this is the workaround.

And... still working weeks later now! (I'll stop bumping after this)

gitbls commented

It appears that Hover are not proactively checking/reinforcing this, so if you can figure out a way to make it work, it will stay working. For now, anyhow. πŸ€·β€β™€οΈ

I've concluded that if mine ever stops working I'll be putting my ddns on noip (but keep Hover as the registrar). They are much more proactive than Hover about enabling api access even with 2FA enabled.

I should not say this in case someone from Hover is watching and is actively trying to sabotage use of the API, but it turns out this is still very workable.

In order for the browser to authenticate against the API, 3 cookies are needed (__zlcmid, hover_session, and hoverauth). The latter two have an expiry of "Session" (meaning the API itself does not seem to associate an expiry with them). The former has an expiry of almost a year.

So... if you just manually craft cookies in place of the username/password authentication formerly used, you should only have to login to the browser and go through the 2FA cycle once per year. This is very doable, at least for me!

I've adjusted my scripts to account for this and it works without any other changes. Time will tell if the usability of the session persists for up to a year or not.

can you please help me for do this workaround with python script ?

@tj90241 Is your workaround still working? Any chance you can share the scripts?

Hover Support's recommendation to use Cloudflare's free plan as nameserver still with Hover as the registrar was very easy. Cloudflare auto-imported all the DNS records from Hover, whilst Hover keeps the originals available for reference with a note at the top: "This domain is using third-party nameservers. DNS records added here won't have an effect." I'll probably just transfer my domain to Cloudflare anyway since it's half the price but thought I'd try this first.

I have managed to write my own script that goes through 2FA on hover. Essentially, I curl to the login page first (https://www.hover.com/signin) and store the cookies in a cookie file - this holds the session details. I then reuse that cookie file and POST my username and password to https://www.hover.com/signin/auth.json. This should return with a JSON answer stating 2FA code is required. I then monitor my inbox for the 2FA code and retrieve it and post it to https://www.hover.com/signin/auth2.json again using same cookie file. Once done I can then modify my A records by submitting a PUT request to https://www.hover.com/control_panel/domain/yourdomain.com/dns. Dev tools in chrome was pretty useful in helping me figure this out.

My code automates everything and allows me to failover DNS records to our secondary site within seconds.

@l0ckm4 ... are you going to share? πŸ˜„

`
import requests
from flask import jsonify
from lexicon.config import ConfigResolver
from lexicon.client import Client

def dohoverapi(nome,ip):

try:
    lexicon_config = {
        "provider_name" : "hover", # lexicon shortname for provider, see providers directory for available proviers
        "action": "create", # create, list, update, delete
        "domain": "vetere.tech", # domain name
        "name" : nome,
        "ttl" : "15",
        "content" : ip,
        "type": "A", # specify a type for record filtering, case sensitive in some cases.
        "hover": {
            "auth_username": "simonevetere",
            "auth_password": "xyz",
            "auth_totp_secret" : "xyz===="
        }
    }

    config = ConfigResolver()
    config.with_env().with_dict(dict_object=lexicon_config)
    client = Client(config)
    results = client.execute()

except:
    
    return jsonify({"message": "error"}), 400

return jsonify({"message": "successful"}), 200`

@l0ckm4 ... are you going to share? πŸ˜„

I can - I wrote mine in PHP though but if that's ok with you then I will extract out the good bits and post it.

Here is my PHP Code - I hope it helps someone. Replace mydom.com with your actual domain name and of course change the first 3 variables to match what you want - if you want to change multiple A records then simply add more elements to the $myRecord array. My code writing is basic so please bear that in mind. I hope it helps someone

//A records you want to change
$myRecord['www'] = '88.88.88.88';
$username = 'hoverusername';
$mypassword = 'hoverpassword';

Get Initial Cookie

//Get Initial Cookie 
$cookieFile = tempnam(sys_get_temp_dir(), "curl_cookies");
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://www.hover.com/signin");
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_USERAGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36");
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookieFile);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);

Start Sign In

//Start Sign In
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://www.hover.com/signin/auth.json');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
	'username' => $myusername,
	'password' => $mypassword,
	'token' => null
]));
$headers = [
	'Accept: */*',
	'Accept-Language: en-GB,en;q=0.9',
	'Connection: keep-alive',
	'Content-type: application/json;charset=UTF-8',
	'Origin: https://www.hover.com',
	'Referer: https://www.hover.com/signin',
	'Sec-Fetch-Dest: empty',
	'Sec-Fetch-Mode: cors',
	'Sec-Fetch-Site: same-origin',
	'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
	'sec-ch-ua: "Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
	'sec-ch-ua-mobile: ?0',
	'sec-ch-ua-platform: "Windows"'
];
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookieFile);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookieFile);
$response = curl_exec($ch);
if (curl_errno($ch)) {
	echo "\t\tError:" . curl_error($ch);
	die();
}
curl_close($ch);
$ret = json_decode($response);
if ($ret->status!='need_2fa') die("\tInvalid Response Received");
print_r($ret);

It should be asking you for the 2fa code now. Retrieve it how you want to - i scripted my code to pick it from the email but you can wait and ask for the code for simplicity here.

$code = trim(fgets(STDIN));

//Submit 2FA code
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://www.hover.com/signin/auth2.json');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(['code' => $code]));
$headers = [
	'Accept: */*',
	'Accept-Language: en-GB,en;q=0.9',
	'Connection: keep-alive',
	'Content-type: application/json;charset=UTF-8',
	'Origin: https://www.hover.com',
	'Referer: https://www.hover.com/signin',
	'Sec-Fetch-Dest: empty',
	'Sec-Fetch-Mode: cors',
	'Sec-Fetch-Site: same-origin',
	'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
	'sec-ch-ua: "Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
	'sec-ch-ua-mobile: ?0',
	'sec-ch-ua-platform: "Windows"'
];
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookieFile);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookieFile);
$response = curl_exec($ch);
if (curl_errno($ch)) {
	echo "\t\tError:" . curl_error($ch);
}
curl_close($ch);
$ret = json_decode($response);
if ($ret->succeeded!='1') die("\t\tInvalid Response Received");

Iterate through your DNS records for domain mydom.com

//Iterate through your DNS records for domain mydom.com
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://www.hover.com/api/control_panel/dns/mydom.com');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$headers = [
	'Accept: */*',
	'Accept-Language: en-GB,en;q=0.9',
	'Connection: keep-alive',
	'Referer: https://www.hover.com/control_panel/control_panel/domain/mydom.com/dns',
	'Sec-Fetch-Dest: empty',
	'Sec-Fetch-Mode: cors',
	'Sec-Fetch-Site: same-origin',
	'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
	'sec-ch-ua: "Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
	'sec-ch-ua-mobile: ?0',
	'sec-ch-ua-platform: "Windows"'
];
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookieFile);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookieFile);
$response = curl_exec($ch);
if (curl_errno($ch)) {
	echo "\t\tError:" . curl_error($ch);
}
curl_close($ch);

$response = json_decode($response);


foreach ($response->domain->dns as $dnsEntry) {
	print_r($dnsEntry);
	if (isset($myRecord[$dnsEntry->name]) && $dnsEntry->type=="A") {
		echo "\t\tSending update request for '".$dnsEntry->name."' record";
		$array = [
			"domain" => [
				"id" => "domain-mydom.com",
				"dns_records" => [
					[
						"id" => $dnsEntry->id,
						"name" => $dnsEntry->name,
						"type" => $dnsEntry->type,
						"content" => $dnsEntry->content,
						"ttl" => $dnsEntry->ttl,
						"is_default" => $dnsEntry->is_default,
						"can_revert" => $dnsEntry->can_revert
					]
				]
			],
			"fields" => [
				"content" => $myRecord[$dnsEntry->name],
				"ttl" => "300"
			]
		];
		$ret = update_dns($array,$cookieFile);
		if ($ret['status']=='success') {
			echo " - Record updated successfully\n";
		} elseif ($ret['status']=='error') {
			echo " - Oops, ".$ret['Error']."\n";
		}
	}
}

unlink($cookieFile);

Here is update_dns function

function update_dns($array,$cookieFile) {
	$curl = curl_init();
	curl_setopt_array($curl, [
		CURLOPT_URL => 'https://www.hover.com/api/control_panel/dns',
		CURLOPT_RETURNTRANSFER => true,
		CURLOPT_CUSTOMREQUEST => 'PUT',
		CURLOPT_HTTPHEADER => [
			'Accept: */*',
			'Accept-Language: en-GB,en;q=0.9',
			'Connection: keep-alive',
			'Content-type: application/json;charset=UTF-8',
			'Origin: https://www.hover.com',
			'Referer: https://www.hover.com/control_panel/domain/mydom.com/dns',
			'Sec-Fetch-Dest: empty',
			'Sec-Fetch-Mode: cors',
			'Sec-Fetch-Site: same-origin',
			'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
			'sec-ch-ua: "Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
			'sec-ch-ua-mobile: ?0',
			'sec-ch-ua-platform: "Windows"'
		],
		CURLOPT_COOKIEJAR => $cookieFile,
		CURLOPT_COOKIEFILE => $cookieFile,
		CURLOPT_POSTFIELDS => json_encode($array)
	]);
	$response = curl_exec($curl);
	$http_code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
	$error = curl_error($curl);
	curl_close($curl);
	if ($response === false) {
		return ['status'=>'error',"Error" => $error];
	}
	$ret = json_decode($response);
	if ($ret->succeeded!='1') {
		return ['status'=>'error',"Error" => 'Error updating Record'];
	} else {
		return ['status'=>'success'];
	}
}	

@simonevetere absolutely stellar, I used yours and it works great! I cleaned it up a bit (and you could do this in a small docker container even, setting env vars):

import json
import sys
from argparse import ArgumentParser
from typing import Literal

import requests
from lexicon.client import Client
from lexicon.config import ConfigResolver

parser = ArgumentParser()
parser.add_argument(
    "domain_name",
    help="The domain name to add the A record to",
    type=str,
)

known_args, other = parser.parse_known_args()


def build_lexicon_config(
    domain_name: str, ip: str, *, action: Literal["create", "list", "update", "delete"]
) -> dict:
    return {
        "provider_name": "hover",
        "action": action,
        "domain": domain_name,
        "ttl": "15",
        "name": "@",
        "content": ip,
        "type": "A",
        "hover": {
            "auth_username": os.getenv("HOVER_USERNAME", None),
            "auth_password": os.getenv("HOVER_PASSWORD", None),
            "auth_totp_secret": os.getenv("HOVER_TOTP_SHARED_SECRET", None),
        },
    }


def main(
    domain_name: str = known_args.domain_name,
    *,
    ip: str | None = None,
):
    if ip is None:
        ip = requests.get("https://api.ipify.org").text

    config = ConfigResolver()
    list_current_a_records = build_lexicon_config(domain_name, ip, action="list")
    create_a_record = build_lexicon_config(domain_name, ip, action="create")
    update_a_record = build_lexicon_config(domain_name, ip, action="update")
    action = "Create"

    try:
        config.with_env().with_dict(dict_object=list_current_a_records)
        client = Client(config)
        list_result: bool | list[dict[str, Any]] = client.execute()

        config = ConfigResolver()

        if not list_result:
            print(f"No A records found for {domain_name}")
            config.with_env().with_dict(dict_object=create_a_record)
        else:
            action = "Update"
            print(f"Found A records for {domain_name}")
            first_record: dict[str, Any] | None = (
                {}
                if not isinstance(list_result, Iterable)
                else next(iter(list_result), {})
            )
            print(json.dumps(first_record, indent=2))
            config.with_env().with_dict(dict_object=update_a_record)

            try:
                if first_record and first_record["content"] == ip:
                    print(f"IP address already set to {ip}")
                    return
            except IndexError:
                pass

        client = Client(config)
        result = client.execute()

        if result:
            print(f"{action}d A record for @ {domain_name} β†’ {ip}")

        else:
            print(f"Failed to {action.lower()} A record for @ {domain_name} β†’ {ip}")
            print(result)

    except Exception as e:
        print(e)


if __name__ == "__main__":
    print(sys.argv)

    main(known_args.domain_name)

Then simply:

python hover.py <yourdomain>