Decentralized Evernode DNS system

Post your evernode tutorials and ideas here!

Moderator: EvrSteward

Post Reply
everdns
Posts: 1
Joined: Thu Sep 26, 2024 10:48 pm

Decentralized Evernode DNS system

Post by everdns »

Video showing how it works:
https://www.youtube.com/watch?v=bhSTu8RvUgI

The idea:
The idea is to run a domain pointing database in an evernode hotpocket smart contract cluster which loadbalancers can fetch domain information from for the purpose of pointing traffic to evernode instances.

The smart contract is a cluster of evernode nodes that users submit domain names to. These clusters need to come to consensus for saving the domain information into the state folder. The information they save currently is the information they receive from the domains TXT records.

domain_lookup.sh

Code: Select all

#!/bin/bash
domain=$1
dig +short TXT $domain | grep  everdns
mycontract.js

Code: Select all

const HotPocket = require("hotpocket-nodejs-contract");
const { exec } = require('child_process');
const fs = require("fs").promises;

// Function to send a confirmation response
const sendConfirmation = async (user, domain) => {
    console.log("Sending 'Your domain is' message");
    return user.send(`Your domain is '${domain}'`);
};

// Function to send a message about starting the lookup
const sendLookupStarting = async (user, domain) => {
    console.log(`Let us review the txt records '${domain}'`);
    return user.send(`Your txt records are being reviewed '${domain}'`);
};

// Function to extract and format the result
const formatLookupResult = (stdout, domain) => {
    // Initialize an array to store formatted results
    const formattedResults = [];

    // Split the stdout by newlines and remove the double quotes at the start and end
    const lines = stdout.trim().split('\n').map(line => line.replace(/^"|"$/g, ''));

    // Loop through each line of the output
    for (const line of lines) {
        const matchRoute = line.match(/route=([^ ]+)/);
        const matchPort = line.match(/port=([^ ]+)/);

        // If both route and port are found, format them
        if (matchRoute && matchPort) {
            const route = matchRoute[1];
            const port = matchPort[1];
            formattedResults.push(`${domain},${route}:${port}`);
        }
    }

    // Return the formatted results joined with newlines
    return formattedResults.join('\n');
};

// Function to remove old domain records from the list
const removeOldRecords = async (domain) => {
    const filename = `domain.list`;
    
    try {
        // Check if the file exists, read its contents
        let fileContent = await fs.readFile(filename, 'utf8');
        
        // Split content by newlines and filter out any lines with the current domain
        const updatedContent = fileContent
            .split('\n')
            .filter(line => !line.startsWith(domain))  // Remove lines starting with the domain
            .join('\n');

        // Write the updated content back to the file (overwrite the file)
        await fs.writeFile(filename, updatedContent, 'utf8');
    } catch (error) {
        // If the file doesn't exist, ignore this error, or handle accordingly
        if (error.code !== 'ENOENT') {
            console.error("Error reading/writing domain.list:", error);
        }
    }
};

// Function to send the result of the domain lookup
const sendLookupResult = async (user, domain, stdout) => {
    console.log("Sending lookup result");

    // Extract and format the result
    const formattedResult = formatLookupResult(stdout, domain);
    
    // Log and send the formatted result to the user
    console.log(`'${domain}':\n${formattedResult}`);
    return user.send(`Your txt records look like this: '${domain}':\n${formattedResult}, sometimes it takes a while before TXT changes can be fetched.`);
};

// Function to send an error response
const sendError = async (user, domain, error) => {
    console.log("Sending error response");
    return user.send(`Error looking up domain '${domain}': ${error}`);
};

// Function to send a final message to the user
const sendFinalMessage = async (user) => {
    console.log("Sending message regarding submission");
    return user.send("Your submission is now being progressed.");
};

// Main contract logic
const mycontract = async (ctx) => {
    // Loop through all users
    for (const user of ctx.users.list()) {
        console.log("User public key", user.publicKey);

        // Loop through inputs sent by the user
        for (const input of user.inputs) {
            try {
                // Read input from the user
                const buffer = await ctx.users.read(input);
                const domain = buffer.toString();
                console.log("Received domain:", domain);

                // Send confirmation and start lookup messages sequentially
                await sendConfirmation(user, domain);
                await sendLookupStarting(user, domain);

                // Perform the domain lookup (run exec and await its result)
                await new Promise((resolve) => {
                    exec(`bash domain_lookup.sh ${domain}`, async (error, stdout, stderr) => {
                        if (error) {
                            // Send error response
                            await sendError(user, domain, error.message);
                        } else {
                            // Remove old domain records first
                            await removeOldRecords(domain);

                            // Send lookup result
                            await sendLookupResult(user, domain, stdout);

                            // Capture and write the formatted result to the domain list file
                            const formattedResult = formatLookupResult(stdout, domain);
                            const filename = `domain.list`;
                            await fs.appendFile(filename, formattedResult + "\n");  // Append each result on a new line
                        }
                        resolve();
                    });
                });

                // Finally, send the final message
                await sendFinalMessage(user);

            } catch (error) {
                console.error("Error processing input:", error);
            }
        }
    }
};

// Initialize the HotPocket contract
const hpc = new HotPocket.Contract();
hpc.init(mycontract);
The submission page is communicating with a websocket

server.js

Code: Select all

const express = require('express');
const path = require('path');
const fs = require('fs');
const https = require('https');
const http = require('http');
const bodyParser = require('body-parser');
const HotPocket = require('hotpocket-js-client');

const ports = Array.from({ length: 36535 - 36525 + 1 }, (_, i) => 36525 + i);
const directoryPath = '/home/everdns/public';
const sslDirectoryPath = '/home/everdns/certs';

// Middleware to parse JSON bodies
const app = express();
app.use(bodyParser.json());

// Check if SSL files exist
let options = {};
const keyPath = path.join(sslDirectoryPath, 'selfsigned.key');
const certPath = path.join(sslDirectoryPath, 'selfsigned.crt');

if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
    // Load SSL certificate and key if they exist
    options = {
        key: fs.readFileSync(keyPath),
        cert: fs.readFileSync(certPath)
    };
    console.log('SSL certificate and key loaded.');
} else {
    console.warn('SSL certificate and key not found, running without SSL.');
}

// Serve static files (HTML, CSS, JS) from the public directory
app.use(express.static(path.resolve(directoryPath)));  // Use absolute path

// Serve the index HTML file for the root URL ('/')
app.get('/', (req, res) => {
    const filePath = path.resolve(directoryPath, 'index.html');  // Use absolute path
    res.sendFile(filePath);
});

// Handle file requests
app.get('*', (req, res) => {
    const filePath = path.resolve(directoryPath, req.path);  // Use absolute path
    fs.stat(filePath, (err, stats) => {
        if (err || !stats.isFile()) {
            return res.status(404).send('File not found');
        }
        res.sendFile(filePath);
    });
});

// Handle domain and WebSocket submission
app.post('/submit', async (req, res) => {
    let { domain, wsAddress } = req.body;

    if (!domain || !wsAddress) {
        return res.status(400).json({ error: 'Domain and WebSocket address are required.' });
    }

    try {
        // Force prepend wss:// to WebSocket address
        if (!wsAddress.startsWith('wss://')) {
            wsAddress = `wss://${wsAddress}`;
        }

        const userKeyPair = await HotPocket.generateKeys();
        const client = await HotPocket.createClient([wsAddress], userKeyPair);

        // Establish connection with HotPocket WebSocket
        const connected = await client.connect();
        if (!connected) {
            return res.status(500).json({ error: 'Connection to WebSocket server failed.' });
        }

        console.log('Connection to WebSocket successful.');

        // Handle contract output
        client.on(HotPocket.events.contractOutput, (result) => {
            console.log('Received outputs:', result.outputs);
            res.json({ outputs: result.outputs });
            client.close();
        });

        // Handle errors
        client.on('error', (error) => {
            console.error('WebSocket error:', error);
            res.status(500).json({ error: 'WebSocket error occurred.' });
        });

        // Send domain as input to the WebSocket
        const input = Buffer.from(domain);
        await client.submitContractInput(input);
    } catch (error) {
        console.error('Error in processing request:', error);
        res.status(500).json({ error: 'Server error occurred.' });
    }
});

// Create HTTPS or HTTP server based on SSL availability
ports.forEach(port => {
    if (options.key && options.cert) {
        // Create an HTTPS server if SSL options are available
        const server = https.createServer(options, app);
        server.listen(port, () => {
            console.log(`HTTPS server is listening on port ${port}`);
        });
    } else {
        // Fallback to HTTP if no SSL certificate is available
        const server = http.createServer(app);
        server.listen(port, () => {
            console.log(`HTTP server is listening on port ${port}`);
        });
    }
});
index.html

Code: Select all

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Domain and WebSocket Submission</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f4f4f9;
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
        }

        .container {
            background-color: #fff;
            padding: 20px 40px;
            border-radius: 10px;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
            max-width: 400px;
            width: 100%;
        }

        h1 {
            text-align: center;
            color: #333;
        }

        form {
            display: flex;
            flex-direction: column;
            gap: 15px;
        }

        label {
            font-size: 14px;
            color: #333;
        }

        input[type="text"] {
            padding: 10px;
            font-size: 16px;
            border: 1px solid #ccc;
            border-radius: 5px;
            width: 100%;
        }

        button {
            padding: 10px;
            font-size: 16px;
            color: #fff;
            background-color: #007BFF;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            transition: background-color 0.3s ease;
        }

        button:hover {
            background-color: #0056b3;
        }

        #output {
            margin-top: 20px;
            font-size: 14px;
            color: #555;
            background-color: #f0f0f0;
            padding: 10px;
            border-radius: 5px;
        }

        p {
            margin: 5px 0;
        }

        ul {
            list-style-type: none;
            padding: 0;
        }

        ul li {
            margin: 5px 0;
            background-color: #e9ecef;
            padding: 10px;
            border-radius: 5px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Submit Domain & WebSocket</h1>
        <form id="domainForm">
            <label for="domain">Domain Name:</label>
            <input type="text" id="domain" name="domain" placeholder="Enter domain name" required>

            <label for="wsAddress">WebSocket Address:</label>
            <input type="text" id="wsAddress" name="wsAddress" placeholder="Enter WebSocket address (without wss://)" required>

            <button type="submit">Submit</button>
        </form>

        <div id="output"></div>
    </div>

    <script>
        document.getElementById('domainForm').addEventListener('submit', function(e) {
            e.preventDefault();

            const domain = document.getElementById('domain').value;
            const wsAddress = document.getElementById('wsAddress').value;

            fetch('/submit', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ domain, wsAddress }),
            })
            .then(response => response.json())
            .then(data => {
                // Format output as a list
                const outputDiv = document.getElementById('output');
                outputDiv.innerHTML = "<h3>Received Output:</h3><ul>";
                data.outputs.forEach((item) => {
                    outputDiv.innerHTML += `<li>${item.replace(/\n/g, '<br>')}</li>`;
                });
                outputDiv.innerHTML += "</ul>";
            })
            .catch(error => {
                document.getElementById('output').innerHTML = '<p>Error: ' + error.message + '</p>';
            });
        });
    </script>
</body>
</html>
To make the domain.list accessible for the public we run listscript.sh every minute with cron.

Code: Select all

sudo cp /contract/contract_fs/seed/state/domain.list /home/everdns/public/domain.list
sudo chown everdns:everdns /home/everdns/public/domain.list
The code above is basically the domain system of this idea.

Dockerfile example

Code: Select all

FROM evernode/sashimono:hp.latest-ubt.20.04-njs.20

# Install stuff
RUN apt-get update && \
    apt-get install -y openssh-server openssl sudo bash nano dnsutils curl cron && \
    rm -rf /var/lib/apt/lists/*



# Create a new user with sudo privileges
RUN useradd -m -s /bin/bash everdns && \
    echo "everdns:default" | chpasswd && \
    usermod -aG sudo everdns && \
    echo "everdns ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers

# Configure SSH
RUN mkdir /var/run/sshd && \
    for port in $(seq 36525 36535); do echo "Port $port" >> /etc/ssh/sshd_config; done && \
    echo "PermitRootLogin no" >> /etc/ssh/sshd_config && \
    echo "ListenAddress 0.0.0.0" >> /etc/ssh/sshd_config

RUN mkdir /home/everdns/public
RUN mkdir /home/everdns/nodeserver
RUN mkdir /home/everdns/nodeserver/node_modules
RUN mkdir /home/everdns/certs

# Expose SSH ports
EXPOSE 36525-36535

#start
COPY start.sh /start.sh
COPY traefik.py /home/everdns/traefik.py
COPY nginx.py /home/everdns/nginx.py
COPY listscript.sh /home/everdns/listscript.sh
COPY index.html /home/everdns/public/index.html
COPY /nodeserver/ /home/everdns/nodeserver
COPY /nodeserver/node_modules/ /home/everdns/nodeserver/node_modules
COPY generate_ssl.sh /home/everdns/generate_ssl.sh

#Cronies
#RUN echo "*/1 * * * * /usr/bin/python3 /home/everdns/traefik.py >> /var/log/cron.log 2>&1" >> /etc/cron.d/cronjobs
#RUN echo "*/1 * * * * /usr/bin/python3 /home/everdns/nginx.py >> /var/log/cron.log 2>&1" >> /etc/cron.d/cronjobs
RUN echo "*/1 * * * * /home/everdns/listscript.sh >> /var/log/cron.log 2>&1" >> /etc/cron.d/cronjobs
RUN echo "0 0 1 1,4,7,10 * /home/everdns/generate_ssl.sh >> /var/log/ssl_cert_generation.log 2>&1" >> /etc/cron.d/cronjobs

# Apply permissions for cron job file
RUN chmod 0644 /etc/cron.d/cronjobs
RUN crontab /etc/cron.d/cronjobs

RUN chown -R everdns:everdns /home/everdns



# Set the working directory
WORKDIR /home/everdns

# Set the default entry point to the start.sh script
ENTRYPOINT ["/start.sh"]
start.sh example

Code: Select all

#!/bin/bash
# Generate SSH host keys if they don't exist
if [ ! -f /etc/ssh/ssh_host_rsa_key ]; then
    ssh-keygen -A
fi

# Start the SSH service
#/usr/sbin/sshd

#Create a random ssl
bash /home/everdns/generate_ssl.sh

# Cronies
sudo /usr/sbin/cron
sudo chown -R everdns:everdns /home/everdns
chown -R everdns:everdns /home/everdns

#start node server
/usr/bin/node /home/everdns/nodeserver/server.js &

# Start Evernode hpcore
/usr/local/bin/hotpocket/hpcore $@

# Keep the container running
tail -f /dev/null




Now, let us get to the loadbalancer.

The loadbalancer runs nginx and it utilize a python script to fetch the domain list, write the nginx configuration files and issue the certbot ssl certificates.

Installation of dependencies

Code: Select all

sudo apt update
sudo apt install nginx cron certbot python3-certbot-nginx
Creation of a an initial ssl cert

Code: Select all

sudo mkdir -p /etc/nginx/ssl/

sudo chown -R www-data:www-data /etc/nginx/ssl/

sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout /etc/nginx/ssl/dummy.key -out /etc/nginx/ssl/dummy.crt -subj "/CN=localhost"


The python script:
You need to set the domain list url to an evernode domain instance.

Code: Select all

import os
import subprocess
from datetime import datetime
import requests

# Paths to important directories and files
nginx_conf_dir = '/etc/nginx/conf.d/'
sites_available_dir = '/etc/nginx/sites-available/'
sites_enabled_dir = '/etc/nginx/sites-enabled/'
certbot_webroot = '/var/www/certbot'

# URL to fetch domain.list
domain_list_url = 'https:// address:port /domain.list'  # Replace with actual URL

def fetch_domain_list(url):
    try:
        response = requests.get(url, verify=False)
        response.raise_for_status()  # Raise an error if the request failed
        return response.text
    except requests.exceptions.RequestException as e:
        print(f"Error fetching domain list: {e}")
        return None

# Fetch the domain list from the website
domain_list_content = fetch_domain_list(domain_list_url)

if domain_list_content:
    # Save the content to a local file (optional)
    domain_file = 'domain.list'
    with open(domain_file, 'w') as file:
        file.write(domain_list_content)
    
    print(f"Domain list successfully fetched and saved to {domain_file}")
else:
    print("Failed to fetch the domain list.")
    
# Dictionary to store domain configurations
domain_configs = {}

# Function to check if certificate is valid and within the expiration window
def is_certificate_expiring(domain):
    cert_path = f"/etc/letsencrypt/live/{domain}/cert.pem"
    try:
        # Run the OpenSSL command to get the expiration date
        result = subprocess.run(['openssl', 'x509', '-enddate', '-noout', '-in', cert_path], 
                                stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
        expiry_str = result.stdout.decode('utf-8').strip().split('=')[1]
        expiry_date = datetime.strptime(expiry_str, '%b %d %H:%M:%S %Y %Z')

        # Check if the certificate is expiring within the next 30 days
        days_left = (expiry_date - datetime.now()).days
        return days_left < 30

    except subprocess.CalledProcessError as e:
        # If OpenSSL fails (e.g., no certificate exists), we treat the cert as invalid
        print(f"Error checking certificate for {domain}: {e}")
        return True

# Set to track which upstreams have been generated
generated_upstreams = set()

# Step 1: Read the domain list file
try:
    with open(domain_file, 'r') as file:
        for line in file:
            # Skip empty lines
            if not line.strip():
                continue

            # Split line into domain and proxy destination (comma-separated)
            domain, proxy = line.strip().split(',')

            # Add to domain configuration list
            if domain not in domain_configs:
                domain_configs[domain] = []
            domain_configs[domain].append(proxy)
except Exception as e:
    print(f"Error reading domain list file: {e}")
    exit(1)

# Step 2: Clear the old upstream config file and start fresh
upstream_conf_file = os.path.join(nginx_conf_dir, 'upstreams.conf')

try:
    with open(upstream_conf_file, 'w') as upstream_config:
        # Start fresh by clearing any old upstreams
        upstream_config.write("")
    print(f"Cleared old upstream config at {upstream_conf_file}")
except Exception as e:
    print(f"Error clearing upstream config: {e}")
    exit(1)

# Step 3: Generate Nginx upstream block in global scope
try:
    with open(upstream_conf_file, 'a') as upstream_config:
        # Write upstream blocks for all domains (only once per domain)
        for domain, proxies in domain_configs.items():
            if domain in generated_upstreams:
                continue  # Skip if upstream for this domain is already generated

            # Write the upstream block
            upstream_config.write(f"upstream backend_{domain.replace('.', '_')} {{\n")
            for proxy in proxies:
                upstream_config.write(f"    server {proxy};\n")
            if len(proxies) > 1:
                upstream_config.write(f"    random;\n")  # Use random load balancing strategy
            upstream_config.write(f"}}\n\n")

            generated_upstreams.add(domain)  # Mark this domain as generated
    
    print(f"Generated global upstream config at {upstream_conf_file}")

except Exception as e:
    print(f"Error generating global upstream config: {e}")
    exit(1)


# Step 4: Generate site-specific configurations for each domain
for domain in domain_configs.keys():
    site_conf_file = os.path.join(sites_available_dir, f"{domain}")
    
    # Check if a valid certificate already exists
    cert_path = f"/etc/letsencrypt/live/{domain}/fullchain.pem"
    key_path = f"/etc/letsencrypt/live/{domain}/privkey.pem"
    use_dummy_cert = not os.path.exists(cert_path)  # Use dummy cert if no valid cert exists
    
    try:
        with open(site_conf_file, 'w') as config:
            config.write(f"server {{\n")
            config.write(f"    listen 80;\n")
            config.write(f"    server_name {domain};\n\n")

            # Serve Let's Encrypt challenge requests
            config.write(f"    location /.well-known/acme-challenge/ {{\n")
            config.write(f"        root {certbot_webroot};\n")
            config.write(f"    }}\n\n")

            # Redirect all other HTTP traffic to HTTPS
            config.write(f"    location / {{\n")
            config.write(f"        return 301 https://$host$request_uri;\n")
            config.write(f"    }}\n")
            config.write(f"}}\n\n")

            # SSL setup
            config.write(f"server {{\n")
            config.write(f"    listen 443 ssl;\n")
            config.write(f"    server_name {domain};\n\n")
            
            # Check whether to use Let's Encrypt or dummy certificate
            if use_dummy_cert:
                config.write(f"    ssl_certificate /etc/nginx/ssl/dummy.crt;\n")
                config.write(f"    ssl_certificate_key /etc/nginx/ssl/dummy.key;\n")
                print(f"Using dummy SSL certificate for {domain}.")
            else:
                config.write(f"    ssl_certificate {cert_path};\n")
                config.write(f"    ssl_certificate_key {key_path};\n")
                print(f"Using Let's Encrypt SSL certificate for {domain}.")
            
            config.write(f"    ssl_protocols TLSv1.2 TLSv1.3;\n")
            config.write(f"    ssl_prefer_server_ciphers on;\n\n")

            # Use the upstream block in the proxy pass
            config.write(f"    location / {{\n")
            config.write(f"        proxy_pass https://backend_{domain.replace('.', '_')};\n")
            config.write(f"        proxy_set_header Host $host;\n")
            config.write(f"        proxy_set_header X-Real-IP $remote_addr;\n")
            config.write(f"        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n")
            config.write(f"        proxy_set_header X-Forwarded-Proto $scheme;\n")
            config.write(f"        proxy_ssl_verify off;\n")
            config.write(f"    }}\n")
            config.write(f"}}\n\n")

        print(f"Generated site config for {domain} at {site_conf_file}")

    except Exception as e:
        print(f"Error generating site config for {domain}: {e}")
        continue



    # Step 5: Create a symlink in sites-enabled/ for the domain
    symlink_file = os.path.join(sites_enabled_dir, domain)
    if not os.path.exists(symlink_file):
        os.symlink(site_conf_file, symlink_file)

# Step 6: Test Nginx configuration
try:
    subprocess.run(['nginx', '-t'], check=True)
    subprocess.run(['systemctl', 'reload', 'nginx'], check=True)
    print("Nginx configuration test successful and reloaded.")
except subprocess.CalledProcessError as e:
    print(f"Error testing or reloading Nginx configuration: {e}")


# Step 7: Run Certbot for each domain
for domain in domain_configs.keys():

    # Check if the certificate needs renewal or reinstallation
    if not is_certificate_expiring(domain):
        print(f"Certificate for {domain} is still valid. Skipping Certbot.")
        continue

    # Proceed with Certbot if the certificate is expiring or doesn't exist
    try:
        subprocess.run(['certbot', '--nginx', '-d', domain, '--register-unsafely-without-email', '--reinstall'], check=True)
        print(f"SSL certificate reinstalled for {domain}.")
    except subprocess.CalledProcessError as e:
        print(f"Error reinstalling SSL certificate for {domain}: {e}")
        # If reinstallation fails, try to issue a new certificate
        try:
            subprocess.run(['certbot', '--nginx', '-d', domain, '--register-unsafely-without-email'], check=True)
            print(f"New SSL certificate issued for {domain}.")
        except subprocess.CalledProcessError as e2:
            print(f"Error issuing new SSL certificate for {domain}: {e2}")
Once you've tried the python script once and agreed to the terms of service, you can run certbot and the python script with cron.

command crontab -e

Code: Select all

0 */12 * * * certbot renew --quiet  # Run Certbot renew every 12 hours
* * * * * /usr/bin/python3 path/script.py # Make sure you have the right path and so
Post Reply