diff --git a/.gitignore b/.gitignore index 8cdd224..8eea333 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,5 @@ src/log/ # dev-tags /Dockerfile -/Entrypoint.sh \ No newline at end of file +/Entrypoint.sh +example/plugins/zerotiernc/authtoken.secret diff --git a/example/plugins/helloworld/main.go b/example/plugins/helloworld/main.go index b05ab65..d4aec15 100644 --- a/example/plugins/helloworld/main.go +++ b/example/plugins/helloworld/main.go @@ -1,6 +1,7 @@ package main import ( + "embed" _ "embed" "fmt" "net/http" @@ -9,12 +10,14 @@ import ( plugin "example.com/zoraxy/helloworld/zoraxy_plugin" ) -//go:embed index.html -var indexHTML string +const ( + PLUGIN_ID = "com.example.helloworld" + UI_PATH = "/" + WEB_ROOT = "/www" +) -func helloWorldHandler(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, indexHTML) -} +//go:embed www/* +var content embed.FS func main() { // Serve the plugin intro spect @@ -33,17 +36,23 @@ func main() { // As this is a utility plugin, we don't need to capture any traffic // but only serve the UI, so we set the UI (relative to the plugin path) to "/" - UIPath: "/", + UIPath: UI_PATH, }) - if err != nil { //Terminate or enter standalone mode here panic(err) } - // Serve the hello world page - // This will serve the index.html file embedded in the binary - http.HandleFunc("/", helloWorldHandler) - fmt.Println("Server started at http://localhost:" + strconv.Itoa(runtimeCfg.Port)) - http.ListenAndServe(":"+strconv.Itoa(runtimeCfg.Port), nil) + // Register the shutdown handler + plugin.RegisterShutdownHandler(func() { + // Do cleanup here if needed + fmt.Println("Hello World Plugin Exited") + }) + + embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH) + + // Serve the hello world page in the www folder + http.Handle(UI_PATH, embedWebRouter.Handler()) + fmt.Println("Hello World started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port)) + http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil) } diff --git a/example/plugins/helloworld/www/index.html b/example/plugins/helloworld/www/index.html new file mode 100644 index 0000000..2dcf1f1 --- /dev/null +++ b/example/plugins/helloworld/www/index.html @@ -0,0 +1,34 @@ + + + + + + + + + + + + + Hello World + + + + + + +
+

Hello World

+

Welcome to your first Zoraxy plugin

+
+ + \ No newline at end of file diff --git a/example/plugins/helloworld/zoraxy_plugin/README.txt b/example/plugins/helloworld/zoraxy_plugin/README.txt new file mode 100644 index 0000000..ed8a405 --- /dev/null +++ b/example/plugins/helloworld/zoraxy_plugin/README.txt @@ -0,0 +1,19 @@ +# Zoraxy Plugin + +## Overview +This module serves as a template for building your own plugins for the Zoraxy Reverse Proxy. By copying this module to your plugin mod folder, you can create a new plugin with the necessary structure and components. + +## Instructions + +1. **Copy the Module:** + - Copy the entire `zoraxy_plugin` module to your plugin mod folder. + +2. **Include the Structure:** + - Ensure that you maintain the directory structure and file organization as provided in this module. + +3. **Modify as Needed:** + - Customize the copied module to implement the desired functionality for your plugin. + +## Directory Structure + zoraxy_plugin: Handle -introspect and -configuration process required for plugin loading and startup + embed_webserver: Handle embeded web server routing and injecting csrf token to your plugin served UI pages \ No newline at end of file diff --git a/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go b/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go new file mode 100644 index 0000000..35580dd --- /dev/null +++ b/example/plugins/helloworld/zoraxy_plugin/embed_webserver.go @@ -0,0 +1,106 @@ +package zoraxy_plugin + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "net/url" + "strings" + "time" +) + +type PluginUiRouter struct { + PluginID string //The ID of the plugin + TargetFs *embed.FS //The embed.FS where the UI files are stored + TargetFsPrefix string //The prefix of the embed.FS where the UI files are stored, e.g. /web + HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui +} + +// NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS +// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored +// The targetFsPrefix should be relative to the root of the embed.FS +// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(targetFsPrefix, "/") { + targetFsPrefix = "/" + targetFsPrefix + } + targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") + + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiRouter{ + PluginID: pluginID, + TargetFs: targetFs, + TargetFsPrefix: targetFsPrefix, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, "/") { + // Redirect to the index.html + http.Redirect(w, r, r.URL.Path+"index.html", http.StatusFound) + return + } + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from embed.FS + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetFsPrefix + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + fmt.Println(targetFilePath) + targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + http.ServeContent(w, r, r.URL.Path, time.Now(), strings.NewReader(body)) + return + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL, _ = url.Parse(rewrittenURL) + r.RequestURI = rewrittenURL + //Serve the file from the embed.FS + subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/")) + if err != nil { + fmt.Println(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) + }) +} diff --git a/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go b/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go index 4778e4e..1691591 100644 --- a/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go +++ b/example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go @@ -4,7 +4,9 @@ import ( "encoding/json" "fmt" "os" + "os/signal" "strings" + "syscall" ) /* @@ -184,3 +186,25 @@ func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { ServeIntroSpect(pluginSpect) return RecvConfigureSpec() } + +/* + +Shutdown handler + +This function will register a shutdown handler for the plugin +The shutdown callback will be called when the plugin is shutting down +You can use this to clean up resources like closing database connections +*/ + +func RegisterShutdownHandler(shutdownCallback func()) { + // Set up a channel to receive OS signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Start a goroutine to listen for signals + go func() { + <-sigChan + shutdownCallback() + os.Exit(0) + }() +} diff --git a/example/plugins/ztnc/README.md b/example/plugins/ztnc/README.md new file mode 100644 index 0000000..a942efd --- /dev/null +++ b/example/plugins/ztnc/README.md @@ -0,0 +1,11 @@ +## Global Area Network Plugin + +This plugin implements a user interface for ZeroTier Network Controller in Zoraxy + + + + + +## License + +AGPL \ No newline at end of file diff --git a/example/plugins/ztnc/authtoken.secret b/example/plugins/ztnc/authtoken.secret new file mode 100644 index 0000000..fa08db2 --- /dev/null +++ b/example/plugins/ztnc/authtoken.secret @@ -0,0 +1 @@ +hgaode9ptnpuaoi1ilbdw9i4 \ No newline at end of file diff --git a/example/plugins/ztnc/go.mod b/example/plugins/ztnc/go.mod new file mode 100644 index 0000000..aa0cc97 --- /dev/null +++ b/example/plugins/ztnc/go.mod @@ -0,0 +1,11 @@ +module aroz.org/zoraxy/ztnc + +go 1.23.6 + +require ( + github.com/boltdb/bolt v1.3.1 + github.com/syndtr/goleveldb v1.0.0 + golang.org/x/sys v0.30.0 +) + +require github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect diff --git a/example/plugins/ztnc/go.sum b/example/plugins/ztnc/go.sum new file mode 100644 index 0000000..875979f --- /dev/null +++ b/example/plugins/ztnc/go.sum @@ -0,0 +1,30 @@ +github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= +github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/example/plugins/ztnc/icon.png b/example/plugins/ztnc/icon.png new file mode 100644 index 0000000..e19e043 Binary files /dev/null and b/example/plugins/ztnc/icon.png differ diff --git a/example/plugins/ztnc/icon.psd b/example/plugins/ztnc/icon.psd new file mode 100644 index 0000000..e8c221b Binary files /dev/null and b/example/plugins/ztnc/icon.psd differ diff --git a/example/plugins/ztnc/main.go b/example/plugins/ztnc/main.go new file mode 100644 index 0000000..b302275 --- /dev/null +++ b/example/plugins/ztnc/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + + "embed" + + "aroz.org/zoraxy/ztnc/mod/database" + "aroz.org/zoraxy/ztnc/mod/ganserv" + plugin "aroz.org/zoraxy/ztnc/mod/zoraxy_plugin" +) + +const ( + PLUGIN_ID = "org.aroz.zoraxy.ztnc" + UI_RELPATH = "/ui" + EMBED_FS_ROOT = "/web" + DB_FILE_PATH = "ztnc.db" + AUTH_TOKEN_PATH = "./authtoken.secret" +) + +//go:embed web/* +var content embed.FS + +var ( + sysdb *database.Database + ganManager *ganserv.NetworkManager +) + +func main() { + // Serve the plugin intro spect + runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{ + ID: PLUGIN_ID, + Name: "ztnc", + Author: "aroz.org", + AuthorContact: "zoraxy.aroz.org", + Description: "UI for ZeroTier Network Controller", + URL: "https://zoraxy.aroz.org", + Type: plugin.PluginType_Utilities, + VersionMajor: 1, + VersionMinor: 0, + VersionPatch: 0, + + // As this is a utility plugin, we don't need to capture any traffic + // but only serve the UI, so we set the UI (relative to the plugin path) to "/ui/" to match the HTTP Handler + UIPath: UI_RELPATH, + }) + if err != nil { + //Terminate or enter standalone mode here + panic(err) + } + + // Register the shutdown handler + plugin.RegisterShutdownHandler(func() { + fmt.Println("Shutting down ZeroTier Network Controller") + if sysdb != nil { + sysdb.Close() + } + fmt.Println("ZeroTier Network Controller Exited") + }) + + // Create a new PluginEmbedUIRouter that will serve the UI from web folder + uiRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, EMBED_FS_ROOT, UI_RELPATH) + + // This will serve the index.html file embedded in the binary + http.Handle(UI_RELPATH+"/", uiRouter.Handler()) + + // Start the GAN Network Controller + err = startGanNetworkController() + if err != nil { + panic(err) + } + + // Initiate the API endpoints + initApiEndpoints() + + // Start the HTTP server, only listen to loopback interface + fmt.Println("Plugin UI server started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port) + UI_RELPATH) + http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil) +} diff --git a/example/plugins/ztnc/mod/database/database.go b/example/plugins/ztnc/mod/database/database.go new file mode 100644 index 0000000..bf82ae0 --- /dev/null +++ b/example/plugins/ztnc/mod/database/database.go @@ -0,0 +1,146 @@ +package database + +/* + ArOZ Online Database Access Module + author: tobychui + + This is an improved Object oriented base solution to the original + aroz online database script. +*/ + +import ( + "log" + "runtime" + + "aroz.org/zoraxy/ztnc/mod/database/dbinc" +) + +type Database struct { + Db interface{} //This will be nil on openwrt, leveldb.DB on x64 platforms or bolt.DB on other platforms + BackendType dbinc.BackendType + Backend dbinc.Backend +} + +func NewDatabase(dbfile string, backendType dbinc.BackendType) (*Database, error) { + if runtime.GOARCH == "riscv64" { + log.Println("RISCV hardware detected, ignoring the backend type and using FS emulated database") + } + return newDatabase(dbfile, backendType) +} + +// Get the recommended backend type for the current system +func GetRecommendedBackendType() dbinc.BackendType { + //Check if the system is running on RISCV hardware + if runtime.GOARCH == "riscv64" { + //RISCV hardware, currently only support FS emulated database + return dbinc.BackendFSOnly + } else if runtime.GOOS == "windows" || (runtime.GOOS == "linux" && runtime.GOARCH == "amd64") { + //Powerful hardware + return dbinc.BackendBoltDB + //return dbinc.BackendLevelDB + } + + //Default to BoltDB, the safest option + return dbinc.BackendBoltDB +} + +/* + Create / Drop a table + Usage: + err := sysdb.NewTable("MyTable") + err := sysdb.DropTable("MyTable") +*/ + +// Create a new table +func (d *Database) NewTable(tableName string) error { + return d.newTable(tableName) +} + +// Check is table exists +func (d *Database) TableExists(tableName string) bool { + return d.tableExists(tableName) +} + +// Drop the given table +func (d *Database) DropTable(tableName string) error { + return d.dropTable(tableName) +} + +/* +Write to database with given tablename and key. Example Usage: + + type demo struct{ + content string + } + + thisDemo := demo{ + content: "Hello World", + } + +err := sysdb.Write("MyTable", "username/message",thisDemo); +*/ +func (d *Database) Write(tableName string, key string, value interface{}) error { + return d.write(tableName, key, value) +} + +/* + Read from database and assign the content to a given datatype. Example Usage: + + type demo struct{ + content string + } + thisDemo := new(demo) + err := sysdb.Read("MyTable", "username/message",&thisDemo); +*/ + +func (d *Database) Read(tableName string, key string, assignee interface{}) error { + return d.read(tableName, key, assignee) +} + +/* +Check if a key exists in the database table given tablename and key + + if sysdb.KeyExists("MyTable", "username/message"){ + log.Println("Key exists") + } +*/ +func (d *Database) KeyExists(tableName string, key string) bool { + return d.keyExists(tableName, key) +} + +/* +Delete a value from the database table given tablename and key + +err := sysdb.Delete("MyTable", "username/message"); +*/ +func (d *Database) Delete(tableName string, key string) error { + return d.delete(tableName, key) +} + +/* + //List table example usage + //Assume the value is stored as a struct named "groupstruct" + + entries, err := sysdb.ListTable("test") + if err != nil { + panic(err) + } + for _, keypairs := range entries{ + log.Println(string(keypairs[0])) + group := new(groupstruct) + json.Unmarshal(keypairs[1], &group) + log.Println(group); + } + +*/ + +func (d *Database) ListTable(tableName string) ([][][]byte, error) { + return d.listTable(tableName) +} + +/* +Close the database connection +*/ +func (d *Database) Close() { + d.close() +} diff --git a/example/plugins/ztnc/mod/database/database_core.go b/example/plugins/ztnc/mod/database/database_core.go new file mode 100644 index 0000000..347b000 --- /dev/null +++ b/example/plugins/ztnc/mod/database/database_core.go @@ -0,0 +1,70 @@ +//go:build !mipsle && !riscv64 +// +build !mipsle,!riscv64 + +package database + +import ( + "errors" + + "aroz.org/zoraxy/ztnc/mod/database/dbbolt" + "aroz.org/zoraxy/ztnc/mod/database/dbinc" + "aroz.org/zoraxy/ztnc/mod/database/dbleveldb" +) + +func newDatabase(dbfile string, backendType dbinc.BackendType) (*Database, error) { + if backendType == dbinc.BackendFSOnly { + return nil, errors.New("Unsupported backend type for this platform") + } + + if backendType == dbinc.BackendLevelDB { + db, err := dbleveldb.NewDB(dbfile) + return &Database{ + Db: nil, + BackendType: backendType, + Backend: db, + }, err + } + + db, err := dbbolt.NewBoltDatabase(dbfile) + return &Database{ + Db: nil, + BackendType: backendType, + Backend: db, + }, err +} + +func (d *Database) newTable(tableName string) error { + return d.Backend.NewTable(tableName) +} + +func (d *Database) tableExists(tableName string) bool { + return d.Backend.TableExists(tableName) +} + +func (d *Database) dropTable(tableName string) error { + return d.Backend.DropTable(tableName) +} + +func (d *Database) write(tableName string, key string, value interface{}) error { + return d.Backend.Write(tableName, key, value) +} + +func (d *Database) read(tableName string, key string, assignee interface{}) error { + return d.Backend.Read(tableName, key, assignee) +} + +func (d *Database) keyExists(tableName string, key string) bool { + return d.Backend.KeyExists(tableName, key) +} + +func (d *Database) delete(tableName string, key string) error { + return d.Backend.Delete(tableName, key) +} + +func (d *Database) listTable(tableName string) ([][][]byte, error) { + return d.Backend.ListTable(tableName) +} + +func (d *Database) close() { + d.Backend.Close() +} diff --git a/example/plugins/ztnc/mod/database/database_openwrt.go b/example/plugins/ztnc/mod/database/database_openwrt.go new file mode 100644 index 0000000..e128a3a --- /dev/null +++ b/example/plugins/ztnc/mod/database/database_openwrt.go @@ -0,0 +1,196 @@ +//go:build mipsle || riscv64 +// +build mipsle riscv64 + +package database + +import ( + "encoding/json" + "errors" + "log" + "os" + "path/filepath" + "strings" + + "aroz.org/zoraxy/zerotiernc/mod/database/dbinc" +) + +/* + OpenWRT or RISCV backend + + For OpenWRT or RISCV platform, we will use the filesystem as the database backend + as boltdb or leveldb is not supported on these platforms, including boltDB and LevelDB + in conditional compilation will create a build error on these platforms +*/ + +func newDatabase(dbfile string, backendType dbinc.BackendType) (*Database, error) { + dbRootPath := filepath.ToSlash(filepath.Clean(dbfile)) + dbRootPath = "fsdb/" + dbRootPath + err := os.MkdirAll(dbRootPath, 0755) + if err != nil { + return nil, err + } + + log.Println("Filesystem Emulated Key-value Database Service Started: " + dbRootPath) + return &Database{ + Db: dbRootPath, + BackendType: dbinc.BackendFSOnly, + Backend: nil, + }, nil +} + +func (d *Database) dump(filename string) ([]string, error) { + //Get all file objects from root + rootfiles, err := filepath.Glob(filepath.Join(d.Db.(string), "/*")) + if err != nil { + return []string{}, err + } + + //Filter out the folders + rootFolders := []string{} + for _, file := range rootfiles { + if !isDirectory(file) { + rootFolders = append(rootFolders, filepath.Base(file)) + } + } + + return rootFolders, nil +} + +func (d *Database) newTable(tableName string) error { + + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + if !fileExists(tablePath) { + return os.MkdirAll(tablePath, 0755) + } + return nil +} + +func (d *Database) tableExists(tableName string) bool { + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + if _, err := os.Stat(tablePath); errors.Is(err, os.ErrNotExist) { + return false + } + + if !isDirectory(tablePath) { + return false + } + + return true +} + +func (d *Database) dropTable(tableName string) error { + + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + if d.tableExists(tableName) { + return os.RemoveAll(tablePath) + } else { + return errors.New("table not exists") + } + +} + +func (d *Database) write(tableName string, key string, value interface{}) error { + + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + js, err := json.Marshal(value) + if err != nil { + return err + } + + key = strings.ReplaceAll(key, "/", "-SLASH_SIGN-") + + return os.WriteFile(filepath.Join(tablePath, key+".entry"), js, 0755) +} + +func (d *Database) read(tableName string, key string, assignee interface{}) error { + if !d.keyExists(tableName, key) { + return errors.New("key not exists") + } + + key = strings.ReplaceAll(key, "/", "-SLASH_SIGN-") + + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + entryPath := filepath.Join(tablePath, key+".entry") + content, err := os.ReadFile(entryPath) + if err != nil { + return err + } + + err = json.Unmarshal(content, &assignee) + return err +} + +func (d *Database) keyExists(tableName string, key string) bool { + key = strings.ReplaceAll(key, "/", "-SLASH_SIGN-") + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + entryPath := filepath.Join(tablePath, key+".entry") + return fileExists(entryPath) +} + +func (d *Database) delete(tableName string, key string) error { + + if !d.keyExists(tableName, key) { + return errors.New("key not exists") + } + key = strings.ReplaceAll(key, "/", "-SLASH_SIGN-") + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + entryPath := filepath.Join(tablePath, key+".entry") + + return os.Remove(entryPath) +} + +func (d *Database) listTable(tableName string) ([][][]byte, error) { + if !d.tableExists(tableName) { + return [][][]byte{}, errors.New("table not exists") + } + tablePath := filepath.Join(d.Db.(string), filepath.Base(tableName)) + entries, err := filepath.Glob(filepath.Join(tablePath, "/*.entry")) + if err != nil { + return [][][]byte{}, err + } + + var results [][][]byte = [][][]byte{} + for _, entry := range entries { + if !isDirectory(entry) { + //Read it + key := filepath.Base(entry) + key = strings.TrimSuffix(key, filepath.Ext(key)) + key = strings.ReplaceAll(key, "-SLASH_SIGN-", "/") + + bkey := []byte(key) + bval := []byte("") + c, err := os.ReadFile(entry) + if err != nil { + break + } + + bval = c + results = append(results, [][]byte{bkey, bval}) + } + } + return results, nil +} + +func (d *Database) close() { + //Nothing to close as it is file system +} + +func isDirectory(path string) bool { + fileInfo, err := os.Stat(path) + if err != nil { + return false + } + + return fileInfo.IsDir() +} + +func fileExists(name string) bool { + _, err := os.Stat(name) + if err == nil { + return true + } + if errors.Is(err, os.ErrNotExist) { + return false + } + return false +} diff --git a/example/plugins/ztnc/mod/database/dbbolt/dbbolt.go b/example/plugins/ztnc/mod/database/dbbolt/dbbolt.go new file mode 100644 index 0000000..8cf7ec0 --- /dev/null +++ b/example/plugins/ztnc/mod/database/dbbolt/dbbolt.go @@ -0,0 +1,141 @@ +package dbbolt + +import ( + "encoding/json" + "errors" + + "github.com/boltdb/bolt" +) + +type Database struct { + Db interface{} //This is the bolt database object +} + +func NewBoltDatabase(dbfile string) (*Database, error) { + db, err := bolt.Open(dbfile, 0600, nil) + if err != nil { + return nil, err + } + + return &Database{ + Db: db, + }, err +} + +// Create a new table +func (d *Database) NewTable(tableName string) error { + err := d.Db.(*bolt.DB).Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(tableName)) + if err != nil { + return err + } + return nil + }) + + return err +} + +// Check is table exists +func (d *Database) TableExists(tableName string) bool { + return d.Db.(*bolt.DB).View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(tableName)) + if b == nil { + return errors.New("table not exists") + } + return nil + }) == nil +} + +// Drop the given table +func (d *Database) DropTable(tableName string) error { + err := d.Db.(*bolt.DB).Update(func(tx *bolt.Tx) error { + err := tx.DeleteBucket([]byte(tableName)) + if err != nil { + return err + } + return nil + }) + return err +} + +// Write to table +func (d *Database) Write(tableName string, key string, value interface{}) error { + jsonString, err := json.Marshal(value) + if err != nil { + return err + } + err = d.Db.(*bolt.DB).Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(tableName)) + if err != nil { + return err + } + b := tx.Bucket([]byte(tableName)) + err = b.Put([]byte(key), jsonString) + return err + }) + return err +} + +func (d *Database) Read(tableName string, key string, assignee interface{}) error { + err := d.Db.(*bolt.DB).View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(tableName)) + v := b.Get([]byte(key)) + json.Unmarshal(v, &assignee) + return nil + }) + return err +} + +func (d *Database) KeyExists(tableName string, key string) bool { + resultIsNil := false + if !d.TableExists(tableName) { + //Table not exists. Do not proceed accessing key + //log.Println("[DB] ERROR: Requesting key from table that didn't exist!!!") + return false + } + err := d.Db.(*bolt.DB).View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(tableName)) + v := b.Get([]byte(key)) + if v == nil { + resultIsNil = true + } + return nil + }) + + if err != nil { + return false + } else { + if resultIsNil { + return false + } else { + return true + } + } +} + +func (d *Database) Delete(tableName string, key string) error { + err := d.Db.(*bolt.DB).Update(func(tx *bolt.Tx) error { + tx.Bucket([]byte(tableName)).Delete([]byte(key)) + return nil + }) + + return err +} + +func (d *Database) ListTable(tableName string) ([][][]byte, error) { + var results [][][]byte + err := d.Db.(*bolt.DB).View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(tableName)) + c := b.Cursor() + + for k, v := c.First(); k != nil; k, v = c.Next() { + results = append(results, [][]byte{k, v}) + } + return nil + }) + return results, err +} + +func (d *Database) Close() { + d.Db.(*bolt.DB).Close() +} diff --git a/example/plugins/ztnc/mod/database/dbbolt/dbbolt_test.go b/example/plugins/ztnc/mod/database/dbbolt/dbbolt_test.go new file mode 100644 index 0000000..05e708a --- /dev/null +++ b/example/plugins/ztnc/mod/database/dbbolt/dbbolt_test.go @@ -0,0 +1,67 @@ +package dbbolt_test + +import ( + "os" + "testing" + + "aroz.org/zoraxy/ztnc/mod/database/dbbolt" +) + +func TestNewBoltDatabase(t *testing.T) { + dbfile := "test.db" + defer os.Remove(dbfile) + + db, err := dbbolt.NewBoltDatabase(dbfile) + if err != nil { + t.Fatalf("Failed to create new Bolt database: %v", err) + } + defer db.Close() + + if db.Db == nil { + t.Fatalf("Expected non-nil database object") + } +} + +func TestNewTable(t *testing.T) { + dbfile := "test.db" + defer os.Remove(dbfile) + + db, err := dbbolt.NewBoltDatabase(dbfile) + if err != nil { + t.Fatalf("Failed to create new Bolt database: %v", err) + } + defer db.Close() + + err = db.NewTable("testTable") + if err != nil { + t.Fatalf("Failed to create new table: %v", err) + } +} + +func TestTableExists(t *testing.T) { + dbfile := "test.db" + defer os.Remove(dbfile) + + db, err := dbbolt.NewBoltDatabase(dbfile) + if err != nil { + t.Fatalf("Failed to create new Bolt database: %v", err) + } + defer db.Close() + + tableName := "testTable" + err = db.NewTable(tableName) + if err != nil { + t.Fatalf("Failed to create new table: %v", err) + } + + exists := db.TableExists(tableName) + if !exists { + t.Fatalf("Expected table %s to exist", tableName) + } + + nonExistentTable := "nonExistentTable" + exists = db.TableExists(nonExistentTable) + if exists { + t.Fatalf("Expected table %s to not exist", nonExistentTable) + } +} diff --git a/example/plugins/ztnc/mod/database/dbinc/dbinc.go b/example/plugins/ztnc/mod/database/dbinc/dbinc.go new file mode 100644 index 0000000..8e60ba0 --- /dev/null +++ b/example/plugins/ztnc/mod/database/dbinc/dbinc.go @@ -0,0 +1,39 @@ +package dbinc + +/* + dbinc is the interface for all database backend +*/ +type BackendType int + +const ( + BackendBoltDB BackendType = iota //Default backend + BackendFSOnly //OpenWRT or RISCV backend + BackendLevelDB //LevelDB backend + + BackEndAuto = BackendBoltDB +) + +type Backend interface { + NewTable(tableName string) error + TableExists(tableName string) bool + DropTable(tableName string) error + Write(tableName string, key string, value interface{}) error + Read(tableName string, key string, assignee interface{}) error + KeyExists(tableName string, key string) bool + Delete(tableName string, key string) error + ListTable(tableName string) ([][][]byte, error) + Close() +} + +func (b BackendType) String() string { + switch b { + case BackendBoltDB: + return "BoltDB" + case BackendFSOnly: + return "File System Emulated Key-Value Store" + case BackendLevelDB: + return "LevelDB" + default: + return "Unknown" + } +} diff --git a/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb.go b/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb.go new file mode 100644 index 0000000..59b9667 --- /dev/null +++ b/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb.go @@ -0,0 +1,152 @@ +package dbleveldb + +import ( + "encoding/json" + "log" + "path/filepath" + "strings" + "sync" + "time" + + "aroz.org/zoraxy/ztnc/mod/database/dbinc" + "github.com/syndtr/goleveldb/leveldb" + "github.com/syndtr/goleveldb/leveldb/util" +) + +// Ensure the DB struct implements the Backend interface +var _ dbinc.Backend = (*DB)(nil) + +type DB struct { + db *leveldb.DB + Table sync.Map //For emulating table creation + batch leveldb.Batch //Batch write + writeFlushTicker *time.Ticker //Ticker for flushing data into disk + writeFlushStop chan bool //Stop channel for write flush ticker +} + +func NewDB(path string) (*DB, error) { + //If the path is not a directory (e.g. /tmp/dbfile.db), convert the filename to directory + if filepath.Ext(path) != "" { + path = strings.ReplaceAll(path, ".", "_") + } + + db, err := leveldb.OpenFile(path, nil) + if err != nil { + return nil, err + } + + thisDB := &DB{ + db: db, + Table: sync.Map{}, + batch: leveldb.Batch{}, + } + + //Create a ticker to flush data into disk every 1 seconds + writeFlushTicker := time.NewTicker(1 * time.Second) + writeFlushStop := make(chan bool) + go func() { + for { + select { + case <-writeFlushTicker.C: + if thisDB.batch.Len() == 0 { + //No flushing needed + continue + } + err = db.Write(&thisDB.batch, nil) + if err != nil { + log.Println("[LevelDB] Failed to flush data into disk: ", err) + } + thisDB.batch.Reset() + case <-writeFlushStop: + return + } + } + }() + + thisDB.writeFlushTicker = writeFlushTicker + thisDB.writeFlushStop = writeFlushStop + + return thisDB, nil +} + +func (d *DB) NewTable(tableName string) error { + //Create a table entry in the sync.Map + d.Table.Store(tableName, true) + return nil +} + +func (d *DB) TableExists(tableName string) bool { + _, ok := d.Table.Load(tableName) + return ok +} + +func (d *DB) DropTable(tableName string) error { + d.Table.Delete(tableName) + iter := d.db.NewIterator(nil, nil) + defer iter.Release() + + for iter.Next() { + key := iter.Key() + if filepath.Dir(string(key)) == tableName { + err := d.db.Delete(key, nil) + if err != nil { + return err + } + } + } + + return nil +} + +func (d *DB) Write(tableName string, key string, value interface{}) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + d.batch.Put([]byte(filepath.ToSlash(filepath.Join(tableName, key))), data) + return nil +} + +func (d *DB) Read(tableName string, key string, assignee interface{}) error { + data, err := d.db.Get([]byte(filepath.ToSlash(filepath.Join(tableName, key))), nil) + if err != nil { + return err + } + return json.Unmarshal(data, assignee) +} + +func (d *DB) KeyExists(tableName string, key string) bool { + _, err := d.db.Get([]byte(filepath.ToSlash(filepath.Join(tableName, key))), nil) + return err == nil +} + +func (d *DB) Delete(tableName string, key string) error { + return d.db.Delete([]byte(filepath.ToSlash(filepath.Join(tableName, key))), nil) +} + +func (d *DB) ListTable(tableName string) ([][][]byte, error) { + iter := d.db.NewIterator(util.BytesPrefix([]byte(tableName+"/")), nil) + defer iter.Release() + + var result [][][]byte + for iter.Next() { + key := iter.Key() + //The key contains the table name as prefix. Trim it before returning + value := iter.Value() + result = append(result, [][]byte{[]byte(strings.TrimPrefix(string(key), tableName+"/")), value}) + } + + err := iter.Error() + if err != nil { + return nil, err + } + return result, nil +} + +func (d *DB) Close() { + //Write the remaining data in batch back into disk + d.writeFlushStop <- true + d.writeFlushTicker.Stop() + d.db.Write(&d.batch, nil) + d.db.Close() +} diff --git a/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb_test.go b/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb_test.go new file mode 100644 index 0000000..c091684 --- /dev/null +++ b/example/plugins/ztnc/mod/database/dbleveldb/dbleveldb_test.go @@ -0,0 +1,141 @@ +package dbleveldb_test + +import ( + "os" + "testing" + + "aroz.org/zoraxy/ztnc/mod/database/dbleveldb" +) + +func TestNewDB(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() +} + +func TestNewTable(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() + + err = db.NewTable("testTable") + if err != nil { + t.Fatalf("Failed to create new table: %v", err) + } +} + +func TestTableExists(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() + + db.NewTable("testTable") + if !db.TableExists("testTable") { + t.Fatalf("Table should exist") + } +} + +func TestDropTable(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() + + db.NewTable("testTable") + err = db.DropTable("testTable") + if err != nil { + t.Fatalf("Failed to drop table: %v", err) + } + + if db.TableExists("testTable") { + t.Fatalf("Table should not exist") + } +} + +func TestWriteAndRead(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() + + db.NewTable("testTable") + err = db.Write("testTable", "testKey", "testValue") + if err != nil { + t.Fatalf("Failed to write to table: %v", err) + } + + var value string + err = db.Read("testTable", "testKey", &value) + if err != nil { + t.Fatalf("Failed to read from table: %v", err) + } + + if value != "testValue" { + t.Fatalf("Expected 'testValue', got '%v'", value) + } +} +func TestListTable(t *testing.T) { + path := "/tmp/testdb" + defer os.RemoveAll(path) + + db, err := dbleveldb.NewDB(path) + if err != nil { + t.Fatalf("Failed to create new DB: %v", err) + } + defer db.Close() + + db.NewTable("testTable") + err = db.Write("testTable", "testKey1", "testValue1") + if err != nil { + t.Fatalf("Failed to write to table: %v", err) + } + err = db.Write("testTable", "testKey2", "testValue2") + if err != nil { + t.Fatalf("Failed to write to table: %v", err) + } + + result, err := db.ListTable("testTable") + if err != nil { + t.Fatalf("Failed to list table: %v", err) + } + + if len(result) != 2 { + t.Fatalf("Expected 2 entries, got %v", len(result)) + } + + expected := map[string]string{ + "testTable/testKey1": "\"testValue1\"", + "testTable/testKey2": "\"testValue2\"", + } + + for _, entry := range result { + key := string(entry[0]) + value := string(entry[1]) + if expected[key] != value { + t.Fatalf("Expected value '%v' for key '%v', got '%v'", expected[key], key, value) + } + } +} diff --git a/example/plugins/ztnc/mod/ganserv/authkey.go b/example/plugins/ztnc/mod/ganserv/authkey.go new file mode 100644 index 0000000..006e90d --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/authkey.go @@ -0,0 +1,80 @@ +package ganserv + +import ( + "errors" + "log" + "os" + "runtime" + "strings" +) + +func TryLoadorAskUserForAuthkey() (string, error) { + //Check for zt auth token + value, exists := os.LookupEnv("ZT_AUTH") + if !exists { + log.Println("Environment variable ZT_AUTH not defined. Trying to load authtoken from file.") + } else { + return value, nil + } + + authKey := "" + if runtime.GOOS == "windows" { + if isAdmin() { + //Read the secret file directly + b, err := os.ReadFile("C:\\ProgramData\\ZeroTier\\One\\authtoken.secret") + if err == nil { + log.Println("Zerotier authkey loaded") + authKey = string(b) + } else { + log.Println("Unable to read authkey at C:\\ProgramData\\ZeroTier\\One\\authtoken.secret: ", err.Error()) + } + } else { + //Elavate the permission to admin + ak, err := readAuthTokenAsAdmin() + if err == nil { + log.Println("Zerotier authkey loaded") + authKey = ak + } else { + log.Println("Unable to read authkey at C:\\ProgramData\\ZeroTier\\One\\authtoken.secret: ", err.Error()) + } + } + + } else if runtime.GOOS == "linux" { + if isAdmin() { + //Try to read from source using sudo + ak, err := readAuthTokenAsAdmin() + if err == nil { + log.Println("Zerotier authkey loaded") + authKey = strings.TrimSpace(ak) + } else { + log.Println("Unable to read authkey at /var/lib/zerotier-one/authtoken.secret: ", err.Error()) + } + } else { + //Try read from source + b, err := os.ReadFile("/var/lib/zerotier-one/authtoken.secret") + if err == nil { + log.Println("Zerotier authkey loaded") + authKey = string(b) + } else { + log.Println("Unable to read authkey at /var/lib/zerotier-one/authtoken.secret: ", err.Error()) + } + } + + } else if runtime.GOOS == "darwin" { + b, err := os.ReadFile("/Library/Application Support/ZeroTier/One/authtoken.secret") + if err == nil { + log.Println("Zerotier authkey loaded") + authKey = string(b) + } else { + log.Println("Unable to read authkey at /Library/Application Support/ZeroTier/One/authtoken.secret ", err.Error()) + } + } + + authKey = strings.TrimSpace(authKey) + + if authKey == "" { + return "", errors.New("Unable to load authkey from file") + } + + return authKey, nil +} diff --git a/example/plugins/ztnc/mod/ganserv/authkeyLinux.go b/example/plugins/ztnc/mod/ganserv/authkeyLinux.go new file mode 100644 index 0000000..8423c56 --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/authkeyLinux.go @@ -0,0 +1,37 @@ +//go:build linux +// +build linux + +package ganserv + +import ( + "os" + "os/exec" + "os/user" + "strings" + + "aroz.org/zoraxy/zerotiernc/mod/utils" +) + +func readAuthTokenAsAdmin() (string, error) { + if utils.FileExists("./conf/authtoken.secret") { + authKey, err := os.ReadFile("./conf/authtoken.secret") + if err == nil { + return strings.TrimSpace(string(authKey)), nil + } + } + + cmd := exec.Command("sudo", "cat", "/var/lib/zerotier-one/authtoken.secret") + output, err := cmd.Output() + if err != nil { + return "", err + } + return string(output), nil +} + +func isAdmin() bool { + currentUser, err := user.Current() + if err != nil { + return false + } + return currentUser.Username == "root" +} diff --git a/example/plugins/ztnc/mod/ganserv/authkeyWin.go b/example/plugins/ztnc/mod/ganserv/authkeyWin.go new file mode 100644 index 0000000..aa03e31 --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/authkeyWin.go @@ -0,0 +1,73 @@ +//go:build windows +// +build windows + +package ganserv + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "aroz.org/zoraxy/ztnc/mod/utils" + "golang.org/x/sys/windows" +) + +// Use admin permission to read auth token on Windows +func readAuthTokenAsAdmin() (string, error) { + //Check if the previous startup already extracted the authkey + if utils.FileExists("./conf/authtoken.secret") { + authKey, err := os.ReadFile("./conf/authtoken.secret") + if err == nil { + return strings.TrimSpace(string(authKey)), nil + } + } + + verb := "runas" + exe := "cmd.exe" + cwd, _ := os.Getwd() + + output, _ := filepath.Abs(filepath.Join("./conf/", "authtoken.secret")) + os.WriteFile(output, []byte(""), 0775) + args := fmt.Sprintf("/C type \"C:\\ProgramData\\ZeroTier\\One\\authtoken.secret\" > \"" + output + "\"") + + verbPtr, _ := syscall.UTF16PtrFromString(verb) + exePtr, _ := syscall.UTF16PtrFromString(exe) + cwdPtr, _ := syscall.UTF16PtrFromString(cwd) + argPtr, _ := syscall.UTF16PtrFromString(args) + + var showCmd int32 = 1 //SW_NORMAL + + err := windows.ShellExecute(0, verbPtr, exePtr, argPtr, cwdPtr, showCmd) + if err != nil { + return "", err + } + + log.Println("Please click agree to allow access to ZeroTier authtoken from ProgramData") + retry := 0 + time.Sleep(3 * time.Second) + for !utils.FileExists("./conf/authtoken.secret") && retry < 10 { + time.Sleep(3 * time.Second) + log.Println("Waiting for ZeroTier authtoken extraction...") + retry++ + } + + authKey, err := os.ReadFile("./conf/authtoken.secret") + if err != nil { + return "", err + } + + return strings.TrimSpace(string(authKey)), nil +} + +// Check if admin on Windows +func isAdmin() bool { + _, err := os.Open("\\\\.\\PHYSICALDRIVE0") + if err != nil { + return false + } + return true +} diff --git a/example/plugins/ztnc/mod/ganserv/ganserv.go b/example/plugins/ztnc/mod/ganserv/ganserv.go new file mode 100644 index 0000000..f81e39b --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/ganserv.go @@ -0,0 +1,130 @@ +package ganserv + +import ( + "log" + "net" + + "aroz.org/zoraxy/ztnc/mod/database" +) + +/* + Global Area Network + Server side implementation + + This module do a few things to help manage + the system GANs + + - Provide DHCP assign to client + - Provide a list of connected nodes in the same VLAN + - Provide proxy of packet if the target VLAN is online but not reachable + + Also provide HTTP Handler functions for management + - Create Network + - Update Network Properties (Name / Desc) + - Delete Network + + - Authorize Node + - Deauthorize Node + - Set / Get Network Prefered Subnet Mask + - Handle Node ping +*/ + +type Node struct { + Auth bool //If the node is authorized in this network + ClientID string //The client ID + MAC string //The tap MAC this client is using + Name string //Name of the client in this network + Description string //Description text + ManagedIP net.IP //The IP address assigned by this network + LastSeen int64 //Last time it is seen from this host + ClientVersion string //Client application version + PublicIP net.IP //Public IP address as seen from this host +} + +type Network struct { + UID string //UUID of the network, must be a 16 char random ASCII string + Name string //Name of the network, ASCII only + Description string //Description of the network + CIDR string //The subnet masked use by this network + Nodes []*Node //The nodes currently attached in this network +} + +type NetworkManagerOptions struct { + Database *database.Database + AuthToken string + ApiPort int +} + +type NetworkMetaData struct { + Desc string +} + +type MemberMetaData struct { + Name string +} + +type NetworkManager struct { + authToken string + apiPort int + ControllerID string + option *NetworkManagerOptions + networksMetadata map[string]NetworkMetaData +} + +// Create a new GAN manager +func NewNetworkManager(option *NetworkManagerOptions) *NetworkManager { + option.Database.NewTable("ganserv") + + //Load network metadata + networkMeta := map[string]NetworkMetaData{} + if option.Database.KeyExists("ganserv", "networkmeta") { + option.Database.Read("ganserv", "networkmeta", &networkMeta) + } + + //Start the zerotier instance if not exists + + //Get controller info + instanceInfo, err := getControllerInfo(option.AuthToken, option.ApiPort) + if err != nil { + log.Println("ZeroTier connection failed: ", err.Error()) + return &NetworkManager{ + authToken: option.AuthToken, + apiPort: option.ApiPort, + ControllerID: "", + option: option, + networksMetadata: networkMeta, + } + } + + return &NetworkManager{ + authToken: option.AuthToken, + apiPort: option.ApiPort, + ControllerID: instanceInfo.Address, + option: option, + networksMetadata: networkMeta, + } +} + +func (m *NetworkManager) GetNetworkMetaData(netid string) *NetworkMetaData { + md, ok := m.networksMetadata[netid] + if !ok { + return &NetworkMetaData{} + } + + return &md +} + +func (m *NetworkManager) WriteNetworkMetaData(netid string, meta *NetworkMetaData) { + m.networksMetadata[netid] = *meta + m.option.Database.Write("ganserv", "networkmeta", m.networksMetadata) +} + +func (m *NetworkManager) GetMemberMetaData(netid string, memid string) *MemberMetaData { + thisMemberData := MemberMetaData{} + m.option.Database.Read("ganserv", "memberdata_"+netid+"_"+memid, &thisMemberData) + return &thisMemberData +} + +func (m *NetworkManager) WriteMemeberMetaData(netid string, memid string, meta *MemberMetaData) { + m.option.Database.Write("ganserv", "memberdata_"+netid+"_"+memid, meta) +} diff --git a/example/plugins/ztnc/mod/ganserv/handlers.go b/example/plugins/ztnc/mod/ganserv/handlers.go new file mode 100644 index 0000000..4ab76da --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/handlers.go @@ -0,0 +1,504 @@ +package ganserv + +import ( + "encoding/json" + "net" + "net/http" + "regexp" + "strings" + + "aroz.org/zoraxy/ztnc/mod/utils" +) + +func (m *NetworkManager) HandleGetNodeID(w http.ResponseWriter, r *http.Request) { + if m.ControllerID == "" { + //Node id not exists. Check again + instanceInfo, err := getControllerInfo(m.option.AuthToken, m.option.ApiPort) + if err != nil { + utils.SendErrorResponse(w, "unable to access node id information") + return + } + + m.ControllerID = instanceInfo.Address + } + + js, _ := json.Marshal(m.ControllerID) + utils.SendJSONResponse(w, string(js)) +} + +func (m *NetworkManager) HandleAddNetwork(w http.ResponseWriter, r *http.Request) { + networkInfo, err := m.createNetwork() + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + //Network created. Assign it the standard network settings + err = m.configureNetwork(networkInfo.Nwid, "192.168.192.1", "192.168.192.254", "192.168.192.0/24") + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + // Return the new network ID + js, _ := json.Marshal(networkInfo.Nwid) + utils.SendJSONResponse(w, string(js)) +} + +func (m *NetworkManager) HandleRemoveNetwork(w http.ResponseWriter, r *http.Request) { + networkID, err := utils.PostPara(r, "id") + if err != nil { + utils.SendErrorResponse(w, "invalid or empty network id given") + return + } + + if !m.networkExists(networkID) { + utils.SendErrorResponse(w, "network id not exists") + return + } + + err = m.deleteNetwork(networkID) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + } + + utils.SendOK(w) +} + +func (m *NetworkManager) HandleListNetwork(w http.ResponseWriter, r *http.Request) { + netid, _ := utils.GetPara(r, "netid") + if netid != "" { + targetNetInfo, err := m.getNetworkInfoById(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + js, _ := json.Marshal(targetNetInfo) + utils.SendJSONResponse(w, string(js)) + + } else { + // Return the list of networks as JSON + networkIds, err := m.listNetworkIds() + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + networkInfos := []*NetworkInfo{} + for _, id := range networkIds { + thisNetInfo, err := m.getNetworkInfoById(id) + if err == nil { + networkInfos = append(networkInfos, thisNetInfo) + } + } + + js, _ := json.Marshal(networkInfos) + utils.SendJSONResponse(w, string(js)) + } + +} + +func (m *NetworkManager) HandleNetworkNaming(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "network id not given") + return + } + + if !m.networkExists(netid) { + utils.SendErrorResponse(w, "network not eixsts") + } + + newName, _ := utils.PostPara(r, "name") + newDesc, _ := utils.PostPara(r, "desc") + if newName != "" && newDesc != "" { + //Strip away html from name and desc + re := regexp.MustCompile("<[^>]*>") + newName := re.ReplaceAllString(newName, "") + newDesc := re.ReplaceAllString(newDesc, "") + + //Set the new network name and desc + err = m.setNetworkNameAndDescription(netid, newName, newDesc) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + utils.SendOK(w) + } else { + //Get current name and description + name, desc, err := m.getNetworkNameAndDescription(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + js, _ := json.Marshal([]string{name, desc}) + utils.SendJSONResponse(w, string(js)) + } +} + +func (m *NetworkManager) HandleNetworkDetails(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "netid not given") + return + } + + targetNetwork, err := m.getNetworkInfoById(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + js, _ := json.Marshal(targetNetwork) + utils.SendJSONResponse(w, string(js)) +} + +func (m *NetworkManager) HandleSetRanges(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "netid not given") + return + } + cidr, err := utils.PostPara(r, "cidr") + if err != nil { + utils.SendErrorResponse(w, "cidr not given") + return + } + ipstart, err := utils.PostPara(r, "ipstart") + if err != nil { + utils.SendErrorResponse(w, "ipstart not given") + return + } + ipend, err := utils.PostPara(r, "ipend") + if err != nil { + utils.SendErrorResponse(w, "ipend not given") + return + } + + //Validate the CIDR is real, the ip range is within the CIDR range + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + utils.SendErrorResponse(w, "invalid cidr string given") + return + } + + startIP := net.ParseIP(ipstart) + endIP := net.ParseIP(ipend) + if startIP == nil || endIP == nil { + utils.SendErrorResponse(w, "invalid start or end ip given") + return + } + + withinRange := ipnet.Contains(startIP) && ipnet.Contains(endIP) + if !withinRange { + utils.SendErrorResponse(w, "given CIDR did not cover all of the start to end ip range") + return + } + + err = m.configureNetwork(netid, startIP.String(), endIP.String(), strings.TrimSpace(cidr)) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + utils.SendOK(w) +} + +// Handle listing of network members. Set details=true for listing all details +func (m *NetworkManager) HandleMemberList(w http.ResponseWriter, r *http.Request) { + netid, err := utils.GetPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "netid is empty") + return + } + + details, _ := utils.GetPara(r, "detail") + + memberIds, err := m.getNetworkMembers(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + if details == "" { + //Only show client ids + js, _ := json.Marshal(memberIds) + utils.SendJSONResponse(w, string(js)) + } else { + //Show detail members info + detailMemberInfo := []*MemberInfo{} + for _, thisMemberId := range memberIds { + memInfo, err := m.getNetworkMemberInfo(netid, thisMemberId) + if err == nil { + detailMemberInfo = append(detailMemberInfo, memInfo) + } + } + + js, _ := json.Marshal(detailMemberInfo) + utils.SendJSONResponse(w, string(js)) + } +} + +// Handle Authorization of members +func (m *NetworkManager) HandleMemberAuthorization(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + memberid, err := utils.PostPara(r, "memid") + if err != nil { + utils.SendErrorResponse(w, "memid not set") + return + } + + //Check if the target memeber exists + if !m.memberExistsInNetwork(netid, memberid) { + utils.SendErrorResponse(w, "member not exists in given network") + return + } + + setAuthorized, err := utils.PostPara(r, "auth") + if err != nil || setAuthorized == "" { + //Get the member authorization state + memberInfo, err := m.getNetworkMemberInfo(netid, memberid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + js, _ := json.Marshal(memberInfo.Authorized) + utils.SendJSONResponse(w, string(js)) + } else if setAuthorized == "true" { + m.AuthorizeMember(netid, memberid, true) + } else if setAuthorized == "false" { + m.AuthorizeMember(netid, memberid, false) + } else { + utils.SendErrorResponse(w, "unknown operation state: "+setAuthorized) + } +} + +// Handle Delete or Add IP for a member in a network +func (m *NetworkManager) HandleMemberIP(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + memberid, err := utils.PostPara(r, "memid") + if err != nil { + utils.SendErrorResponse(w, "memid not set") + return + } + + opr, err := utils.PostPara(r, "opr") + if err != nil { + utils.SendErrorResponse(w, "opr not defined") + return + } + + targetip, _ := utils.PostPara(r, "ip") + + memberInfo, err := m.getNetworkMemberInfo(netid, memberid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + if opr == "add" { + if targetip == "" { + utils.SendErrorResponse(w, "ip not set") + return + } + + if !isValidIPAddr(targetip) { + utils.SendErrorResponse(w, "ip address not valid") + return + } + + newIpList := append(memberInfo.IPAssignments, targetip) + err = m.setAssignedIps(netid, memberid, newIpList) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + utils.SendOK(w) + + } else if opr == "del" { + if targetip == "" { + utils.SendErrorResponse(w, "ip not set") + return + } + + //Delete user ip from the list + newIpList := []string{} + for _, thisIp := range memberInfo.IPAssignments { + if thisIp != targetip { + newIpList = append(newIpList, thisIp) + } + } + + err = m.setAssignedIps(netid, memberid, newIpList) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + utils.SendOK(w) + } else if opr == "get" { + js, _ := json.Marshal(memberInfo.IPAssignments) + utils.SendJSONResponse(w, string(js)) + } else { + utils.SendErrorResponse(w, "unsupported opr type: "+opr) + } +} + +// Handle naming for members +func (m *NetworkManager) HandleMemberNaming(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + memberid, err := utils.PostPara(r, "memid") + if err != nil { + utils.SendErrorResponse(w, "memid not set") + return + } + + if !m.memberExistsInNetwork(netid, memberid) { + utils.SendErrorResponse(w, "target member not exists in given network") + return + } + + //Read memeber data + targetMemberData := m.GetMemberMetaData(netid, memberid) + + newname, err := utils.PostPara(r, "name") + if err != nil { + //Send over the member data + js, _ := json.Marshal(targetMemberData) + utils.SendJSONResponse(w, string(js)) + } else { + //Write member data + targetMemberData.Name = newname + m.WriteMemeberMetaData(netid, memberid, targetMemberData) + utils.SendOK(w) + } +} + +// Handle delete of a given memver +func (m *NetworkManager) HandleMemberDelete(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + memberid, err := utils.PostPara(r, "memid") + if err != nil { + utils.SendErrorResponse(w, "memid not set") + return + } + + //Check if that member is authorized. + memberInfo, err := m.getNetworkMemberInfo(netid, memberid) + if err != nil { + utils.SendErrorResponse(w, "member not exists in given GANet") + return + } + + if memberInfo.Authorized { + //Deauthorized this member before deleting + m.AuthorizeMember(netid, memberid, false) + } + + //Remove the memeber + err = m.deleteMember(netid, memberid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + utils.SendOK(w) +} + +// Check if a given network id is a network hosted on this zoraxy node +func (m *NetworkManager) IsLocalGAN(networkId string) bool { + networks, err := m.listNetworkIds() + if err != nil { + return false + } + + for _, network := range networks { + if network == networkId { + return true + } + } + + return false +} + +// Handle server instant joining a given network +func (m *NetworkManager) HandleServerJoinNetwork(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + //Check if the target network is a network hosted on this server + if !m.IsLocalGAN(netid) { + utils.SendErrorResponse(w, "given network is not a GAN hosted on this node") + return + } + + if m.memberExistsInNetwork(netid, m.ControllerID) { + utils.SendErrorResponse(w, "controller already inside network") + return + } + + //Join the network + err = m.joinNetwork(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + utils.SendOK(w) +} + +// Handle server instant leaving a given network +func (m *NetworkManager) HandleServerLeaveNetwork(w http.ResponseWriter, r *http.Request) { + netid, err := utils.PostPara(r, "netid") + if err != nil { + utils.SendErrorResponse(w, "net id not set") + return + } + + //Check if the target network is a network hosted on this server + if !m.IsLocalGAN(netid) { + utils.SendErrorResponse(w, "given network is not a GAN hosted on this node") + return + } + + //Leave the network + err = m.leaveNetwork(netid) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + //Remove it from target network if it is authorized + err = m.deleteMember(netid, m.ControllerID) + if err != nil { + utils.SendErrorResponse(w, err.Error()) + return + } + + utils.SendOK(w) +} diff --git a/example/plugins/ztnc/mod/ganserv/network.go b/example/plugins/ztnc/mod/ganserv/network.go new file mode 100644 index 0000000..9f4ec73 --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/network.go @@ -0,0 +1,39 @@ +package ganserv + +import ( + "fmt" + "math/rand" + "net" + "time" +) + +//Get a random free IP from the pool +func (n *Network) GetRandomFreeIP() (net.IP, error) { + // Get all IP addresses in the subnet + ips, err := GetAllAddressFromCIDR(n.CIDR) + if err != nil { + return nil, err + } + + // Filter out used IPs + usedIPs := make(map[string]bool) + for _, node := range n.Nodes { + usedIPs[node.ManagedIP.String()] = true + } + availableIPs := []string{} + for _, ip := range ips { + if !usedIPs[ip] { + availableIPs = append(availableIPs, ip) + } + } + + // Randomly choose an available IP + if len(availableIPs) == 0 { + return nil, fmt.Errorf("no available IP") + } + rand.Seed(time.Now().UnixNano()) + randIndex := rand.Intn(len(availableIPs)) + pickedFreeIP := availableIPs[randIndex] + + return net.ParseIP(pickedFreeIP), nil +} diff --git a/example/plugins/ztnc/mod/ganserv/network_test.go b/example/plugins/ztnc/mod/ganserv/network_test.go new file mode 100644 index 0000000..2002b9f --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/network_test.go @@ -0,0 +1,55 @@ +package ganserv_test + +import ( + "fmt" + "net" + "strconv" + "testing" + + "aroz.org/zoraxy/ztnc/mod/ganserv" +) + +func TestGetRandomFreeIP(t *testing.T) { + n := ganserv.Network{ + CIDR: "172.16.0.0/12", + Nodes: []*ganserv.Node{ + { + Name: "nodeC1", + ManagedIP: net.ParseIP("172.16.1.142"), + }, + { + Name: "nodeC2", + ManagedIP: net.ParseIP("172.16.5.174"), + }, + }, + } + + // Call the function for 10 times + for i := 0; i < 10; i++ { + freeIP, err := n.GetRandomFreeIP() + fmt.Println("["+strconv.Itoa(i)+"] Free IP address assigned: ", freeIP) + + // Assert that no error occurred + if err != nil { + t.Errorf("Unexpected error: %s", err.Error()) + } + + // Assert that the returned IP is a valid IPv4 address + if freeIP.To4() == nil { + t.Errorf("Invalid IP address format: %s", freeIP.String()) + } + + // Assert that the returned IP is not already used by a node + for _, node := range n.Nodes { + if freeIP.Equal(node.ManagedIP) { + t.Errorf("Returned IP is already in use: %s", freeIP.String()) + } + } + + n.Nodes = append(n.Nodes, &ganserv.Node{ + Name: "NodeT" + strconv.Itoa(i), + ManagedIP: freeIP, + }) + } + +} diff --git a/example/plugins/ztnc/mod/ganserv/utils.go b/example/plugins/ztnc/mod/ganserv/utils.go new file mode 100644 index 0000000..684f597 --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/utils.go @@ -0,0 +1,55 @@ +package ganserv + +import ( + "net" +) + +//Generate all ip address from a CIDR +func GetAllAddressFromCIDR(cidr string) ([]string, error) { + ip, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, err + } + + var ips []string + for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) { + ips = append(ips, ip.String()) + } + // remove network address and broadcast address + return ips[1 : len(ips)-1], nil +} + +func inc(ip net.IP) { + for j := len(ip) - 1; j >= 0; j-- { + ip[j]++ + if ip[j] > 0 { + break + } + } +} + +func isValidIPAddr(ipAddr string) bool { + ip := net.ParseIP(ipAddr) + if ip == nil { + return false + } + + return true +} + +func ipWithinCIDR(ipAddr string, cidr string) bool { + // Parse the CIDR string + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + return false + } + + // Parse the IP address + ip := net.ParseIP(ipAddr) + if ip == nil { + return false + } + + // Check if the IP address is in the CIDR range + return ipNet.Contains(ip) +} diff --git a/example/plugins/ztnc/mod/ganserv/zerotier.go b/example/plugins/ztnc/mod/ganserv/zerotier.go new file mode 100644 index 0000000..fa1fd0b --- /dev/null +++ b/example/plugins/ztnc/mod/ganserv/zerotier.go @@ -0,0 +1,669 @@ +package ganserv + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" +) + +/* + zerotier.go + + This hold the functions that required to communicate with + a zerotier instance + + See more on + https://docs.zerotier.com/self-hosting/network-controllers/ + +*/ + +type NodeInfo struct { + Address string `json:"address"` + Clock int64 `json:"clock"` + Config struct { + Settings struct { + AllowTCPFallbackRelay bool `json:"allowTcpFallbackRelay,omitempty"` + ForceTCPRelay bool `json:"forceTcpRelay,omitempty"` + HomeDir string `json:"homeDir,omitempty"` + ListeningOn []string `json:"listeningOn,omitempty"` + PortMappingEnabled bool `json:"portMappingEnabled,omitempty"` + PrimaryPort int `json:"primaryPort,omitempty"` + SecondaryPort int `json:"secondaryPort,omitempty"` + SoftwareUpdate string `json:"softwareUpdate,omitempty"` + SoftwareUpdateChannel string `json:"softwareUpdateChannel,omitempty"` + SurfaceAddresses []string `json:"surfaceAddresses,omitempty"` + TertiaryPort int `json:"tertiaryPort,omitempty"` + } `json:"settings"` + } `json:"config"` + Online bool `json:"online"` + PlanetWorldID int `json:"planetWorldId"` + PlanetWorldTimestamp int64 `json:"planetWorldTimestamp"` + PublicIdentity string `json:"publicIdentity"` + TCPFallbackActive bool `json:"tcpFallbackActive"` + Version string `json:"version"` + VersionBuild int `json:"versionBuild"` + VersionMajor int `json:"versionMajor"` + VersionMinor int `json:"versionMinor"` + VersionRev int `json:"versionRev"` +} +type ErrResp struct { + Message string `json:"message"` +} + +type NetworkInfo struct { + AuthTokens []interface{} `json:"authTokens"` + AuthorizationEndpoint string `json:"authorizationEndpoint"` + Capabilities []interface{} `json:"capabilities"` + ClientID string `json:"clientId"` + CreationTime int64 `json:"creationTime"` + DNS []interface{} `json:"dns"` + EnableBroadcast bool `json:"enableBroadcast"` + ID string `json:"id"` + IPAssignmentPools []interface{} `json:"ipAssignmentPools"` + Mtu int `json:"mtu"` + MulticastLimit int `json:"multicastLimit"` + Name string `json:"name"` + Nwid string `json:"nwid"` + Objtype string `json:"objtype"` + Private bool `json:"private"` + RemoteTraceLevel int `json:"remoteTraceLevel"` + RemoteTraceTarget interface{} `json:"remoteTraceTarget"` + Revision int `json:"revision"` + Routes []interface{} `json:"routes"` + Rules []struct { + Not bool `json:"not"` + Or bool `json:"or"` + Type string `json:"type"` + } `json:"rules"` + RulesSource string `json:"rulesSource"` + SsoEnabled bool `json:"ssoEnabled"` + Tags []interface{} `json:"tags"` + V4AssignMode struct { + Zt bool `json:"zt"` + } `json:"v4AssignMode"` + V6AssignMode struct { + SixPlane bool `json:"6plane"` + Rfc4193 bool `json:"rfc4193"` + Zt bool `json:"zt"` + } `json:"v6AssignMode"` +} + +type MemberInfo struct { + ActiveBridge bool `json:"activeBridge"` + Address string `json:"address"` + AuthenticationExpiryTime int `json:"authenticationExpiryTime"` + Authorized bool `json:"authorized"` + Capabilities []interface{} `json:"capabilities"` + CreationTime int64 `json:"creationTime"` + ID string `json:"id"` + Identity string `json:"identity"` + IPAssignments []string `json:"ipAssignments"` + LastAuthorizedCredential interface{} `json:"lastAuthorizedCredential"` + LastAuthorizedCredentialType string `json:"lastAuthorizedCredentialType"` + LastAuthorizedTime int `json:"lastAuthorizedTime"` + LastDeauthorizedTime int `json:"lastDeauthorizedTime"` + NoAutoAssignIps bool `json:"noAutoAssignIps"` + Nwid string `json:"nwid"` + Objtype string `json:"objtype"` + RemoteTraceLevel int `json:"remoteTraceLevel"` + RemoteTraceTarget interface{} `json:"remoteTraceTarget"` + Revision int `json:"revision"` + SsoExempt bool `json:"ssoExempt"` + Tags []interface{} `json:"tags"` + VMajor int `json:"vMajor"` + VMinor int `json:"vMinor"` + VProto int `json:"vProto"` + VRev int `json:"vRev"` +} + +// Get the zerotier node info from local service +func getControllerInfo(token string, apiPort int) (*NodeInfo, error) { + url := "http://localhost:" + strconv.Itoa(apiPort) + "/status" + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("X-ZT1-AUTH", token) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + //Read from zerotier service instance + + defer resp.Body.Close() + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + //Parse the payload into struct + thisInstanceInfo := NodeInfo{} + err = json.Unmarshal(payload, &thisInstanceInfo) + if err != nil { + return nil, err + } + + return &thisInstanceInfo, nil +} + +/* + Network Functions +*/ +//Create a zerotier network +func (m *NetworkManager) createNetwork() (*NetworkInfo, error) { + url := fmt.Sprintf("http://localhost:"+strconv.Itoa(m.apiPort)+"/controller/network/%s______", m.ControllerID) + + data := []byte(`{}`) + req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + req.Header.Set("X-ZT1-AUTH", m.authToken) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + networkInfo := NetworkInfo{} + err = json.Unmarshal(payload, &networkInfo) + if err != nil { + return nil, err + } + + return &networkInfo, nil +} + +// List network details +func (m *NetworkManager) getNetworkInfoById(networkId string) (*NetworkInfo, error) { + req, err := http.NewRequest("GET", os.ExpandEnv("http://localhost:"+strconv.Itoa(m.apiPort)+"/controller/network/"+networkId+"/"), nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Zt1-Auth", m.authToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, errors.New("network error. Status code: " + strconv.Itoa(resp.StatusCode)) + } + + thisNetworkInfo := NetworkInfo{} + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(payload, &thisNetworkInfo) + if err != nil { + return nil, err + } + + return &thisNetworkInfo, nil +} + +func (m *NetworkManager) setNetworkInfoByID(networkId string, newNetworkInfo *NetworkInfo) error { + payloadBytes, err := json.Marshal(newNetworkInfo) + if err != nil { + return err + } + payloadBuffer := bytes.NewBuffer(payloadBytes) + + // Create the HTTP request + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + networkId + "/" + req, err := http.NewRequest("POST", url, payloadBuffer) + if err != nil { + return err + } + req.Header.Set("X-Zt1-Auth", m.authToken) + req.Header.Set("Content-Type", "application/json") + + // Send the HTTP request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Print the response status code + if resp.StatusCode != 200 { + return errors.New("network error. status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +// List network IDs +func (m *NetworkManager) listNetworkIds() ([]string, error) { + req, err := http.NewRequest("GET", "http://localhost:"+strconv.Itoa(m.apiPort)+"/controller/network/", nil) + if err != nil { + return []string{}, err + } + req.Header.Set("X-Zt1-Auth", m.authToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return []string{}, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return []string{}, errors.New("network error") + } + + networkIds := []string{} + payload, err := io.ReadAll(resp.Body) + if err != nil { + return []string{}, err + } + + err = json.Unmarshal(payload, &networkIds) + if err != nil { + return []string{}, err + } + + return networkIds, nil +} + +// wrapper for checking if a network id exists +func (m *NetworkManager) networkExists(networkId string) bool { + networkIds, err := m.listNetworkIds() + if err != nil { + return false + } + + for _, thisid := range networkIds { + if thisid == networkId { + return true + } + } + + return false +} + +// delete a network +func (m *NetworkManager) deleteNetwork(networkID string) error { + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + networkID + "/" + client := &http.Client{} + + // Create a new DELETE request + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + + // Add the required authorization header + req.Header.Set("X-Zt1-Auth", m.authToken) + + // Send the request and get the response + resp, err := client.Do(req) + if err != nil { + return err + } + + // Close the response body when we're done + defer resp.Body.Close() + s, err := io.ReadAll(resp.Body) + fmt.Println(string(s), err, resp.StatusCode) + + // Print the response status code + if resp.StatusCode != 200 { + return errors.New("network error. status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +// Configure network +// Example: configureNetwork(netid, "192.168.192.1", "192.168.192.254", "192.168.192.0/24") +func (m *NetworkManager) configureNetwork(networkID string, ipRangeStart string, ipRangeEnd string, routeTarget string) error { + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + networkID + "/" + data := map[string]interface{}{ + "ipAssignmentPools": []map[string]string{ + { + "ipRangeStart": ipRangeStart, + "ipRangeEnd": ipRangeEnd, + }, + }, + "routes": []map[string]interface{}{ + { + "target": routeTarget, + "via": nil, + }, + }, + "v4AssignMode": "zt", + "private": true, + } + + payload, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-ZT1-AUTH", m.authToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + // Print the response status code + if resp.StatusCode != 200 { + return errors.New("network error. status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +func (m *NetworkManager) setAssignedIps(networkID string, memid string, newIps []string) error { + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + networkID + "/member/" + memid + data := map[string]interface{}{ + "ipAssignments": newIps, + } + + payload, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-ZT1-AUTH", m.authToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + // Print the response status code + if resp.StatusCode != 200 { + return errors.New("network error. status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +func (m *NetworkManager) setNetworkNameAndDescription(netid string, name string, desc string) error { + // Convert string to rune slice + r := []rune(name) + + // Loop over runes and remove non-ASCII characters + for i, v := range r { + if v > 127 { + r[i] = ' ' + } + } + + // Convert back to string and trim whitespace + name = strings.TrimSpace(string(r)) + + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + netid + "/" + data := map[string]interface{}{ + "name": name, + } + + payload, err := json.Marshal(data) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-ZT1-AUTH", m.authToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + // Print the response status code + if resp.StatusCode != 200 { + return errors.New("network error. status code: " + strconv.Itoa(resp.StatusCode)) + } + + meta := m.GetNetworkMetaData(netid) + if meta != nil { + meta.Desc = desc + m.WriteNetworkMetaData(netid, meta) + } + + return nil +} + +func (m *NetworkManager) getNetworkNameAndDescription(netid string) (string, string, error) { + //Get name from network info + netinfo, err := m.getNetworkInfoById(netid) + if err != nil { + return "", "", err + } + + name := netinfo.Name + + //Get description from meta + desc := "" + networkMeta := m.GetNetworkMetaData(netid) + if networkMeta != nil { + desc = networkMeta.Desc + } + + return name, desc, nil +} + +/* + Member functions +*/ + +func (m *NetworkManager) getNetworkMembers(networkId string) ([]string, error) { + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + networkId + "/member" + reqBody := bytes.NewBuffer([]byte{}) + req, err := http.NewRequest("GET", url, reqBody) + if err != nil { + return nil, err + } + + req.Header.Set("X-ZT1-AUTH", m.authToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("failed to get network members") + } + + memberList := map[string]int{} + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(payload, &memberList) + if err != nil { + return nil, err + } + + members := make([]string, 0, len(memberList)) + for k := range memberList { + members = append(members, k) + } + + return members, nil +} + +func (m *NetworkManager) memberExistsInNetwork(netid string, memid string) bool { + //Get a list of member + memberids, err := m.getNetworkMembers(netid) + if err != nil { + return false + } + for _, thisMemberId := range memberids { + if thisMemberId == memid { + return true + } + } + + return false +} + +// Get a network memeber info by netid and memberid +func (m *NetworkManager) getNetworkMemberInfo(netid string, memberid string) (*MemberInfo, error) { + req, err := http.NewRequest("GET", "http://localhost:"+strconv.Itoa(m.apiPort)+"/controller/network/"+netid+"/member/"+memberid, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-Zt1-Auth", m.authToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + thisMemeberInfo := &MemberInfo{} + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(payload, &thisMemeberInfo) + if err != nil { + return nil, err + } + + return thisMemeberInfo, nil +} + +// Set the authorization state of a member +func (m *NetworkManager) AuthorizeMember(netid string, memberid string, setAuthorized bool) error { + url := "http://localhost:" + strconv.Itoa(m.apiPort) + "/controller/network/" + netid + "/member/" + memberid + payload := []byte(`{"authorized": true}`) + if !setAuthorized { + payload = []byte(`{"authorized": false}`) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return err + } + req.Header.Set("X-ZT1-AUTH", m.authToken) + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return errors.New("network error. Status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +// Delete a member from the network +func (m *NetworkManager) deleteMember(netid string, memid string) error { + req, err := http.NewRequest("DELETE", "http://localhost:"+strconv.Itoa(m.apiPort)+"/controller/network/"+netid+"/member/"+memid, nil) + if err != nil { + return err + } + req.Header.Set("X-Zt1-Auth", os.ExpandEnv(m.authToken)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return errors.New("network error. Status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +// Make the host to join a given network +func (m *NetworkManager) joinNetwork(netid string) error { + req, err := http.NewRequest("POST", "http://localhost:"+strconv.Itoa(m.apiPort)+"/network/"+netid, nil) + if err != nil { + return err + } + req.Header.Set("X-Zt1-Auth", os.ExpandEnv(m.authToken)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return errors.New("network error. Status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} + +// Make the host to leave a given network +func (m *NetworkManager) leaveNetwork(netid string) error { + req, err := http.NewRequest("DELETE", "http://localhost:"+strconv.Itoa(m.apiPort)+"/network/"+netid, nil) + if err != nil { + return err + } + req.Header.Set("X-Zt1-Auth", os.ExpandEnv(m.authToken)) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return errors.New("network error. Status code: " + strconv.Itoa(resp.StatusCode)) + } + + return nil +} diff --git a/example/plugins/ztnc/mod/utils/conv.go b/example/plugins/ztnc/mod/utils/conv.go new file mode 100644 index 0000000..6adf753 --- /dev/null +++ b/example/plugins/ztnc/mod/utils/conv.go @@ -0,0 +1,105 @@ +package utils + +import ( + "archive/zip" + "io" + "os" + "path/filepath" + "strconv" + "strings" +) + +func StringToInt64(number string) (int64, error) { + i, err := strconv.ParseInt(number, 10, 64) + if err != nil { + return -1, err + } + return i, nil +} + +func Int64ToString(number int64) string { + convedNumber := strconv.FormatInt(number, 10) + return convedNumber +} + +func ReplaceSpecialCharacters(filename string) string { + replacements := map[string]string{ + "#": "%pound%", + "&": "%amp%", + "{": "%left_cur%", + "}": "%right_cur%", + "\\": "%backslash%", + "<": "%left_ang%", + ">": "%right_ang%", + "*": "%aster%", + "?": "%quest%", + " ": "%space%", + "$": "%dollar%", + "!": "%exclan%", + "'": "%sin_q%", + "\"": "%dou_q%", + ":": "%colon%", + "@": "%at%", + "+": "%plus%", + "`": "%backtick%", + "|": "%pipe%", + "=": "%equal%", + ".": "_", + "/": "-", + } + + for char, replacement := range replacements { + filename = strings.ReplaceAll(filename, char, replacement) + } + + return filename +} + +/* Zip File Handler */ +// zipFiles compresses multiple files into a single zip archive file +func ZipFiles(filename string, files ...string) error { + newZipFile, err := os.Create(filename) + if err != nil { + return err + } + defer newZipFile.Close() + + zipWriter := zip.NewWriter(newZipFile) + defer zipWriter.Close() + + for _, file := range files { + if err := addFileToZip(zipWriter, file); err != nil { + return err + } + } + return nil +} + +// addFileToZip adds an individual file to a zip archive +func addFileToZip(zipWriter *zip.Writer, filename string) error { + fileToZip, err := os.Open(filename) + if err != nil { + return err + } + defer fileToZip.Close() + + info, err := fileToZip.Stat() + if err != nil { + return err + } + + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + + header.Name = filepath.Base(filename) + header.Method = zip.Deflate + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + _, err = io.Copy(writer, fileToZip) + return err +} diff --git a/example/plugins/ztnc/mod/utils/template.go b/example/plugins/ztnc/mod/utils/template.go new file mode 100644 index 0000000..e5772a8 --- /dev/null +++ b/example/plugins/ztnc/mod/utils/template.go @@ -0,0 +1,19 @@ +package utils + +import ( + "net/http" +) + +/* + Web Template Generator + + This is the main system core module that perform function similar to what PHP did. + To replace part of the content of any file, use {{paramter}} to replace it. + + +*/ + +func SendHTMLResponse(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(msg)) +} diff --git a/example/plugins/ztnc/mod/utils/utils.go b/example/plugins/ztnc/mod/utils/utils.go new file mode 100644 index 0000000..2fe1ffd --- /dev/null +++ b/example/plugins/ztnc/mod/utils/utils.go @@ -0,0 +1,202 @@ +package utils + +import ( + "errors" + "log" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +/* + Common + + Some commonly used functions in ArozOS + +*/ + +// Response related +func SendTextResponse(w http.ResponseWriter, msg string) { + w.Write([]byte(msg)) +} + +// Send JSON response, with an extra json header +func SendJSONResponse(w http.ResponseWriter, json string) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(json)) +} + +func SendErrorResponse(w http.ResponseWriter, errMsg string) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{\"error\":\"" + errMsg + "\"}")) +} + +func SendOK(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("\"OK\"")) +} + +// Get GET parameter +func GetPara(r *http.Request, key string) (string, error) { + // Get first value from the URL query + value := r.URL.Query().Get(key) + if len(value) == 0 { + return "", errors.New("invalid " + key + " given") + } + return value, nil +} + +// Get GET paramter as boolean, accept 1 or true +func GetBool(r *http.Request, key string) (bool, error) { + x, err := GetPara(r, key) + if err != nil { + return false, err + } + + // Convert to lowercase and trim spaces just once to compare + switch strings.ToLower(strings.TrimSpace(x)) { + case "1", "true", "on": + return true, nil + case "0", "false", "off": + return false, nil + } + + return false, errors.New("invalid boolean given") +} + +// Get POST parameter +func PostPara(r *http.Request, key string) (string, error) { + // Try to parse the form + if err := r.ParseForm(); err != nil { + return "", err + } + // Get first value from the form + x := r.Form.Get(key) + if len(x) == 0 { + return "", errors.New("invalid " + key + " given") + } + return x, nil +} + +// Get POST paramter as boolean, accept 1 or true +func PostBool(r *http.Request, key string) (bool, error) { + x, err := PostPara(r, key) + if err != nil { + return false, err + } + + // Convert to lowercase and trim spaces just once to compare + switch strings.ToLower(strings.TrimSpace(x)) { + case "1", "true", "on": + return true, nil + case "0", "false", "off": + return false, nil + } + + return false, errors.New("invalid boolean given") +} + +// Get POST paramter as int +func PostInt(r *http.Request, key string) (int, error) { + x, err := PostPara(r, key) + if err != nil { + return 0, err + } + + x = strings.TrimSpace(x) + rx, err := strconv.Atoi(x) + if err != nil { + return 0, err + } + + return rx, nil +} + +func FileExists(filename string) bool { + _, err := os.Stat(filename) + if err == nil { + // File exists + return true + } else if errors.Is(err, os.ErrNotExist) { + // File does not exist + return false + } + // Some other error + return false +} + +func IsDir(path string) bool { + if !FileExists(path) { + return false + } + fi, err := os.Stat(path) + if err != nil { + log.Fatal(err) + return false + } + switch mode := fi.Mode(); { + case mode.IsDir(): + return true + case mode.IsRegular(): + return false + } + return false +} + +func TimeToString(targetTime time.Time) string { + return targetTime.Format("2006-01-02 15:04:05") +} + +// Check if given string in a given slice +func StringInArray(arr []string, str string) bool { + for _, a := range arr { + if a == str { + return true + } + } + return false +} + +func StringInArrayIgnoreCase(arr []string, str string) bool { + smallArray := []string{} + for _, item := range arr { + smallArray = append(smallArray, strings.ToLower(item)) + } + + return StringInArray(smallArray, strings.ToLower(str)) +} + +// Validate if the listening address is correct +func ValidateListeningAddress(address string) bool { + // Check if the address starts with a colon, indicating it's just a port + if strings.HasPrefix(address, ":") { + return true + } + + // Split the address into host and port parts + host, port, err := net.SplitHostPort(address) + if err != nil { + // Try to parse it as just a port + if _, err := strconv.Atoi(address); err == nil { + return false // It's just a port number + } + return false // It's an invalid address + } + + // Check if the port part is a valid number + if _, err := strconv.Atoi(port); err != nil { + return false + } + + // Check if the host part is a valid IP address or empty (indicating any IP) + if host != "" { + if net.ParseIP(host) == nil { + return false + } + } + + return true +} \ No newline at end of file diff --git a/example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go b/example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go new file mode 100644 index 0000000..d9b3fde --- /dev/null +++ b/example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go @@ -0,0 +1,106 @@ +package zoraxy_plugin + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "net/url" + "strings" + "time" +) + +type PluginUiRouter struct { + PluginID string //The ID of the plugin + TargetFs *embed.FS //The embed.FS where the UI files are stored + TargetFsPrefix string //The prefix of the embed.FS where the UI files are stored, e.g. /web + HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui +} + +// NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS +// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored +// The targetFsPrefix should be relative to the root of the embed.FS +// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(targetFsPrefix, "/") { + targetFsPrefix = "/" + targetFsPrefix + } + targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") + + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiRouter{ + PluginID: pluginID, + TargetFs: targetFs, + TargetFsPrefix: targetFsPrefix, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, "/") { + // Redirect to the index.html + http.Redirect(w, r, r.URL.Path+"index.html", http.StatusFound) + return + } + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from embed.FS + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetFsPrefix + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + http.ServeContent(w, r, r.URL.Path, time.Now(), strings.NewReader(body)) + return + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL, _ = url.Parse(rewrittenURL) + r.RequestURI = rewrittenURL + + //Serve the file from the embed.FS + subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/")) + if err != nil { + fmt.Println(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) + }) +} diff --git a/src/mod/plugins/includes.go b/example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go similarity index 89% rename from src/mod/plugins/includes.go rename to example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go index 89fb5f9..1691591 100644 --- a/src/mod/plugins/includes.go +++ b/example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go @@ -1,18 +1,20 @@ -package plugins +package zoraxy_plugin import ( "encoding/json" "fmt" "os" + "os/signal" "strings" + "syscall" ) /* Plugins Includes.go - This file contains the common types and structs that are used by the plugins - If you are building a Zoraxy plugin with Golang, you can use this file to include - the common types and structs that are used by the plugins + This file is copied from Zoraxy source code + You can always find the latest version under mod/plugins/includes.go + Usually this file are backward compatible */ type PluginType int @@ -184,3 +186,25 @@ func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { ServeIntroSpect(pluginSpect) return RecvConfigureSpec() } + +/* + +Shutdown handler + +This function will register a shutdown handler for the plugin +The shutdown callback will be called when the plugin is shutting down +You can use this to clean up resources like closing database connections +*/ + +func RegisterShutdownHandler(shutdownCallback func()) { + // Set up a channel to receive OS signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Start a goroutine to listen for signals + go func() { + <-sigChan + shutdownCallback() + os.Exit(0) + }() +} diff --git a/example/plugins/ztnc/start.go b/example/plugins/ztnc/start.go new file mode 100644 index 0000000..1090031 --- /dev/null +++ b/example/plugins/ztnc/start.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" + "net/http" + "os" + + "aroz.org/zoraxy/ztnc/mod/database" + "aroz.org/zoraxy/ztnc/mod/database/dbinc" + "aroz.org/zoraxy/ztnc/mod/ganserv" + "aroz.org/zoraxy/ztnc/mod/utils" +) + +func startGanNetworkController() error { + fmt.Println("Starting ZeroTier Network Controller") + //Create a new database + var err error + sysdb, err = database.NewDatabase(DB_FILE_PATH, dbinc.BackendBoltDB) + if err != nil { + return err + } + + //Initiate the GAN server manager + usingZtAuthToken := "" + ztAPIPort := 9993 + + if utils.FileExists(AUTH_TOKEN_PATH) { + authToken, err := os.ReadFile(AUTH_TOKEN_PATH) + if err != nil { + fmt.Println("Error reading auth config file:", err) + return err + } + usingZtAuthToken = string(authToken) + fmt.Println("Loaded ZeroTier Auth Token from file") + } + + if usingZtAuthToken == "" { + usingZtAuthToken, err = ganserv.TryLoadorAskUserForAuthkey() + if err != nil { + fmt.Println("Error getting ZeroTier Auth Token:", err) + } + } + + ganManager = ganserv.NewNetworkManager(&ganserv.NetworkManagerOptions{ + AuthToken: usingZtAuthToken, + ApiPort: ztAPIPort, + Database: sysdb, + }) + + return nil +} + +func initApiEndpoints() { + //UI_RELPATH must be the same as the one in the plugin intro spect + // as Zoraxy plugin UI proxy will only forward the UI path to your plugin + http.HandleFunc(UI_RELPATH+"/api/gan/network/info", ganManager.HandleGetNodeID) + http.HandleFunc(UI_RELPATH+"/api/gan/network/add", ganManager.HandleAddNetwork) + http.HandleFunc(UI_RELPATH+"/api/gan/network/remove", ganManager.HandleRemoveNetwork) + http.HandleFunc(UI_RELPATH+"/api/gan/network/list", ganManager.HandleListNetwork) + http.HandleFunc(UI_RELPATH+"/api/gan/network/name", ganManager.HandleNetworkNaming) + http.HandleFunc(UI_RELPATH+"/api/gan/network/setRange", ganManager.HandleSetRanges) + http.HandleFunc(UI_RELPATH+"/api/gan/network/join", ganManager.HandleServerJoinNetwork) + http.HandleFunc(UI_RELPATH+"/api/gan/network/leave", ganManager.HandleServerLeaveNetwork) + http.HandleFunc(UI_RELPATH+"/api/gan/members/list", ganManager.HandleMemberList) + http.HandleFunc(UI_RELPATH+"/api/gan/members/ip", ganManager.HandleMemberIP) + http.HandleFunc(UI_RELPATH+"/api/gan/members/name", ganManager.HandleMemberNaming) + http.HandleFunc(UI_RELPATH+"/api/gan/members/authorize", ganManager.HandleMemberAuthorization) + http.HandleFunc(UI_RELPATH+"/api/gan/members/delete", ganManager.HandleMemberDelete) +} diff --git a/example/plugins/ztnc/web/details.html b/example/plugins/ztnc/web/details.html new file mode 100644 index 0000000..37db9a0 --- /dev/null +++ b/example/plugins/ztnc/web/details.html @@ -0,0 +1,747 @@ + +
+ +
+ +

+ +
+

+
+

+ +
+ +
+

Settings

+
+ + + + + + + + + +
IPv4 Auto-Assign
+
+
+
+

Custom IP Range

+

Manual IP Range Configuration. The IP range must be within the selected CIDR range. +
Use Utilities > IP to CIDR tool if you are not too familiar with CIDR notations.

+
+
+ + +
+
+ + +
+
+
+ + +
+

Members

+

To join this network using command line, type sudo zerotier-cli join on your device terminal

+
+ + +
+
+ + + + + + + + + + + + + + + + + +
AuthAddressNameManaged IPAuthorized SinceVersionRemove
+
+
+

Add Controller as Member

+

Optionally you can add the network controller (ZeroTier running on the Zoraxy node) as member for cross GAN reverse proxy to bypass NAT limitations.

+ + +

+
+ diff --git a/example/plugins/ztnc/web/index.html b/example/plugins/ztnc/web/index.html new file mode 100644 index 0000000..97108bd --- /dev/null +++ b/example/plugins/ztnc/web/index.html @@ -0,0 +1,257 @@ + + + + + + + + + Global Area Network | Zoraxy + + + + + + + + + + + + + +
+
+

Global Area Network

+

Virtual Network Hub that allows all networked devices to communicate as if they all reside in the same physical data center or cloud region

+
+
+
+
+

+ +
Network Controller ID
+

+
+
+
+ +
+
0
+
Networks
+
+
+
+ +
+
0
+
Connected Nodes
+
+
+
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + +
Network IDNameDescriptionSubnet (Assign Range)NodesActions
No Global Area Network Found on this host
+
+
+
+
+ + + \ No newline at end of file diff --git a/example/plugins/ztnc/ztnc.db b/example/plugins/ztnc/ztnc.db new file mode 100644 index 0000000..70a17b5 Binary files /dev/null and b/example/plugins/ztnc/ztnc.db differ diff --git a/example/plugins/ztnc/ztnc.db.lock b/example/plugins/ztnc/ztnc.db.lock new file mode 100644 index 0000000..e69de29 diff --git a/src/mod/plugins/introspect.go b/src/mod/plugins/introspect.go index 4c40776..988289e 100644 --- a/src/mod/plugins/introspect.go +++ b/src/mod/plugins/introspect.go @@ -6,6 +6,8 @@ import ( "fmt" "os/exec" "time" + + zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin" ) // LoadPlugin loads a plugin from the plugin directory @@ -42,8 +44,8 @@ func (m *Manager) LoadPluginSpec(pluginPath string) (*Plugin, error) { } // GetPluginEntryPoint returns the plugin entry point -func (m *Manager) GetPluginSpec(entryPoint string) (*IntroSpect, error) { - pluginSpec := IntroSpect{} +func (m *Manager) GetPluginSpec(entryPoint string) (*zoraxyPlugin.IntroSpect, error) { + pluginSpec := zoraxyPlugin.IntroSpect{} ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/src/mod/plugins/lifecycle.go b/src/mod/plugins/lifecycle.go index 3c13069..7c7ebbf 100644 --- a/src/mod/plugins/lifecycle.go +++ b/src/mod/plugins/lifecycle.go @@ -13,6 +13,7 @@ import ( "time" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" + zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin" ) func (m *Manager) StartPlugin(pluginID string) error { @@ -37,7 +38,7 @@ func (m *Manager) StartPlugin(pluginID string) error { } //Prepare plugin start configuration - pluginConfiguration := ConfigureSpec{ + pluginConfiguration := zoraxyPlugin.ConfigureSpec{ Port: getRandomPortNumber(), RuntimeConst: *m.Options.SystemConst, } @@ -100,18 +101,25 @@ func (m *Manager) StartUIHandlerForPlugin(targetPlugin *Plugin, pluginListeningP pluginUIRelPath = "/" + pluginUIRelPath } + // Remove the trailing slash if it exists + pluginUIRelPath = strings.TrimSuffix(pluginUIRelPath, "/") + pluginUIURL, err := url.Parse("http://127.0.0.1:" + strconv.Itoa(pluginListeningPort) + pluginUIRelPath) if err != nil { return err } + + // Generate the plugin subpath to be trimmed + pluginMatchingPath := filepath.ToSlash(filepath.Join("/plugin.ui/"+targetPlugin.Spec.ID+"/")) + "/" if targetPlugin.Spec.UIPath != "" { targetPlugin.uiProxy = dpcore.NewDynamicProxyCore( pluginUIURL, - "", + pluginMatchingPath, &dpcore.DpcoreOptions{ IgnoreTLSVerification: true, }, ) + targetPlugin.AssignedPort = pluginListeningPort m.LoadedPlugins.Store(targetPlugin.Spec.ID, targetPlugin) } return nil diff --git a/src/mod/plugins/no_img.png b/src/mod/plugins/no_img.png index 2b85efb..dcf5bda 100644 Binary files a/src/mod/plugins/no_img.png and b/src/mod/plugins/no_img.png differ diff --git a/src/mod/plugins/no_img.psd b/src/mod/plugins/no_img.psd index a5691e2..8e83901 100644 Binary files a/src/mod/plugins/no_img.psd and b/src/mod/plugins/no_img.psd differ diff --git a/src/mod/plugins/plugins.go b/src/mod/plugins/plugins.go index 181d3e5..6353ad1 100644 --- a/src/mod/plugins/plugins.go +++ b/src/mod/plugins/plugins.go @@ -2,7 +2,7 @@ package plugins import ( "errors" - "fmt" + "net/http" "os" "os/exec" "path/filepath" @@ -13,23 +13,27 @@ import ( "imuslab.com/zoraxy/mod/database" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" "imuslab.com/zoraxy/mod/info/logger" + zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin" "imuslab.com/zoraxy/mod/utils" ) type Plugin struct { - RootDir string //The root directory of the plugin - Spec *IntroSpect //The plugin specification - Enabled bool //Whether the plugin is enabled + RootDir string //The root directory of the plugin + Spec *zoraxyPlugin.IntroSpect //The plugin specification + Enabled bool //Whether the plugin is enabled - uiProxy *dpcore.ReverseProxy //The reverse proxy for the plugin UI - process *exec.Cmd //The process of the plugin + //Runtime + AssignedPort int //The assigned port for the plugin + uiProxy *dpcore.ReverseProxy //The reverse proxy for the plugin UI + process *exec.Cmd //The process of the plugin } type ManagerOptions struct { - PluginDir string - SystemConst *RuntimeConstantValue - Database *database.Database - Logger *logger.Logger + PluginDir string + SystemConst *zoraxyPlugin.RuntimeConstantValue + Database *database.Database + Logger *logger.Logger + CSRFTokenGen func(*http.Request) string //The CSRF token generator function } type Manager struct { @@ -80,7 +84,6 @@ func (m *Manager) LoadPluginsFromDisk() error { m.Log("Loaded plugin: "+thisPlugin.Spec.Name, nil) // If the plugin was enabled, start it now - fmt.Println(m.GetPluginPreviousEnableState(thisPlugin.Spec.ID)) if m.GetPluginPreviousEnableState(thisPlugin.Spec.ID) { err = m.StartPlugin(thisPlugin.Spec.ID) if err != nil { diff --git a/src/mod/plugins/uirouter.go b/src/mod/plugins/uirouter.go index 62414de..d2ac1c9 100644 --- a/src/mod/plugins/uirouter.go +++ b/src/mod/plugins/uirouter.go @@ -2,6 +2,9 @@ package plugins import ( "net/http" + "net/url" + "strconv" + "strings" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" "imuslab.com/zoraxy/mod/utils" @@ -28,14 +31,25 @@ func (m *Manager) HandlePluginUI(pluginID string, w http.ResponseWriter, r *http return } + upstreamOrigin := "127.0.0.1:" + strconv.Itoa(plugin.AssignedPort) + matchingPath := "/plugin.ui/" + plugin.Spec.ID + + //Rewrite the request path to the plugin UI path + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, matchingPath) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL, _ = url.Parse(rewrittenURL) + //Call the plugin UI handler plugin.uiProxy.ServeHTTP(w, r, &dpcore.ResponseRewriteRuleSet{ UseTLS: false, OriginalHost: r.Host, - ProxyDomain: r.Host, + ProxyDomain: upstreamOrigin, NoCache: true, - PathPrefix: "/plugin.ui/" + pluginID, + PathPrefix: matchingPath, Version: m.Options.SystemConst.ZoraxyVersion, + UpstreamHeaders: [][]string{ + {"X-Zoraxy-Csrf", m.Options.CSRFTokenGen(r)}, + }, }) - } diff --git a/src/mod/plugins/utils.go b/src/mod/plugins/utils.go index aeb0e1f..a4e89a1 100644 --- a/src/mod/plugins/utils.go +++ b/src/mod/plugins/utils.go @@ -8,6 +8,7 @@ import ( "runtime" "imuslab.com/zoraxy/mod/netutils" + zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin" ) /* @@ -61,7 +62,7 @@ func getRandomPortNumber() int { return portNo } -func validatePluginSpec(pluginSpec *IntroSpect) error { +func validatePluginSpec(pluginSpec *zoraxyPlugin.IntroSpect) error { if pluginSpec.Name == "" { return errors.New("plugin name is empty") } diff --git a/src/mod/plugins/zoraxy_plugin/README.txt b/src/mod/plugins/zoraxy_plugin/README.txt new file mode 100644 index 0000000..ed8a405 --- /dev/null +++ b/src/mod/plugins/zoraxy_plugin/README.txt @@ -0,0 +1,19 @@ +# Zoraxy Plugin + +## Overview +This module serves as a template for building your own plugins for the Zoraxy Reverse Proxy. By copying this module to your plugin mod folder, you can create a new plugin with the necessary structure and components. + +## Instructions + +1. **Copy the Module:** + - Copy the entire `zoraxy_plugin` module to your plugin mod folder. + +2. **Include the Structure:** + - Ensure that you maintain the directory structure and file organization as provided in this module. + +3. **Modify as Needed:** + - Customize the copied module to implement the desired functionality for your plugin. + +## Directory Structure + zoraxy_plugin: Handle -introspect and -configuration process required for plugin loading and startup + embed_webserver: Handle embeded web server routing and injecting csrf token to your plugin served UI pages \ No newline at end of file diff --git a/src/mod/plugins/zoraxy_plugin/embed_webserver.go b/src/mod/plugins/zoraxy_plugin/embed_webserver.go new file mode 100644 index 0000000..d9b3fde --- /dev/null +++ b/src/mod/plugins/zoraxy_plugin/embed_webserver.go @@ -0,0 +1,106 @@ +package zoraxy_plugin + +import ( + "embed" + "fmt" + "io/fs" + "net/http" + "net/url" + "strings" + "time" +) + +type PluginUiRouter struct { + PluginID string //The ID of the plugin + TargetFs *embed.FS //The embed.FS where the UI files are stored + TargetFsPrefix string //The prefix of the embed.FS where the UI files are stored, e.g. /web + HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui +} + +// NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS +// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored +// The targetFsPrefix should be relative to the root of the embed.FS +// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS +// The handlerPrefix is the prefix of the handler used to route this router +// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path +// All prefix should not end with a slash +func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter { + //Make sure all prefix are in /prefix format + if !strings.HasPrefix(targetFsPrefix, "/") { + targetFsPrefix = "/" + targetFsPrefix + } + targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/") + + if !strings.HasPrefix(handlerPrefix, "/") { + handlerPrefix = "/" + handlerPrefix + } + handlerPrefix = strings.TrimSuffix(handlerPrefix, "/") + + //Return the PluginUiRouter + return &PluginUiRouter{ + PluginID: pluginID, + TargetFs: targetFs, + TargetFsPrefix: targetFsPrefix, + HandlerPrefix: handlerPrefix, + } +} + +func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler { + //Get the CSRF token from header + csrfToken := r.Header.Get("X-Zoraxy-Csrf") + if csrfToken == "" { + csrfToken = "missing-csrf-token" + } + + //Return the middleware + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request is for an HTML file + if strings.HasSuffix(r.URL.Path, "/") { + // Redirect to the index.html + http.Redirect(w, r, r.URL.Path+"index.html", http.StatusFound) + return + } + if strings.HasSuffix(r.URL.Path, ".html") { + //Read the target file from embed.FS + targetFilePath := strings.TrimPrefix(r.URL.Path, "/") + targetFilePath = p.TargetFsPrefix + "/" + targetFilePath + targetFilePath = strings.TrimPrefix(targetFilePath, "/") + targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + body := string(targetFileContent) + body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken) + http.ServeContent(w, r, r.URL.Path, time.Now(), strings.NewReader(body)) + return + } + + //Call the next handler + fsHandler.ServeHTTP(w, r) + }) + +} + +// GetHttpHandler returns the http.Handler for the PluginUiRouter +func (p *PluginUiRouter) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //Remove the plugin UI handler path prefix + rewrittenURL := r.RequestURI + rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix) + rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/") + r.URL, _ = url.Parse(rewrittenURL) + r.RequestURI = rewrittenURL + + //Serve the file from the embed.FS + subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/")) + if err != nil { + fmt.Println(err.Error()) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Replace {{csrf_token}} with the actual CSRF token and serve the file + p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r) + }) +} diff --git a/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go b/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go new file mode 100644 index 0000000..1691591 --- /dev/null +++ b/src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go @@ -0,0 +1,210 @@ +package zoraxy_plugin + +import ( + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + "syscall" +) + +/* + Plugins Includes.go + + This file is copied from Zoraxy source code + You can always find the latest version under mod/plugins/includes.go + Usually this file are backward compatible +*/ + +type PluginType int + +const ( + PluginType_Router PluginType = 0 //Router Plugin, used for handling / routing / forwarding traffic + PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore +) + +type CaptureRule struct { + CapturePath string `json:"capture_path"` + IncludeSubPaths bool `json:"include_sub_paths"` +} + +type ControlStatusCode int + +const ( + ControlStatusCode_CAPTURED ControlStatusCode = 280 //Traffic captured by plugin, ask Zoraxy not to process the traffic + ControlStatusCode_UNHANDLED ControlStatusCode = 284 //Traffic not handled by plugin, ask Zoraxy to process the traffic + ControlStatusCode_ERROR ControlStatusCode = 580 //Error occurred while processing the traffic, ask Zoraxy to process the traffic and log the error +) + +type SubscriptionEvent struct { + EventName string `json:"event_name"` + EventSource string `json:"event_source"` + Payload string `json:"payload"` //Payload of the event, can be empty +} + +type RuntimeConstantValue struct { + ZoraxyVersion string `json:"zoraxy_version"` + ZoraxyUUID string `json:"zoraxy_uuid"` +} + +/* +IntroSpect Payload + +When the plugin is initialized with -introspect flag, +the plugin shell return this payload as JSON and exit +*/ +type IntroSpect struct { + /* Plugin metadata */ + ID string `json:"id"` //Unique ID of your plugin, recommended using your own domain in reverse like com.yourdomain.pluginname + Name string `json:"name"` //Name of your plugin + Author string `json:"author"` //Author name of your plugin + AuthorContact string `json:"author_contact"` //Author contact of your plugin, like email + Description string `json:"description"` //Description of your plugin + URL string `json:"url"` //URL of your plugin + Type PluginType `json:"type"` //Type of your plugin, Router(0) or Utilities(1) + VersionMajor int `json:"version_major"` //Major version of your plugin + VersionMinor int `json:"version_minor"` //Minor version of your plugin + VersionPatch int `json:"version_patch"` //Patch version of your plugin + + /* + + Endpoint Settings + + */ + + /* + Global Capture Settings + + Once plugin is enabled these rules always applies, no matter which HTTP Proxy rule it is enabled on + This captures the whole traffic of Zoraxy + + Notes: Will raise a warning on the UI when the user enables the plugin on a HTTP Proxy rule + */ + GlobalCapturePath []CaptureRule `json:"global_capture_path"` //Global traffic capture path of your plugin + GlobalCaptureIngress string `json:"global_capture_ingress"` //Global traffic capture ingress path of your plugin (e.g. /g_handler) + + /* + Always Capture Settings + + Once the plugin is enabled on a given HTTP Proxy rule, + these always applies + */ + AlwaysCapturePath []CaptureRule `json:"always_capture_path"` //Always capture path of your plugin when enabled on a HTTP Proxy rule (e.g. /myapp) + AlwaysCaptureIngress string `json:"always_capture_ingress"` //Always capture ingress path of your plugin when enabled on a HTTP Proxy rule (e.g. /a_handler) + + /* + Dynamic Capture Settings + + Once the plugin is enabled on a given HTTP Proxy rule, + the plugin can capture the request and decided if the request + shall be handled by itself or let it pass through + + */ + DynmaicCaptureIngress string `json:"capture_path"` //Traffic capture path of your plugin (e.g. /capture) + DynamicHandleIngress string `json:"handle_path"` //Traffic handle path of your plugin (e.g. /handler) + + /* UI Path for your plugin */ + UIPath string `json:"ui_path"` //UI path of your plugin (e.g. /ui), will proxy the whole subpath tree to Zoraxy Web UI as plugin UI + + /* Subscriptions Settings */ + SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered + SubscriptionsEvents map[string]string `json:"subscriptions_events"` //Subscriptions events of your plugin, see Zoraxy documentation for more details +} + +/* +ServeIntroSpect Function + +This function will check if the plugin is initialized with -introspect flag, +if so, it will print the intro spect and exit + +Place this function at the beginning of your plugin main function +*/ +func ServeIntroSpect(pluginSpect *IntroSpect) { + if len(os.Args) > 1 && os.Args[1] == "-introspect" { + //Print the intro spect and exit + jsonData, _ := json.MarshalIndent(pluginSpect, "", " ") + fmt.Println(string(jsonData)) + os.Exit(0) + } +} + +/* +ConfigureSpec Payload + +Zoraxy will start your plugin with -configure flag, +the plugin shell read this payload as JSON and configure itself +by the supplied values like starting a web server at given port +that listens to 127.0.0.1:port +*/ +type ConfigureSpec struct { + Port int `json:"port"` //Port to listen + RuntimeConst RuntimeConstantValue `json:"runtime_const"` //Runtime constant values + //To be expanded +} + +/* +RecvExecuteConfigureSpec Function + +This function will read the configure spec from Zoraxy +and return the ConfigureSpec object + +Place this function after ServeIntroSpect function in your plugin main function +*/ +func RecvConfigureSpec() (*ConfigureSpec, error) { + for i, arg := range os.Args { + if strings.HasPrefix(arg, "-configure=") { + var configSpec ConfigureSpec + if err := json.Unmarshal([]byte(arg[11:]), &configSpec); err != nil { + return nil, err + } + return &configSpec, nil + } else if arg == "-configure" { + var configSpec ConfigureSpec + var nextArg string + if len(os.Args) > i+1 { + nextArg = os.Args[i+1] + if err := json.Unmarshal([]byte(nextArg), &configSpec); err != nil { + return nil, err + } + } else { + return nil, fmt.Errorf("No port specified after -configure flag") + } + return &configSpec, nil + } + } + return nil, fmt.Errorf("No -configure flag found") +} + +/* +ServeAndRecvSpec Function + +This function will serve the intro spect and return the configure spec +See the ServeIntroSpect and RecvConfigureSpec for more details +*/ +func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) { + ServeIntroSpect(pluginSpect) + return RecvConfigureSpec() +} + +/* + +Shutdown handler + +This function will register a shutdown handler for the plugin +The shutdown callback will be called when the plugin is shutting down +You can use this to clean up resources like closing database connections +*/ + +func RegisterShutdownHandler(shutdownCallback func()) { + // Set up a channel to receive OS signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Start a goroutine to listen for signals + go func() { + <-sigChan + shutdownCallback() + os.Exit(0) + }() +} diff --git a/src/start.go b/src/start.go index f9d757d..6f20c93 100644 --- a/src/start.go +++ b/src/start.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/gorilla/csrf" "imuslab.com/zoraxy/mod/access" "imuslab.com/zoraxy/mod/acme" "imuslab.com/zoraxy/mod/auth" @@ -27,6 +28,7 @@ import ( "imuslab.com/zoraxy/mod/netstat" "imuslab.com/zoraxy/mod/pathrule" "imuslab.com/zoraxy/mod/plugins" + "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin" "imuslab.com/zoraxy/mod/sshprox" "imuslab.com/zoraxy/mod/statistic" "imuslab.com/zoraxy/mod/statistic/analytic" @@ -324,12 +326,15 @@ func startupSequence() { pluginManager = plugins.NewPluginManager(&plugins.ManagerOptions{ PluginDir: "./plugins", - SystemConst: &plugins.RuntimeConstantValue{ + SystemConst: &zoraxy_plugin.RuntimeConstantValue{ ZoraxyVersion: SYSTEM_VERSION, ZoraxyUUID: nodeUUID, }, Database: sysdb, Logger: SystemWideLogger, + CSRFTokenGen: func(r *http.Request) string { + return csrf.Token(r) + }, }) err = pluginManager.LoadPluginsFromDisk() diff --git a/src/web/components/plugins.html b/src/web/components/plugins.html index 7d0e780..a509beb 100644 --- a/src/web/components/plugins.html +++ b/src/web/components/plugins.html @@ -3,7 +3,7 @@

Plugins

Custom features on Zoraxy

- +
@@ -35,9 +35,9 @@ function initiatePluginList(){
Plugin Name

- +
- ${plugin.Spec.name} + ${plugin.Spec.name}
${versionString} by ${plugin.Spec.author}

@@ -60,7 +60,7 @@ function initiatePluginList(){ } function openPluginUI(pluginid){ - showSideWrapper(`/plugin.ui/${pluginid}/`); + showSideWrapper(`/plugin.ui/${pluginid}/`, true); } initiatePluginList(); diff --git a/src/web/index.html b/src/web/index.html index c2aaca3..3975c15 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -414,7 +414,7 @@ Toggles for side wrapper */ - function showSideWrapper(scriptpath=""){ + function showSideWrapper(scriptpath="", extendedMode=false){ if (scriptpath != ""){ $(".sideWrapper iframe").attr("src", scriptpath); } @@ -422,6 +422,12 @@ if ($(".sideWrapper .content").transition("is animating") || $(".sideWrapper .content").transition("is visible")){ return } + + if (extendedMode){ + $(".sideWrapper").addClass("extendedMode"); + }else{ + $(".sideWrapper").removeClass("extendedMode"); + } $(".sideWrapper").show(); $(".sideWrapper .fadingBackground").fadeIn("fast"); $(".sideWrapper .content").transition('slide left in', 300); diff --git a/src/web/main.css b/src/web/main.css index 3989f5b..b69c0f6 100644 --- a/src/web/main.css +++ b/src/web/main.css @@ -188,6 +188,10 @@ body{ z-index: 10; } +.sideWrapper.extendedMode{ + max-width: calc(80% - 1em); +} + .sideWrapper .content{ height: 100%; width: 100%;