Python Based Linux Server Disaster Recovery App

GitHub: https://github.com/1SUSHANT1/DisasterRecoveryApp

About

This is a project where I create a disaster recovery app that is able to bring back my servers from complete obliteration. If you are following my work, you may know that I have 2 servers in an High Availability server cluster, active-passive system. The app I build in this project will not only bring back the servers but also configure the server cluster. The app will be a hybrid-automated app. The app will largely automate what can be automated, get user input for things like APIs, keys and instruct the user to do certain tasks. It is impossible to build the app without doing so.

Plan

This project will be split into 2 phases. Phase 1 will configure the web servers, and Phase 2 will configure the server cluster. For Phase 1, to build the web server, I will take help from this guy Sushant(terrific guy btw) who has a server setup guide at: https://www.sushantadk.com/SupportingDocuments/buildingAServer.html. The app will be built using Python. I know this can come as a shock to everybody because historically, I have always used Bash for automating almost everything. The reason I am using Python is because there are many things that can go wrong in this project. I am not saying that Bash can't handle errors because I have written a whole server cluster system in Bash. In this particular case, Python is much easier because of its libraries and built-in functionality for handling files, directories, JSON, subprocesses, networking, and exceptions. The goal of this application is not simply to execute a series of commands. It needs to make decisions based on the current state of the server, validate files and directories, handle user input, detect problems, and determine whether it is safe to continue with the recovery process. Python provides a much cleaner way to implement this logic while still allowing me to interact directly with the Linux system when necessary. This is also a good opportunity for me to become more comfortable with Python while building something that I actually need.

Acknowledgements

I have to thank my friend Nyaisa again for donating me her broken MacBook Air. It wasn't booting because of missing system files. I absolutely didn't care about the system files or the MacOs, so some of you might already be able to tell that I zapped away MacOs and installed Debian on it. This project will be done in the Mac. I thought about installing docker on my servers and doing it, but my servers are like bad bad kids. They'll start throwing fit in a heartbeat. Combine that with my history of breaking things, that didn't seem like a great idea.

What will be documented?

Unlike my other projects which go heavily into systems, distributed systems, networking and whatnot, this one is code-heavy project. I will document the engineering decisions behind the app in this project. It will contain things like what I tried to do, if it worked or not, what I did instead, and so on. I will also use the provided GitHub link to explain the developments in specific commits.

Let's Get Started. Commit 1-3

Installing services

Let's use python to install something on the OS. Python's subprocess module allows us to launch another program directly from the code. It will be heavily used throughout this script. Let's import subprocess and run apt to install nginx.

sushant@sushant:~$ nano DRapp.py 
sushant@sushant:~$ cat DRapp.py 
import subprocess

subprocess.run(["apt", "install", "nginx"])
sushant@sushant:~$ sudo chmod +x DRapp.py 
sushant@sushant:~$ python3 DRapp.py 
Error: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)
Error: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?
sushant@sushant:~$ sudo python3 DRapp.py 
Installing:                     
  nginx

Installing dependencies:
  nginx-common

Suggested packages:
  fcgiwrap  nginx-doc  ssl-cert

Summary:
  Upgrading: 0, Installing: 2, Removing: 0, Not Upgrading: 51
  Download size: 725 kB
  Space needed: 1,906 kB / 39.9 GB available

Continue? [Y/n] 

You can see that the subprocess can install nginx, and is asking me for the confirmation [Y/n]. We can bypass this by providing "-y" as a argument when running the subprocess. It looks like this:
subprocess.run(["apt", "install","-y", "nginx"])
You may also have noticed that it doesn't work without root privileges. Let's make the app detect the user and abort if it is not root. We can use getpass.getuser() to get the current user's username. It looks like this:

sushant@sushant:~/DR$ head -7 DRapp.py | tail -6
import getpass

user=getpass.getuser()
if user != "root":
	print("Insufficient Priviledges. Please run as root. Abort")
	exit()
sushant@sushant:~/DR$ python3 DRapp.py 
Insufficient Priviledges. Please run as root. Abort
sushant@sushant:~/DR$ sudo python3 DRapp.py 
[sudo] password for sushant: 
nginx is already the newest version (1.26.3-3+deb13u9).
Summary:
  Upgrading: 0, Installing: 0, Removing: 0, Not Upgrading: 51
sushant@sushant:~/DR$ 

Now, instead of running into an error, we handle it gracefully. Next, let's create a function for the subprocess, so instead of willy nilly installing nginx, the code only runs when the function is called. I also added a return code so that we know if the installation was successful or not. It looks like this:

def installNGINX():
        result=subprocess.run(["apt", "install","-y", "nginx"])
        return result.returncode

Next, I also need Gunicorn and flask. I could copy and paste this code to make it install Gunicorn and flask but where's the fun in that? Let's modify the function to accept a string which will be used as the fourth parameter in the subprocess command, so we can install any software with ease in the future. It looks like this:

def getService(service):
        result=subprocess.run(["apt", "install","-y", service])
        return result.returncode

And it can be called like this:

ng=getService("nginx")
gn=getService("gunicorn")
fl=getService("python3-flask")

This does work but it can fail, and there is no way to handle that. We need to detect the failure and give the user a chance to try again. This is obviously a while loop job. I created another function like this:

def installThis(service):
        while True:
                code=getService(service)
                if code == 0:
                        print(service, "successfully installed.")
                        break
                else:
                        print(service, "installation failed")
                        choice=input("Enter 1 to try again. Enter any other key to quit")
                        if choice == "1":
                                continue
                        else:
                                print(serice,"couldn't be installed. Fatal Abort")
                                exit()

You can see that it breaks out when the service is successfully installed, gives the user a chance to try again or exit in case installation fails. I used exit() because the server cannot be configured without NGINX, Gunicorn or flask. Existing software is giving me some trouble because the code isn't entering the failed states. I can get around this by manually removing the services, but let's automate this too. The cleanup app looks like this:

sushant@sushant:~/DR$ cat cleanUp.py 
import subprocess

def remService(service):
	subprocess.run(["apt", "remove","-y", service])
	subprocess.run(["apt", "purge", "-y", service])
	subprocess.run(["apt", "-y","autoremove"])


remService("nginx")
remService("gunicorn")
remService("python3-flask")

This will make testing much easier now.

Git Clone. Commit 4-5

Git

Now to the meat of this project, the GitHub repo with all the data, and configuration files. Yep! the configuration files are in GitHub. Here is the function:

def gitClone():
        subprocess.run(["git","clone","https://github.com/1SUSHANT1/myProjects.git"])

Nothing out of the ordinary here, just a git clone command ran from subprocess. But it won't work just yet because its got a dependency. 2$ for anybody who can guess the dependency(not legally binding or admissible in court. Mwehehehe). The dependency is git itself. git clone doesn't work without git but we can quickly install it the same way installed the other services. But there is a problem. If we run the script twice, it says:
fatal: destination path 'myProjects' already exists and is not an empty directory.
We need to handle this gracefully. Let's make our app detect the existing directory before it runs git clone. Here is what we need to do:
  1. Iterate through all the characters in the provided repository address
  2. Construct a word letter by letter until you get to a "/"
  3. When you get to the "/", save that word and iterate again constructing a second word
  4. If you can detect another "/", the first word isn't the one we are looking for (directory name is after the last "/" in a GItHub repo link)
  5. Keep doing this until you reach end of the file. Now your saved word will look like "myProjects.git"
  6. Iterate through this word again until you find the "."
  7. Everything before the "." will be your directory name
Ah, am I using Python? Oh well, then I can just do

defaultRepo="https://github.com/1SUSHANT1/myProjects.git"
        dirName=defaultRepo.split("/")[-1].removesuffix(".git")

This splits the directory address at the forward slashes, grabs the last item on the list, and removes the trailing ".git". Python lovers, count your days.

Path

After extracting the directory name, we need to ask python to treat it as a path. For this, we need to import the Path library. It looks like this:

from pathlib import Path
def gitClone():
        defaultRepo="https://github.com/1SUSHANT1/myProjects.git"
        dirName=defaultRepo.split("/")[-1].removesuffix(".git")
        print(dirName)

        alreadyEx=Path(dirName)
        if alreadyEx.exists():
                print("Directory already exists")
        else:
                subprocess.run(["git","clone",defaultRepo])


Even though this is not a final version, we can see how the detection works. If the directory already exists, it prints a debug line, but if it doesn't exist, it clones the given repo. Now let's build on this foundation and let the user enter a custom repo. Something like this:

print("This is the default repo:", defaultRepo)
                choice=input("Enter 1 to use the default repo. Enter 2 to use a custom repo. Enter any other key to exit: ")
                usingRepo=""
                if choice == "1":
                        usingRepo=defaultRepo
                elif choice == "2":
                        usingRepo=input("Enter your custom GitHub repo")
                else:
                        print("FATAL. Git clone unsuccessful. Abort")
                        exit()

Next, let's give user the option to remove the current directory and get a fresh copy from GitHub in case of existing directory. We can use shutil.rmtree() to remove a directory. This is a very sensitive operation and we don't want to accidentally or hastily remove the directory without getting a proper confirmation. Something like this?

def gitClone():
        while True:
                defaultRepo="https://github.com/1SUSHANT1/myProjects.git"
                print("This is the default repo:", defaultRepo)
                choice=input("Enter 1 to use the default repo. Enter 2 to use a custom repo. Enter any other key to exit: ")
              --------------------------snip---------------------------
                        print("Directory already exists")
                        desicion=input("Enter 1 to work with the existing directory. Enter 2 to remove the existing directory. Press any other key to restart git clone: ")
                        if desicion == "1":
                                print("Using the existing directory")
                                break
                        elif desicion == "2":
                                conf=input(f"WARNING: This will remove the existing {dirName} directory. Enter C to proceed. Press any other key to restart git clone: ")
                                if conf == "C":
                                        print("Removing the existing directory")
                                        try:
                                                shutil.rmtree(alreadyEx)
                                        except Exception as e:
                                                print("Existing directory couldn't be removed.",e," Please try again")
                                                continue
                                        clcode=subprocess.run(["git","clone",usingRepo]).returncode
                                        if clcode == 0:
                                                print("Remote Repo successfully cloned.")
                                                break
                                        else:
                                                print("Remote Repo couldn't be cloned. Please try again")
                                                continue
                                else:
                                        continue
                else:
                                clcode=subprocess.run(["git","clone",usingRepo]).returncode
                                if clcode == 0:
                                        print("Remote Repo successfully cloned.")
                                        break
                                else:
                                        print("Remote Repo couldn't be cloned. Please try again")
                                        continue

This looks complex but the logic is very simple. Now we have all the services we need and we also downloaded the files from GitHub. Next, let's verify the files and directory structure.

Directory verification

We just cloned a remote GitHub repo. But the repo could be just repo->banana. There's nothing we can do with a directory like this, so we need to verify the directory compliance. I want to split the verification between server critical configuration files and overall directory structure. Missing server critical files will get a hard exit, but different directory structure will only get a warning. Basically, if we can't find the files to build the web server, we will exit, but if only the directory structure is different, we build the server, and let that off with a warning that the website may not be served correctly. I will also include a option where the user can enter the location of the critical files if I can't find it where it is supposed to be. I will even supply my own configuration files from my GitHub repo if it is appropriate and the user consents. After having the clearer plan, I ran into a problem right off the bat. Two problems to be exact. I don't have the default repo, or the user's custom repo directory name. They only exist in the gitClone() function. I fixed it by moving the default repo address out of the gitClone() function and passed it as an argument. Like this:
def gitClone(defaultRepo):
Then in the gitClone function, I returned the directory name before breaking. Like this:

 return(dirName)
                                                break

Now the function call looks like:

defaultRepo="https://github.com/1SUSHANT1/myProjects.git"
rootDir=gitClone(defaultRepo)

Critical Files

With the default repo and custom directory name accessible, let's move on to check the critical files. The critical files are:
  1. Python App. User must provide this because it is custom to directory structure.
  2. NGINX configuration. User must provide this for the same reason.
  3. Firewall configuration. I can supply this from my GitHub repo if the user doesn't have it.
These are the only critical files to set up the server. However, we will have more critical files when we configure the server cluster. The Expected locations are as follows:
  1. Python App. rootDirectory->myPyApp.py
  2. NGINX configuration. rootDirectory->serverConfiguration->nginx->nginx.conf
  3. Firewall configuration. rootDirectory->serverConfiguration->firewall->nftables.conf
To check this, I am going to declare the directories where the files should be, and look into the directories and try to locate the file. Even if the file doesn't exist, I will still have the directories where the files "should" be so I can move the files if the user provides them. The foundation looks like this:

def verifyDirectory(defaultRepo,rootDir):
        rootDir=Path(rootDir)
        expPyApp=rootDir
        expNgConf=rootDir/"serverConfiguration"/"nginx"
        expFiConf=rootDir/"serverConfiguration"/"firewall"

        if (expPyApp/"myPyScript.py").exists():
                print("Python app found")
        else:
                print("Python app wasn't found")

        if(expNgConf/"nginx.conf").exists():
                print("NGINX conf file found")
        else:
                print("NGINX conf file not found")

        if(expFiConf/"nftables.conf").exists():
                print("Nftables conf file found")
        else:
                print("Nftables conf file not found")

Let's implement the logic which handles the case where the file doesn't exist. We are just going to ask the user if the file exists on the repo they provided and try to locate it. Something like this:

while True:
                if (expPyApp/"myPyScript.py").exists():
                        print("Python app found")
                        return (expPyApp/"myPyScript.py")
                else:
                        print("Python app wasn't found")
                        existence=input("Enter 1 if the repository contains the Python app. Press any other key to exit: ")
                        if existence == "1":
                                loc=input(f"Enter the path to the python app. The path should start from {rootDir}: ") 
                                loc=Path(loc)
                                if loc.exists():
                                        print("Python app located")
                                        return loc
                                else:
                                        print("The file couldn't be located")
                                        cont=input("Enter 1 to try again. Press any other key to exit: ")
                                        if cont == "1":
                                                continue
                                        else:
                                                exit()
                        else:
                                exit()

This can be converted into a function where we can pass:
  1. root directory: To construct the path
  2. file name: To locate the script
  3. service name: To let the user know what files we are searching for
  4. expected directory: To see if the directory structure is correct and files already exist in the correct positions
It looks like this:

def locateFiles(rootDir,file,name,expDir):
        while True:
                if (expDir/file).exists():
                        print(name, "file found")
                        return (expDir/file)
                else:
                        print(name," file wasn't found")
                        existence=input(f"Enter 1 if the repository contains the {name} file. Press any other key to exit: ")
                        if existence == "1":
                                loc=input(f"Enter the path to the {name} file. The path should start from {rootDir}: ") 
                                loc=Path(loc)
                                if loc.exists():
                                        print(name, "file located")
                                        return loc
                                else:
                                        print("The file couldn't be located")
                                        cont=input("Enter 1 to try again. Press any other key to exit: ")
                                        if cont == "1":
                                                continue
                                        else:
                                                exit()
                        else:
                                exit()

It's the same thing as above but now we can use it to get the path to as many number of files as we need. It will be used again when setting up the server cluster. It is called like this:

rootDir=Path(rootDir)
expPyApp=rootDir
expNgConf=rootDir/"serverConfiguration"/"nginx"
expFiConf=rootDir/"serverConfiguration"/"firewall"

actPyLoc=locateFiles(rootDir,"myPyScript.py","Python",expPyApp)
actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
actFiConf=locateFiles(rootDir,"nftables.conf","Firewall", expFiConf)


print("Python file is at: ", actPyLoc)
print("NGINX file is at: ", actNgConf)
print("Firewall file is at: ", actFiConf)

Now we have the actual file location of the mission critical files. But still, what if the files contain: file->banana? Well, there's very little what I can do regarding the content of the files that the users provide but I can check the syntax of the files and see if they can be used where they are supposed to.

Syntax

Now this one is actually easy. We use: python3 to verify the python file, nginx to verify the NGINX file, and nft to verify the firewall file. It looks like this:

subprocess.run(["python3", "-m", "py_compile",actPyLoc])
subprocess.run(["nginx","-t","-c",Path.cwd()/actNgConf])
subprocess.run(["nft", "-c","-f",actFiConf])

Let me give you a hint as to what is happening here:
  1. python3 loads the compiler -module
  2. nginx -tests the -configuration
  3. nft -checks the -file
Also, you can see that I have given nginx the absolute path because it was looking for the file in another path. python3 and nft however, are working fine with the relative path. So are we done with the syntax section? Absolutely not. We need to handle the cases where the files fail the syntax verification. We will use .returncode to check for success or failure, and in case of failure, we give the user options to either provide a valid file or exit. Like this:

while True:
        pc=subprocess.run(["python3", "-m", "py_compile",actPyLoc])
        if pc.returncode != 0:
                print("The python app syntax is invalid")
                choice=input("Enter 1 to provide another file. Press any other key to exit: ")
                if choice == "1":
                        actPyLoc=locateFiles(rootDir,"myPyScript.py","Python",expPyApp)
                        continue
                else:
                        exit()
        else:
                print("The python app syntax is valid")
                break

In another life, I would make you stay.. WHAT? let me try that again. In another world, maybe this piece of code would take the user back to where they would enter the file location and everybody would live happily ever after. But the second argument in locateFiles, "myPyScript.py" always breaks the while loop if the file exists and the user can never enter another file name. I can get around it like this:
actPyLoc=locateFiles(rootDir,"thisFIleShouldntExist.IfThisFileExists,IWillCrashOut.#nonexistentFile","Python",expPyApp)
Now this does work normally, but if I do this, the thought of " what if the 'thisFIleShouldntExist.IfThisFileExists,IWillCrashOut.#nonexistentFile' file does exist? then, again, the user can't enter a new file path" will haunt me for the rest of my life. I absolutely didn't want to touch the locateFiles function because it is so beautiful, so I was looking for a way to exploit the semantics of the Path library and Linux filesystem to provide a input that will cause a problem and lead .exists() to always return false triggering the else block where the users can enter the new file path. I tried inputs like: ".........", "/////////////", "/.//././././////./././", "$%^&6215///////./././" and realized how robust Linux filesystem was. It was resolving everything I threw its away. Defeated, now I should modify the locateFiles function to ask the user if they want to enter another file even if the default file "myPyScript.py" exists. This will create extra steps while using the app but then again, I wished there was a piece of code like "configureServersAndCluster=True" which would automatically configure everything reducing the number of steps to 1. The modified locateFiles function looks like this:

def locateFiles(rootDir,file,name,expDir):
        while True:
                expLoc=expDir/file
                if (expLoc).exists():
                        print(name, "file found at:",expLoc)
                        choice=input(f"Enter 1 if you want to provide a different {name} file. Press any other key to use the default {file} file: ")
                        if choice == "1":
                                loc=input(f"Enter the path to the {name} file. The path should start from {rootDir}: ") 
                                loc=Path(loc)
                                if loc.exists():
                                        print(name, "file located")
                                        return loc
                                else:
                                        print("The file couldn't be located")
                                        cont=input("Enter 1 to try again. Press any other key to exit: ")
                                        if cont == "1":
                                                continue
                                        else:
                                                exit()
                        else:
                                return (expLoc)
                else:
                        print(name," file wasn't found")
                        existence=input(f"Enter 1 if the repository contains the {name} file. Press any other key to exit: ")
                        if existence == "1":
                                loc=input(f"Enter the path to the {name} file. The path should start from {rootDir}: ") 
                                loc=Path(loc)
                                if loc.exists():
                                        print(name, "file located")
                                        return loc
                                else:
                                        print("The file couldn't be located")
                                        cont=input("Enter 1 to try again. Press any other key to exit: ")
                                        if cont == "1":
                                                continue
                                        else:
                                                exit()
                        else:
                                exit()

Copy/Paste? What Copy/Paste? Now the workflow will be:
  1. while True:
  2. check syntax
  3. if syntax is OK, break
  4. if the syntax is bad, direct the user to locateFiles where they can enter a new file path
  5. continue
  6. check syntax
  7. merry go round until the user either gives a valid file, or gives up. (You think Future will sign me?)
The firewall configuration file's syntax is verified the same way as the python file. Let's move on to NGINX. The code looks like this:
while True:
        try:
                shutil.copy(actNgConf,"/etc/nginx/conf.d/")
        except:
                print("The file could not be copied. Please try again. You might want to run the cleanUp.py app if the problem persists")
                choice=input("Enter 1 to provide another file. Press any other key to exit: ")
                if choice == "1":
                        actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
                        continue
                else:
                        exit()
        else:
                print("NGINX file copied to the nginx directory")
                confStr=str(actNgConf)
                fileName=confStr.split("/")[-1]
                print("Filename is: ",fileName)
                filePath=Path("/etc/nginx/conf.d")/fileName
                print("FilePath is: ", filePath)


        nc=subprocess.run(["nginx","-t","-c","/etc/nginx/nginx.conf"])
        if nc.returncode != 0:
                print("The NGINX configuration file is invalid. This file should be removed")
                try:
                        filePath.unlink()
                except:
                        print("file couldn't be removed")
                        choice=input("Enter 1 to provide another file. Press any other key to exit: ")
                        if choice == "1":
                                actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
                                continue
                        else:
                                exit()
                else:
                        print("The file was removed")
                        desicion=input("Enter 1 if you want to provide another file. Press any other key to exit: ")
                        if desicion == "1":
                                actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
                                continue
                        else:
                                exit()

        else:
                print("The NGINX file syntax is valid")
                break

Let me explain what is going on here:
  1. try block. I try to copy the nginx file using shutil.copy().
  2. If copy is successful, I proceed to extract the file name. To do this, I convert the Path object to string object. Split it at "/" and grab the last item on the list. Then I construct the path to where the file is. Why did I do it? stay tuned.
  3. Then I run the syntax test from the subprocess. Test the main file: /etc/nginx/nginx.conf, not the standalone file we copied because the main file includes our file which will also be checked during the test. Testing the standalone server file will always return 1 because the file isn't complete in nginx syntax.
  4. If the test passes, we break
  5. If it fails, we immediately remove the file using the file path I constructed above.
  6. Then we prompt the user to enter another file.
  7. Why remove the failed file? because if a file with incorrect syntax exists on the /etc/nginx/conf.d directory, future tests will always fail. We remove the failed file immediately after we realize the syntax is wrong and save us loads of trouble in the future.
  8. Ask me how I know this. I dare you, I double dare you, I just done had a huge fight with nginx and Python and I don't mind another one.

But I missed something. While running the syntax check on the main file, I realized that nginx configuration might mean the main file to most people. If I put the main file in /conf.d, there will be two main files one nested in the other and it will surely always fail. I need to let the user choose if the file is the main file or the server file and move it to correct places accordingly. Shouldn't take long.

AAAAAAAAAAAAA CUSS PYTHON CUSS NGINX CUSS C++ CUSS COMPUTERS CUSS CODE CUSS PROGRAMMING CUSS LINUX CUSS SERVERS CUSSING EVERYTHING

WHY THE CUSS IS PYTHON SO OBSESSED WITH INDENTS AND WHY IS NGINX DOING WHATEVER THE CUSS IT WANTS

Let me show what I mean. Look at this:


while True:
	whichFile=input("Enter 1 if the NGINX file is the main configuration file. Enter 2 if it is the server configuration file: ")
	if whichFile !="1" and whichFile != "2":
		desicion=input("Invalid input. Enter 1 to try again. Press any other key to exit: ")
		if desicion == "1":
			continue
		else:
			exit()

	try:
		if whichFile == "1":
			try:
				shutil.copy("/etc/nginx/nginx.conf","/etc/nginx/nginxBack.conf")
			except:
				print("Original File couldn't be copied")
				choice=input("Enter 1 to provide another file. Enter 2 to ignore and proceed. Press any other key to exit: ")
				if choice == "1":
					actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
					continue
				elif choice == "2":
					conf=input("This will remove the original configuration file without backup. Enter C to continue. Press any other key to try again")
					if conf == "C":
						print("Proceeding without the creating the backup")
					else:
						continue
				else:
					exit()
			else:
				originalFile=Path("/etc/nginx/nginxBack.conf")
				print("Original file was copied to:",originalFile)
				recoverFlag=True

			shutil.copy(actNgConf,"/etc/nginx/")
		else:
			shutil.copy(actNgConf,"/etc/nginx/conf.d")
	except:
		print("The file could not be copied. Please try again. You might want to run the cleanUp.py app if the problem persists")
		choice=input("Enter 1 to provide another file. Press any other key to exit: ")
		if choice == "1":
			actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
			continue
		else:
			exit()
	else:
		print("NGINX file copied to the nginx directory")
		confStr=str(actNgConf)
		fileName=confStr.split("/")[-1]
		print("Filename is: ",fileName)

		if recoverFlag==False:
			filePath=Path("/etc/nginx/conf.d")/fileName
			print("FilePath is: ", filePath)
		else:
			filePath=Path("/etc/nginx")/fileName
			print("FilePath is: ", filePath)

	nc=subprocess.run(["nginx","-t","-c","/etc/nginx/nginx.conf"])
	if nc.returncode != 0:

		if recoverFlag == False:

			print("The NGINX configuration file is invalid. This file should be removed")
			try:
				filePath.unlink()
			except:
				print("file couldn't be removed")
				choice=input("Enter 1 to provide another file. Press any other key to exit: ")
				if choice == "1":
					actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
					continue
				else:
					exit()
			else:
				print("The file was removed")
				desicion=input("Enter 1 if you want to provide another file. Press any other key to exit: ")
				if desicion == "1":
					actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
					continue
				else:
					exit()

		else:
			print("The NGINX configuration file is invalid")
			desicion=input("Enter 1 to provide another file. Press any other key to recover the original file and exit")
			if desicion == "1":
				actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
				continue
			else:
				try:
					shutil.copy(originalFile,"/etc/nginx/nginx.conf")
				except:
					print("The original file couldn't be recovered")
				else:
					print("Original file was successfully recovered")	
					exit()

	else:
		print("The NGINX file syntax is valid")
		break


You might not be able to picture this properly so I've got pictures.

1. First version: Cute little code, Happiness, Sunshine, and Rainbow

2. Second version: What the cuss is this?

Let me see if I can explain what the cuss is going on here:

This description is intentionally short and dense. I gotta sleep. Goodnight!

Reflection

(Few hours later) Nice day today! And if you have to know, I already apologized to Python and NGINX, and kissed all my servers (even the remote backup server over SSH) so we're good to go. At this point, the files have been located and the syntax has been verified. Now we move the files. And since NGINX very practically taught me the dangers of not keeping backup of the original files, we will create backups for all the files we replace. Also, since NGINX's lesson stuck so hard with me, after this section, we will go back to the cleanup file and extensively work on it so that once it is run, rather than just dumbly removing 3 services, it will also try and recover the files that were replaced with their original backups. This way, the damage if there is any will be minimized. Also, I did find some bugs on the code but we'll come to that after I get over the PTSD.

Moving The Files

First, let's work with the Firewall file. Before moving it, first, we have to understand how nft uses the configuration file. nft can load multiple configuration files, so we will be adding the file to existing rules rather than replacing files. That brings us to one major concern: flush ruleset. If the new file contains flush ruleset, it will delete the existing configuration and load new rules. So the solution is obvious right? just create a backup of the existing files so that you can recover them when you need. It's the same principle but a different approach. Once you load a file, nftables parse the file and add the rules to the kernel's ruleset. However, we don't know which files those rules came from because there's no logs for it. So how do we create a backup? We extract the current rules and keep it as a backup.
sudo nft list ruleset
This gives us all the rules that the kernel currently has. But we can't just directly forward its output to a file and call it a backup. If we do this and later apply it, the backed up rules will be added on top of the new rules. That's not what I want the backup to do. I want the backup to replace the current rules with the rules in the backup file. 2$ for anybody who can tell me how its done (not legally binding or admissible in court. Mwehehehe). The simplest solution is to add "flush ruleset" on top of the rules. So the workflow will be: The code looks like this:

backFire=Path(expFiConf/"backFire.conf")
cuRule=subprocess.run(["nft","list","ruleset"],
capture_output=True,
text=True
).stdout
backFire.write_text(f"flush ruleset\n{cuRule}")

That's it! backup is made. I created a file called backFire.conf. I captured the current nft rules as a text in a variable. In a single command, I wrote "flush ruleset {next line} current rules". So when we load this file into nft, it will remove all the existing rules and the previous rules will go into effect. But wait a minute (here we go), dumbly writing "flush ruleset" is not a good idea because if we do it many times, it will just keep adding the line "flush ruleset" on top of existing "flush ruleset"s and will cause problems. So if you think the solution is to detect "flush ruleset" and handle it accordingly, you may be right but that's not what I'm going to do. The reason I did backFire.write_text(f"flush ruleset\n{cuRule}") is because I like atomic operations, but this one has to be ..unatomic? We change: backFire.write_text(f"flush ruleset\n{cuRule}") to:

backFire.write_text(cuRule)

And when we are performing the recovery, instead of just loading the file, we clear the ruleset first, then we load the file. We are separating the file automatically flushing the ruleset to we flushing the ruleset and loading the file. Slightly less ..atomic but it'll have to do. Now we absolutely don't care if the user provided file has flush ruleset or not: Basically, if anything goes wrong, zap the current ones, recover the old ones. But wait, there may be a case where the old one might be corrupted. If there was a corrupted firewall loaded in the previous run, it will be taken as a trusted backup(not really) in this run. OH MY GOD THIS KEEPS HAPPENING. THE MOMENT I EVEN BEGIN TO THINK I MAY BE DONE, THIS PIECE OF CUSS THROWS SOMETHING ELSE MY WAY. Calm down guys violence is not the solution. We'll just have to detect if a firewall backup already exists. If it does, we recommend the user to not make a new backup because the existing file very well may be the original file which was once made and not modified. So let's make two backups. This one is: backFire.conf, and we make another one called backFireImmutable.conf. The app will never give the user an option to mess with the immutable file. The logic will just be: That's it. No user involvement at all. Matter of fact, let's do this with every backup. Make a directory called immutable. The files on this directory will never be modified or updated once created. Also, make this script not use the immutable files because there is too much going on here. Have the cleanUp app use the immutable files and rename them to immutableOld files once the immutable files have been successfully used for recovery. We never delete immutable files. We store them like: nginxImmutableOld1, nginxImmutableOld2, nginxImmutableOld3,.... , and if the user is very determined, he may crash his computer at nginxImmutableOld1000000000, but that's by design. If you don't like this, go ahead and make your own app and quit bothering me. Again, here is the separation of responsibilities.
  1. Fresh run. DRapp.py creates regular backups and immutable backups.
  2. Subsequent runs. Regular backups may be updated. Immutable backups are not touched.
  3. DRapp.py never uses the immutable backups
  4. In case of a catastrophe, we direct the user to run the cleanUp.py app
  5. cleanUp.py restores the system files from immutable backups because regular backups may be corrupted
  6. cleanUp.py moves immutable files to immutableOld files so that new immutable files can be created again on the fresh run.
  7. Nothing accesses the immutableOld files, not even the cleanup app. If the cleanup app is run more than once, it will not replace any files because immutable files have been converted to immutableOld files which are the files no app will access.
  8. Only the user can use and perform recovery from the immutableOld files
  9. No app will ever delete an immutable file. The cleanup app can only rename them and that's it.
I don't know what I hit my head with but I do think I came up with something useful here. I am a bit conflicted as to the location of the immutable files but I'll just use the home directory for now. I put this code right after the user is verified to be root:

home=Path.home()
if (home/"immutables").exists():
        print("Immutables directory already exists")
else:
        try:
                (home/"immutables").mkdir()
        except:
                print("immutables directory could not be created. The app will exit")
                exit()
        else: 
                print("Immutables directory was created")

Let's finish the firewall section and we'll move to the cleanup app. This is how I check for the immutable firewall file:

 writeToImmutableFile=False
        if (immutables/"immutableFire.conf").exists():
                print("Immutable firewall file exists")
        else:
                print("Immutable firewall file doesn't exist. Creating now")
                immutableFirewall=immutables/"immutableFire.conf"       
                try:
                        immutableFirewall.touch()
                except:
                        print("Couldn't create the Immutable firewall file")
                        choice=input("Enter 1 to try again. Enter 2 to continue without creating the immutable file. Press any other key to exit")
                        if choice == "1":
                                continue
                        elif choice == "2": 
                                print("Continuing without creating the immutable file")
                        else:
                                exit()
                else:
                        print("Immutable firewall file was created")
                        writeToImmutableFile=True

if writeToImmutableFile == True:
                try:
                        immutableFirewall.write_text(cuRule)
                except:
                        print("Couldn't write to the Immutable firewall file")
                        choice=input("Enter 1 to try again. Enter 2 to continue without writing to the immutable file. Press any other key to exit")
                        if choice == "1":
                                continue
                        elif choice == "2": 
                                print("Continuing without writing to the immutable file")
                        else:
                                exit()
                else:
                        print("Immutable firewall backup created")



It's a shame that I hadn't thought about backups and fail safes and all of this bullcuss when I thought about this project. I thought it was just going to be:
  1. download files
  2. move files
  3. done
But even though this is only about halfway through phase 1, it has already gotten to:
  1. Download files/ Download custom files
  2. Verify Files
  3. Check Syntax. Differently for each file
  4. Make regular backups
  5. Make immutable backups
  6. Move files
  7. Have a cleanUp app
  8. Try to restore the original machine state from immutable files
  9. Still not done. Still have to do:
Well, let's get back to the firewall file. We have already created both backups. Now let's just load it into nft. We are not going to implement recovery here the way we did with NGINX. For NGINX, we immediately know when the file fails syntax check and we have to remove it. But for the firewall configuration file, the file has already passed a syntax check. Let's just load it to nft. Recovery is the cleanUp app's job. Here it is:

loadFirewall=subprocess.run(["nft","-f",actFiConf])
        fireCode=loadFirewall.returncode
        if fireCode == 0:
                print("The firewall configuration file was correctly loaded into nft")
                break
        else:
                print("The firewall configuration file wasn't loaded into nft")
                choice=input("Enter 1 to try again. Press any other use the existing firewall rules")
                if choice =="1":
                        continue
                else:
                        print("Using the existing firewall rules")
                        break

We load the file into nft from the subprocess and handle the exit code. Nothing else to see here. Let's go back to NGINX and fix the bug I found.

Back to NGINX

Right here is the problematic part:

print("The NGINX configuration file is invalid")
                        desicion=input("Enter 1 to provide another file. Press any other key to recover the original file and exit")
                        if desicion == "1":
                                actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
                                continue
                        else:
                                try:
                                        shutil.copy(originalFile,"/etc/nginx/nginx.conf")
                                except:
                                        print("The original file couldn't be recovered")
                                else:
                                        print("Original file was successfully recovered")       
                                        exit()

If the user enters 1, I send him back to locate a new file. That's okay but I didn't remove the problematic file. This lead to problems but being a non-hasty guy that I am, here is what I did to recover.

sushant@sushant:/tmp$ sudo apt download nginx-common
Get:1 http://deb.debian.org/debian-security trixie-security/main amd64 nginx-common all 1.26.3-3+deb13u9 [112 kB]
Fetched 112 kB in 0s (2,148 kB/s)      
Warning: Download is performed unsandboxed as root as file '/tmp/nginx-common_1.26.3-3+deb13u9_all.deb' couldn't be accessed by user '_apt'. - pkgAcquire::Run (13: Permission denied)
sushant@sushant:/tmp$ ls
nginx-common_1.26.3-3+deb13u9_all.deb  
sushant@sushant:/tmp$ sudo dpkg-deb -x nginx-common_1.26.3-3+deb13u9_all.deb .
sushant@sushant:/tmp$ ls
etc              var              nginx-common_1.26.3-3+deb13u9_all.deb              usr
sushant@sushant:/tmp$ sudo mv etc/nginx/nginx.conf /etc/nginx/

I just downloaded nginx-common, extracted it and move the fresh file to my /etc/nginx directory. Didn't crash out at all. But let's get back to the solution of the bug. The solution is the rule of thumb I created which was to handle the file immediately after it fails syntax text. Like this:

 print("The NGINX configuration file is invalid")
print("The NGINX configuration file is invalid")
                        try:
                                shutil.copy(originalFile,"/etc/nginx/nginx.conf")
                        except:
                                print("The original file couldn't be recovered. This may cause problems.")
                        else:
                                print("Original file was successfully recovered")       

                        desicion=input("Enter 1 to provide another file. Press any other key to exit: ")
                        if desicion == "1":
                                actNgConf=locateFiles(rootDir,"nginx.conf","NGINX", expNgConf)
                                continue
                        else:
                                exit()

Now, the moment the file fails the syntax check, it is immediately replaced by the original file before proceeding. Also, there are a lot of times where I did something like: But let's get NGINX to also make an immutable file first. Server file won't get a immutable file because we are not replacing anything. We are just adding a new file and removing if it is bad. Let'd create a immutable file for the main configuration file. Here is how I did it:
 
else:
                print("The NGINX file syntax is valid")
                
        if (immutables/"immutableNGINX.conf").exists():
                        print("Immutable NGINX file exists")
        else:
                print("Immutable NGINX  file doesn't exist. Creating now")
                immutableNGINX=immutables/"immutableNGINX.conf"
                try:
                        shutil.copy("/etc/nginx/nginx.conf",immutableNGINX)
                except:
                        print("Couldn't create the Immutable NGINX file")
                        choice=input("Enter 1 to try again. Enter 2 to continue without creating the immutable file. Press any other key to exit")
                        if choice == "1":
                                continue
                        elif choice == "2": 
                                print("Continuing without creating the immutable file")
                        else:
                                exit()
                else:
                        print("Immutable NGINX file was created")
        break

You can see that I checked for the immutable file and create the file AFTER the file's syntax has been verified. This is better from the firewall file where I create a file in one section and write to it on another. But they both work, so I'll leave them be for now.

Python App

I initially thought I was going to have to move the python file too but I realized, with custom repositories, there is no way for me to know where the file is supposed to operate from and how the routes are set up. So the python app will run from whatever directory it is currently it is in. For that reason, the python app doesn't get a backup because we are never going to remove or move it. It will just operate from whatever directory it is in.

CleanUp app

At first, my plan was to have the main app write a json to a file which would be read by the cleanUp app and cleaning would be done accordingly. But currently, I think we can decide what to do just by looking around. We do remove all the services that we installed. But for the file recovery, we check for the existence of the immutable files. If they exist, we use them for recovery and rename them. If they don't exist or have been renamed, either the user chose to proceed without the immutable files, or nothing has altered so no recovery is needed. First thing I did was add the root check on the cleanUp app because it too requires root privileges. Then I modified the existing code to:

def remService(service):
        subprocess.run(["apt", "remove","-y", service])
        subprocess.run(["apt", "purge", "-y", service])

remService("nginx")
remService("gunicorn")
remService("python3-flask")
remService("git")

subprocess.run(["apt","-y","autoremove"])

This is a small modification to the existing setup where I add git removal and run autoremove only once after all the services have been removed. Then here come the fun part, immutable files. Here is how I recover the firewall from the immutableFire file.

home=Path.home()
immutables=Path.home()/"immutables"
if immutables.exists():
        print("Immutables folder exists")
        immutableFire=immutables/"immutableFire.conf"

        if immutableFire.exists():
                print("immutableFIre here")
                subprocess.run(["nft","flush","ruleset"])
                loadFire=subprocess.run(["nft","-f",immutableFire])
                fireCode=loadFire.returncode
                if fireCode == 0:
                        print("Firewall loaded")
                        #shutil.move(immutableFire,immutables/"immutableFireOld")
                        subprocess.run(["mv","--backup=numbered",immutableFire,immutables/"immutableFireOld"])
                else:
                        print("Firewall couldn't be loaded. Immutable file is left unchanged")
        else:
                print("immutable fire doesnt exist. skipping")

Here is what's happening: And here is how I handled NGINX file:

immutableNGINX=immutables/"immutableNGINX.conf"
        if immutableNGINX.exists():
                print("immutableNGINX here")
                try:
                        shutil.copy2(immutableNGINX,"/etc/nginx/nginx.conf")
                except:
                        print("immutableNGINX file couldn't be used for recovery. The file is left unchanged")
                else:
                        print("immutableNGINX file was used to perform recovery")
                        subprocess.run(["mv","--backup=numbered",immutableNGINX,immutables/"immutableNGINXOld"])
                        #shutil.move(immutableNGINX,immutables/"immutableNGINXOld")
        else:
                print("immutableNginx doesn't exist. skipping")

This is similar to the firewall file but instead of loading it, I move it to the /etc/nginx directory and name it "nginx.conf". Then if the copy is successful, I rename this file too so it can't be used next time the app runs. Like I mentioned before, immutable files are never deleted and old immutable files are not touched by my apps. Only the user can perform manual recovery from the files.

Testing

Now that we have all the services, all the files , and appropriate NGINX and firewall rules in place, let's test it. Apache will listen on port 80 by default so if we just start it, we should be able to see something on the local network. We are not going to worry about https and port 443 right now because when we get the certificates, certbot will automatically configure it for us. Let's start nginx by:

startNGINX=subprocess.run(["systemctl", "start","nginx"])

That's it! Now if I plug in the IP address of the server on my browser, I should be able to see something. First, let me show you the working of the code on a fresh machine:

sushant@sushant:~$ sudo python3 DRapp.py 
[sudo] password for sushant: 
Immutables directory was created
Installing:                     
  nginx

---------------------------SNIP---------------------------
nginx successfully installed.
Installing:                     
  gunicorn
---------------------------SNIP---------------------------
gunicorn successfully installed.
Installing:                     
  python3-flask
---------------------------SNIP---------------------------
python3-flask successfully installed.
Installing:                     
  git
---------------------------SNIP---------------------------
git successfully installed.
This is the default repo: https://github.com/1SUSHANT1/myProjects.git
Enter 1 to use the default repo. Enter 2 to use a custom repo. Enter any other key to exit: 1
myProjects
Cloning into 'myProjects'...
remote: Enumerating objects: 503, done.
remote: Counting objects: 100% (197/197), done.
remote: Compressing objects: 100% (154/154), done.
remote: Total 503 (delta 38), reused 167 (delta 32), pack-reused 306 (from 2)
Receiving objects: 100% (503/503), 83.73 MiB | 33.91 MiB/s, done.
Resolving deltas: 100% (45/45), done.
Remote Repo successfully cloned.
Python file found at: myProjects/myPyScript.py
Enter 1 if you want to provide a different Python file. Press any other key to use the default myPyScript.py file: 2
NGINX file found at: myProjects/serverConfiguration/nginx/nginx.conf
Enter 1 if you want to provide a different NGINX file. Press any other key to use the default nginx.conf file: 2
Firewall file found at: myProjects/serverConfiguration/firewall/nftables.conf
Enter 1 if you want to provide a different Firewall file. Press any other key to use the default nftables.conf file: 2
Python file is at:  myProjects/myPyScript.py
NGINX file is at:  myProjects/serverConfiguration/nginx/nginx.conf
Firewall file is at:  myProjects/serverConfiguration/firewall/nftables.conf
The python app syntax is valid
The Firewall file syntax is valid
Enter 1 if the NGINX file is the main configuration file. Enter 2 if it is the server configuration file: 2
NGINX file copied to the nginx directory
Filename is:  nginx.conf
FilePath is:  /etc/nginx/conf.d/nginx.conf
2026/09/24 01:28:29 [warn] 5693#5693: conflicting server name "sushantadk.com" on 0.0.0.0:80, ignored
2026/09/24 01:28:29 [warn] 5693#5693: conflicting server name "www.sushantadk.com" on 0.0.0.0:80, ignored
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
The NGINX file syntax is valid
Immutable firewall file doesn't exist. Creating now
Immutable firewall file was created
Immutable firewall backup created
The firewall configuration file was correctly loaded into nft
NGINX started

Okay it took about 45 seconds and it already installed all the services, cloned the remote repo, located all the files, verified the syntax, made immutable backups and started nginx. Let's run the server's local IP address on a browser and see what happens.

YEEEEEE it works! we got a 403 Forbidden from nginx. From the terrific guy Sushant's server setup guide, I quote "Success! (kinda). Now we are getting 403 forbidden error instead of 404 not found. I am sure it is because of my user permissions. I will recursively add execute permissions to all the files on my website root directory for others, and try again." So all we have to do now is recursively add execute permissions and we should be good to go. Except, I can't follow his instruction exactly because my home directory looks like this:


drwx------  7 sushant sushant 4096 Sep 24 01:28 sushant

So I will have to add read and execute permissions on my home directory as well. Me being crafty as I am, I did this:

curDir=Path.cwd()
curDir=str(curDir)
homeDir=curDir.split("/")
strHome="/"+homeDir[1]+"/"+homeDir[2]
pathHome=Path(strHome)

subprocess.run(["chmod","o+rx",pathHome])
subprocess.run(["chmod","-R","755",rootDir])

Since I am running the code with sudo/root, the native python commands kept giving me location to root's home directory so I used cwd() which I know gives the directory where the process is running. Then I did some crafty parsing to create a path to the home directory and added read and execute permissions to others there. Then I also recursively added read and execute permissions for everyone in the directory pulled from GitHub. Security professionals might be losing their minds right now but we're going to ignore them for now. If I run the IP address again, nginx should return the correct webpage. Let's try it.

Yayyyyyyy!! it works! Now we can see my actual website being served on the local network. This is amazing! no way it took me like 45 seconds to rebuild this entire server. First time I did it, it took me like 4-5 days.

Now let me show you how the cleanUp app works:

sushant@sushant:~$ sudo python3 cleanUp.py 
[sudo] password for sushant: 
REMOVING:                       
  nginx

---------------------------SNIP---------------------------

REMOVING:
  gunicorn

---------------------------SNIP---------------------------

REMOVING:
  python3-flask

---------------------------SNIP---------------------------

REMOVING:
  git

---------------------------SNIP---------------------------

(Reading database ... 58182 files and directories currently installed.)
Purging configuration files for git (1:2.47.3-0+deb13u1) ...
REMOVING:                       
  git-man        python-babel-localedata  python3-babel    python3-click   python3-gunicorn      python3-jinja2      python3-packaging   python3-pyinotify
  liberror-perl  python3-asgiref          python3-blinker  python3-dotenv  python3-itsdangerous  python3-markupsafe  python3-pyasyncore  python3-werkzeug

Summary:
  Upgrading: 0, Installing: 0, Removing: 16, Not Upgrading: 51
  Freed space: 36.7 MB

---------------------------SNIP---------------------------

Immutables folder exists
immutableFIre here
Firewall loaded
immutableNginx doesn't exist. skipping
sushant@sushant:~$ 

And just like that, the server is ...unbuilt?

Port Forwarding and HTTPS Certificate

Now let's get the user to set the port forwarding. First, lets acquire the current IP address of the machine. Generally, I use ip addr to see the current IP address but that command produces an output so big that it is very difficult to parse. So we will be using the command hostname -I. This produces an output like this: 192.168.1.208 172.17.0.1 2600:xxxxxxxxxx:cdbd . The first one is the machine's IPv4 address. So we can run the command from the subprocess, capture the output and parse it. Something like this:


ipad=subprocess.run(["hostname","-I"],
capture_output=True,
text=True
).stdout;
ipad=ipad.split()[0]
print(ipad)

Capture the output, split on whitespaces and grab the first item. Now we ask the user to set port forwarding to this address. Verifying if the port forwarding was done correctly without having an access to the router itself is not easy but it is still very much possible. During this phase tho, I'll skip the verification and trust the user. We'll get to that later.

HTTPS Certificate

I will use certbot to get the HTTPS certificates. The instructions can be found here: https://certbot.eff.org/instructions?ws=nginx&os=pip Basically, you have to run these commands:


subprocess.run(["apt", "install", "python3", "python3-dev", "python3-venv", "libaugeas-dev", "gcc"])
subprocess.run(["python3", "-m", "venv", "/opt/certbot/"])
subprocess.run(["/opt/certbot/bin/pip", "install", "certbot", "certbot-nginx"])
subprocess.run(["/opt/certbot/bin/certbot","--nginx"])

There is a lot going on but this is what the documentation says so we stick to it. And when I run the app now, IT WORKS? THAT WAS IT? GUYS I DON'T THINK YOU'RE GOING TO BELIEVE THIS BUT IT IS PUBLICLY REACHABLE FROM THE INTERNET OVER HTTPS!

I don't know why this Sushant guy did all this: https://www.sushantadk.com/SupportingDocuments/buildingAServer.html, wasting his time when he could've just run this script.

Let's put verification around these subprocesses before we move on. Here are the functions.


def certPrep():
        result=0
        a=subprocess.run(["apt", "install", "python3", "python3-dev", "python3-venv", "libaugeas-dev", "gcc"])
        result=result+a.returncode
        b=subprocess.run(["python3", "-m", "venv", "/opt/certbot/"])
        result=result+b.returncode
        c=subprocess.run(["/opt/certbot/bin/pip", "install", "certbot", "certbot-nginx"])
        result=result+c.returncode

        return result


def installCert():
        a=subprocess.run(["/opt/certbot/bin/certbot","--nginx"])
        if a.returncode!=0:
                print("Certificate installation has failed")
                choice=input("Enter 1 to try again. Press any other key to skip: ")
                if choice == "1":
                        installCert()

You can see that in the certPrep() function, instead of returning after a subprocess fails, I began collecting the fails. Good news, at one time I had achieved 100% failure. Plus this doesn't have any negative impact (I think) because a completed process will be skipped in the next run so it stays. And these are called like this:


while True:
        result=certPrep()
        if result != 0:
                print(f"Some certbot dependent actions have failed")
                choice=input("Enter 1 to try again. Press any other key to skip certificate installation: ")
                if choice =="1":
                        continue
                else:
                        break
        else:
                installCert()
                break


Checkpoint

The app now does the following automatically:

The user does provide some input but it's usually just entering either 1 or 2 assuming the directory structure, file names and syntax are correct. The time for it to finish building the server will differ depending on computer and internet, but I am proud to tell you that on my 2015 MacBook Air, it takes about 1 minute and 30 seconds to finish building a HTTPS certified publicly reachable web server from scratch.

Groups and permissions

The previous quick method of adding read and execute permissions to others and recursively applying "755" permissions to the whole directory was fragile was unsafe. Fragile because if the user was running the app one directory deeper, the permissions wouldn't work and "755" permissions is too open for the website root directory. So now, let's fix them by doing the following:

Not only is this more secure, this is the setup I use on my main server. Execute permissions to all the files may not be necessary, but the app can't distinguish which files need to be executed from the ones which don't so this is a small compromise.


def addGroup(group):
        gA=subprocess.run(["groupadd",group])
        if gA.returncode==9:
                return 0
        else:
                return gA.returncode


def addToGroup(user,group):
        aN=subprocess.run(["groupmod","-aU",user,group])
        return aN.returncode

curDir=Path.cwd()
curDir=str(curDir)
traverseThis=curDir.split("/")
traverseThis.pop(0)

group="webManagers"
nginx="www-data"

if addGroup(group)==0:
        print("Group Added")
        if addToGroup(nginx,group)==0:
                print(f"{nginx} added to {group}")
        else:
                print(f"{nginx} couldn't be added to {group}")

        constructPath=""
        firstDir=True
        for dir in traverseThis:
                constructPath=constructPath+"/"+dir
                print(f"Dir is: {constructPath}")
                if firstDir==True:
                        firstDir=False
                else:
                        subprocess.run(["chgrp","webManagers",constructPath])
                        subprocess.run(["chmod","g+x",constructPath])
        subprocess.run(["chgrp","-R","webManagers",rootDir])
        subprocess.run(["chmod","-R","g+rx",rootDir])

else:
        print("Group wasn't added")

Couple things that jump out are that exit code 9 from "groupadd" is treated as exit code 0, it is because code 9 means that the group already exists, which very likely means that the app was run before and the group was created in the previous run so it is treated as success.

And there is things like traverseThis.pop(0) and firstDir=True. These are used to avoid changing permissions in the directories "/" and "/home". The only recursive changes are changing the owning group and owning group permissions to the website root directory.

I noticed that NGINX was running as the user "www-data". Then I also installed apache2 to see what user it would run as and it also ran as the user "www-data". My previous experiences were: NGINX running as the user "nginx" and apache2 running as the user "apache". Because of this knowledge, the app will now also perform user detection to add the correct user to the group. Not now tho.. we'll skip that for now.

Updates to the configuration files

Since I use users with different names on my servers, files kept running into "directory not found issue". For instance, the nginx file I use on my backup server traverses directory like this: root /home/susie/myProjects/templates but the experiment machine doesn't have the "susie" directory so it runs into an issue then I'll have to go back and fix the name. So I am changing all the configuration files to use the directory "/home/user/...." so not only me but anyone who runs this app using the "home/user" directory will not have any "directory not found" issues.

A sophisticated user may use his own directory with his custom files in which case this will still work because the directory on the file will point to his directory. The main idea is that the directories the configuration files point to should exist. And the standard approach would be to run the app with default repository and default user where one can easily configure a fleet of servers.

Gunicorn and Flask

Gunicorn and Flask are the one of the final pieces of the puzzle. The good thing is, Flask doesn't need to be separately configured and Gunicorn is pretty easy to set up. Gunicorn file is located the same way other 3 were, and by the way there were actually 4 critical files including gunicorn file. The syntax is verified like this:


while True:
        checkFile=Path("/tmp/gunicorn.service")
        shutil.copy(actGuLoc,checkFile)
        verifyGunicorn=subprocess.run(["systemd-analyze","verify",checkFile])
        checkFile.unlink()

        if verifyGunicorn.returncode==0:
                try:
                        shutil.copy(actGuLoc,"/etc/systemd/system/gunicorn.service")
                except:
                        print("Gunicorn systemd service couldn't be created")
                else:
                        print("Gunicorn systemd service created")
        else:
                print("Gunicorn configuration file failed syntax check")
                choice=input("Enter 1 to provide another file. Press any other key to skip")
                if choice=="1":
                        actGuLoc=locateFiles(rootDir,"gunicorn","Gunicorn",expGuConf)
                        continue
                else:
                        break

We move the file to the /tmp directory, name it gunicorn.service, run the syntax check and store the exit message. Then we remove the copy and extract the return code. If it was success, we copy the file to /etc/systemd/system/, name it gunicorn.service which will be used to configure gunicorn using systemd.

Then we also add a user which runs the Gunicorn service. Like this:

gUser=input("Enter 1 to enter a custom gunicorn user. Press any other key to use the default \"gunicorn\" user: ")
if gUser=="1":
        gunicorn=input("Enter the name of the gunicorn user: ")
else:
        gunicorn="gunicorn"

addGUser=subprocess.run(["useradd","-mr",gunicorn])
if addGUser.returncode==0 or addGUser.returncode==9:
        print("Gunicorn user created")
else:
        print("Failed to user Gunicorn user")

Notice the line addGUser=subprocess.run(["useradd","-mr",gunicorn]). If you think this is saying "Useradd Mister Gunicorn" you'd be totally wrong. The m and r flag are used to configure it as a system service with a home directory. Then we add the user to the webManagers group and start it just like we start NGINX.

Since we have already verified that the python file exists, Gunicorn should automatically run the file considering the content of Gunicorn configuration file was correct.

Since we are not checking the content because that would require a gigantic parser, we are just verifying the syntax and moving the files to the intended locations. But I can confirm that with a correct Gunicorn configuration file and the python app, the website will now also serve the dynamic content.

Startup

Now let's configure NGINX and Gunicorn to automatically run at boot. It's just two commands:


def startup(service):
        sT=subprocess.run(["systemctl", "enable", service])
        if sT.returncode==0:
                print(f"{service} was successfully enabled")
        else:
                print(f"{service} couldn't be enabled")

startup("gunicorn")
startup("nginx")

Well, not exactly two commands but this is it. Now even if you reboot your machine, the website will be served at startup, before even logging in to the machine.

Now I am done following that guy Sushant's server setup guide but something tells me that we're not done yet. What am I missing? Oh is it OPERATING SYSTEM DETECTION?

Operating System Detection

Yep you heard that correctly, next, I am integrating OS detection on the app. This script, as it is only works on a Debian server. Although my backup server runs Debian, my production server runs Alpine Linux. For that reason, the app will be modified to recognize an Alpine server and configure it the Alpine way, the right way if you may(did he hit his head with something?). Plus, since Debian and Ubuntu are similar, I might also add Ubuntu but Alpine and Debian will be my main goal.

Side note: I currently don't have a spare Alpine server to develop and verify as we go. Development will be done with an Alpine server in reference but testing will only be done after the development has finished.

Detection

We are going to have to watch out for things like package repositories, directory structures, different services, different commands, etc. But the app is not going to be like: if debian: this, elif alpine, that, ........ on every line. Instead it will be modified to perform the detection and determine what it is going to do for the rest of the runtime. Let's start with the detection. We use the file /etc/os-release to get the info about a Linux OS. But that file contains so much more than what I actually need, so we are going to have to do some parsing. Here is the contents of the file:


root@sushant:/home/user# cat /etc/os-release 
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
-----------snip-----------------
ID=debian
---------------snip-----------------

I am only interested in the ID part so the way I am going to parse it is by splitting every line at "=". If the first value I get is "ID", I simply store the second value which will be the operating system. Very simple, something like:


OS=""
with open ("/etc/os-release") as file:
        for line in file:
                idField=line.strip().split("=")
                if idField[0]=="ID":
                        OS=idField[1]
                        break

Immutable directory creation process should be same on both so let's move on to installThis() function. Debian uses apt, but alpine uses apk, so the function will be modified from: def installThis(service) to installThis(service,OS). Nothing too crazy, something like this:

Package repositories


def installThis(service,OS):
        while True:
                if OS=="debian":
                        code=(subprocess.run(["apt", "install", "-y", service])).returncode
                elif OS=="alpine":
                        code=(subprocess.run(["apk","add",service])).returncode
                else:
                        print("Operating system not supported. Fatal. Abort")
                        exit()

This should install the services on both the servers correctly. Let's move on.

Cloning the GitHub repo, expected file locations, actual file locations, and python file syntax check should be the same on both distributions. Let's move to the firewall.

Firewall

The firewall name on Debian is "nftables.conf", but on Alpine, it is "nftables.nft". Luckily, since both are managed by nft, everything else should be the same. (P.S: Future Sushant might come and beat me with a stick if this doesn't work). I created a new section:


if OS=="debian":
        firewallFile="nftables.conf"
elif OS=="alpine":
        firewallFile="nftables.nft"

As I move forward, I will add more stuff here so we can just use them in the app without having to worry about which OS it is every time. After the update, this line was changed: actFiConf=locateFiles(rootDir,firewallFile,"Firewall", expFiConf), and after some minor substitutions on the firewall loading section, it is behaving correctly on Debian. We'll have to do tests on an actual Alpine server but we do know that Debian is detected properly and firewall file verified and loaded properly.

NGINX

NGINX is pretty simple too. The major difference here is in the file path, not the file names. Both distributions do use the file nginx.conf and even the main configuration path and file are same at: etc/nginx/nginx.conf. For the server file however, the path on Debian is: /etc/nginx/conf.d/nginx.conf, but the path on Alpine is:/etc/nginx/http.d/nginx.conf. Here is the logic, the previous code was updated to:


if OS=="debian":
        firewallFile="nftables.conf"
        nginxSFLoc="/etc/nginx/conf.d"
elif OS=="alpine":
        firewallFile="nftables.nft"
        nginxSFLoc="/etc/nginx/http.d"

Then I walked around substituting every "/etc/nginx/conf.d" with nginxSFLoc and it worked correctly on Debian. I didn't like that, I was expecting "kaboom", but whatever.

Gunicorn

Now this one is interesting. The service file for Gunicorn has different content, it is in different paths, and it had different names on both distributions since it is run directly by the OS service manager. On Alpine, it is run by open-rc at /etc/init.d/gunicorn, and on Debian, it is run by systemd at: /etc/systemd/system/gunicorn.service plus, the content is different, so the provided repository should have distribution specific files for gunicorn. And, just remembered, syntax check is different on different servers. Because there is so much difference, I will create different functions to handle it rather than modifying the one I currently have. Here are the changes:


-------------------snip---------------
elif OS=="alpine":
        gunicornFileName="gunicornAlpine"
---------------snip--------------
actGuLoc=locateFiles(rootDir,gunicornFileName,"Gunicorn",expGuConf)
------------------------snip-----------------
if OS=="debian":
        gunicornDeb(actGuLoc,rootDir,expGuConf)
elif OS=="alpine":
        gunicornAlp(actGuLoc,rootDir,expGuConf)

So now the locate function will try to locate distribution specific gunicorn files. gunicornDebian for Debian and gunicornAlpine for Alpine. If they don't exist, then the user can still the provide the file path so we are fine there. And the new function looks like:

def gunicornAlp(actGuLoc,rootDir,expGuConf):
        while True:
                checkFile=Path("/tmp/gunicorn")
                shutil.copy(actGuLoc,checkFile)
                verifyGunicorn=subprocess.run(["sh","-n",checkFile])
                checkFile.unlink()

                if verifyGunicorn.returncode==0:
                        try:
                                shutil.copy(actGuLoc,"/etc/init.d/gunicorn")
                        except:
                                print("Gunicorn systemd service couldn't be created")
                                break
                        else:
                                print("Gunicorn systemd service created")
                                break
                else:
                        print("Gunicorn configuration file failed shell syntax check")
                        choice==input("Enter 1 to ignore and proceed. Press any other key to provide another file")
                        if input=="1":
                                try:
                                        shutil.copy(actGuLoc,"/etc/init.d/gunicorn")
                                except:
                                        print("Gunicorn systemd service couldn't be created")
                                        break
                                else:
                                        print("Gunicorn systemd service created")
                                        break
                        else:
                                actGuLoc=locateFiles(rootDir,"gunicornAlpine","Gunicorn",expGuConf)
                                continue

I know someone is complaining about the little copy paste there but we're going to ignore them forever. They more important things to notice are that the file syntax is being checked by shell and there is a option to proceed even if the check fails. This is because open-rc doesn't have something equivalent to systemd-analyze on systemd, so the app just checks if the file is a valid shell file. Since open-rc init files are generally shell, it should work but if someone passes a python file or an executable, since they can still work but I can't check them, the users can ignore the gunicorn syntax check and proceed.

Users

Let's address the problem with NGINX user. I probably could hard code the usernames and get away with it but getting the username which is currently running NGINX might be a better idea. After some research, I found out the python library which could do exactly that; psutil. I use psutil to get info about the running processes, find the service name "nginx", see if it is a worker process, (because the master process is root and that's not what we need) and catch the worker's username. Something like this:


nginx=""
for process in psutil.process_iter(["name", "username", "cmdline"]):
        info = process.info
        cmdline = " ".join(info["cmdline"] or [])

        if info["name"] == "nginx" and "worker process" in cmdline:
                nginx = info["username"]
                break
else:
        if OS=="debian":
                nginx="www-data"
        elif OS=="alpine":
                nginx="nginx"

This does have a fail-safe where if psutil fails for whatever reason, we proceed with standard known usernames for each distributions. And this made me realize something because I ran into python module not found error. It means that the python modules I am importing:


import subprocess
import getpass
from pathlib import Path
import shutil
import psutil

These are the dependencies of my app, so before importing these, I have to detect the OS, install them, and only then import them. Sounds like tomorrow's problem tho ngl.

Next, the process for adding users, groups, running groupmod, and getting HTTPS certificates should be same on both distributions. So let's move on to using the appropriate system manager to run the services.

System Manager Starts Services

The previous logic was replaced by this function:


def startSv(service,OS):
        if OS=="debian":
                sR=subprocess.run(["systemctl", "start",service])
                sT=subprocess.run(["systemctl", "enable", service])
        elif OS=="alpine":
                sR=subprocess.run(["rc-service",service,"start"])
                sT=subprocess.run(["rc-update","add",service,"default"])
        if sR.returncode==0:
                print(f"{service} was successfully started")
        else:
                print(f"{service} couldn't be started")
        if sT.returncode==0:
                print(f"{service} was successfully enabled")
        else:
                print(f"{service} couldn't be enabled")

It runs the service from the appropriate system manager, and also adds the services to startup.

But before I declare phase 1 complete, we have something remaining.

Remaining: Python Libraries and Port Forwarding detection and Testing on Alpine server