This is some useful Java FX Code I use in a project using IntelliJ IDEA and jdk1.8.0_161.jdk. I am sharing this code here for the #100DaysOfCode readers and people looking for code answers.
Aside
If you have not read my previous posts I have now moved my blog to the awesome UpCloud host (signup using this link to get $25 free UpCloud VM credit). I compared Digital Ocean, Vultr and UpCloud Disk IO here and UpCloud came out on top by a long way (read the blog post here). Here is my blog post on moving from Vultr to UpCloud.
Buy a domain name here
As promised, Java code.
Background
I am developing a Java (Windows/Mac/Linux) JavaFX app to assist me (and others) click their way through a Server deployment (domain name purchase, email setup, DNS etc) to the configuration (server host, operating, web server, database etc) to securing (DNSSEC, SPF, DMARC, Firewall etc).
I want to automate much of what I have learned over the last 2 years building apps and services (see all blog DevSecOps posts here: https://fearby.com/all/ ) on a self-managed Linux stack. I love the lower cost, higher security and flexibility of deploying self-managed servers on Linux (I love UpCloud) but not the command line interactions (that’s why I am learning Java, I do not want to develop another web-based GUI as I find them less secure than a good SSH connection). I have been developing on Windows since 1999.
Now show me Jave code.
Save date-time variables in Controller Initialize event to use in Logging
I like to log console events to a filename that is the date-time an app was started. Add this to “public class Controller implements Initializable”
public Date todaysDateTime = new Date(); DateFormat datetimeFileNameCompatible = new SimpleDateFormat("dd-MMM-yyyy HH-mm-ss.S"); public String strLogFileTimeFilename = "zAPPDISCO - " + datetimeFileNameCompatible.format(todaysDateTime);
Now the log filename can be used below in a log function (DoLog).
Read Ini File
Download http://ini4j.sourceforge.net/ (binary files) and add ini4j-0.5.4.jar to your source folder (or use Maven etc to download the package)
Import the ini4j class to your Controller.
import org.ini4j.Ini;
Sample appname.ini file
# Ini file comment goes here [config] forceoffline = false log = true [settings] checkipatstartup = true checkinternetconnectionatstartup = true [app] name=blah blah app name app_major = 1 appminor = 0 apprevision = 18 [mainwindow] mainwidth = 1619.0 mainheight = 1119.0 mainleft = 147.0 maintop = 44.0 [settingswindow] settingswidth = 1286.0 settingsheight = 1010.0 settingsleft = 9.0 settingstop = 153.0
Load an Ini file
objIni = new Ini(); System.out.println(" - Opening app.ini" ); try { objIni.load( new FileReader("src/appname/appname.ini")); System.out.println(" - Loaded INI (OK)" ); } catch (IOException ex) { System.out.println(" - Error Loading appname.ini ( " + ex.toString() + ")"); }
Now we are ready to load values from the loaded Ini file.
Load a Boolean from Ini file
I like to set if an app can talk to the net (by checking an Ini file to see if a user does not want online activity).
private Boolean FORCE_OFFLINE = false; FORCE_OFFLINE = objIni.get("config","forceoffline",boolean.class);
Load String from an Ini File
System.out.println (" - App Name: " + objIni.get("app","name"));
Global Variables Class
package appname; public class GlobalVariables { public int APP_MAJOR = 0; public int APP_MINOR = 0; public int APP_REVISION = 18; //etc }
Copy the global variables class in a new controller, add the following code to your new controller’s “public class Controller implements Initializable” function
GlobalVariables global = new GlobalVariables();
Read the constants in the global variable class (copy)
DoLog("Started App Disco (v" + global.APP_MAJOR + "." + global.APP_MINOR + "." + global.APP_REVISION + ") - https://fearby.com");
Log function that writes to the stdout console and physical log file if desired (set in the ini file)
private void DoLog(String logThis) { // Log to std out System.out.println(logThis); // Write to log file try{ // Declare a new Ini object objIni = new Ini(); System.out.println(" - Opening appname.ini" ); try { objIni.load( new FileReader("src/appname/appname.ini")); System.out.println(" - Loaded INI (OK)" ); } catch (IOException ex) { // Error System.out.println(" - Error Reading from appname.ini ( " + ex.toString() + ")"); } // Check the ini file and see if the user wants to out output a log file. System.out.println(" - Reading [config] section in Ini file" ); Boolean DO_LOG; DO_LOG = objIni.get("config","log",boolean.class); System.out.println(" - DO_LOG = " + DO_LOG.toString() ); // Does the use want the console to be logged to a date stamped file too? if (DO_LOG == true) { // Append To File FileWriter fstream = new FileWriter( strLogFileTimeFilename.toString() + ".log", true); BufferedWriter out = new BufferedWriter(fstream); // Get Latest Time to prefix to the log entry Date todaysDateTimeLog = new Date(); DateFormat datetimeHumanReadableFormat = new SimpleDateFormat("dd/MM/yyyy HH:mm:ss"); String strLogFileTimeHuman = datetimeHumanReadableFormat.format(todaysDateTimeLog); // Log Desired Line String strLogThisTS = strLogFileTimeHuman.toString() + " = " + logThis; out.write(strLogThisTS + "\n"); //Close the output stream out.close(); } else { //System.out.println(" - Skipping logging as set in the ini file.." ); } } catch (Exception e){ //Catch exception if any // todo: Need to write to System.out.printl to prevent loop System.out.println ("Error: " + e.getMessage()); } }
Get Contents from URL
private static String getContentsFromURL(String theUrl) { StringBuilder content = new StringBuilder(); try { // create a url object URL url = new URL(theUrl); //todo: Add Newwor check code URLConnection urlConnection = url.openConnection(); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream())); String line; while ((line = bufferedReader.readLine()) != null) { content.append(line + "\n"); } bufferedReader.close(); } catch(Exception e) { e.printStackTrace(); } return content.toString(); }
Test the internet connection by downloading the contents of a URL
String isup = getContentsFromURL("https://audit.fearby.com/isup/"); System.out.println( "Is UP: " + isup.trim().toUpperCase().toString());
Get the IPV4 address of the calling client
String isup = getContentsFromURL("https://audit.fearby.com/ip/"); System.out.println( "Is UP: " + isup.trim().toUpperCase().toString());
The IP page contains
<?php echo htmlspecialchars($_SERVER['REMOTE_ADDR'], ENT_QUOTES, 'UTF-8'); ?>
SSH to a server and run a command
This is handy for connecting to a server via SSH (and using an SSH passphrase, not a system username password). This is using the Jsch library from http://www.jcraft.com/jsch/
try { JSch jsch = new JSch(); String user = "sshusername"; DoLog("User: " + user); String host = "yourserver.com"; DoLog("Host: " + host); int port = 22; DoLog("Port: " + port); String privateKey = System.getProperty("user.dir") + "/" + "yourserver.rsa"; DoLog("Private Key: " + System.getProperty("user.dir") + "/" + "yourserver.rsa"); String sshPassPhrase = "@dd-y0ur-really-str0ng-ssh-pa$word-h3r3"; DoLog("Adding ssh key passphrase to session"); jsch.addIdentity(privateKey, sshPassPhrase); DoLog("Private Key added to ssh session"); Session session = jsch.getSession(user, host, port); DoLog("Session Created"); java.util.Properties config = new java.util.Properties(); // see http://stackoverflow.com/questions/30178936/jsch-sftp-security-with-session-setconfigstricthostkeychecking-no DoLog("SSH Config: StrictHostKeyChecking: no"); config.put("StrictHostKeyChecking", "no"); session.setConfig(config); DoLog("Connecting"); session.connect(); DoLog("Preparing Remote Command"); String command = "uptime"; Channel channel = session.openChannel("exec"); ((ChannelExec)channel).setCommand(command); channel.setInputStream(null); ((ChannelExec)channel).setErrStream(System.err); InputStream input = channel.getInputStream(); channel.connect(); try { InputStreamReader inputReader = new InputStreamReader(input); BufferedReader bufferedReader = new BufferedReader(inputReader); String line = null; while((line = bufferedReader.readLine()) != null){ System.out.println(line); } bufferedReader.close(); inputReader.close(); } catch (IOException ex) { ex.printStackTrace(); } channel.disconnect(); session.disconnect(); DoLog("SSH session disconnected....."); } catch (Exception e) { System.err.println(e); }
Output:
16:32:06 up 7 days, 14:59, 1 user, load average: 0.08, 0.02, 0.01
Read more on RSA keys here.
Generate an RSA key here.
Upload a file to a server via SCP over SSH
try { JSch jsch = new JSch(); String user = "sshusername"; DoLog("User: " + user); String host = "yourserver.com"; DoLog("Host: " + host); int port = 22; DoLog("Port: " + port); String privateKey = System.getProperty("user.dir") + "/" + "yourserver.rsa"; DoLog("Private Key: " + System.getProperty("user.dir") + "/" + "yourserver.rsa"); String sshPassPhrase = "@dd-y0ur-really-str0ng-ssh-pa$word-h3r3"; DoLog("Adding ssh key passphrase to session"); jsch.addIdentity(privateKey, sshPassPhrase); DoLog("Private Key added to ssh session"); Session session = jsch.getSession(user, host, port); DoLog("Session Created"); java.util.Properties config = new java.util.Properties(); //session.setConfig( // "PreferredAuthentications", // "publickey,gssapi-with-mic,keyboard-interactive,password"); // disabling StrictHostKeyChecking may help to make connection but makes it insecure // see http://stackoverflow.com/questions/30178936/jsch-sftp-security-with-session-setconfigstricthostkeychecking-no DoLog("SSH Config: StrictHostKeyChecking: no"); config.put("StrictHostKeyChecking", "no"); session.setConfig(config); DoLog("Connecting"); session.connect(); DoLog("Preparing SFTP"); Channel channel = session.openChannel("sftp"); channel.setInputStream(System.in); channel.setOutputStream(System.out); channel.connect(); ChannelSftp c = (ChannelSftp) channel; String fileName = "upload.txt"; DoLog("Uplaoding file: " + fileName + " to " + "/"); c.put(fileName, "/"); c.exit(); DoLog("Done Uploading File"); channel.disconnect(); } catch (Exception e) { System.err.println(e); }
On the server:
cat /upload.txt
>Hello World
Encrypt Text (AES/CBC/PKCS5/SHA-256)
public static byte[] encrypt(String strInput, String strPasswordKey) throws Exception { System.out.println("\n\nEncrypt()"); byte[] clean = strInput.getBytes(); // Generating IV int ivSize = 16; byte[] iv = new byte[ivSize]; SecureRandom strRandom = new SecureRandom(); strRandom.nextBytes(iv); IvParameterSpec ivParamSpec = new IvParameterSpec(iv); // Hashing key MessageDigest strDigest = MessageDigest.getInstance("SHA-256"); strDigest.update(strPasswordKey.getBytes("UTF-8")); byte[] keyBytes = new byte[16]; System.arraycopy(strDigest.digest(), 0, keyBytes, 0, keyBytes.length); SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "AES"); // Encrypt Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParamSpec); byte[] encrypted = cipher.doFinal(clean); // Combine IV and encrypted part byte[] encryptedIVAndText = new byte[ivSize + encrypted.length]; System.arraycopy(iv, 0, encryptedIVAndText, 0, ivSize); System.arraycopy(encrypted, 0, encryptedIVAndText, ivSize, encrypted.length); String senctext = new String(encrypted, StandardCharsets.UTF_8); System.out.println(" encrypted: " + senctext.toString() + ", encryptedIVAndText: " + encryptedIVAndText.toString() + ", size: " + ivSize + ", length: " + encrypted.length); return encryptedIVAndText; }
Decrypt Text (AES/CBC/PKCS5/SHA-256)
public static String decrypt(byte[] encryptedIvTextBytes, String strPasswordKey) throws Exception { System.out.println("\n\ndecrypt()"); int ivSize = 16; int keySize = 16; // Extract IV byte[] iv = new byte[ivSize]; System.arraycopy(encryptedIvTextBytes, 0, iv, 0, iv.length); IvParameterSpec ivParamSpec = new IvParameterSpec(iv); // Extract encrypted part int encryptedSize = encryptedIvTextBytes.length - ivSize; byte[] encryptedBytes = new byte[encryptedSize]; System.arraycopy(encryptedIvTextBytes, ivSize, encryptedBytes, 0, encryptedSize); // Hash key byte[] keyBytes = new byte[keySize]; MessageDigest strDigest = MessageDigest.getInstance("SHA-256"); strDigest.update(strPasswordKey.getBytes()); System.arraycopy(strDigest.digest(), 0, keyBytes, 0, keyBytes.length); SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "AES"); // Decrypt Cipher cipherDecrypt = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipherDecrypt.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParamSpec); byte[] decrypted = cipherDecrypt.doFinal(encryptedBytes); return new String(decrypted); }
Input Dialog and Display the Input text in a Dialog
// Input Dialog TextInputDialog dialog = new TextInputDialog("Simon"); dialog.setTitle("Text Input Dialog"); dialog.setHeaderText("Look, a Text Input Dialog"); dialog.setContentText("Please enter your name:"); Optional<String> result = dialog.showAndWait(); System.out.println("Dialog Result: " + result); Alert alertinputdialog = new Alert(AlertType.INFORMATION); alertinputdialog.setTitle("Input Result"); alertinputdialog.setHeaderText(null); if (result.isPresent()) { alertinputdialog.setContentText( "Result: " + result.get().toString()); } else { alertinputdialog.setContentText("Result: "); } alertinputdialog.showAndWait();
Input:
GUI Output:
Standard Message Box
// Standard Message Box Alert alertmessagebox = new Alert(AlertType.INFORMATION); alertmessagebox.setTitle("Base64 (Style 1/2)"); alertmessagebox.setHeaderText(null); alertmessagebox.setContentText("About to convert '" + result.get().toString() + "' to Base64"); alertmessagebox.showAndWait();
Output:
Detailed Message Box
// Detailed Message Box Alert alertwarning = new Alert(AlertType.WARNING); alertwarning.setTitle("Base64 (Style 2/2)"); alertwarning.setHeaderText("About to convert '" + result.get().toString() + "' to Base64"); alertwarning.setContentText("Base64 strings can be reversed"); alertwarning.showAndWait();
GUI Output:
Exception Dialog
//Exception Message Box Alert alertexception = new Alert(AlertType.ERROR); alertexception.setTitle("Exception Dialog"); alertexception.setHeaderText("Look, an Exception Dialog"); alertexception.setContentText("Could not find file blablah.txt!"); Exception ex = new FileNotFoundException("Could not find file blablah.txt"); // Create expandable Exception. StringWriter sw = new StringWriter(); PrintWriter pw = new PrintWriter(sw); ex.printStackTrace(pw); String exceptionText = sw.toString(); Label label = new Label("The exception stacktrace was:"); TextArea textArea = new TextArea(exceptionText); textArea.setEditable(false); textArea.setWrapText(true); textArea.setMaxWidth(Double.MAX_VALUE); textArea.setMaxHeight(Double.MAX_VALUE); GridPane.setVgrow(textArea, Priority.ALWAYS); GridPane.setHgrow(textArea, Priority.ALWAYS); GridPane expContent = new GridPane(); expContent.setMaxWidth(Double.MAX_VALUE); expContent.add(label, 0, 0); expContent.add(textArea, 0, 1); // Set expandable Exception into the dialog pane. alertexception.getDialogPane().setExpandableContent(expContent); alertexception.showAndWait();
GUI Output:
Close Application
Platform.exit(); System.exit(0);
Close Child Windows
- Add Label to the scene (so we can get the parent scene later)
- Declare the label in the Controller initialize function ( “public Label lblTitle;”)
- Close the stage
Stage stage = (Stage) lblTitle.getScene().getWindow(); stage.close();
Handle Listview Item Select
try{ DoLog("Selected Server: " + lvwServers.getSelectionModel().getSelectedItem().toString() ); } catch( java.lang.NullPointerException null_error ) { return; }
Open URL in Browser
try { Desktop.getDesktop().browse(new URI("https://fearby.com")); } catch (IOException e1) { e1.printStackTrace(); } catch (URISyntaxException e1) { e1.printStackTrace(); }
Ask for Input, Add to listview and soft the listview
// Get Parent Stage (use existing label in scene) Stage stage = (Stage) lblTitle.getScene().getWindow(); // Input Dialog TextInputDialog dialog = new TextInputDialog("Apple"); dialog.setTitle("Enter a Fruit"); dialog.setHeaderText("Enter a Fruit"); dialog.initOwner(stage.getScene().getWindow()); dialog.initModality(Modality.APPLICATION_MODAL); dialog.setResizable(Boolean.FALSE); dialog.setContentText("e.g enter a long description here..."); Optional<String> result = dialog.showAndWait(); if (result.isPresent()) { System.out.println( "Fruit: " + result.get()); lvwFruit.getItems().add(result.get()); } else { System.out.println("Result: "); } // Sory Listview lvwFruit.getItems().sorted();
Open FXML Scene File and Modal Child Window
try { FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("childscene.fxml")); Parent root1 = (Parent) fxmlLoader.load(); Stage stage = new Stage(); stage.initModality(Modality.APPLICATION_MODAL); stage.initStyle(StageStyle.UNDECORATED); stage.initStyle((StageStyle.UTILITY)); stage.setTitle("Child Scene Window Title"); stage.setScene(new Scene(root1)); stage.show(); } catch(IOException e) { e.printStackTrace(); }
Base64 Encode and Decode
// Base 64 String sString = result.get().toString(); System.out.println("String sString: " + sString); try { byte[] sbString = sString.getBytes("UTF-8"); System.out.println("Byte Array - sbString: " + sbString); for(byte b : sbString){ System.out.println(b); } } catch (Exception ex2) { ex2.printStackTrace(); } String sbbase64EncodedString = Base64.getEncoder().encodeToString(sString.getBytes()); System.out.println("Base64 Encoded - base64EncodedString: " + sbbase64EncodedString); byte[] sbbase64DecodedString = Base64.getMimeDecoder().decode(sbbase64EncodedString); String sbase64DecodedString = new String(sbbase64DecodedString); System.out.println("Base64 Decoded - sbase64DecodedString: " + sbase64DecodedString); // Message Box Result of Base64 Alert alertresult = new Alert(AlertType.INFORMATION); alertresult.setTitle("Base64"); alertresult.setHeaderText("Input String: " + sString); alertresult.setContentText("Base64 Encoded: " + sbbase64EncodedString + "\nDecoded Base64: " + sbase64DecodedString); alertresult.showAndWait();
CLI Output:
> Base64 Encoded – base64EncodedString: U2ltb24=
> Base64 Decoded – sbase64DecodedString: Simon
GUI Output:
OK and Cancel Confirmation Box
// MessageBox with Buttons Alert alert = new Alert(AlertType.CONFIRMATION); alert.setTitle("Confirmation Dialog"); alert.setHeaderText("Look, a Confirmation Dialog"); alert.setContentText("Are you ok with this?"); Optional<ButtonType> result2 = alert.showAndWait(); if (result2.get() == ButtonType.OK){ System.out.println("OK Clicked"); } else { System.out.println("Other Clicked"); }
GUI Output:
Confirmation Box with four options
// Messagebox with many buttons Alert alertmessaegmanyoptions = new Alert(AlertType.CONFIRMATION); alertmessaegmanyoptions.setTitle("Confirmation Dialog with Custom Actions"); alertmessaegmanyoptions.setHeaderText("Look, a Confirmation Dialog with Custom Actions"); alertmessaegmanyoptions.setContentText("Choose your option."); ButtonType buttonTypeOne = new ButtonType("Ubuntu", ButtonData.HELP_2); ButtonType buttonTypeTwo = new ButtonType("Debian"); ButtonType buttonTypeThree = new ButtonType("Windows"); ButtonType buttonTypeCancel = new ButtonType("Cancel", ButtonData.CANCEL_CLOSE); alert.getButtonTypes().setAll(buttonTypeOne, buttonTypeTwo, buttonTypeThree, buttonTypeCancel); Optional<ButtonType> result3 = alert.showAndWait(); if (result3.get() == buttonTypeOne){ System.out.println("Ubuntu Clicked"); } else if (result3.get() == buttonTypeTwo) { System.out.println("Debian Clicked"); } else if (result3.get() == buttonTypeThree) { System.out.println("Windows Clicked"); } else if (result3.get() == buttonTypeCancel) { System.out.println("Cancel Clicked"); } else { System.out.println("Nothing Clicked"); }
GUI Output:
Encrypt, Base64, AES/CBC/SHA-256 Encryption and Decrypt
fyi: Encrypt and Decrypt functions are above.
String sInputString = result.get().toString(); String sInputStringBas64 = Base64.getEncoder().encodeToString(sInputString.getBytes()); String sEncryptionKey = "pa$w1rd1"; try { byte[] encrypted = encrypt(sInputStringBas64, sEncryptionKey); String sEncryptedText = new String(encrypted, StandardCharsets.UTF_8); System.out.println(sEncryptedText); try { String sDecryptedText = decrypt(encrypted, sEncryptionKey); System.out.println("decrypted: " + sDecryptedText); byte[] sbbase64EncryptedDecodedString = Base64.getMimeDecoder().decode(sDecryptedText); String sbase64EncryptedDecodedString = new String(sbbase64EncryptedDecodedString); // Encryption Message Box Alert encryptionmessagebox = new Alert(AlertType.INFORMATION); encryptionmessagebox.setTitle("Encryption"); encryptionmessagebox.setHeaderText(null); encryptionmessagebox.setContentText("Input String:\n" + sInputString + "\n\n Input as Base64:\n " + sInputStringBas64 + "\n\n Encryption Key:\n " + sEncryptionKey + "\n\n Encryption Method:\n AES/CBC/PKCS5Padding SHA-256 IV" + "\n\n Encrypted Text:\n " + sEncryptedText + "\n\n Decrypted Text:\n " + sDecryptedText + "\n\nDecoded Base64:\n" + sbase64EncryptedDecodedString ); encryptionmessagebox.showAndWait(); } catch (Exception ee) { ee.printStackTrace(); } } catch (Exception ex2) { ex2.printStackTrace(); }
GUI Output:
Credits
Encryption Help: https://proandroiddev.com/security-best-practices-symmetric-encryption-with-aes-in-java-7616beaaade9
Dialogue Help: https://code.makery.ch/blog/javafx-dialogs-official/
General JavaFX Help: http://tutorials.jenkov.com/javafx/index.html
Ini File Help: Simple Java API Windows style .ini file handling. Also provide Java Preferences API
functionality on top of .ini file. https://sourceforge.net/projects/ini4j/
SSH JScH Help – JSch is a pure Java implementation of SSH2: http://www.jcraft.com/jsch/
JSch 0.0.* was released under the GNU LGPL license. Later, we have switched
over to a BSD-style license.
——————————————————————————
Copyright (c) 2002-2015 Atsuhiko Yamanaka, JCraft,Inc.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the distribution.
3. The names of the authors may not be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESSED OR IMPLIED WARRANTIES,
INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL JCRAFT,
INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
——————————————————————————
——————————————————————————
I hope this guide helps someone.
Please consider using my referral code and get $25 UpCloud VM credit if you need to create a server online.
https://www.upcloud.com/register/?promo=D84793
Ask a question or recommend an article
[contact-form-7 id=”30″ title=”Ask a Question”]
Revision History
v1.0 Initial Post