From 8be7f90c34f1d2a3163a78bf2dc23bb51d176772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Freitas?= <1160907@isep.ipp.pt> Date: Thu, 5 Dec 2024 15:45:55 +0000 Subject: [PATCH] refactor: bootstrap configuration values if .env file does not exist (#2) * refactor: bootstrap node configuration values if .env cannot be lookup * refactor: bootstrap master configuration values if .env cannot be lookup * test: create pub/pem key files before tests run * fix: use Listen instead of Dial to connect sub server * chore: add initialization scripts for windows environments * refactor: remove init program * fix: remove init args from homepage code snippets * fix: dont return if .env fails before checking config dir * chore: add debug print informing that .env file couldn't be lookup * docs: reference bolt db env key as required --- .env.tpl | 2 +- .github/get-master | 18 -- .github/get-master.bat | 100 +++++++++ .github/get-node | 18 -- .github/get-node.bat | 110 +++++----- .github/workflows/release.yml | 1 - README.md | 4 +- TODO.md | 5 +- cmd/init/init.go | 230 -------------------- cmd/master/master.go | 51 +++-- cmd/node/node.go | 34 ++- docs/env.md | 2 +- internal/bootstrap/boostrap.go | 89 ++++++++ internal/bootstrap/helper.go | 57 +++++ internal/data/db/db-bolt/connection.go | 19 -- internal/data/db/db-bolt/connection_test.go | 14 -- internal/env/env.go | 122 ++++++++++- internal/http/connection.go | 29 +-- internal/http/handler+dashboard.go | 5 +- internal/http/handler+util.go | 6 + internal/http/virtual-host.go | 11 +- internal/mq/authentication.go | 23 -- internal/mq/connection.go | 35 +-- internal/mq/crypto.go | 68 ++---- internal/mq/crypto_int_test.go | 101 +++++++++ internal/mq/crypto_test.go | 28 --- internal/mq/pub.go | 16 +- public/index.gohtml | 9 +- 28 files changed, 645 insertions(+), 562 deletions(-) create mode 100644 .github/get-master.bat delete mode 100644 cmd/init/init.go create mode 100644 internal/bootstrap/boostrap.go create mode 100644 internal/bootstrap/helper.go delete mode 100644 internal/data/db/db-bolt/connection.go delete mode 100644 internal/data/db/db-bolt/connection_test.go create mode 100644 internal/mq/crypto_int_test.go diff --git a/.env.tpl b/.env.tpl index be72d47..171d306 100644 --- a/.env.tpl +++ b/.env.tpl @@ -13,7 +13,7 @@ server_virtual_host=/ mq_sub_host=0.0.0.0 mq_sub_port=36113 -## If these variables aren't filled, pem/pub files are lookup on configuration directory +## Supply public/private keys for encrypting message queue mq_transport_pem_key= mq_transport_pub_key= diff --git a/.github/get-master b/.github/get-master index e104267..5b23849 100755 --- a/.github/get-master +++ b/.github/get-master @@ -1,8 +1,5 @@ #!/usr/bin/env bash -# Save program arguments to later pass on init binary. -init_args="$@" - # Common urls. new_issue_url="https://github.com/guackamolly/zero-monitor/issues/new" latest_release_url="https://api.github.com/repos/guackamolly/zero-monitor/releases/latest" @@ -11,7 +8,6 @@ jq_release_url="https://github.com/jqlang/jq/releases/download/jq-1.7.1" # Installation directory and program paths. install_dir="$HOME/.config/zero-monitor" bin_path="$install_dir/master" -init_bin_path="$install_dir/init" jq_bin_path="$install_dir/jq" if [ ! -d "$install_dir" ]; then @@ -28,10 +24,6 @@ jq() { } exec_bin() { - if [ ! -z "$init_args" ]; then - "$init_bin_path" $init_args - fi - exec "$bin_path" } @@ -79,20 +71,10 @@ if [ -z "$latest_release_version" ]; then fatal "Failed to extract release version, please raise an issue to alert maintainers about this bug.\n$new_issue_url" fi -# If local init binary does not exist and no arguments have been passed, then bootstrap master. -if [[ ! -f "$init_bin_path" && -z "$init_args" ]]; then - init_args="master" -fi - # If local target binary version is different than the latest release version, download it again. if ! [[ -f "$bin_path" && "$latest_release_version" != "$($bin_path -version)" ]]; then download "$(echo $response | jq -r '.assets[] | select(.name == "master_'${os}'_'${arch}'") | .browser_download_url')" fi -# If local init binary version is different than the latest release version, download it again. -if ! [[ -f "$init_bin_path " && "$latest_release_version" != "$($init_bin_path -version)" ]]; then - download "$(echo $response | jq -r '.assets[] | select(.name == "init_'${os}'_'${arch}'") | .browser_download_url')" -fi - # Run the binary. exec_bin diff --git a/.github/get-master.bat b/.github/get-master.bat new file mode 100644 index 0000000..3941afa --- /dev/null +++ b/.github/get-master.bat @@ -0,0 +1,100 @@ +@ECHO OFF & setlocal enabledelayedexpansion + +rem Common urls. +set new_issue_url=https://github.com/guackamolly/zero-monitor/issues/new +set latest_release_url=https://api.github.com/repos/guackamolly/zero-monitor/releases/latest +set jq_release_url=https://github.com/jqlang/jq/releases/download/jq-1.7.1 + +rem Installation directory and program paths. +set install_dir=%APPDATA%\zero-monitor +set temp_input_dir=%TEMP%\.zero-monitor +set bin_path=%install_dir%\master.exe +set jq_bin_path=%install_dir%\jq.exe + + +if not exist "%install_dir%" ( + mkdir "%install_dir%" +) + +if not exist "%temp_input_dir%" ( + mkdir "%temp_input_dir%" +) + + +rem Query host OS +set os=windows + +rem Query CPU architecture +set arch=%PROCESSOR_ARCHITECTURE% +if "%arch%" EQU "AMD64" ( + set arch=amd64 +) else if "%arch%" EQU "X86" ( + set arch=386 +) else if "%arch%" EQU "ARM64" ( + set arch=arm64 +) else ( + echo "%arch%" is not supported right now, please raise an issue to get support on this architecture. ^%new_issue_url% + goto :fatal +) + +rem Download jq if not available. +if not exist "%jq_bin_path%" ( + call :download "%jq_release_url%/jq-%os%-%arch%.exe" jq.exe +) + +curl -s ^%latest_release_url% > %temp_input_dir%\latest-release + +if not %ERRORLEVEL% EQU 0 ( + echo Failed to head release, please raise an issue to alert maintainers about this bug. ^&%new_issue_url% + goto :fatal +) + +rem Head latest release +call:jq .tag_name %temp_input_dir%\latest-release>%temp_input_dir%\version +set /P latest_release_version=<%temp_input_dir%\version + +if %latest_release_version% EQU "" ( + echo Failed to extract release version, please raise an issue to alert maintainers about this bug. ^&%new_issue_url% + goto :fatal +) + +rem If local target binary version is different than the latest release version, download it again. +"%bin_path%" "-version">%temp_input_dir%\bin_version +set /P bin_version=<%temp_input_dir%\bin_version + +if %latest_release_version% NEQ "%bin_version%" ( + call:jq -r ".assets[] | select(.name == \"master_%os%_%arch%\") | .browser_download_url" %temp_input_dir%\latest-release>%temp_input_dir%\download_url + set /P download_url=<%temp_input_dir%\download_url + call :download !download_url! master.exe +) + +rem Run the binary. +call:exec_bin + +REM %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +REM %%%%%%%%%%% FUNCTION DEFINITIONS %%%%%%%%%%%% +REM %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +:fatal + echo %1 + exit /b 1 + +:jq + "%jq_bin_path%" %* + exit /b 0 + +:exec_bin + call "%bin_path%" + exit /b 0 + +:download + set url=%1 + set bin_name=%2 + if "%url%" EQU "" ( + echo Failed to extract url, please raise an issue to alert maintainers about this bug. ^&%new_issue_url% + goto :fatal + ) + + echo Downloading %bin_name% ... + curl -L "%url%" -o "%install_dir%\%bin_name%" + exit /b 0 \ No newline at end of file diff --git a/.github/get-node b/.github/get-node index fcd34e3..52de491 100755 --- a/.github/get-node +++ b/.github/get-node @@ -1,8 +1,5 @@ #!/usr/bin/env bash -# Save program arguments to later pass on init binary. -init_args="$@" - # Common urls. new_issue_url="https://github.com/guackamolly/zero-monitor/issues/new" latest_release_url="https://api.github.com/repos/guackamolly/zero-monitor/releases/latest" @@ -11,7 +8,6 @@ jq_release_url="https://github.com/jqlang/jq/releases/download/jq-1.7.1" # Installation directory and program paths. install_dir="$HOME/.config/zero-monitor" bin_path="$install_dir/node" -init_bin_path="$install_dir/init" jq_bin_path="$install_dir/jq" if [ ! -d "$install_dir" ]; then @@ -28,10 +24,6 @@ jq() { } exec_bin() { - if [ ! -z "$init_args" ]; then - "$init_bin_path" $init_args - fi - exec "$bin_path" } @@ -79,20 +71,10 @@ if [ -z "$latest_release_version" ]; then fatal "Failed to extract release version, please raise an issue to alert maintainers about this bug.\n$new_issue_url" fi -# If local init binary does not exist and no arguments have been passed, then bootstrap node. -if [[ ! -f "$init_bin_path" && -z "$init_args" ]]; then - init_args="--node=" -fi - # If local target binary version is different than the latest release version, download it again. if ! [[ -f "$bin_path" && "$latest_release_version" != "$($bin_path -version)" ]]; then download "$(echo $response | jq -r '.assets[] | select(.name == "node_'${os}'_'${arch}'") | .browser_download_url')" fi -# If local init binary version is different than the latest release version, download it again. -if ! [[ -f "$init_bin_path " && "$latest_release_version" != "$($init_bin_path -version)" ]]; then - download "$(echo $response | jq -r '.assets[] | select(.name == "init_'${os}'_'${arch}'") | .browser_download_url')" -fi - # Run the binary. exec_bin diff --git a/.github/get-node.bat b/.github/get-node.bat index 7c73005..840ce72 100644 --- a/.github/get-node.bat +++ b/.github/get-node.bat @@ -1,7 +1,4 @@ -@ECHO OFF - -rem Save program arguments to later pass on init binary. -set init_args=%* +@ECHO OFF & setlocal enabledelayedexpansion rem Common urls. set new_issue_url=https://github.com/guackamolly/zero-monitor/issues/new @@ -9,82 +6,95 @@ set latest_release_url=https://api.github.com/repos/guackamolly/zero-monitor/rel set jq_release_url=https://github.com/jqlang/jq/releases/download/jq-1.7.1 rem Installation directory and program paths. -set install_dir=%~dp0\config\zero-monitor +set install_dir=%APPDATA%\zero-monitor +set temp_input_dir=%TEMP%\.zero-monitor set bin_path=%install_dir%\node.exe -set init_bin_path=%install_dir%\init.exe set jq_bin_path=%install_dir%\jq.exe + if not exist "%install_dir%" ( mkdir "%install_dir%" ) -:fatal - echo %1 - exit /b 1 - -:jq - "%jq_bin_path%" %* - -:exec_bin - if "%init_args%" NEQ "" ( - "%init_bin_path%" %init_args% - ) - call "%bin_path%" - -:download - set url=%1 - if "%url%" EQU "" ( - echo Failed to extract url, please raise an issue to alert maintainers about this bug. ^&%new_issue_url% - goto :fatal - ) - set bin_name=%url:~-10% +if not exist "%temp_input_dir%" ( + mkdir "%temp_input_dir%" +) - echo Downloading %bin_name% ... - for /f "usebackq delims=\\" %%a in (`where wget`) do ( - %%a -O "%install_dir%\%bin_name%" "%url%" - ) - attrib +x "%install_dir%\%bin_name%" rem Query host OS -set os=windos +set os=windows rem Query CPU architecture -set arch= -for /f "tokens=2 delims=:" %%a in ('wmic cpu get architecture /format:List') do set "arch=%%a" - -if "%arch%" EQU "x86_64" ( +set arch=%PROCESSOR_ARCHITECTURE% +if "%arch%" EQU "AMD64" ( set arch=amd64 -) else if "%arch%" EQU "i686" ( +) else if "%arch%" EQU "X86" ( set arch=386 +) else if "%arch%" EQU "ARM64" ( + set arch=arm64 ) else ( - echo "%arch%" is not supported right now, please raise an issue to get support on this architecture. ^&%new_issue_url% + echo "%arch%" is not supported right now, please raise an issue to get support on this architecture. ^%new_issue_url% goto :fatal ) rem Download jq if not available. if not exist "%jq_bin_path%" ( - call :download "%jq_release_url%\jq-%os%-%arch%" + call :download "%jq_release_url%/jq-%os%-%arch%.exe" jq.exe ) -rem Head latest release -for /f "usebackq delims=:" %%a in (`curl -s ^&%latest_release_url%`) do set "response=%%a" -if not "!errorlevel!" EQU "0" ( +curl -s ^%latest_release_url% > %temp_input_dir%\latest-release + +if not %ERRORLEVEL% EQU 0 ( echo Failed to head release, please raise an issue to alert maintainers about this bug. ^&%new_issue_url% goto :fatal ) -set latest_release_version=%response:~10,1% +rem Head latest release +call:jq .tag_name %temp_input_dir%\latest-release>%temp_input_dir%\version +set /P latest_release_version=<%temp_input_dir%\version -if "%latest_release_version%" EQU "" ( +if %latest_release_version% EQU "" ( echo Failed to extract release version, please raise an issue to alert maintainers about this bug. ^&%new_issue_url% goto :fatal ) -rem If local init binary does not exist and no arguments have been passed, then bootstrap node. -if not exist "%init_bin_path%" AND "%init_args%" EQU "" ( - set init_args="--node=" +rem If local target binary version is different than the latest release version, download it again. +"%bin_path%" "-version">%temp_input_dir%\bin_version +set /P bin_version=<%temp_input_dir%\bin_version + +if %latest_release_version% NEQ "%bin_version%" ( + call:jq -r ".assets[] | select(.name == \"node_%os%_%arch%\") | .browser_download_url" %temp_input_dir%\latest-release>%temp_input_dir%\download_url + set /P download_url=<%temp_input_dir%\download_url + call :download !download_url! node.exe ) -rem If local target binary version is different than the latest release version, download it again. -if exist "%bin_path%" AND "%latest_release_version%" NEQ "%bin_path% -version%" ( - for /f "usebackq delims=:" %%a in (`echo ^&response% | jq -r '.assets[] | select(.name == "node_%os%_%arch%") | .browser_download_url'`) \ No newline at end of file +rem Run the binary. +call:exec_bin + +REM %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +REM %%%%%%%%%%% FUNCTION DEFINITIONS %%%%%%%%%%%% +REM %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +:fatal + echo %1 + exit /b 1 + +:jq + "%jq_bin_path%" %* + exit /b 0 + +:exec_bin + call "%bin_path%" + exit /b 0 + +:download + set url=%1 + set bin_name=%2 + if "%url%" EQU "" ( + echo Failed to extract url, please raise an issue to alert maintainers about this bug. ^&%new_issue_url% + goto :fatal + ) + + echo Downloading %bin_name% ... + curl -L "%url%" -o "%install_dir%\%bin_name%" + exit /b 0 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c5b0a1..4483db6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,6 @@ jobs: run: | ./tools/build master ./tools/build node - ./tools/build init - name: Prepare Version Tag id: version_tag diff --git a/README.md b/README.md index d6626fd..2ec68dc 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,13 @@ The simplest way to get started is by using the one-click deploy scripts provide bash <(wget -qO- https://raw.githubusercontent.com/guackamolly/zero-monitor/refs/heads/master/.github/get-master) ``` -Running this command will download `master` + `init` binaries specifically for your OS + Architecture, generate a ready to use `.env` file and finally deploy `master`. +Running this command will download `master` binary specifically for your OS + Architecture and then run it. ```bash bash <(wget -qO- https://raw.githubusercontent.com/guackamolly/zero-monitor/refs/heads/master/.github/get-node) ``` -Running this command will download `node` + `init` binaries specifically for your OS + Architecture, generate a ready to use `.env` file and finally deploy `node`. +Running this command will download `node` binary specifically for your OS + Architecture and then run it. --- diff --git a/TODO.md b/TODO.md index c6a98d5..32ef3d8 100644 --- a/TODO.md +++ b/TODO.md @@ -11,8 +11,5 @@ Looking to contribute to the project but you don't know how to start? The todo's - Organize common template code (e.g., `error.gohtml`, websocket initialization, meta tags) - Optimize network view template render - Speedtests is not saved if client breaks Websocket connection (unbuffered channel not being consumed) -- Remove depending on environment variables (100% portability) -- Add initialization scripts for Windows - Make Linux/macOS initialization scripts POSIX compliant (remove bash, use shell) -- Cover comment //TODOs -- Fix init not printing build version \ No newline at end of file +- Cover comment //TODOs \ No newline at end of file diff --git a/cmd/init/init.go b/cmd/init/init.go deleted file mode 100644 index 08fce49..0000000 --- a/cmd/init/init.go +++ /dev/null @@ -1,230 +0,0 @@ -package main - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/json" - "encoding/pem" - "flag" - "fmt" - "io" - "log" - "net/http" - "net/url" - "os" - "path/filepath" - "reflect" - - build "github.com/guackamolly/zero-monitor/internal/build" - "github.com/guackamolly/zero-monitor/internal/config" - _http "github.com/guackamolly/zero-monitor/internal/http" - "github.com/joho/godotenv" -) - -const ( - BootstrapMaster Action = iota - BootstrapNode -) - -type Action int - -type NodeEnv struct { - MessageQueueHost string `env:"mq_sub_host"` - MessageQueuePort int `env:"mq_sub_port"` - MessageQueueTransportPubKey string `env:"mq_transport_pub_key"` - MessageQueueInviteCode string `env:"mq_invite_code"` -} - -type MasterEnv struct { - ServerHost string `env:"server_host"` - ServerPort int `env:"server_port"` - MessageQueueHost string `env:"mq_sub_host"` - MessageQueuePort int `env:"mq_sub_port"` - MessageQueueTransportPubKey string `env:"mq_transport_pub_key"` - MessageQueueTransportPemKey string `env:"mq_transport_pem_key"` - BoltDBPath string `env:"bolt_db_path"` -} - -var action = BootstrapMaster - -var inviteLink *url.URL - -func init() { - flag.BoolFunc("version", "prints build version", func(s string) error { - println(build.Version()) - - os.Exit(0) - return nil - }) - - flag.Func("node", "configures the environment for starting a node", func(s string) error { - if len(s) == 0 { - println("Waiting for invite link... (press enter to resume)") - fmt.Scanln(&s) - } - - url, err := url.Parse(s) - if err != nil { - return err - } - - inviteLink = url - action = BootstrapNode - return nil - }) - - flag.Parse() -} - -func main() { - switch action { - case BootstrapMaster: - bootstrapMaster() - case BootstrapNode: - bootstrapNode() - } -} - -func bootstrapMaster() { - println("Bootstrapping master configuration") - configPath := must(config.Dir()) - - // Generate a RSA private key with 2048 bits - privateKey := must(rsa.GenerateKey(rand.Reader, 2048)) - - // Create a PEM-encoded block for the private key - privBytes := x509.MarshalPKCS1PrivateKey(privateKey) - privBlock := pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: privBytes, - } - - // Extract the public key from the private key - publicKey := &privateKey.PublicKey - - // Create a PEM-encoded block for the public key - pubBytes := must(x509.MarshalPKIXPublicKey(publicKey)) - pubBlock := pem.Block{ - Type: "RSA PUBLIC KEY", - Bytes: pubBytes, - } - - // Write the public key to a file - pubFile := must(os.Create(filepath.Join(configPath, "master.pub"))) - defer pubFile.Close() - must0(pem.Encode(pubFile, &pubBlock)) - - // Write the private key to a file - privFile := must(os.Create(filepath.Join(configPath, "master.pem"))) - defer privFile.Close() - must0(pem.Encode(privFile, &privBlock)) - - boltDbFile := must(os.Create(filepath.Join(configPath, "master.db"))) - defer boltDbFile.Close() - - env := MasterEnv{ - ServerHost: "0.0.0.0", - MessageQueueHost: "0.0.0.0", - ServerPort: 8080, - MessageQueuePort: 36113, - MessageQueueTransportPubKey: pubFile.Name(), - MessageQueueTransportPemKey: privFile.Name(), - BoltDBPath: boltDbFile.Name(), - } - - envpath := fmt.Sprintf("%s/master.env", must(config.Dir())) - writeEnv(env, envpath) - - println("> Generated private key on: %s", privFile.Name()) - println("> Generated public key on: %s", pubFile.Name()) - println("> Generated .env on: %s", envpath) -} - -func bootstrapNode() { - println("Bootstrapping node configuration using invite link: %s", inviteLink) - configPath := must(config.Dir()) - inviteCode := inviteLink.Query().Get("join") - - v := downloadUnmarshal[_http.NetworkJoinView](inviteLink.String()) - pubKey := string(download(v.PublicKeyURL)) - endpoint := downloadUnmarshal[_http.NetworkConnectionEndpointView](v.ConnectionEndpointURL) - - pubFile := must(os.Create(filepath.Join(configPath, "node.pub"))) - defer pubFile.Close() - io.WriteString(pubFile, pubKey) - - env := NodeEnv{ - MessageQueueHost: endpoint.Host, - MessageQueuePort: endpoint.Port, - MessageQueueTransportPubKey: pubFile.Name(), - MessageQueueInviteCode: inviteCode, - } - - envpath := fmt.Sprintf("%s/node.env", configPath) - writeEnv(env, envpath) - - println("> Extracted invite code: %s", inviteCode) - println("> Saved public key on: %s", pubFile.Name()) - println("> Generated .env on: %s", envpath) -} - -// helper functions -func writeEnv(env any, path string) { - v := reflect.ValueOf(env) - t := v.Type() - m := map[string]string{} - for i := 0; i < t.NumField(); i++ { - f := t.Field(i) - m[f.Tag.Get("env")] = fmt.Sprintf("%v", v.Field(i)) - } - - godotenv.Write(m, path) -} - -func download(url string) []byte { - println("> GET %s", url) - - resp := must(http.Get(url)) - if sc := resp.StatusCode; sc != 200 { - panic(fmt.Sprintf("sc: %d", resp.StatusCode)) - } - - bs := must(io.ReadAll(resp.Body)) - - return bs -} - -func downloadUnmarshal[T any](url string) T { - var v T - must0(json.Unmarshal(download(url), &v)) - - return v -} - -func must[T any](t T, err error) T { - if err != nil { - panic(err) - } - - return t -} - -func must0(err error) { - if err != nil { - panic(err) - } -} - -func panic(v any) { - log.Fatal(v) -} - -func println(f any, v ...any) { - if _, ok := f.(string); !ok || len(v) == 0 { - fmt.Println(f) - return - } - - fmt.Printf("%s\n", fmt.Sprintf(f.(string), v)) -} diff --git a/cmd/master/master.go b/cmd/master/master.go index 49de7eb..0246172 100644 --- a/cmd/master/master.go +++ b/cmd/master/master.go @@ -8,11 +8,13 @@ import ( "syscall" "time" + "github.com/guackamolly/zero-monitor/internal/bootstrap" "github.com/guackamolly/zero-monitor/internal/config" dbb "github.com/guackamolly/zero-monitor/internal/data/db" dbbolt "github.com/guackamolly/zero-monitor/internal/data/db/db-bolt" "github.com/guackamolly/zero-monitor/internal/data/models" "github.com/guackamolly/zero-monitor/internal/data/repositories" + "github.com/guackamolly/zero-monitor/internal/env" "github.com/guackamolly/zero-monitor/internal/event" "github.com/guackamolly/zero-monitor/internal/http" "github.com/guackamolly/zero-monitor/internal/logging" @@ -23,7 +25,6 @@ import ( build "github.com/guackamolly/zero-monitor/internal/build" flags "github.com/guackamolly/zero-monitor/internal/build/flags" - _ "github.com/guackamolly/zero-monitor/internal/env" ) func main() { @@ -32,7 +33,8 @@ func main() { } logging.AddLogger(logging.NewConsoleLogger()) - // 1. Load config + // 1. Load env + config + env := loadEnv() cfg := loadConfig() // 2. Initialize DI. @@ -42,19 +44,19 @@ func main() { ctx = mq.InjectSubscribeContainer(ctx, suc) // 3. Initialize sub server. - loadCrypto() + loadCrypto(env.MessageQueueTransportPemKey) - s := initializeSubServer(ctx) + s := initializeSubServer(ctx, env.MessageQueueHost, env.MessageQueuePort) defer s.Close() // 4. Initialize database. - db := initializeDatabase() + db := initializeDatabase(env.BoltDBPath) defer db.Close() // 5. Initialize http server. sc = updateServiceContainer(sc, &s, db) ctx = http.InjectServiceContainer(ctx, sc) - e := initializeHttpServer(ctx) + e := initializeHttpServer(ctx, env.ServerHost, env.ServerPort, env.ServerTLSCert, env.ServerTLSKey, env.ServerVirtualHost) defer e.Close() // 6. Await termination... @@ -66,6 +68,15 @@ func main() { saveConfig(sc.MasterConfiguration) } +func loadEnv() env.MasterEnv { + if env, err := env.Master(); err == nil { + return env + } + + logging.LogDebug("couldn't lookup .env, bootstrapping configuration values...") + return bootstrap.Master() +} + func loadConfig() config.Config { cfg, err := config.Load() if err != nil { @@ -82,17 +93,18 @@ func saveConfig(s *service.MasterConfigurationService) { } } -func loadCrypto() { - err := mq.LoadAsymmetricBlock(false) +func loadCrypto(keyfilepath string) { + err := mq.LoadAsymmetricBlock(keyfilepath) if err != nil { - logging.LogFatal("failed to load message queue private key, %v", err) + logging.LogError("failed to load message queue private key, %v", err) + logging.LogWarning("message queue sensitive data will not be encrypted!") } } -func initializeSubServer(ctx context.Context) mq.Socket { +func initializeSubServer(ctx context.Context, host, port string) mq.Socket { s := mq.NewSubSocket(ctx) s.RegisterSubscriptions() - err := mq.ConnectSubscribe(s) + err := mq.ConnectSubscribe(s, host, port) if err != nil { s.Close() log.Fatalf("coudln't open zeromq sub socket, %v\n", err) @@ -102,7 +114,11 @@ func initializeSubServer(ctx context.Context) mq.Socket { return s } -func initializeHttpServer(ctx context.Context) *echo.Echo { +func initializeHttpServer( + ctx context.Context, + host, port, certfilepath, keyfilepath string, + virtualhost string, +) *echo.Echo { // Initialize echo framework. e := echo.New() @@ -111,17 +127,22 @@ func initializeHttpServer(ctx context.Context) *echo.Echo { http.RegisterMiddlewares(e, ctx) http.RegisterStaticFiles(e, public.FS) http.RegisterTemplates(e, public.FS) + http.SetVirtualHost(virtualhost) // Start server. go func() { - logging.LogFatal("server exit %v", http.Start(e)) + if len(certfilepath) > 0 && len(keyfilepath) > 0 { + logging.LogFatal("server exit %v", http.StartTLS(e, host, port, certfilepath, keyfilepath)) + } + + logging.LogFatal("server exit %v", http.Start(e, host, port)) }() return e } -func initializeDatabase() dbb.Database { - db := dbbolt.NewBoltDatabase(dbbolt.Path()) +func initializeDatabase(dbpath string) dbb.Database { + db := dbbolt.NewBoltDatabase(dbpath) err := db.Open() if err != nil { logging.LogFatal("couldn't initialize database, %v", db) diff --git a/cmd/node/node.go b/cmd/node/node.go index 40f26f8..dc16ce5 100644 --- a/cmd/node/node.go +++ b/cmd/node/node.go @@ -7,7 +7,9 @@ import ( "os/signal" "syscall" + "github.com/guackamolly/zero-monitor/internal/bootstrap" "github.com/guackamolly/zero-monitor/internal/data/repositories" + "github.com/guackamolly/zero-monitor/internal/env" "github.com/guackamolly/zero-monitor/internal/logging" "github.com/guackamolly/zero-monitor/internal/mq" "github.com/guackamolly/zero-monitor/internal/service" @@ -15,7 +17,6 @@ import ( build "github.com/guackamolly/zero-monitor/internal/build" flags "github.com/guackamolly/zero-monitor/internal/build/flags" - _ "github.com/guackamolly/zero-monitor/internal/env" ) func main() { @@ -24,33 +25,46 @@ func main() { } logging.AddLogger(logging.NewConsoleLogger()) - // 1. Initialize DI. + // 1. Load env + env := loadEnv() + + // 2. Initialize DI. pc := createPublishContainer() ctx := context.Background() ctx = mq.InjectPublishContainer(ctx, pc) - // 2. Initialize pub server. - loadCrypto() + // 3. Initialize pub server. + loadCrypto(env.MessageQueueTransportPubKey) s := mq.NewPubSocket(ctx) defer s.Close() - err := mq.ConnectPublish(s) + err := mq.ConnectPublish(s, env.MessageQueueHost, env.MessageQueuePort) if err != nil { log.Fatalf("coudln't open zeromq pub socket, %v\n", err) } log.Printf("started zeromq pub socket on addr: %s\n", s.Addr()) - s.RegisterPublishers() + s.RegisterPublishers(env.MessageQueueInviteCode) - // 3. Await termination... + // 4. Await termination... c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) <-c } -func loadCrypto() { - err := mq.LoadAsymmetricBlock(true) +func loadEnv() env.NodeEnv { + if env, err := env.Node(); err == nil { + return env + } + + logging.LogDebug("couldn't lookup .env, bootstrapping configuration values...") + return bootstrap.Node() +} + +func loadCrypto(keyfilepath string) { + err := mq.LoadAsymmetricBlock(keyfilepath) if err != nil { - logging.LogFatal("failed to load message queue public key, %v", err) + logging.LogError("failed to load message queue public key, %v", err) + logging.LogWarning("message queue sensitive data will not be encrypted!") } } diff --git a/docs/env.md b/docs/env.md index 6b47b24..c4cc5bb 100644 --- a/docs/env.md +++ b/docs/env.md @@ -14,6 +14,7 @@ Master expects the .env file to be present in the working directory under the na |`mq_sub_port`|Specifies the port to bind the ZeroMQ listener connection|`36113`|-| |`mq_transport_pem_key`|Specifies the path of the `RSA 2048 PKCS 1` private key used for decrypting the communication during the key-exchange of node-master|`~/.config/zero-monitor/mq.pem`|`${CFG_DIR}/mq.pem`| |`mq_transport_pub_key`|Specifies the path of the `RSA 2048 PKCS 1` public key used for encrypting the communication during the key-exchange of node-master|`~/.config/zero-monitor/mq.pub`|`${CFG_DIR}/mq.pub`| +|`bolt_db_path`|Specifies the path of the Bolt in-memory database used to store speedtests results|`~/.config/zero-monitor/master.db`|`${WORKING_DIR}/master.db`| --- @@ -21,7 +22,6 @@ Additionally you can set these variables to customize your experience while usin |Environment Variable|Description|Example Value|Default Value| |--------------------|-----------|-------------|-------------| -|`bolt_db_path`|Specifies the path of the Bolt in-memory database used to store speedtests results|`~/.config/zero-monitor/master.db`|`${WORKING_DIR}/master.db`| |`server_tls_crt_fp`|Specifies the path of the signed certificate to encrypt web-server communication (HTTPS)|`~/.config/zero-monitor/master.crt`|-| |`server_tls_crt_key`|Specifies the path of the signed certificate private key to encrypt web-server communication (HTTPS)|`~/.config/zero-monitor/master.crt.key`|-| |`server_virtual_host`|If you want to deploy the web server as a virtual path|`/zero-monitor`|-| diff --git a/internal/bootstrap/boostrap.go b/internal/bootstrap/boostrap.go new file mode 100644 index 0000000..d9f5350 --- /dev/null +++ b/internal/bootstrap/boostrap.go @@ -0,0 +1,89 @@ +package bootstrap + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + + "github.com/guackamolly/zero-monitor/internal/config" + "github.com/guackamolly/zero-monitor/internal/env" + "github.com/guackamolly/zero-monitor/internal/http" +) + +const ( + defaultHttpPort = "8080" + defaultMessageQueuePort = "36113" + + defaultHttpHost = "0.0.0.0" + defaultMessageQueueHost = "0.0.0.0" +) + +func Master() env.MasterEnv { + configPath := must(config.Dir()) + + // Generate a RSA private key with 2048 bits + privateKey := must(rsa.GenerateKey(rand.Reader, 2048)) + + // Create a PEM-encoded block for the private key + privBytes := x509.MarshalPKCS1PrivateKey(privateKey) + privBlock := pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privBytes, + } + + // Write the private key to a file + privFile := must(os.Create(filepath.Join(configPath, "master.pem"))) + defer privFile.Close() + must0(pem.Encode(privFile, &privBlock)) + + boltDbFile := must(os.Create(filepath.Join(configPath, "master.db"))) + defer boltDbFile.Close() + + e := env.MasterEnv{ + ServerHost: defaultHttpHost, + MessageQueueHost: defaultMessageQueueHost, + ServerPort: defaultHttpPort, + MessageQueuePort: defaultMessageQueuePort, + MessageQueueTransportPemKey: privFile.Name(), + BoltDBPath: boltDbFile.Name(), + } + + // Save bootstrapped env. + return must(e, env.Save(e)) +} + +func Node() env.NodeEnv { + // Wait for user input regarding the network invite link. + var inviteLink string + println("Waiting for invite link... (press enter to resume)") + fmt.Scanln(&inviteLink) + + configPath := must(config.Dir()) + inviteCode := must(url.Parse(inviteLink)).Query().Get("join") + + // Query connection information and public key for key exchange. + joinView := downloadUnmarshal[http.NetworkJoinView](inviteLink) + pubKey := string(download(joinView.PublicKeyURL)) + endpoint := downloadUnmarshal[http.NetworkConnectionEndpointView](joinView.ConnectionEndpointURL) + + // Write pub key to config folder. + pubFile := must(os.Create(filepath.Join(configPath, "node.pub"))) + defer pubFile.Close() + io.WriteString(pubFile, pubKey) + + e := env.NodeEnv{ + MessageQueueHost: endpoint.Host, + MessageQueuePort: fmt.Sprintf("%d", endpoint.Port), + MessageQueueTransportPubKey: pubFile.Name(), + MessageQueueInviteCode: inviteCode, + } + + // Save bootstrapped env. + return must(e, env.Save(e)) +} diff --git a/internal/bootstrap/helper.go b/internal/bootstrap/helper.go new file mode 100644 index 0000000..162be51 --- /dev/null +++ b/internal/bootstrap/helper.go @@ -0,0 +1,57 @@ +package bootstrap + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" +) + +// helper functions +func download(url string) []byte { + println("> GET %s", url) + + resp := must(http.Get(url)) + if sc := resp.StatusCode; sc != 200 { + panic(fmt.Sprintf("sc: %d", resp.StatusCode)) + } + + bs := must(io.ReadAll(resp.Body)) + + return bs +} + +func downloadUnmarshal[T any](url string) T { + var v T + must0(json.Unmarshal(download(url), &v)) + + return v +} + +func must[T any](t T, err error) T { + if err != nil { + panic(err) + } + + return t +} + +func must0(err error) { + if err != nil { + panic(err) + } +} + +func panic(v any) { + log.Fatal(v) +} + +func println(f any, v ...any) { + if _, ok := f.(string); !ok || len(v) == 0 { + fmt.Println(f) + return + } + + fmt.Printf("%s\n", fmt.Sprintf(f.(string), v)) +} diff --git a/internal/data/db/db-bolt/connection.go b/internal/data/db/db-bolt/connection.go deleted file mode 100644 index 51b37db..0000000 --- a/internal/data/db/db-bolt/connection.go +++ /dev/null @@ -1,19 +0,0 @@ -package dbbolt - -import "os" - -const ( - boltDbPathKey = "bolt_db_path" -) - -var ( - boltDbPath = os.Getenv(boltDbPathKey) -) - -func Path() string { - if len(boltDbPath) == 0 { - return "master.db" - } - - return boltDbPath -} diff --git a/internal/data/db/db-bolt/connection_test.go b/internal/data/db/db-bolt/connection_test.go deleted file mode 100644 index d5f3b35..0000000 --- a/internal/data/db/db-bolt/connection_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package dbbolt_test - -import ( - "testing" - - dbbolt "github.com/guackamolly/zero-monitor/internal/data/db/db-bolt" -) - -func TestPathReturnsDefaultIfEnvKeyIsNotSet(t *testing.T) { - def := "master.db" - if dbbolt.Path() != def { - t.Errorf("expected %s but got %s", def, dbbolt.Path()) - } -} diff --git a/internal/env/env.go b/internal/env/env.go index 75f71a2..28587d4 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -1,33 +1,133 @@ package env import ( - "log" "os" "path/filepath" + "reflect" "github.com/guackamolly/zero-monitor/internal/config" "github.com/joho/godotenv" ) -func init() { - if err := godotenv.Load(); err == nil { - return +type MasterEnv struct { + ServerHost string `env:"server_host"` + ServerPort string `env:"server_port"` + ServerTLSCert string `env:"server_tls_crt_fp"` + ServerTLSKey string `env:"server_tls_key_fp"` + ServerVirtualHost string `env:"server_virtual_host"` + MessageQueueHost string `env:"mq_sub_host"` + MessageQueuePort string `env:"mq_sub_port"` + MessageQueueTransportPemKey string `env:"mq_transport_pem_key"` + BoltDBPath string `env:"bolt_db_path"` +} + +type NodeEnv struct { + MessageQueueHost string `env:"mq_sub_host"` + MessageQueuePort string `env:"mq_sub_port"` + MessageQueueTransportPubKey string `env:"mq_transport_pub_key"` + MessageQueueInviteCode string `env:"mq_invite_code"` +} + +// If not nil, it means env has been already loaded. +var nodeEnv *NodeEnv +var masterEnv *MasterEnv + +// Loads environment variables for master server. +// If error is not nil, it means neither .env on the working directory or ${CFG_DIR}/master.env could be lookup. +func Master() (MasterEnv, error) { + if masterEnv != nil { + return *masterEnv, nil + } + + err := loadEnv("master.env") + if err != nil { + return MasterEnv{}, err } + masterEnv = fromEnv(MasterEnv{}) + return *masterEnv, err +} + +// Loads environment variables for node agent. +// If error is not nil, it means neither .env on the working directory or ${CFG_DIR}/node.env could be lookup. +func Node() (NodeEnv, error) { + if nodeEnv != nil { + return *nodeEnv, nil + } + + err := loadEnv("node.env") + if err != nil { + return NodeEnv{}, err + } + + nodeEnv = fromEnv(NodeEnv{}) + return *nodeEnv, err +} + +func Save[T MasterEnv | NodeEnv]( + env T, +) error { d, err := config.Dir() if err != nil { - log.Fatal("couldn't lookup a .env file!") + return err + } + + if reflect.TypeOf(env) == reflect.TypeFor[NodeEnv]() { + return setEnv(env, filepath.Join(d, "node.env")) + } else { + return setEnv(env, filepath.Join(d, "master.env")) + } +} + +func loadEnv( + filename string, +) error { + if err := godotenv.Load(); err == nil { + return nil + } + + d, err := config.Dir() + if err == nil { + err = godotenv.Load(filepath.Join(d, filename)) } - exe, err := os.Executable() if err != nil { - log.Fatal("couldn't lookup a .env file!") + return err } - envp := filepath.Join(d, filepath.Base(exe)+".env") - if err = godotenv.Load(envp); err == nil { - return + return nil +} + +func fromEnv[T MasterEnv | NodeEnv]( + env T, +) *T { + v := reflect.ValueOf(env) + addr := reflect.ValueOf(&env).Elem() + t := v.Type() + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + k := f.Tag.Get("env") + addr.Field(i).SetString(os.Getenv(k)) + } + + return &env +} + +func setEnv[T MasterEnv | NodeEnv]( + env T, + path string, +) error { + + m := map[string]string{} + v := reflect.ValueOf(env) + t := v.Type() + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + k := f.Tag.Get("env") + v := v.Field(i) + + m[k] = v.String() } - log.Fatalf("couldn't load .env or %s", envp) + return godotenv.Write(m, path) } diff --git a/internal/http/connection.go b/internal/http/connection.go index 0440643..3fc3e08 100644 --- a/internal/http/connection.go +++ b/internal/http/connection.go @@ -2,35 +2,14 @@ package http import ( "fmt" - "os" "github.com/labstack/echo/v4" ) -const ( - serverHostEnvKey = "server_host" - serverPortEnvKey = "server_port" - serverTLSCertFilePathKey = "server_tls_crt_fp" - serverTLSKeyFilePathKey = "server_tls_key_fp" -) - -var ( - serverHost = os.Getenv(serverHostEnvKey) - serverPort = os.Getenv(serverPortEnvKey) - serverTLSCertFilePath = os.Getenv(serverTLSCertFilePathKey) - serverTLSKeyFilePath = os.Getenv(serverTLSKeyFilePathKey) -) - -func Start(e *echo.Echo) error { - addr := ServerAddress() - - if len(serverTLSCertFilePath) == 0 { - return e.Start(addr) - } - - return e.StartTLS(addr, serverTLSCertFilePath, serverTLSKeyFilePath) +func Start(e *echo.Echo, host string, port string) error { + return e.Start(fmt.Sprintf("[%s]:%s", host, port)) } -func ServerAddress() string { - return fmt.Sprintf("%s:%s", serverHost, serverPort) +func StartTLS(e *echo.Echo, host string, port string, certfilepath string, keyfilepath string) error { + return e.StartTLS(fmt.Sprintf("[%s]:%s", host, port), certfilepath, keyfilepath) } diff --git a/internal/http/handler+dashboard.go b/internal/http/handler+dashboard.go index b725179..44ce3a6 100644 --- a/internal/http/handler+dashboard.go +++ b/internal/http/handler+dashboard.go @@ -22,7 +22,7 @@ func dashboardFormHandler(ectx echo.Context) error { return ectx.Redirect(301, ectx.Request().URL.Path) } - host := ServerAddress() + host := ServerAddress(ectx) if IsReverseProxyRequest(ectx) { host = net.JoinHostPort(ExtractReverseProxyIP(ectx), ExtractPort(ectx)) } else if IsBindToUnspecified(ectx) { @@ -32,7 +32,8 @@ func dashboardFormHandler(ectx echo.Context) error { return echo.ErrInternalServerError } - host = net.JoinHostPort(ip.String(), serverPort) + _, port, _ := net.SplitHostPort(host) + host = net.JoinHostPort(ip.String(), port) } code := sc.NodeManager.Code() diff --git a/internal/http/handler+util.go b/internal/http/handler+util.go index 3a806e0..9b4cbda 100644 --- a/internal/http/handler+util.go +++ b/internal/http/handler+util.go @@ -1,6 +1,7 @@ package http import ( + "fmt" "net" "net/url" "strconv" @@ -118,6 +119,11 @@ func RawURL(ectx echo.Context, host string, path string, query map[string]string } } +func ServerAddress(ectx echo.Context) string { + addr := extractServerAddr(ectx) + return fmt.Sprintf("[%s]:%d", addr.IP, addr.Port) +} + func IsLocalRequest(ectx echo.Context) bool { ip := net.ParseIP(ectx.RealIP()) diff --git a/internal/http/virtual-host.go b/internal/http/virtual-host.go index 3d25ccb..f676717 100644 --- a/internal/http/virtual-host.go +++ b/internal/http/virtual-host.go @@ -2,17 +2,14 @@ package http import ( "fmt" - "os" "strings" ) -const ( - serverVirtualHostEnvKey = "server_virtual_host" -) +var serverVirtualHost string -var ( - serverVirtualHost = os.Getenv(serverVirtualHostEnvKey) -) +func SetVirtualHost(host string) { + serverVirtualHost = host +} func WithVirtualHost(path string) string { if !strings.HasPrefix(serverVirtualHost, "/") { diff --git a/internal/mq/authentication.go b/internal/mq/authentication.go index 449751a..8a64dc3 100644 --- a/internal/mq/authentication.go +++ b/internal/mq/authentication.go @@ -1,28 +1,5 @@ package mq -import ( - "fmt" - "os" -) - -const ( - mqInviteCodeEnvKey = "mq_invite_code" -) - -var ( - mqInviteCode = os.Getenv(mqInviteCodeEnvKey) -) - // This flag controls whether or not the current running node has already handshaked with the master node. // It's used to disallow handshaking more than one time. var handshaked = false - -// Returns the invite code passed through environment variables ([mqInviteCodeEnvKey]) or reads it from std input. -func InviteCode() string { - if len(mqInviteCode) == 0 { - println("Waiting for invite code... (press enter to resume)") - fmt.Scanln(&mqInviteCode) - } - - return mqInviteCode -} diff --git a/internal/mq/connection.go b/internal/mq/connection.go index 07dc338..6bd843f 100644 --- a/internal/mq/connection.go +++ b/internal/mq/connection.go @@ -3,50 +3,27 @@ package mq import ( "fmt" "net" - "os" "github.com/guackamolly/zero-monitor/internal/data/repositories" "github.com/guackamolly/zero-monitor/internal/logging" ) -const ( - mqSubHostEnvKey = "mq_sub_host" - mqSubPortEnvKey = "mq_sub_port" -) - -var ( - mqSubHost = os.Getenv(mqSubHostEnvKey) - mqSubPort = os.Getenv(mqSubPortEnvKey) -) - // Connects a socket for publishing messages to master node. -func ConnectPublish(s Socket) error { - if len(mqSubHost) > 0 && len(mqSubPort) > 0 { - return s.Dial(fmt.Sprintf("tcp://[%s]:%s", mqSubHost, mqSubPort)) - } - - return fmt.Errorf("sub host and port haven't been provided") +func ConnectPublish(s Socket, host string, port string) error { + return s.Dial(fmt.Sprintf("tcp://[%s]:%s", host, port)) } // Connects a socket for subscribing messages from reporting nodes. -func ConnectSubscribe(s Socket) error { - ip := subHostIP() - - // if port is unspecified, default to 0 so go internals - // choose a random available port - if len(mqSubPort) == 0 { - mqSubPort = "0" - } - - return s.Listen(fmt.Sprintf("tcp://[%s]:%s", ip, mqSubPort)) +func ConnectSubscribe(s Socket, host string, port string) error { + return s.Listen(fmt.Sprintf("tcp://[%s]:%s", lookupHost(host), port)) } func Close(s Socket) error { return s.Close() } -func subHostIP() net.IP { - if ip := net.ParseIP(mqSubHost); ip != nil { +func lookupHost(host string) net.IP { + if ip := net.ParseIP(host); ip != nil { return ip } diff --git a/internal/mq/crypto.go b/internal/mq/crypto.go index b9bf945..6a77a4c 100644 --- a/internal/mq/crypto.go +++ b/internal/mq/crypto.go @@ -10,20 +10,6 @@ import ( "fmt" "io" "os" - "path/filepath" - - "github.com/guackamolly/zero-monitor/internal/config" - "github.com/guackamolly/zero-monitor/internal/logging" -) - -const ( - mqTransportPrivateKeyFileEnvKey = "mq_transport_pem_key" - mqTransportPublicKeyFileEnvKey = "mq_transport_pub_key" -) - -var ( - mqTransportPrivateKeyFile = os.Getenv(mqTransportPrivateKeyFileEnvKey) - mqTransportPublicKeyFile = os.Getenv(mqTransportPublicKeyFileEnvKey) ) // Global map of blocks used to encrypt/decrypt sensitive @@ -36,39 +22,11 @@ var cipherBlocks = map[string]cipher.Block{} // the key exchange between nodes. var blk *pem.Block -func init() { - peml := len(mqTransportPrivateKeyFile) - publ := len(mqTransportPublicKeyFile) - - if peml > 0 && publ > 0 { - return - } - - d, err := config.Dir() - if err != nil { - logging.LogWarning("couldn't lookup pem/pub key files to encrypt message queue messages. either communication with master node fail OR it won't be encrypted") - return - } - - if peml == 0 { - mqTransportPrivateKeyFile = filepath.Join(d, "mq.pem") - } - - if publ == 0 { - mqTransportPublicKeyFile = filepath.Join(d, "mq.pub") - } -} - // Loads the public/private key block to be used on encryption/decryption. func LoadAsymmetricBlock( - pub bool, + keyfilepath string, ) error { - p := mqTransportPrivateKeyFile - if pub { - p = mqTransportPublicKeyFile - } - - f, err := os.ReadFile(p) + f, err := os.ReadFile(keyfilepath) if err != nil { return err } @@ -178,5 +136,25 @@ func GenerateCipherKey() ([]byte, error) { // TODO: derive from private key instead func DerivePublicKey() ([]byte, error) { - return os.ReadFile(mqTransportPublicKeyFile) + key, err := x509.ParsePKCS1PrivateKey(blk.Bytes) + if err != nil { + return nil, err + } + + // Extract the public key from the private key + publicKey := &key.PublicKey + + // Marshal the public key into DER format + publicKeyBytes, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + return nil, err + } + + // Encode the public key into a PEM block + publicKeyBlock := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: publicKeyBytes, + }) + + return publicKeyBlock, nil } diff --git a/internal/mq/crypto_int_test.go b/internal/mq/crypto_int_test.go new file mode 100644 index 0000000..00c40c6 --- /dev/null +++ b/internal/mq/crypto_int_test.go @@ -0,0 +1,101 @@ +//go:build integration + +package mq_test + +import ( + "os" + "slices" + "testing" + + "github.com/guackamolly/zero-monitor/internal/mq" +) + +var rsaPem = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAw358uANE26qvIs51rjjBRo3v58MJFLja9/padm7iM+ZSchAj +LTNksmsJI1pbSRvA6gbmMxqqrvaWXu2TfQqponGc/iwBPzIa4dl8gHi1CLMME1Nl +7yJw+uKK15XGo/Qtaz7KYdtHCpwRs5Gc+8Ww7Cq7jEYPu+KL/TrrqxXldUV/Hocv +LRkydtRDeuSdefGjokTi2LEQspdpZ4HRbBncl/ntKLboN2R3RAomx1bWTqA/M0xx +QqOiHO9zai9yrYuFYUuSmeyusqv3wH2mhLrEhPPQYzG/BLvq4rmZautJDnbZV613 +/WKzqg2hhMgwJtAzbm7yCMSlyTB69uNLHpVK9QIDAQABAoIBAADwsUsYMJ9lrnu3 +8rWihsgNvYxd1wjDhIayQDKXFMcxgacn8AU3+lO7pw0s5hylyQXtRPPFvMp9Awm+ +OU3KOhGhb2f5EuYnVPWwax9yl6GH9upTfowziIy5RJWZscSu8OoOeXHbRNpkGCYp +XNnhSizPXhxkLHwyuidmSC7gpzJv6Ysy6+3PJubro1IyBIuMmQdiKtVbWL9Razk6 +IPhCMuOCknCHgZbOETh8NeqXh7Ztiragb58h5Fc8RSONdYsmkmfirWXFBArSs/Xw +YNyYZGwfZ5Tyt2TggA2iFBF6oPJtQk9cOnyLOhjmCLc7w+TF6crOPxZRx6usOkP2 +ElduSBsCgYEA9gGloYDhK2oEmtU/3ZtI+NT9wEVW/W23aPaG4QqB7yI5XaW3zX9P +Mq3pAFxR9xjOhvpW8ed+f9qg+5toc73fLLuMDWFuA5Pr34dxV0DeFdNZ8h8qPBtz +j7kvUTtH2h+zTc4IvVpbcBKkqT2SJpr1wO3xThBajfOCh+8V/VdOwncCgYEAy2+J +A2EuPAjHtybyJpcZp/lyMWzwN4c3N511e/PvJqT0TSQjRrM9M4TREmX4eQghE2Va +QkKkm+RoN4mxFP7C6sjq7kmvFbZG4JC9kBtDqV79mmEGw8/IvaSTXXG0RL8tKGIg +bul8Q0VVWlZgkx780bII9TMYOZRZP9FUSicb7PMCgYAsnN3ZrRKomeBd5+BeIuQX +5CBkdu6wpO4HBfYt54bqxA0dM4lipfzJ1woTO6rNod0KU2njErU5IH/jQSqvGrbX +WOesIYge8/tpnRlr1mKwGJUOOKKjJeNOJCo1lAeSwf71VDD3jeRZLbhYzMatY5q/ +syb4njSd25RHbI9TUzsAPwKBgDdy/C5uo5J7diwmsmPwVW7iX8y2+7a25UcEZQxX +Db1Dws7v5amUmz7amb3hC1u56oIF4xciYQmYtQtGPX0Sf4BNKTOv48gQObtl2DVa +KRQWLxuQDK78iKOgIwaaQl9mmGFkdaClhVg0orIPzxzqmlBxrV1gAt9W3wi0/ruD +c2ofAoGBAL+GDfuCrJPbnp37C4ZEMgWiJ73Mgu7tFVh53irll1IXp4pqtn7YHRNL +P7dplKCTY0RUyckE8WGqLl2sY5HrA3RrB+SzjFJcESsBrWxk56JIquEQPCY1kaFZ ++6J3fC5Nz8oA5jVUtyFGj156hP/A0grfWLQnauJpFNhOLS/VATBx +-----END RSA PRIVATE KEY-----` + +var rsaPub = ` +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw358uANE26qvIs51rjjB +Ro3v58MJFLja9/padm7iM+ZSchAjLTNksmsJI1pbSRvA6gbmMxqqrvaWXu2TfQqp +onGc/iwBPzIa4dl8gHi1CLMME1Nl7yJw+uKK15XGo/Qtaz7KYdtHCpwRs5Gc+8Ww +7Cq7jEYPu+KL/TrrqxXldUV/HocvLRkydtRDeuSdefGjokTi2LEQspdpZ4HRbBnc +l/ntKLboN2R3RAomx1bWTqA/M0xxQqOiHO9zai9yrYuFYUuSmeyusqv3wH2mhLrE +hPPQYzG/BLvq4rmZautJDnbZV613/WKzqg2hhMgwJtAzbm7yCMSlyTB69uNLHpVK +9QIDAQAB +-----END PUBLIC KEY-----` + +var rsaPemKeypath string +var rsaPubKeypath string + +func init() { + rsaPemFile, err := os.CreateTemp("", "") + if err != nil { + panic(err) + } + rsaPemFile.WriteString(rsaPem) + defer rsaPemFile.Close() + + rsaPubFile, err := os.CreateTemp("", "") + if err != nil { + panic(err) + } + rsaPubFile.WriteString(rsaPub) + defer rsaPemFile.Close() + + rsaPemKeypath = rsaPemFile.Name() + rsaPubKeypath = rsaPubFile.Name() +} + +func TestEncryptAndDecryptAsymmetric(t *testing.T) { + data := []byte("zero-monitor") + + err := mq.LoadAsymmetricBlock(rsaPubKeypath) + if err != nil { + t.Fatalf("didn't expect load public key block to fail, %v", err) + } + + encrypted, err := mq.EncryptAsymmetric(data) + if err != nil { + t.Fatalf("didn't expect encrypt asymmetric to fail, %v", err) + } + + err = mq.LoadAsymmetricBlock(rsaPemKeypath) + if err != nil { + t.Fatalf("didn't expect load private key block to fail, %v", err) + } + + decrypted, err := mq.DecryptAsymmetric(encrypted) + if err != nil { + t.Fatalf("didn't expect decrypt asymmetric to fail, %v", err) + } + + if !slices.Equal(decrypted, data) { + t.Errorf("expected decrypt to return %v, but got %v", data, decrypted) + } +} diff --git a/internal/mq/crypto_test.go b/internal/mq/crypto_test.go index ae246df..cab9e5e 100644 --- a/internal/mq/crypto_test.go +++ b/internal/mq/crypto_test.go @@ -168,31 +168,3 @@ func TestEncryptAndDecryptCipher(t *testing.T) { t.Errorf("expected decrypt to return %v, but got %v", data, plain) } } - -func TestEncryptAndDecryptAsymmetric(t *testing.T) { - data := []byte("zero-monitor") - - err := mq.LoadAsymmetricBlock(true) - if err != nil { - t.Fatalf("didn't expect load public key block to fail, %v", err) - } - - encrypted, err := mq.EncryptAsymmetric(data) - if err != nil { - t.Fatalf("didn't expect encrypt asymmetric to fail, %v", err) - } - - err = mq.LoadAsymmetricBlock(false) - if err != nil { - t.Fatalf("didn't expect load private key block to fail, %v", err) - } - - decrypted, err := mq.DecryptAsymmetric(encrypted) - if err != nil { - t.Fatalf("didn't expect decrypt asymmetric to fail, %v", err) - } - - if !slices.Equal(decrypted, data) { - t.Errorf("expected decrypt to return %v, but got %v", data, decrypted) - } -} diff --git a/internal/mq/pub.go b/internal/mq/pub.go index 079d15f..060a308 100644 --- a/internal/mq/pub.go +++ b/internal/mq/pub.go @@ -8,7 +8,8 @@ import ( "github.com/guackamolly/zero-monitor/internal/logging" ) -func (s Socket) RegisterPublishers() { +// Invite code is required if it's the first connection with master server. +func (s Socket) RegisterPublishers(inviteCode string) { pc := ExtractPublishContainer(s.ctx) if pc == nil { log.Fatalln("publish container hasn't been injected") @@ -37,7 +38,7 @@ func (s Socket) RegisterPublishers() { logging.LogDebug("(pub) handling topic: %d", topic) switch topic { case JoinNetwork: - err = handleJoinNetworkResponse(s, m, pc.StartNodeStatsPolling, pc.GetCurrentNode) + err = handleJoinNetworkResponse(s, m, pc.StartNodeStatsPolling, pc.GetCurrentNode, inviteCode) case AuthenticateNetwork: err = handleAuthenticateNetworkResponse(s, m, pc.GetCurrentNode) case UpdateNodeStatsPollDuration: @@ -69,9 +70,10 @@ func handleJoinNetworkResponse( m Msg, start domain.StartNodeStatsPolling, currentNode domain.GetCurrentNode, + inviteCode string, ) error { if _, ok := m.Data.(RequiresAuthenticationResponse); ok { - return handleRequiresAuthenticationResponse(s, currentNode) + return handleRequiresAuthenticationResponse(s, currentNode, inviteCode) } resp, ok := m.Data.(JoinNetworkResponse) @@ -107,14 +109,20 @@ func handleAuthenticateNetworkResponse( func handleRequiresAuthenticationResponse( s Socket, currentNode domain.GetCurrentNode, + inviteCode string, ) error { // disallow handshaking more than once, otherwise both master and node will enter in a race condition like state if handshaked { logging.LogFatal("invalid state: already authenticated but master replied with ") } + if len(inviteCode) == 0 { + println("Waiting for invite code... (press enter to resume)") + fmt.Scanln(&inviteCode) + } + handshaked = true - return s.PublishMsg(Compose(AuthenticateNetwork, AuthenticateNetworkRequest{InviteCode: InviteCode(), Node: currentNode()})) + return s.PublishMsg(Compose(AuthenticateNetwork, AuthenticateNetworkRequest{InviteCode: inviteCode, Node: currentNode()})) } func handleUpdateStatsPollDurationRequest( diff --git a/public/index.gohtml b/public/index.gohtml index d6e6415..8f2940d 100644 --- a/public/index.gohtml +++ b/public/index.gohtml @@ -72,11 +72,10 @@

Linux/macOS

- {{ if .ShowInviteLink }} -
bash <(wget -qO- https://raw.githubusercontent.com/guackamolly/zero-monitor/refs/heads/master/.github/get-node) --node={{.InviteLink}}
- {{ else }} -
bash <(wget -qO- https://raw.githubusercontent.com/guackamolly/zero-monitor/refs/heads/master/.github/get-node) --node=
- {{ end }} +
bash <(wget -qO- https://raw.githubusercontent.com/guackamolly/zero-monitor/refs/heads/master/.github/get-node)
+ +

Windows

+
curl -s -o %TEMP%\get-node.bat https://raw.githubusercontent.com/guackamolly/zero-monitor/refs/heads/master/.github/get-node.bat
"%TEMP%\get-node.bat"