From ece500c342b6acb78b8c5f6a524c57cb69977244 Mon Sep 17 00:00:00 2001 From: Paulo Truta Date: Mon, 20 Mar 2023 00:54:42 +0100 Subject: [PATCH] Cloudflare Tunnel Support (#29) * Adding necessary changes for tunnel setup supporting cloudflared * Applied shwrap pattern to login, create and delete tunnel actions, many fixes * Refactoring sesh 1 * Added utils, remaining tunnel tasks, cleanup --- .gitignore | 3 + Makefile | 9 +- internal/system/system.go | 201 +++++++++++++++++++++++++++ internal/tasks/tasks.go | 175 +++++++++++++++++++++-- internal/utils/utils.go | 44 ++++++ scripts/cloudflared_login.sh | 6 + scripts/cloudflared_tunnel_create.sh | 7 + scripts/cloudflared_tunnel_delete.sh | 7 + 8 files changed, 434 insertions(+), 18 deletions(-) create mode 100644 scripts/cloudflared_login.sh create mode 100755 scripts/cloudflared_tunnel_create.sh create mode 100755 scripts/cloudflared_tunnel_delete.sh diff --git a/.gitignore b/.gitignore index d571598..964bc3f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ main # Build /bin + +# Logs from executed scripts +/scripts/output.log diff --git a/Makefile b/Makefile index f8be198..c09a58b 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,7 @@ test-with-coverage: go test -tags=unit -timeout=600s -v ./... -coverprofile=coverage.out install-cloud: build-cloud + systemctl stop edgeboxctl cp ./bin/edgeboxctl /usr/local/bin/edgeboxctl cp ./edgeboxctl/edgeboxctl.service /lib/systemd/system/edgeboxctl.service systemctl daemon-reload @@ -42,8 +43,10 @@ install-cloud: build-cloud @echo "To start edgeboxctl run: systemctl start edgeboxctl" install-prod: build-prod - cp ./bin/edgeboxctl /usr/local/bin/edgeboxctl - cp ./edgeboxctl/edgeboxctl.service /lib/systemd/system/edgeboxctl.service - systemctl daemon-reload + -sudo systemctl stop edgeboxctl + sudo rm -rf /usr/local/bin/edgeboxctl /lib/systemd/system/edgeboxctl.service + sudo cp ./bin/edgeboxctl /usr/local/bin/edgeboxctl + sudo cp ./edgeboxctl.service /lib/systemd/system/edgeboxctl.service + sudo systemctl daemon-reload @echo "Edgeboxctl installed successfully" @echo "To start edgeboxctl run: systemctl start edgeboxctl" \ No newline at end of file diff --git a/internal/system/system.go b/internal/system/system.go index a8684e8..95a6538 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -4,6 +4,13 @@ import ( "fmt" "strconv" "strings" + "log" + "os" + "os/exec" + "bufio" + "path/filepath" + "io/ioutil" + "encoding/json" "github.com/edgebox-iot/edgeboxctl/internal/utils" @@ -11,6 +18,12 @@ import ( "github.com/shirou/gopsutil/host" ) +type cloudflaredTunnelJson struct { + AccountTag string `json:"AccountTag"` + TunnelSecret string `json:"TunnelSecret"` + TunnelID string `json:"TunnelID"` +} + // GetUptimeInSeconds: Returns a value (as string) of the total system uptime func GetUptimeInSeconds() string { uptime, _ := host.Uptime() @@ -85,3 +98,191 @@ func SetupCloudOptions() { // In the end of this operation takes place, remove the env file as to not overwrite any options once they are set. utils.Exec("/", "rm", []string{cloudEnvFileLocationPath}) } + +// StartService: Starts a service +func StartService(serviceID string) { + wsPath := utils.GetPath(utils.WsPath) + fmt.Println("Starting" + serviceID + "service") + cmdargs := []string{"start", serviceID} + utils.Exec(wsPath, "systemctl", cmdargs) +} + +// StopService: Stops a service +func StopService(serviceID string) { + wsPath := utils.GetPath(utils.WsPath) + fmt.Println("Stopping" + serviceID + "service") + cmdargs := []string{"stop", "cloudflared"} + utils.Exec(wsPath, "systemctl", cmdargs) +} + +// RestartService: Restarts a service +func RestartService(serviceID string) { + wsPath := utils.GetPath(utils.WsPath) + fmt.Println("Restarting" + serviceID + "service") + cmdargs := []string{"restart", serviceID} + utils.Exec(wsPath, "systemctl", cmdargs) +} + +// GetServiceStatus: Returns the status output of a service +func GetServiceStatus(serviceID string) string { + wsPath := utils.GetPath(utils.WsPath) + cmdargs := []string{"status", serviceID} + return utils.Exec(wsPath, "systemctl", cmdargs) +} + +// CreateTunnel: Creates a tunnel via cloudflared, needs to be authenticated first +func CreateTunnel(configDestination string) { + fmt.Println("Creating Tunnel for Edgebox.") + cmd := exec.Command("sh", "/home/system/components/edgeboxctl/scripts/cloudflared_tunnel_create.sh") + stdout, err := cmd.StdoutPipe() + if err != nil { + panic(err) + } + scanner := bufio.NewScanner(stdout) + err = cmd.Start() + if err != nil { + panic(err) + } + for scanner.Scan() { + fmt.Println(scanner.Text()) + text := scanner.Text() + fmt.Println(text) + } + if scanner.Err() != nil { + cmd.Process.Kill() + cmd.Wait() + panic(scanner.Err()) + } + + // This also needs to be executed in root and non root variants + fmt.Println("Reading cloudflared folder to get the JSON file.") + isRoot := false + dir := "/home/system/.cloudflared/" + dir2 := "/root/.cloudflared/" + files, err := os.ReadDir(dir) + if err != nil { + panic(err) + } + + var jsonFile os.DirEntry + for _, file := range files { + // check if file has json extension + if filepath.Ext(file.Name()) == ".json" { + fmt.Println("Non-Root JSON file found: " + file.Name()) + jsonFile = file + } + } + + // If the files are not in the home folder, try the root folder + if jsonFile == nil { + files, err = os.ReadDir(dir2) + if err != nil { + panic(err) + } + for _, file := range files { + // check if file has json extension + if filepath.Ext(file.Name()) == ".json" { + fmt.Println("Root JSON file found: " + file.Name()) + jsonFile = file + isRoot = true + } + } + } + + if jsonFile == nil { + panic("No JSON file found in directory") + } + + fmt.Println("Reading JSON file.") + targetDir := "/home/system/.cloudflared/" + if isRoot { + targetDir = "/root/.cloudflared/" + } + + jsonFilePath := filepath.Join(targetDir, jsonFile.Name()) + jsonBytes, err := ioutil.ReadFile(jsonFilePath) + if err != nil { + panic(err) + } + + fmt.Println("Parsing JSON file.") + var data cloudflaredTunnelJson + err = json.Unmarshal(jsonBytes, &data) + if err != nil { + log.Printf("Error reading tunnel JSON file: %s", err) + } + + fmt.Println("Tunnel ID is:" + data.TunnelID) + + // create the config.yml file with the following content in each line: + // "url": "http://localhost:80" + // "tunnel": "" + // "credentials-file": "/root/.cloudflared/.json" + + file := configDestination + f, err := os.Create(file) + if err != nil { + panic(err) + } + + defer f.Close() + + _, err = f.WriteString("url: http://localhost:80\ntunnel: " + data.TunnelID + "\ncredentials-file: " + jsonFilePath) + + if err != nil { + panic(err) + } +} + +// DeleteTunnel: Deletes a tunnel via cloudflared, this does not remove the service +func DeleteTunnel() { + fmt.Println("Deleting possible previous tunnel.") + + // Configure the service and start it + cmd := exec.Command("sh", "/home/system/components/edgeboxctl/scripts/cloudflared_tunnel_delete.sh") + stdout, err := cmd.StdoutPipe() + if err != nil { + panic(err) + } + scanner := bufio.NewScanner(stdout) + err = cmd.Start() + if err != nil { + panic(err) + } + for scanner.Scan() { + fmt.Println(scanner.Text()) + text := scanner.Text() + fmt.Println(text) + } + if scanner.Err() != nil { + cmd.Process.Kill() + cmd.Wait() + panic(scanner.Err()) + } +} + +// InstallTunnelService: Installs the tunnel service +func InstallTunnelService(config string) { + fmt.Println("Installing cloudflared service.") + cmd := exec.Command("cloudflared", "--config", config, "service", "install") + cmd.Start() + cmd.Wait() +} + +// RemoveTunnelService: Removes the tunnel service +func RemoveTunnelService() { + wsPath := utils.GetPath(utils.WsPath) + fmt.Println("Removing possibly previous service install.") + cmd := exec.Command("cloudflared", "service", "uninstall") + cmd.Start() + cmd.Wait() + + fmt.Println("Removing cloudflared files") + cmdargs := []string{"-rf", "/home/system/.cloudflared"} + utils.Exec(wsPath, "rm", cmdargs) + cmdargs = []string{"-rf", "/etc/cloudflared/config.yml"} + utils.Exec(wsPath, "rm", cmdargs) + cmdargs = []string{"-rf", "/root/.cloudflared/cert.pem"} + utils.Exec(wsPath, "rm", cmdargs) +} + diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go index 779017e..dcf3dd9 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -7,12 +7,17 @@ import ( "log" "strconv" "time" + "os/exec" + "strings" + "os" + "bufio" "github.com/edgebox-iot/edgeboxctl/internal/diagnostics" "github.com/edgebox-iot/edgeboxctl/internal/edgeapps" "github.com/edgebox-iot/edgeboxctl/internal/storage" "github.com/edgebox-iot/edgeboxctl/internal/system" "github.com/edgebox-iot/edgeboxctl/internal/utils" + _ "github.com/go-sql-driver/mysql" // Mysql Driver _ "github.com/mattn/go-sqlite3" // SQlite Driver ) @@ -29,10 +34,7 @@ type Task struct { } type taskSetupTunnelArgs struct { - BootnodeAddress string `json:"bootnode_address"` - BootnodeToken string `json:"bootnode_token"` - AssignedAddress string `json:"assigned_address"` - NodeName string `json:"node_name"` + DomainName string `json:"domain_name"` } type taskStartEdgeAppArgs struct { @@ -64,6 +66,7 @@ type taskEnablePublicDashboardArgs struct { InternetURL string `json:"internet_url"` } + const STATUS_CREATED int = 0 const STATUS_EXECUTING int = 1 const STATUS_FINISHED int = 2 @@ -134,16 +137,36 @@ func ExecuteTask(task Task) Task { switch task.Task { case "setup_tunnel": - log.Println("Setting up bootnode connection...") + log.Println("Setting up Cloudflare Tunnel...") var args taskSetupTunnelArgs err := json.Unmarshal([]byte(task.Args.String), &args) if err != nil { - log.Printf("Error reading arguments of setup_bootnode task: %s", err) + log.Printf("Error reading arguments of setup_tunnel task: %s", err) + status := "{\"status\": \"error\", \"message\": \"The Domain Name you are going to Authorize must be provided beforehand! Please insert a domain name and try again.\"}" + utils.WriteOption("TUNNEL_STATUS", status) } else { taskResult := taskSetupTunnel(args) task.Result = sql.NullString{String: taskResult, Valid: true} } + case "start_tunnel": + + log.Println("Starting Cloudflare Tunnel...") + taskResult := taskStartTunnel() + task.Result = sql.NullString{String: taskResult, Valid: true} + + case "stop_tunnel": + + log.Println("Stopping Cloudflare Tunnel...") + taskResult := taskStopTunnel() + task.Result = sql.NullString{String: taskResult, Valid: true} + + case "disable_tunnel": + + log.Println("Disabling Cloudflare Tunnel...") + taskResult := taskDisableTunnel() + task.Result = sql.NullString{String: taskResult, Valid: true} + case "install_edgeapp": log.Println("Installing EdgeApp...") @@ -328,19 +351,141 @@ func ExecuteSchedules(tick int) { func taskSetupTunnel(args taskSetupTunnelArgs) string { fmt.Println("Executing taskSetupTunnel") + wsPath := utils.GetPath(utils.WsPath) - wsPath := utils.GetPath(utils.WsPath) - cmdargs := []string{"gen", "--name", args.NodeName, "--token", args.BootnodeToken, args.BootnodeAddress + ":8655", "--prefix", args.AssignedAddress} - utils.Exec(wsPath, "tinc-boot", cmdargs) + // Stop a the service if it is running + system.StopService("cloudflared") - cmdargs = []string{"start", "tinc@dnet"} - utils.Exec(wsPath, "systemctl", cmdargs) + // Uninstall the service if it is installed + system.RemoveTunnelService() - cmdargs = []string{"enable", "tinc@dnet"} - utils.Exec(wsPath, "systemctl", cmdargs) + fmt.Println("Creating cloudflared folder") + cmdargs := []string{"/home/system/.cloudflared"} + utils.Exec(wsPath, "mkdir", cmdargs) - output := "OK" // Better check / logging of command execution result. - return output + cmd := exec.Command("sh", "/home/system/components/edgeboxctl/scripts/cloudflared_login.sh") + stdout, err := cmd.StdoutPipe() + if err != nil { + panic(err) + } + scanner := bufio.NewScanner(stdout) + err = cmd.Start() + if err != nil { + panic(err) + } + url := "" + for scanner.Scan() { + fmt.Println(scanner.Text()) + text := scanner.Text() + if strings.Contains(text, "https://") { + url = text + fmt.Println("Tunnel setup is requesting auth with URL: " + url) + status := "{\"status\": \"waiting\", \"login_link\": \"" + url + "\"}" + utils.WriteOption("TUNNEL_STATUS", status) + break + } + } + if scanner.Err() != nil { + cmd.Process.Kill() + cmd.Wait() + panic(scanner.Err()) + } + + go func() { + fmt.Println("Running async") + cmd.Wait() + + // Keep retrying to read cert.pem file until it is created + // When running as a service, the cert is saved to a different folder, + // so we check both :) + for { + _, err := os.Stat("/home/system/.cloudflared/cert.pem") + _, err2 := os.Stat("/root/.cloudflared/cert.pem") + if err == nil || err2 == nil { + fmt.Println("cert.pem file detected") + break + } + time.Sleep(1 * time.Second) + fmt.Println("Waiting for cert.pem file to be created") + } + + fmt.Println("Tunnel auth setup finished without errors.") + status := "{\"status\": \"starting\", \"login_link\": \"" + url + "\"}" + utils.WriteOption("TUNNEL_STATUS", status) + + // Remove old tunnel if it exists, and create from scratch + system.DeleteTunnel() + + // Create new tunnel (destination config file is param) + system.CreateTunnel("/home/system/.cloudflared/config.yml") + + fmt.Println("Creating DNS Routes for @ and *.") + cmd = exec.Command("cloudflared", "tunnel", "route", "dns", "-f" ,"edgebox", "*." + args.DomainName) + cmd.Start() + err = cmd.Wait() + if err != nil { + log.Fatal(err) + } + + cmd = exec.Command("cloudflared", "tunnel", "route", "dns", "-f" ,"edgebox", args.DomainName) + cmd.Start() + err = cmd.Wait() + if err != nil { + log.Fatal(err) + } + + domainNameInfo := args.DomainName + utils.WriteOption("DOMAIN_NAME", domainNameInfo) + + // Install service with given config file + system.InstallTunnelService("/home/system/.cloudflared/config.yml") + + // Start the service + system.StartService("cloudflared") + + if err != nil { + fmt.Println("Tunnel auth setup finished with errors.") + status := "{\"status\": \"error\", \"login_link\": \"" + url + "\"}" + utils.WriteOption("TUNNEL_STATUS", status) + log.Fatal(err) + } else { + fmt.Println("Tunnel auth setup finished without errors.") + status := "{\"status\": \"connected\", \"login_link\": \"" + url + "\", \"domain\": \"" + args.DomainName + "\"}" + utils.WriteOption("TUNNEL_STATUS", status) + } + + fmt.Println("Finished running async") + }() + + return "{\"url\": \"" + url + "\"}" +} + +func taskStartTunnel() string { + fmt.Println("Executing taskStartTunnel") + system.StartService("cloudflared") + domainName := utils.ReadOption("DOMAIN_NAME") + status := "{\"status\": \"connected\", \"domain\": \"" + domainName + "\"}" + utils.WriteOption("TUNNEL_STATUS", status) + return "{\"status\": \"ok\"}" +} + +func taskStopTunnel() string { + fmt.Println("Executing taskStopTunnel") + system.StopService("cloudflared") + domainName := utils.ReadOption("DOMAIN_NAME") + status := "{\"status\": \"stopped\", \"domain\": \"" + domainName + "\"}" + utils.WriteOption("TUNNEL_STATUS", status) + return "{\"status\": \"ok\"}" +} + +func taskDisableTunnel() string { + fmt.Println("Executing taskDisableTunnel") + system.StopService("cloudflared") + system.DeleteTunnel() + system.RemoveTunnelService() + utils.DeleteOption("DOMAIN_NAME") + utils.DeleteOption("TUNNEL_STATUS") + return "{\"status\": \"ok\"}" } func taskInstallEdgeApp(args taskInstallEdgeAppArgs) string { diff --git a/internal/utils/utils.go b/internal/utils/utils.go index e35ac41..e56b7a7 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -192,3 +192,47 @@ func WriteOption(optionKey string, optionValue string) { db.Close() } + +// ReadOption : Reads a key value pair option from the api shared database +func ReadOption(optionKey string) string { + + db, err := sql.Open("sqlite3", GetSQLiteDbConnectionDetails()) + + if err != nil { + log.Fatal(err.Error()) + } + + var optionValue string + + err = db.QueryRow("SELECT value FROM option WHERE name = ?", optionKey).Scan(&optionValue) + + if err != nil { + log.Fatal(err.Error()) + } + + db.Close() + + return optionValue +} + +// DeleteOption : Deletes a key value pair option from the api shared database +func DeleteOption(optionKey string) { + + db, err := sql.Open("sqlite3", GetSQLiteDbConnectionDetails()) + + if err != nil { + log.Fatal(err.Error()) + } + + statement, err := db.Prepare("DELETE FROM option WHERE name = ?;") // Prepare SQL Statement + if err != nil { + log.Fatal(err.Error()) + } + + _, err = statement.Exec(optionKey) // Execute SQL Statement + if err != nil { + log.Fatal(err.Error()) + } + + db.Close() +} diff --git a/scripts/cloudflared_login.sh b/scripts/cloudflared_login.sh new file mode 100644 index 0000000..12e985e --- /dev/null +++ b/scripts/cloudflared_login.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh + +echo "Starting script login" +cloudflared tunnel login 2>&1 | tee /home/system/components/edgeboxctl/scripts/output.log & +echo "sleeping 5 seconds" +sleep 5 \ No newline at end of file diff --git a/scripts/cloudflared_tunnel_create.sh b/scripts/cloudflared_tunnel_create.sh new file mode 100755 index 0000000..097d6b0 --- /dev/null +++ b/scripts/cloudflared_tunnel_create.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh + +echo "Starting script create" +# script -q -c "cloudflared tunnel login 2>&1 | tee /app/output.log" & +cloudflared tunnel create edgebox 2>&1 | tee /home/system/components/edgeboxctl/scripts/output.log +echo "sleeping 5 seconds" +sleep 5 \ No newline at end of file diff --git a/scripts/cloudflared_tunnel_delete.sh b/scripts/cloudflared_tunnel_delete.sh new file mode 100755 index 0000000..5a237bb --- /dev/null +++ b/scripts/cloudflared_tunnel_delete.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env sh + +echo "Starting script delete" +TUNNEL_ORIGIN_CERT=/home/system/.cloudflared/cert.pem +cloudflared tunnel delete edgebox 2>&1 | tee /home/system/components/edgeboxctl/scripts/output.log & +echo "sleeping 5 seconds" +sleep 5 \ No newline at end of file