mirror of
https://github.com/tobychui/zoraxy.git
synced 2025-05-31 04:37:20 +02:00
Merge pull request #566 from tobychui/v3.1.9
- Fixed netstat underflow bug - Fixed origin picker cookie bug - Added prototype plugin system - Added plugin examples - Added notice for build-in Zerotier network controller deprecation (and will be moved to plugins) - Added country code display for quickban list
This commit is contained in:
commit
560b0058cd
6
.gitignore
vendored
6
.gitignore
vendored
@ -44,4 +44,8 @@ src/log/
|
||||
|
||||
# dev-tags
|
||||
/Dockerfile
|
||||
/Entrypoint.sh
|
||||
/Entrypoint.sh
|
||||
|
||||
# plugins
|
||||
example/plugins/ztnc/ztnc.db
|
||||
example/plugins/ztnc/authtoken.secret
|
||||
|
22
example/plugins/build_all.sh
Normal file
22
example/plugins/build_all.sh
Normal file
@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Iterate over all directories in the current directory
|
||||
for dir in */; do
|
||||
if [ -d "$dir" ]; then
|
||||
echo "Processing directory: $dir"
|
||||
cd "$dir"
|
||||
|
||||
# Execute go mod tidy
|
||||
echo "Running go mod tidy in $dir"
|
||||
go mod tidy
|
||||
|
||||
# Execute go build
|
||||
echo "Running go build in $dir"
|
||||
go build
|
||||
|
||||
# Return to the parent directory
|
||||
cd ..
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Build process completed for all directories."
|
3
example/plugins/debugger/go.mod
Normal file
3
example/plugins/debugger/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module aroz.org/zoraxy/debugger
|
||||
|
||||
go 1.23.6
|
70
example/plugins/debugger/main.go
Normal file
70
example/plugins/debugger/main.go
Normal file
@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
plugin "aroz.org/zoraxy/debugger/mod/zoraxy_plugin"
|
||||
)
|
||||
|
||||
const (
|
||||
PLUGIN_ID = "org.aroz.zoraxy.debugger"
|
||||
UI_PATH = "/debug"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Serve the plugin intro spect
|
||||
// This will print the plugin intro spect and exit if the -introspect flag is provided
|
||||
runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{
|
||||
ID: "org.aroz.zoraxy.debugger",
|
||||
Name: "Plugin Debugger",
|
||||
Author: "aroz.org",
|
||||
AuthorContact: "https://aroz.org",
|
||||
Description: "A debugger for Zoraxy <-> plugin communication pipeline",
|
||||
URL: "https://zoraxy.aroz.org",
|
||||
Type: plugin.PluginType_Router,
|
||||
VersionMajor: 1,
|
||||
VersionMinor: 0,
|
||||
VersionPatch: 0,
|
||||
|
||||
GlobalCapturePaths: []plugin.CaptureRule{
|
||||
{
|
||||
CapturePath: "/debug_test", //Capture all traffic of all HTTP proxy rule
|
||||
IncludeSubPaths: true,
|
||||
},
|
||||
},
|
||||
GlobalCaptureIngress: "",
|
||||
AlwaysCapturePaths: []plugin.CaptureRule{},
|
||||
AlwaysCaptureIngress: "",
|
||||
|
||||
UIPath: UI_PATH,
|
||||
|
||||
/*
|
||||
SubscriptionPath: "/subept",
|
||||
SubscriptionsEvents: []plugin.SubscriptionEvent{
|
||||
*/
|
||||
})
|
||||
if err != nil {
|
||||
//Terminate or enter standalone mode here
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Register the shutdown handler
|
||||
plugin.RegisterShutdownHandler(func() {
|
||||
// Do cleanup here if needed
|
||||
fmt.Println("Debugger Terminated")
|
||||
})
|
||||
|
||||
http.HandleFunc(UI_PATH+"/", RenderDebugUI)
|
||||
http.HandleFunc("/gcapture", HandleIngressCapture)
|
||||
fmt.Println("Debugger started at http://127.0.0.1:" + strconv.Itoa(runtimeCfg.Port))
|
||||
http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil)
|
||||
}
|
||||
|
||||
// Handle the captured request
|
||||
func HandleIngressCapture(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, "Capture request received")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte("This request is captured by the debugger"))
|
||||
}
|
19
example/plugins/debugger/mod/zoraxy_plugin/README.txt
Normal file
19
example/plugins/debugger/mod/zoraxy_plugin/README.txt
Normal file
@ -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
|
106
example/plugins/debugger/mod/zoraxy_plugin/embed_webserver.go
Normal file
106
example/plugins/debugger/mod/zoraxy_plugin/embed_webserver.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
198
example/plugins/debugger/mod/zoraxy_plugin/zoraxy_plugin.go
Normal file
198
example/plugins/debugger/mod/zoraxy_plugin/zoraxy_plugin.go
Normal file
@ -0,0 +1,198 @@
|
||||
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
|
||||
|
||||
*/
|
||||
GlobalCapturePaths []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
|
||||
*/
|
||||
AlwaysCapturePaths []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)
|
||||
|
||||
/* 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)
|
||||
}()
|
||||
}
|
26
example/plugins/debugger/ui_info.go
Normal file
26
example/plugins/debugger/ui_info.go
Normal file
@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Render the debug UI
|
||||
func RenderDebugUI(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, "**Plugin UI Debug Interface**\n\n[Recv Headers] \n")
|
||||
|
||||
headerKeys := make([]string, 0, len(r.Header))
|
||||
for name := range r.Header {
|
||||
headerKeys = append(headerKeys, name)
|
||||
}
|
||||
sort.Strings(headerKeys)
|
||||
for _, name := range headerKeys {
|
||||
values := r.Header[name]
|
||||
for _, value := range values {
|
||||
fmt.Fprintf(w, "%s: %s\n", name, value)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
}
|
3
example/plugins/helloworld/go.mod
Normal file
3
example/plugins/helloworld/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module example.com/zoraxy/helloworld
|
||||
|
||||
go 1.23.6
|
BIN
example/plugins/helloworld/icon.png
Normal file
BIN
example/plugins/helloworld/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
63
example/plugins/helloworld/main.go
Normal file
63
example/plugins/helloworld/main.go
Normal file
@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
plugin "example.com/zoraxy/helloworld/zoraxy_plugin"
|
||||
)
|
||||
|
||||
const (
|
||||
PLUGIN_ID = "com.example.helloworld"
|
||||
UI_PATH = "/"
|
||||
WEB_ROOT = "/www"
|
||||
)
|
||||
|
||||
//go:embed www/*
|
||||
var content embed.FS
|
||||
|
||||
func main() {
|
||||
// Serve the plugin intro spect
|
||||
// This will print the plugin intro spect and exit if the -introspect flag is provided
|
||||
runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{
|
||||
ID: "com.example.helloworld",
|
||||
Name: "Hello World Plugin",
|
||||
Author: "foobar",
|
||||
AuthorContact: "admin@example.com",
|
||||
Description: "A simple hello world plugin",
|
||||
URL: "https://example.com",
|
||||
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 "/"
|
||||
UIPath: UI_PATH,
|
||||
})
|
||||
if err != nil {
|
||||
//Terminate or enter standalone mode here
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create a new PluginEmbedUIRouter that will serve the UI from web folder
|
||||
// The router will also help to handle the termination of the plugin when
|
||||
// a user wants to stop the plugin via Zoraxy Web UI
|
||||
embedWebRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, WEB_ROOT, UI_PATH)
|
||||
embedWebRouter.RegisterTerminateHandler(func() {
|
||||
// Do cleanup here if needed
|
||||
fmt.Println("Hello World Plugin Exited")
|
||||
}, nil)
|
||||
|
||||
// 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))
|
||||
err = http.ListenAndServe("127.0.0.1:"+strconv.Itoa(runtimeCfg.Port), nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
}
|
35
example/plugins/helloworld/www/index.html
Normal file
35
example/plugins/helloworld/www/index.html
Normal file
@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!-- CSRF token, if your plugin need to make POST request to backend -->
|
||||
<meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">
|
||||
<link rel="stylesheet" href="/script/semantic/semantic.min.css">
|
||||
<script src="/script/jquery-3.6.0.min.js"></script>
|
||||
<script src="/script/semantic/semantic.min.js"></script>
|
||||
<script src="/script/utils.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/main.css">
|
||||
<title>Hello World</title>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background:none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Dark theme script must be included after body tag-->
|
||||
<link rel="stylesheet" href="/darktheme.css">
|
||||
<script src="/script/darktheme.js"></script>
|
||||
<div style="text-align: center;">
|
||||
<h1>Hello World</h1>
|
||||
<p>Welcome to your first Zoraxy plugin</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
19
example/plugins/helloworld/zoraxy_plugin/README.txt
Normal file
19
example/plugins/helloworld/zoraxy_plugin/README.txt
Normal file
@ -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
|
128
example/plugins/helloworld/zoraxy_plugin/embed_webserver.go
Normal file
128
example/plugins/helloworld/zoraxy_plugin/embed_webserver.go
Normal file
@ -0,0 +1,128 @@
|
||||
package zoraxy_plugin
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"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
|
||||
|
||||
terminateHandler func() //The handler to be called when the plugin is terminated
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
|
||||
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
|
||||
// if mux is nil, the handler will be registered to http.DefaultServeMux
|
||||
func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
|
||||
p.terminateHandler = termFunc
|
||||
if mux == nil {
|
||||
mux = http.DefaultServeMux
|
||||
}
|
||||
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
|
||||
p.terminateHandler()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
go func() {
|
||||
//Make sure the response is sent before the plugin is terminated
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
})
|
||||
}
|
174
example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go
Normal file
174
example/plugins/helloworld/zoraxy_plugin/zoraxy_plugin.go
Normal file
@ -0,0 +1,174 @@
|
||||
package zoraxy_plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
/*
|
||||
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
|
||||
|
||||
*/
|
||||
GlobalCapturePaths []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
|
||||
*/
|
||||
AlwaysCapturePaths []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)
|
||||
|
||||
/* 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()
|
||||
}
|
11
example/plugins/ztnc/README.md
Normal file
11
example/plugins/ztnc/README.md
Normal file
@ -0,0 +1,11 @@
|
||||
## Global Area Network Plugin
|
||||
|
||||
This plugin implements a user interface for ZeroTier Network Controller in Zoraxy
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## License
|
||||
|
||||
AGPL
|
11
example/plugins/ztnc/go.mod
Normal file
11
example/plugins/ztnc/go.mod
Normal file
@ -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
|
30
example/plugins/ztnc/go.sum
Normal file
30
example/plugins/ztnc/go.sum
Normal file
@ -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=
|
BIN
example/plugins/ztnc/icon.png
Normal file
BIN
example/plugins/ztnc/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.7 KiB |
BIN
example/plugins/ztnc/icon.psd
Normal file
BIN
example/plugins/ztnc/icon.psd
Normal file
Binary file not shown.
81
example/plugins/ztnc/main.go
Normal file
81
example/plugins/ztnc/main.go
Normal file
@ -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)
|
||||
}
|
||||
|
||||
// Create a new PluginEmbedUIRouter that will serve the UI from web folder
|
||||
uiRouter := plugin.NewPluginEmbedUIRouter(PLUGIN_ID, &content, EMBED_FS_ROOT, UI_RELPATH)
|
||||
|
||||
// Register the shutdown handler
|
||||
uiRouter.RegisterTerminateHandler(func() {
|
||||
// Do cleanup here if needed
|
||||
if sysdb != nil {
|
||||
sysdb.Close()
|
||||
}
|
||||
fmt.Println("ztnc Exited")
|
||||
}, nil)
|
||||
|
||||
// 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)
|
||||
}
|
146
example/plugins/ztnc/mod/database/database.go
Normal file
146
example/plugins/ztnc/mod/database/database.go
Normal file
@ -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()
|
||||
}
|
70
example/plugins/ztnc/mod/database/database_core.go
Normal file
70
example/plugins/ztnc/mod/database/database_core.go
Normal file
@ -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()
|
||||
}
|
196
example/plugins/ztnc/mod/database/database_openwrt.go
Normal file
196
example/plugins/ztnc/mod/database/database_openwrt.go
Normal file
@ -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/ztnc/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
|
||||
}
|
141
example/plugins/ztnc/mod/database/dbbolt/dbbolt.go
Normal file
141
example/plugins/ztnc/mod/database/dbbolt/dbbolt.go
Normal file
@ -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()
|
||||
}
|
67
example/plugins/ztnc/mod/database/dbbolt/dbbolt_test.go
Normal file
67
example/plugins/ztnc/mod/database/dbbolt/dbbolt_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
39
example/plugins/ztnc/mod/database/dbinc/dbinc.go
Normal file
39
example/plugins/ztnc/mod/database/dbinc/dbinc.go
Normal file
@ -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"
|
||||
}
|
||||
}
|
152
example/plugins/ztnc/mod/database/dbleveldb/dbleveldb.go
Normal file
152
example/plugins/ztnc/mod/database/dbleveldb/dbleveldb.go
Normal file
@ -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()
|
||||
}
|
141
example/plugins/ztnc/mod/database/dbleveldb/dbleveldb_test.go
Normal file
141
example/plugins/ztnc/mod/database/dbleveldb/dbleveldb_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
80
example/plugins/ztnc/mod/ganserv/authkey.go
Normal file
80
example/plugins/ztnc/mod/ganserv/authkey.go
Normal file
@ -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
|
||||
}
|
37
example/plugins/ztnc/mod/ganserv/authkeyLinux.go
Normal file
37
example/plugins/ztnc/mod/ganserv/authkeyLinux.go
Normal file
@ -0,0 +1,37 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package ganserv
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"strings"
|
||||
|
||||
"aroz.org/zoraxy/ztnc/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"
|
||||
}
|
73
example/plugins/ztnc/mod/ganserv/authkeyWin.go
Normal file
73
example/plugins/ztnc/mod/ganserv/authkeyWin.go
Normal file
@ -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
|
||||
}
|
130
example/plugins/ztnc/mod/ganserv/ganserv.go
Normal file
130
example/plugins/ztnc/mod/ganserv/ganserv.go
Normal file
@ -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)
|
||||
}
|
504
example/plugins/ztnc/mod/ganserv/handlers.go
Normal file
504
example/plugins/ztnc/mod/ganserv/handlers.go
Normal file
@ -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)
|
||||
}
|
39
example/plugins/ztnc/mod/ganserv/network.go
Normal file
39
example/plugins/ztnc/mod/ganserv/network.go
Normal file
@ -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
|
||||
}
|
55
example/plugins/ztnc/mod/ganserv/network_test.go
Normal file
55
example/plugins/ztnc/mod/ganserv/network_test.go
Normal file
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
55
example/plugins/ztnc/mod/ganserv/utils.go
Normal file
55
example/plugins/ztnc/mod/ganserv/utils.go
Normal file
@ -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)
|
||||
}
|
669
example/plugins/ztnc/mod/ganserv/zerotier.go
Normal file
669
example/plugins/ztnc/mod/ganserv/zerotier.go
Normal file
@ -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
|
||||
}
|
105
example/plugins/ztnc/mod/utils/conv.go
Normal file
105
example/plugins/ztnc/mod/utils/conv.go
Normal file
@ -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
|
||||
}
|
19
example/plugins/ztnc/mod/utils/template.go
Normal file
19
example/plugins/ztnc/mod/utils/template.go
Normal file
@ -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))
|
||||
}
|
202
example/plugins/ztnc/mod/utils/utils.go
Normal file
202
example/plugins/ztnc/mod/utils/utils.go
Normal file
@ -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
|
||||
}
|
19
example/plugins/ztnc/mod/zoraxy_plugin/README.txt
Normal file
19
example/plugins/ztnc/mod/zoraxy_plugin/README.txt
Normal file
@ -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
|
128
example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go
Normal file
128
example/plugins/ztnc/mod/zoraxy_plugin/embed_webserver.go
Normal file
@ -0,0 +1,128 @@
|
||||
package zoraxy_plugin
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"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
|
||||
|
||||
terminateHandler func() //The handler to be called when the plugin is terminated
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
|
||||
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
|
||||
// if mux is nil, the handler will be registered to http.DefaultServeMux
|
||||
func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
|
||||
p.terminateHandler = termFunc
|
||||
if mux == nil {
|
||||
mux = http.DefaultServeMux
|
||||
}
|
||||
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
|
||||
p.terminateHandler()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
go func() {
|
||||
//Make sure the response is sent before the plugin is terminated
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
})
|
||||
}
|
174
example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go
Normal file
174
example/plugins/ztnc/mod/zoraxy_plugin/zoraxy_plugin.go
Normal file
@ -0,0 +1,174 @@
|
||||
package zoraxy_plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
/*
|
||||
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
|
||||
|
||||
*/
|
||||
GlobalCapturePaths []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
|
||||
*/
|
||||
AlwaysCapturePaths []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)
|
||||
|
||||
/* 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()
|
||||
}
|
69
example/plugins/ztnc/start.go
Normal file
69
example/plugins/ztnc/start.go
Normal file
@ -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)
|
||||
}
|
747
example/plugins/ztnc/web/details.html
Normal file
747
example/plugins/ztnc/web/details.html
Normal file
@ -0,0 +1,747 @@
|
||||
<!-- This is being loaded in index.html as ajax -->
|
||||
<div class="standardContainer">
|
||||
<button onclick="exitToGanList();" class="ui large circular black icon button"><i class="angle left icon"></i></button>
|
||||
<div style="max-width: 300px; margin-top: 1em;">
|
||||
<button onclick='$("#gannetDetailEdit").slideToggle("fast");' class="ui mini basic right floated circular icon button" style="display: inline-block; margin-top: 2.5em;"><i class="ui edit icon"></i></button>
|
||||
<h1 class="ui header">
|
||||
<span class="ganetID"></span>
|
||||
<div class="sub header ganetName"></div>
|
||||
</h1>
|
||||
<div class="ui divider"></div>
|
||||
<p><span class="ganetDesc"></span></p>
|
||||
|
||||
</div>
|
||||
<div id="gannetDetailEdit" class="ui form" style="margin-top: 1em; display:none;">
|
||||
<div class="ui divider"></div>
|
||||
<p>You can change the network name and description below. <br>The name and description is only for easy management purpose and will not effect the network operation.</p>
|
||||
<div class="field">
|
||||
<label>Network Name</label>
|
||||
<input type="text" id="gaNetNameInput" placeholder="">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Network Description</label>
|
||||
<textarea id="gaNetDescInput" style="resize: none;"></textarea>
|
||||
<button onclick="saveNameAndDesc(this);" class="ui basic right floated button" style="margin-top: 0.6em;"><i class="ui save icon"></i> Save</button>
|
||||
<button onclick='$("#gannetDetailEdit").slideUp("fast");' class="ui basic right floated button" style="margin-top: 0.6em;"><i class="ui red remove icon"></i> Cancel</button>
|
||||
</div>
|
||||
<br><br>
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
<h2>Settings</h2>
|
||||
<div class="" style="overflow-x: auto;">
|
||||
<table class="ui basic celled unstackable table" style="min-width: 560px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="4">IPv4 Auto-Assign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ganetRangeTable">
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<br>
|
||||
<div class="ui form">
|
||||
<h3>Custom IP Range</h3>
|
||||
<p>Manual IP Range Configuration. The IP range must be within the selected CIDR range.
|
||||
<br>Use <code>Utilities > IP to CIDR tool</code> if you are not too familiar with CIDR notations.</p>
|
||||
<div class="two fields">
|
||||
<div class="field">
|
||||
<label>IP Start</label>
|
||||
<input type="text" class="ganIpStart" placeholder="">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>IP End</label>
|
||||
<input type="text" class="ganIpEnd" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="setNetworkRange();" class="ui basic button"><i class="ui blue save icon"></i> Save Settings</button>
|
||||
|
||||
<div class="ui divider"></div>
|
||||
<h2>Members</h2>
|
||||
<p>To join this network using command line, type <code>sudo zerotier-cli join <span class="ganetID"></span></code> on your device terminal</p>
|
||||
<div class="ui checkbox" style="margin-bottom: 1em;">
|
||||
<input id="showUnauthorizedMembers" type="checkbox" onchange="changeUnauthorizedVisibility(this.checked);" checked>
|
||||
<label>Show Unauthorized Members</label>
|
||||
</div>
|
||||
<div class="" style="overflow-x: auto;">
|
||||
<table class="ui celled unstackable table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Auth</th>
|
||||
<th>Address</th>
|
||||
<th>Name</th>
|
||||
<th>Managed IP</th>
|
||||
<th>Authorized Since</th>
|
||||
<th>Version</th>
|
||||
<th>Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="networkMemeberTable">
|
||||
<tr>
|
||||
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
<h4>Add Controller as Member</h4>
|
||||
<p>Optionally you can add the network controller (ZeroTier running on the Zoraxy node) as member for cross GAN reverse proxy to bypass NAT limitations.</p>
|
||||
<button class="ui basic small button addControllerToNetworkBtn" onclick="ganAddControllerToNetwork(this);"><i class="green add icon"></i> Add Controller as Member</button>
|
||||
<button class="ui basic small button removeControllerFromNetworkBtn" onclick="ganRemoveControllerFromNetwork(this);"><i class="red sign-out icon"></i> Remove Controller from Member</button>
|
||||
<br><br>
|
||||
</div>
|
||||
<script>
|
||||
$(".checkbox").checkbox();
|
||||
var currentGANetID = "";
|
||||
var currentGANNetMemeberListener = undefined;
|
||||
var currentGaNetDetails = {};
|
||||
var currentGANMemberList = [];
|
||||
var netRanges = {
|
||||
"10.147.17.*": "10.147.17.0/24",
|
||||
"10.147.18.*": "10.147.18.0/24",
|
||||
"10.147.19.*": "10.147.19.0/24",
|
||||
"10.147.20.*": "10.147.20.0/24",
|
||||
"10.144.*.*": "10.144.0.0/16",
|
||||
"10.241.*.*": "10.241.0.0/16",
|
||||
"10.242.*.*": "10.242.0.0/16",
|
||||
"10.243.*.*": "10.243.0.0/16",
|
||||
"10.244.*.*": "10.244.0.0/16",
|
||||
"172.22.*.*": "172.22.0.0/15",
|
||||
"172.23.*.*": "172.23.0.0/16",
|
||||
"172.24.*.*": "172.24.0.0/14",
|
||||
"172.25.*.*": "172.25.0.0/16",
|
||||
"172.26.*.*": "172.26.0.0/15",
|
||||
"172.27.*.*": "172.27.0.0/16",
|
||||
"172.28.*.*": "172.28.0.0/15",
|
||||
"172.29.*.*": "172.29.0.0/16",
|
||||
"172.30.*.*": "172.30.0.0/15",
|
||||
"192.168.191.*": "192.168.191.0/24",
|
||||
"192.168.192.*": "192.168.192.0/24",
|
||||
"192.168.193.*": "192.168.193.0/24",
|
||||
"192.168.194.*": "192.168.194.0/24",
|
||||
"192.168.195.*": "192.168.195.0/24",
|
||||
"192.168.196.*": "192.168.196.0/24"
|
||||
}
|
||||
|
||||
function generateIPRangeTable(netRanges) {
|
||||
$("#ganetRangeTable").empty();
|
||||
const tableBody = document.getElementById('ganetRangeTable');
|
||||
const cidrs = Object.values(netRanges);
|
||||
|
||||
// Set the number of rows and columns to display in the table
|
||||
const numRows = 6;
|
||||
const numCols = 4;
|
||||
|
||||
let row = document.createElement('tr');
|
||||
let col = 0;
|
||||
for (let i = 0; i < cidrs.length; i++) {
|
||||
if (col >= numCols) {
|
||||
tableBody.appendChild(row);
|
||||
row = document.createElement('tr');
|
||||
col = 0;
|
||||
}
|
||||
|
||||
const td = document.createElement('td');
|
||||
td.setAttribute('class', `clickable iprange`);
|
||||
td.setAttribute('CIDR', cidrs[i]);
|
||||
td.innerHTML = cidrs[i];
|
||||
let thisCidr = cidrs[i];
|
||||
td.onclick = function(){
|
||||
selectNetworkRange(thisCidr, td);
|
||||
};
|
||||
|
||||
row.appendChild(td);
|
||||
col++;
|
||||
}
|
||||
|
||||
// Add any remaining cells to the table
|
||||
if (col > 0) {
|
||||
for (let i = col; i < numCols; i++) {
|
||||
row.appendChild(document.createElement('td'));
|
||||
}
|
||||
tableBody.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightCurrentGANetCIDR(){
|
||||
var currentCIDR = currentGaNetDetails.routes[0].target;
|
||||
$(".iprange").each(function(){
|
||||
if ($(this).attr("CIDR") == currentCIDR){
|
||||
$(this).addClass("active");
|
||||
populateStartEndIpByCidr(currentCIDR);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function populateStartEndIpByCidr(cidr){
|
||||
function cidrToRange(cidr) {
|
||||
var range = [2];
|
||||
cidr = cidr.split('/');
|
||||
var start = ip2long(cidr[0]);
|
||||
range[0] = long2ip(start);
|
||||
range[1] = long2ip(Math.pow(2, 32 - cidr[1]) + start - 1);
|
||||
return range;
|
||||
}
|
||||
var cidrRange = cidrToRange(cidr);
|
||||
$(".ganIpStart").val(cidrRange[0]);
|
||||
$(".ganIpEnd").val(cidrRange[1]);
|
||||
}
|
||||
|
||||
function selectNetworkRange(cidr, object){
|
||||
populateStartEndIpByCidr(cidr);
|
||||
|
||||
$(".iprange.active").removeClass("active");
|
||||
$(object).addClass("active");
|
||||
}
|
||||
|
||||
function setNetworkRange(){
|
||||
var ipstart = $(".ganIpStart").val().trim();
|
||||
var ipend = $(".ganIpEnd").val().trim();
|
||||
|
||||
if (ipstart == ""){
|
||||
$(".ganIpStart").parent().addClass("error");
|
||||
}else{
|
||||
$(".ganIpStart").parent().removeClass("error");
|
||||
}
|
||||
|
||||
if (ipend == ""){
|
||||
$(".ganIpEnd").parent().addClass("error");
|
||||
}else{
|
||||
$(".ganIpEnd").parent().removeClass("error");
|
||||
}
|
||||
|
||||
//Get CIDR from selected range group
|
||||
var cidr = $(".iprange.active").attr("cidr");
|
||||
|
||||
$.cjax({
|
||||
url: "./api/gan/network/setRange",
|
||||
metohd: "POST",
|
||||
data:{
|
||||
netid: currentGANetID,
|
||||
cidr: cidr,
|
||||
ipstart: ipstart,
|
||||
ipend: ipend
|
||||
},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false, 5000)
|
||||
}else{
|
||||
msgbox("Network Range Updated")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function saveNameAndDesc(object=undefined){
|
||||
var name = $("#gaNetNameInput").val();
|
||||
var desc = $("#gaNetDescInput").val();
|
||||
if (object != undefined){
|
||||
$(object).addClass("loading");
|
||||
}
|
||||
$.cjax({
|
||||
url: "./api/gan/network/name",
|
||||
method: "POST",
|
||||
data: {
|
||||
netid: currentGANetID,
|
||||
name: name,
|
||||
desc: desc,
|
||||
},
|
||||
success: function(data){
|
||||
initNetNameAndDesc();
|
||||
if (object != undefined){
|
||||
$(object).removeClass("loading");
|
||||
msgbox("Network Metadata Updated");
|
||||
}
|
||||
$("#gannetDetailEdit").slideUp("fast");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initNetNameAndDesc(){
|
||||
//Get the details of the net
|
||||
$.get("./api/gan/network/name?netid=" + currentGANetID, function(data){
|
||||
if (data.error !== undefined){
|
||||
msgbox(data.error, false, 6000);
|
||||
}else{
|
||||
$("#gaNetNameInput").val(data[0]);
|
||||
$(".ganetName").html(data[0]);
|
||||
$("#gaNetDescInput").val(data[1]);
|
||||
$(".ganetDesc").text(data[1]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initNetDetails(){
|
||||
//Get the details of the net
|
||||
$.get("./api/gan/network/list?netid=" + currentGANetID, function(data){
|
||||
if (data.error !== undefined){
|
||||
msgbox(data.error, false, 6000);
|
||||
}else{
|
||||
currentGaNetDetails = data;
|
||||
highlightCurrentGANetCIDR();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//Handle delete IP from memeber
|
||||
function deleteIpFromMemeber(memberid, ip){
|
||||
$.cjax({
|
||||
url: "./api/gan/members/ip",
|
||||
metohd: "POST",
|
||||
data: {
|
||||
netid: currentGANetID,
|
||||
memid: memberid,
|
||||
opr: "del",
|
||||
ip: ip,
|
||||
},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false, 5000);
|
||||
}else{
|
||||
msgbox("IP removed from member " + memberid)
|
||||
}
|
||||
renderMemeberTable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addIpToMemeberFromInput(memberid, newip){
|
||||
function isValidIPv4Address(address) {
|
||||
// Split the address into its 4 components
|
||||
const parts = address.split('.');
|
||||
|
||||
// Check that there are 4 components
|
||||
if (parts.length !== 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that each component is a number between 0 and 255
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const part = parseInt(parts[i], 10);
|
||||
if (isNaN(part) || part < 0 || part > 255) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The address is valid
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isValidIPv4Address(newip)){
|
||||
msgbox(newip + " is not a valid IPv4 address", false, 5000)
|
||||
return
|
||||
}
|
||||
|
||||
$.cjax({
|
||||
url: "./api/gan/members/ip",
|
||||
metohd: "POST",
|
||||
data: {
|
||||
netid: currentGANetID,
|
||||
memid: memberid,
|
||||
opr: "add",
|
||||
ip: newip,
|
||||
},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false, 5000);
|
||||
}else{
|
||||
msgbox("IP added to member " + memberid)
|
||||
}
|
||||
renderMemeberTable();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//Member table populate
|
||||
function renderMemeberTable(forceUpdate = false) {
|
||||
$.ajax({
|
||||
url: './api/gan/members/list?netid=' + currentGANetID + '&detail=true',
|
||||
type: 'GET',
|
||||
success: function(data) {
|
||||
let tableBody = $('#networkMemeberTable');
|
||||
if (tableBody.length == 0){
|
||||
return;
|
||||
}
|
||||
data.sort((a, b) => a.address.localeCompare(b.address));
|
||||
//Check if the new object equal to the old one
|
||||
if (objectEqual(currentGANMemberList, data) && !forceUpdate){
|
||||
//Do not need to update it
|
||||
return;
|
||||
}
|
||||
tableBody.empty();
|
||||
currentGANMemberList = data;
|
||||
|
||||
var authroziedCount = 0;
|
||||
data.forEach((member) => {
|
||||
let lastAuthTime = new Date(member.lastAuthorizedTime).toLocaleString();
|
||||
if (member.lastAuthorizedTime == 0){
|
||||
lastAuthTime = "Never";
|
||||
}
|
||||
|
||||
let version = `${member.vMajor}.${member.vMinor}.${member.vProto}.${member.vRev}`;
|
||||
if (member.vMajor == -1){
|
||||
version = "Unknown";
|
||||
}
|
||||
|
||||
let authorizedCheckbox = `<div class="ui fitted checkbox">
|
||||
<input type="checkbox" addr="${member.address}" name="isAuthrozied" onchange="handleMemberAuth(this);">
|
||||
<label></label>
|
||||
</div>`;
|
||||
if (member.authorized){
|
||||
authorizedCheckbox = `<div class="ui fitted checkbox">
|
||||
<input type="checkbox" addr="${member.address}" name="isAuthrozied" onchange="handleMemberAuth(this);" checked="">
|
||||
<label></label>
|
||||
</div>`
|
||||
}
|
||||
|
||||
let rowClass = "authorized";
|
||||
let unauthorizedStyle = "";
|
||||
if (!$("#showUnauthorizedMembers")[0].checked && !member.authorized){
|
||||
unauthorizedStyle = "display:none;";
|
||||
}
|
||||
if (!member.authorized){
|
||||
rowClass = "unauthorized";
|
||||
}else{
|
||||
authroziedCount++;
|
||||
}
|
||||
|
||||
let assignedIp = "";
|
||||
|
||||
if (member.ipAssignments.length == 0){
|
||||
assignedIp = "Not assigned"
|
||||
}else{
|
||||
assignedIp = `<div class="ui list">`
|
||||
member.ipAssignments.forEach(function(thisIp){
|
||||
assignedIp += `<div class="item" style="width: 100%;">${thisIp} <a style="cursor:pointer; float: right;" title="Remove IP" onclick="deleteIpFromMemeber('${member.address}','${thisIp}');"><i class="red remove icon"></i></a></div>`;
|
||||
})
|
||||
assignedIp += `</div>`
|
||||
}
|
||||
const row = $(`<tr class="GANetMemberEntity ${rowClass}" style="${unauthorizedStyle}">`);
|
||||
row.append($(`<td class="GANetMember ${rowClass}" style="text-align: center;">`).html(authorizedCheckbox));
|
||||
row.append($('<td>').text(member.address));
|
||||
row.append($('<td>').html(`<span class="memberName" addr="${member.address}"></span> <a style="cursor:pointer; float: right;" title="Edit Memeber Name" onclick="renameMember('${member.address}');"><i class="grey edit icon"></i></a>`));
|
||||
row.append($('<td>').html(`${assignedIp}
|
||||
<div class="ui action mini fluid input" style="min-width: 200px;">
|
||||
<input type="text" placeholder="IPv4" onchange="$(this).val($(this).val().trim());">
|
||||
<button onclick="addIpToMemeberFromInput('${member.address}',$(this).parent().find('input').val());" class="ui basic icon button">
|
||||
<i class="add icon"></i>
|
||||
</button>
|
||||
</div>`));
|
||||
row.append($('<td>').text(lastAuthTime));
|
||||
row.append($('<td>').text(version));
|
||||
row.append($(`<td title="Deauthorize & Delete Memeber" style="text-align: center;" onclick="handleMemberDelete('${member.address}');">`).html(`<button class="ui basic mini icon button"><i class="red remove icon"></i></button>`));
|
||||
|
||||
tableBody.append(row);
|
||||
});
|
||||
|
||||
if (data.length == 0){
|
||||
tableBody.append(`<tr>
|
||||
<td colspan="7"><i class="green check circle icon"></i> No member has joined this network yet.</td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
if (data.length > 0 && authroziedCount == 0 && !$("#showUnauthorizedMembers")[0].checked){
|
||||
//All nodes are unauthorized. Show tips to enable unauthorize display
|
||||
tableBody.append(`<tr>
|
||||
<td colspan="7"><i class="yellow exclamation circle icon"></i> Unauthorized nodes detected. Enable "Show Unauthorized Member" to change member access permission.</td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
initNameForMembers();
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.log('Error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initNameForMembers(){
|
||||
$(".memberName").each(function(){
|
||||
let addr = $(this).attr("addr");
|
||||
let targetDOM = $(this);
|
||||
$.cjax({
|
||||
url: "./api/gan/members/name",
|
||||
method: "POST",
|
||||
data: {
|
||||
netid: currentGANetID,
|
||||
memid: addr,
|
||||
},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
$(targetDOM).text("N/A");
|
||||
}else{
|
||||
$(targetDOM).text(data.Name);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function renameMember(targetMemberAddr){
|
||||
if (targetMemberAddr == ""){
|
||||
msgbox("Member address cannot be empty", false, 5000)
|
||||
return
|
||||
}
|
||||
|
||||
let newname = prompt("Enter a easy manageable name for " + targetMemberAddr, "");
|
||||
if (newname != null && newname.trim() != "") {
|
||||
$.cjax({
|
||||
url: "./api/gan/members/name",
|
||||
method: "POST",
|
||||
data: {
|
||||
netid: currentGANetID,
|
||||
memid: targetMemberAddr,
|
||||
name: newname
|
||||
},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false, 6000);
|
||||
}else{
|
||||
msgbox("Member Name Updated");
|
||||
}
|
||||
renderMemeberTable(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//Helper function to check if two objects are equal recursively
|
||||
function objectEqual(obj1, obj2) {
|
||||
// compare types
|
||||
if (typeof obj1 !== typeof obj2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// compare values
|
||||
if (typeof obj1 !== 'object' || obj1 === null) {
|
||||
return obj1 === obj2;
|
||||
}
|
||||
|
||||
const keys1 = Object.keys(obj1);
|
||||
const keys2 = Object.keys(obj2);
|
||||
|
||||
// compare keys
|
||||
if (keys1.length !== keys2.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of keys1) {
|
||||
if (!keys2.includes(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// recursively compare values
|
||||
if (!objectEqual(obj1[key], obj2[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
function changeUnauthorizedVisibility(visable){
|
||||
if(visable){
|
||||
$(".GANetMemberEntity.unauthorized").show();
|
||||
}else{
|
||||
$(".GANetMemberEntity.unauthorized").hide();
|
||||
}
|
||||
}
|
||||
|
||||
function handleMemberAuth(object){
|
||||
let targetMemberAddr = $(object).attr("addr");
|
||||
let isAuthed = object.checked;
|
||||
$.cjax({
|
||||
url: "./api/gan/members/authorize",
|
||||
method: "POST",
|
||||
data: {
|
||||
netid:currentGANetID,
|
||||
memid: targetMemberAddr,
|
||||
auth: isAuthed
|
||||
},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false, 6000);
|
||||
}else{
|
||||
if (isAuthed){
|
||||
msgbox("Member Authorized");
|
||||
}else{
|
||||
msgbox("Member Deauthorized");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
renderMemeberTable(true);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleMemberDelete(addr){
|
||||
if (confirm("Confirm delete member " + addr + " ?")){
|
||||
$.cjax({
|
||||
url: "./api/gan/members/delete",
|
||||
method: "POST",
|
||||
data: {
|
||||
netid:currentGANetID,
|
||||
memid: addr,
|
||||
},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false, 6000);
|
||||
}else{
|
||||
msgbox("Member Deleted");
|
||||
}
|
||||
renderMemeberTable(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//Add and remove this controller node to network as member
|
||||
function ganAddControllerToNetwork(){
|
||||
$(".addControllerToNetworkBtn").addClass("disabled");
|
||||
$(".addControllerToNetworkBtn").addClass("loading");
|
||||
|
||||
$.cjax({
|
||||
url: "./api/gan/network/join",
|
||||
method: "POST",
|
||||
data: {
|
||||
netid:currentGANetID,
|
||||
},
|
||||
success: function(data){
|
||||
$(".addControllerToNetworkBtn").removeClass("disabled");
|
||||
$(".addControllerToNetworkBtn").removeClass("loading");
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false, 6000);
|
||||
}else{
|
||||
msgbox("Controller joint " + currentGANetID);
|
||||
}
|
||||
setTimeout(function(){
|
||||
renderMemeberTable(true);
|
||||
}, 3000)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ganRemoveControllerFromNetwork(){
|
||||
$(".removeControllerFromNetworkBtn").addClass("disabled");
|
||||
$(".removeControllerFromNetworkBtn").addClass("loading");
|
||||
|
||||
$.cjax({
|
||||
url: "./api/gan/network/leave",
|
||||
method: "POST",
|
||||
data: {
|
||||
netid:currentGANetID,
|
||||
},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false, 6000);
|
||||
}else{
|
||||
msgbox("Controller left " + currentGANetID);
|
||||
}
|
||||
renderMemeberTable(true);
|
||||
$(".removeControllerFromNetworkBtn").removeClass("disabled");
|
||||
$(".removeControllerFromNetworkBtn").removeClass("loading");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//Entry points
|
||||
function initGanetDetails(ganetId){
|
||||
currentGANetID = ganetId;
|
||||
$(".ganetID").text(ganetId);
|
||||
initNetNameAndDesc(ganetId);
|
||||
generateIPRangeTable(netRanges);msgbox
|
||||
initNetDetails();
|
||||
renderMemeberTable(true);
|
||||
|
||||
//Setup a listener to listen for member list change
|
||||
if (currentGANNetMemeberListener == undefined){
|
||||
currentGANNetMemeberListener = setInterval(function(){
|
||||
if ($('#networkMemeberTable').length > 0 && currentGANetID){
|
||||
renderMemeberTable();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//Exit point
|
||||
function exitToGanList(){
|
||||
location.href = "./index.html"
|
||||
}
|
||||
|
||||
//Debug functions
|
||||
|
||||
if (typeof(msgbox) == "undefined"){
|
||||
msgbox = function(msg, error=false, timeout=3000){
|
||||
console.log(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function ip2long (argIP) {
|
||||
// discuss at: https://locutus.io/php/ip2long/
|
||||
// original by: Waldo Malqui Silva (https://waldo.malqui.info)
|
||||
// improved by: Victor
|
||||
// revised by: fearphage (https://my.opera.com/fearphage/)
|
||||
// revised by: Theriault (https://github.com/Theriault)
|
||||
// estarget: es2015
|
||||
// example 1: ip2long('192.0.34.166')
|
||||
// returns 1: 3221234342
|
||||
// example 2: ip2long('0.0xABCDEF')
|
||||
// returns 2: 11259375
|
||||
// example 3: ip2long('255.255.255.256')
|
||||
// returns 3: false
|
||||
let i = 0
|
||||
// PHP allows decimal, octal, and hexadecimal IP components.
|
||||
// PHP allows between 1 (e.g. 127) to 4 (e.g 127.0.0.1) components.
|
||||
const pattern = new RegExp([
|
||||
'^([1-9]\\d*|0[0-7]*|0x[\\da-f]+)',
|
||||
'(?:\\.([1-9]\\d*|0[0-7]*|0x[\\da-f]+))?',
|
||||
'(?:\\.([1-9]\\d*|0[0-7]*|0x[\\da-f]+))?',
|
||||
'(?:\\.([1-9]\\d*|0[0-7]*|0x[\\da-f]+))?$'
|
||||
].join(''), 'i')
|
||||
argIP = argIP.match(pattern) // Verify argIP format.
|
||||
if (!argIP) {
|
||||
// Invalid format.
|
||||
return false
|
||||
}
|
||||
// Reuse argIP variable for component counter.
|
||||
argIP[0] = 0
|
||||
for (i = 1; i < 5; i += 1) {
|
||||
argIP[0] += !!((argIP[i] || '').length)
|
||||
argIP[i] = parseInt(argIP[i]) || 0
|
||||
}
|
||||
// Continue to use argIP for overflow values.
|
||||
// PHP does not allow any component to overflow.
|
||||
argIP.push(256, 256, 256, 256)
|
||||
// Recalculate overflow of last component supplied to make up for missing components.
|
||||
argIP[4 + argIP[0]] *= Math.pow(256, 4 - argIP[0])
|
||||
if (argIP[1] >= argIP[5] ||
|
||||
argIP[2] >= argIP[6] ||
|
||||
argIP[3] >= argIP[7] ||
|
||||
argIP[4] >= argIP[8]) {
|
||||
return false
|
||||
}
|
||||
return argIP[1] * (argIP[0] === 1 || 16777216) +
|
||||
argIP[2] * (argIP[0] <= 2 || 65536) +
|
||||
argIP[3] * (argIP[0] <= 3 || 256) +
|
||||
argIP[4] * 1
|
||||
}
|
||||
|
||||
function long2ip (ip) {
|
||||
// discuss at: https://locutus.io/php/long2ip/
|
||||
// original by: Waldo Malqui Silva (https://fayr.us/waldo/)
|
||||
// example 1: long2ip( 3221234342 )
|
||||
// returns 1: '192.0.34.166'
|
||||
if (!isFinite(ip)) {
|
||||
return false
|
||||
}
|
||||
return [ip >>> 24 & 0xFF, ip >>> 16 & 0xFF, ip >>> 8 & 0xFF, ip & 0xFF].join('.')
|
||||
}
|
||||
|
||||
</script>
|
262
example/plugins/ztnc/web/index.html
Normal file
262
example/plugins/ztnc/web/index.html
Normal file
@ -0,0 +1,262 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="theme-color" content="#4b75ff">
|
||||
<meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>Global Area Network | Zoraxy</title>
|
||||
<link rel="stylesheet" href="/script/semantic/semantic.min.css">
|
||||
<script src="/script/jquery-3.6.0.min.js"></script>
|
||||
<script src="/script/semantic/semantic.min.js"></script>
|
||||
<script src="/script/tablesort.js"></script>
|
||||
<script src="/script/countryCode.js"></script>
|
||||
<script src="/script/chart.js"></script>
|
||||
<script src="/script/utils.js"></script>
|
||||
<link rel="stylesheet" href="/main.css">
|
||||
<style>
|
||||
body{
|
||||
background:none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Dark theme script must be included after body tag-->
|
||||
<link rel="stylesheet" href="/darktheme.css">
|
||||
<script src="/script/darktheme.js"></script>
|
||||
<div id="ganetWindow" class="standardContainer">
|
||||
<div class="ui basic segment">
|
||||
<h2>Global Area Network</h2>
|
||||
<p>Virtual Network Hub that allows all networked devices to communicate as if they all reside in the same physical data center or cloud region</p>
|
||||
</div>
|
||||
<div class="gansnetworks">
|
||||
<div class="ganstats ui basic segment">
|
||||
<div style="float: right; max-width: 300px; margin-top: 0.4em;">
|
||||
<h1 class="ui header" style="text-align: right;">
|
||||
<span class="ganControllerID"></span>
|
||||
<div class="sub header">Network Controller ID</div>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="ui list">
|
||||
<div class="item">
|
||||
<i class="exchange icon"></i>
|
||||
<div class="content">
|
||||
<div class="header" style="font-size: 1.2em;" id="ganetCount">0</div>
|
||||
<div class="description">Networks</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<i class="desktop icon"></i>
|
||||
<div class="content">
|
||||
<div class="header" style="font-size: 1.2em;" id="ganodeCount">0</div>
|
||||
<div class="description" id="connectedNodes" count="0">Connected Nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ganlist">
|
||||
<button class="ui basic orange button" onclick="addGANet();">Create New Network</button>
|
||||
<div class="ui divider"></div>
|
||||
<!--
|
||||
<div class="ui icon input" style="margin-bottom: 1em;">
|
||||
<input type="text" placeholder="Search a Network">
|
||||
<i class="circular search link icon"></i>
|
||||
</div>-->
|
||||
<div style="width: 100%; overflow-x: auto;">
|
||||
<table class="ui celled basic unstackable striped table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Network ID</th>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Subnet (Assign Range)</th>
|
||||
<th>Nodes</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="GANetList">
|
||||
<tr>
|
||||
<td colspan="6"><i class="ui green circle check icon"></i> No Global Area Network Found on this host</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
/*
|
||||
Network Management Functions
|
||||
*/
|
||||
function handleAddNetwork(){
|
||||
let networkName = $("#networkName").val().trim();
|
||||
if (networkName == ""){
|
||||
msgbox("Network name cannot be empty", false, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
//Add network with default settings
|
||||
addGANet(networkName, "192.168.196.0/24");
|
||||
$("#networkName").val("");
|
||||
}
|
||||
|
||||
function initGANetID(){
|
||||
$.get("/api/gan/network/info", function(data){
|
||||
if (data.error !== undefined){
|
||||
msgbox(data.error, false, 5000)
|
||||
}else{
|
||||
if (data != ""){
|
||||
$(".ganControllerID").text(data);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function addGANet() {
|
||||
$.cjax({
|
||||
url: "./api/gan/network/add",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
data: {},
|
||||
success: function(response) {
|
||||
if (response.error != undefined){
|
||||
msgbox(response.error, false, 5000);
|
||||
}else{
|
||||
msgbox("Network added successfully");
|
||||
}
|
||||
console.log("Network added successfully:", response);
|
||||
listGANet();
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.log("Error adding network:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function listGANet(){
|
||||
$("#connectedNodes").attr("count", "0");
|
||||
|
||||
$.get("./api/gan/network/list", function(data){
|
||||
$("#GANetList").empty();
|
||||
if (data.error != undefined){
|
||||
console.log(data.error);
|
||||
msgbox("Unable to load auth token for GANet", false, 5000);
|
||||
//token error or no zerotier found
|
||||
$(".gansnetworks").addClass("disabled");
|
||||
$("#GANetList").append(`<tr>
|
||||
<td colspan="6"><i class="red times circle icon"></i> Auth token access error or not found</td>
|
||||
</tr>`);
|
||||
$(".ganControllerID").text('Access Denied');
|
||||
}else{
|
||||
var nodeCount = 0;
|
||||
data.forEach(function(gan){
|
||||
$("#GANetList").append(`<tr class="ganetEntry" addr="${gan.nwid}">
|
||||
<td><a href="#" onclick="event.preventDefault(); openGANetDetails('${gan.nwid}');">${gan.nwid}</a></td>
|
||||
<td>${gan.name}</td>
|
||||
<td class="gandesc" addr="${gan.nwid}"></td>
|
||||
<td class="ganetSubnet"></td>
|
||||
<td class="ganetNodes"></td>
|
||||
<td>
|
||||
<button onclick="openGANetDetails('${gan.nwid}');" class="ui tiny basic icon button" title="Edit Network"><i class="edit icon"></i></button>
|
||||
<button onclick="removeGANet('${gan.nwid}');" class="ui tiny basic icon button" title="Remove Network"><i class="red remove icon"></i></button>
|
||||
</td>
|
||||
</tr>`);
|
||||
|
||||
nodeCount += 0;
|
||||
});
|
||||
|
||||
if (data.length == 0){
|
||||
$("#GANetList").append(`<tr>
|
||||
<td colspan="6"><i class="ui green circle check icon"></i> No Global Area Network Found on this host</td>
|
||||
</tr>`);
|
||||
}
|
||||
|
||||
$("#ganodeCount").text(nodeCount);
|
||||
$("#ganetCount").text(data.length);
|
||||
|
||||
//Load description
|
||||
$(".gandesc").each(function(){
|
||||
let addr = $(this).attr("addr");
|
||||
let domEle = $(this);
|
||||
$.get("./api/gan/network/name?netid=" + addr, function(data){
|
||||
$(domEle).text(data[1]);
|
||||
});
|
||||
});
|
||||
|
||||
$(".ganetEntry").each(function(){
|
||||
let addr = $(this).attr("addr");
|
||||
let subnetEle = $(this).find(".ganetSubnet");
|
||||
let nodeEle = $(this).find(".ganetNodes");
|
||||
|
||||
$.get("./api/gan/network/list?netid=" + addr, function(data){
|
||||
if (data.routes != undefined && data.routes.length > 0){
|
||||
|
||||
if (data.ipAssignmentPools != undefined && data.ipAssignmentPools.length > 0){
|
||||
$(subnetEle).html(`${data.routes[0].target} <br> (${data.ipAssignmentPools[0].ipRangeStart} - ${data.ipAssignmentPools[0].ipRangeEnd})`);
|
||||
}else{
|
||||
$(subnetEle).html(`${data.routes[0].target}<br>(Unassigned Range)`);
|
||||
}
|
||||
}else{
|
||||
$(subnetEle).text("Unassigned");
|
||||
}
|
||||
//console.log(data);
|
||||
});
|
||||
|
||||
$.get("./api/gan/members/list?netid=" + addr, function(data){
|
||||
$(nodeEle).text(data.length);
|
||||
let currentNodesCount = parseInt($("#connectedNodes").attr("count"));
|
||||
currentNodesCount += data.length;
|
||||
$("#connectedNodes").attr("count", currentNodesCount);
|
||||
$("#ganodeCount").text($("#connectedNodes").attr("count"));
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//Remove the given GANet
|
||||
function removeGANet(netid){
|
||||
if (confirm("Confirm remove Network " + netid + " PERMANENTLY ?"))
|
||||
$.cjax({
|
||||
url: "./api/gan/network/remove",
|
||||
type: "POST",
|
||||
dataType: "json",
|
||||
data: {
|
||||
id: netid,
|
||||
},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false, 5000);
|
||||
}else{
|
||||
msgbox("Net " + netid + " removed");
|
||||
}
|
||||
listGANet();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openGANetDetails(netid){
|
||||
$("#ganetWindow").load("./details.html", function(){
|
||||
setTimeout(function(){
|
||||
initGanetDetails(netid);
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
listGANet();
|
||||
initGANetID();
|
||||
});
|
||||
|
||||
if (typeof(msgbox) == "undefined"){
|
||||
msgbox = function(msg, error=false, timeout=3000){
|
||||
console.log(msg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@ -545,3 +546,39 @@ func handleWhitelistEnable(w http.ResponseWriter, r *http.Request) {
|
||||
utils.SendOK(w)
|
||||
}
|
||||
}
|
||||
|
||||
// List all quick ban ip address
|
||||
func handleListQuickBan(w http.ResponseWriter, r *http.Request) {
|
||||
currentSummary := statisticCollector.GetCurrentDailySummary()
|
||||
type quickBanEntry struct {
|
||||
IpAddr string
|
||||
Count int
|
||||
CountryCode string
|
||||
}
|
||||
result := []quickBanEntry{}
|
||||
currentSummary.RequestClientIp.Range(func(key, value interface{}) bool {
|
||||
ip := key.(string)
|
||||
count := value.(int)
|
||||
thisEntry := quickBanEntry{
|
||||
IpAddr: ip,
|
||||
Count: count,
|
||||
}
|
||||
|
||||
//Get the country code
|
||||
geoinfo, err := geodbStore.ResolveCountryCodeFromIP(ip)
|
||||
if err == nil {
|
||||
thisEntry.CountryCode = geoinfo.CountryIsoCode
|
||||
}
|
||||
|
||||
result = append(result, thisEntry)
|
||||
return true
|
||||
})
|
||||
|
||||
//Sort result based on count
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Count > result[j].Count
|
||||
})
|
||||
|
||||
js, _ := json.Marshal(result)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
}
|
||||
|
11
src/api.go
11
src/api.go
@ -114,6 +114,9 @@ func RegisterAccessRuleAPIs(authRouter *auth.RouterDef) {
|
||||
authRouter.HandleFunc("/api/whitelist/ip/add", handleIpWhitelistAdd)
|
||||
authRouter.HandleFunc("/api/whitelist/ip/remove", handleIpWhitelistRemove)
|
||||
authRouter.HandleFunc("/api/whitelist/enable", handleWhitelistEnable)
|
||||
|
||||
/* Quick Ban List */
|
||||
authRouter.HandleFunc("/api/quickban/list", handleListQuickBan)
|
||||
}
|
||||
|
||||
// Register the APIs for path blocking rules management functions, WIP
|
||||
@ -235,6 +238,13 @@ func RegisterNetworkUtilsAPIs(authRouter *auth.RouterDef) {
|
||||
authRouter.HandleFunc("/api/tools/fwdproxy/port", forwardProxy.HandlePort)
|
||||
}
|
||||
|
||||
func RegisterPluginAPIs(authRouter *auth.RouterDef) {
|
||||
authRouter.HandleFunc("/api/plugins/list", pluginManager.HandleListPlugins)
|
||||
authRouter.HandleFunc("/api/plugins/enable", pluginManager.HandleEnablePlugin)
|
||||
authRouter.HandleFunc("/api/plugins/disable", pluginManager.HandleDisablePlugin)
|
||||
authRouter.HandleFunc("/api/plugins/icon", pluginManager.HandleLoadPluginIcon)
|
||||
}
|
||||
|
||||
// Register the APIs for Auth functions, due to scoping issue some functions are defined here
|
||||
func RegisterAuthAPIs(requireAuth bool, targetMux *http.ServeMux) {
|
||||
targetMux.HandleFunc("/api/auth/login", authAgent.HandleLogin)
|
||||
@ -340,6 +350,7 @@ func initAPIs(targetMux *http.ServeMux) {
|
||||
RegisterNetworkUtilsAPIs(authRouter)
|
||||
RegisterACMEAndAutoRenewerAPIs(authRouter)
|
||||
RegisterStaticWebServerAPIs(authRouter)
|
||||
RegisterPluginAPIs(authRouter)
|
||||
|
||||
//Account Reset
|
||||
targetMux.HandleFunc("/api/account/reset", HandleAdminAccountResetEmail)
|
||||
|
@ -30,6 +30,7 @@ import (
|
||||
"imuslab.com/zoraxy/mod/mdns"
|
||||
"imuslab.com/zoraxy/mod/netstat"
|
||||
"imuslab.com/zoraxy/mod/pathrule"
|
||||
"imuslab.com/zoraxy/mod/plugins"
|
||||
"imuslab.com/zoraxy/mod/sshprox"
|
||||
"imuslab.com/zoraxy/mod/statistic"
|
||||
"imuslab.com/zoraxy/mod/statistic/analytic"
|
||||
@ -42,7 +43,7 @@ import (
|
||||
const (
|
||||
/* Build Constants */
|
||||
SYSTEM_NAME = "Zoraxy"
|
||||
SYSTEM_VERSION = "3.1.8"
|
||||
SYSTEM_VERSION = "3.1.9"
|
||||
DEVELOPMENT_BUILD = false /* Development: Set to false to use embedded web fs */
|
||||
|
||||
/* System Constants */
|
||||
@ -139,6 +140,7 @@ var (
|
||||
staticWebServer *webserv.WebServer //Static web server for hosting simple stuffs
|
||||
forwardProxy *forwardproxy.Handler //HTTP Forward proxy, basically VPN for web browser
|
||||
loadBalancer *loadbalance.RouteManager //Global scope loadbalancer, store the state of the lb routing
|
||||
pluginManager *plugins.Manager //Plugin manager for managing plugins
|
||||
|
||||
//Authentication Provider
|
||||
autheliaRouter *authelia.AutheliaRouter //Authelia router for Authelia authentication
|
||||
|
@ -50,7 +50,7 @@ import (
|
||||
/* SIGTERM handler, do shutdown sequences before closing */
|
||||
func SetupCloseHandler() {
|
||||
c := make(chan os.Signal, 2)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
|
||||
go func() {
|
||||
<-c
|
||||
ShutdownSeq()
|
||||
|
@ -314,7 +314,7 @@ func (router *Router) Restart() error {
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(800 * time.Millisecond)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
// Start the server
|
||||
err = router.StartProxyService()
|
||||
if err != nil {
|
||||
|
@ -3,7 +3,6 @@ package loadbalance
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Return if the target host is online
|
||||
@ -36,6 +35,7 @@ func (m *RouteManager) NotifyHostOnlineState(upstreamIP string, isOnline bool) {
|
||||
|
||||
// Set this host unreachable for a given amount of time defined in timeout
|
||||
// this shall be used in passive fallback. The uptime monitor should call to NotifyHostOnlineState() instead
|
||||
/*
|
||||
func (m *RouteManager) NotifyHostUnreachableWithTimeout(upstreamIp string, timeout int64) {
|
||||
//if the upstream IP contains http or https, strip it
|
||||
upstreamIp = strings.TrimPrefix(upstreamIp, "http://")
|
||||
@ -58,6 +58,7 @@ func (m *RouteManager) NotifyHostUnreachableWithTimeout(upstreamIp string, timeo
|
||||
m.NotifyHostOnlineState(upstreamIp, true)
|
||||
}()
|
||||
}
|
||||
*/
|
||||
|
||||
// FilterOfflineOrigins return only online origins from a list of origins
|
||||
func (m *RouteManager) FilterOfflineOrigins(origins []*Upstream) []*Upstream {
|
||||
|
@ -13,6 +13,10 @@ import (
|
||||
by this request.
|
||||
*/
|
||||
|
||||
const (
|
||||
STICKY_SESSION_NAME = "zr_sticky_session"
|
||||
)
|
||||
|
||||
// GetRequestUpstreamTarget return the upstream target where this
|
||||
// request should be routed
|
||||
func (m *RouteManager) GetRequestUpstreamTarget(w http.ResponseWriter, r *http.Request, origins []*Upstream, useStickySession bool) (*Upstream, error) {
|
||||
@ -49,9 +53,8 @@ func (m *RouteManager) GetRequestUpstreamTarget(w http.ResponseWriter, r *http.R
|
||||
//fmt.Println("DEBUG: (Sticky Session) Picking origin " + origins[targetOriginId].OriginIpOrDomain)
|
||||
return origins[targetOriginId], nil
|
||||
}
|
||||
//No sticky session, get a random origin
|
||||
m.clearSessionHandler(w, r) //Clear the session
|
||||
|
||||
//No sticky session, get a random origin
|
||||
//Filter the offline origins
|
||||
origins = m.FilterOfflineOrigins(origins)
|
||||
if len(origins) == 0 {
|
||||
@ -78,7 +81,7 @@ func (m *RouteManager) GetUsableUpstreamCounts(origins []*Upstream) int {
|
||||
/* Features related to session access */
|
||||
//Set a new origin for this connection by session
|
||||
func (m *RouteManager) setSessionHandler(w http.ResponseWriter, r *http.Request, originIpOrDomain string, index int) error {
|
||||
session, err := m.SessionStore.Get(r, "STICKYSESSION")
|
||||
session, err := m.SessionStore.Get(r, STICKY_SESSION_NAME)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -93,24 +96,10 @@ func (m *RouteManager) setSessionHandler(w http.ResponseWriter, r *http.Request,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *RouteManager) clearSessionHandler(w http.ResponseWriter, r *http.Request) error {
|
||||
session, err := m.SessionStore.Get(r, "STICKYSESSION")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.Options.MaxAge = -1
|
||||
session.Options.Path = "/"
|
||||
err = session.Save(r, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the previous connected origin from session
|
||||
func (m *RouteManager) getSessionHandler(r *http.Request, upstreams []*Upstream) (int, error) {
|
||||
// Get existing session
|
||||
session, err := m.SessionStore.Get(r, "STICKYSESSION")
|
||||
session, err := m.SessionStore.Get(r, STICKY_SESSION_NAME)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
@ -119,7 +108,7 @@ func (m *RouteManager) getSessionHandler(r *http.Request, upstreams []*Upstream)
|
||||
originDomainRaw := session.Values["zr_sid_origin"]
|
||||
originIDRaw := session.Values["zr_sid_index"]
|
||||
|
||||
if originDomainRaw == nil || originIDRaw == nil {
|
||||
if originDomainRaw == nil || originIDRaw == nil || originIDRaw == -1 {
|
||||
return -1, errors.New("no session has been set")
|
||||
}
|
||||
originDomain := originDomainRaw.(string)
|
||||
@ -201,21 +190,3 @@ func getRandomUpstreamByWeight(upstreams []*Upstream) (*Upstream, int, error) {
|
||||
|
||||
return nil, -1, errors.New("failed to pick an upstream origin server")
|
||||
}
|
||||
|
||||
// IntRange returns a random integer in the range from min to max.
|
||||
/*
|
||||
func intRange(min, max int) (int, error) {
|
||||
var result int
|
||||
switch {
|
||||
case min > max:
|
||||
// Fail with error
|
||||
return result, errors.New("min is greater than max")
|
||||
case max == min:
|
||||
result = max
|
||||
case max > min:
|
||||
b := rand.Intn(max-min) + min
|
||||
result = min + int(b)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
*/
|
||||
|
@ -3,7 +3,6 @@ package dynamicproxy
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@ -211,9 +210,6 @@ func (h *ProxyHandler) hostRequest(w http.ResponseWriter, r *http.Request, targe
|
||||
http.Error(w, "Request canceled", http.StatusRequestTimeout)
|
||||
h.Parent.logRequest(r, false, http.StatusRequestTimeout, "host-http", r.URL.Hostname())
|
||||
} else {
|
||||
//Notify the load balancer that the host is unreachable
|
||||
fmt.Println(err.Error())
|
||||
h.Parent.loadBalancer.NotifyHostUnreachableWithTimeout(selectedUpstream.OriginIpOrDomain, PassiveLoadBalanceNotifyTimeout)
|
||||
http.ServeFile(w, r, "./web/rperror.html")
|
||||
h.Parent.logRequest(r, false, 521, "host-http", r.URL.Hostname())
|
||||
}
|
||||
|
@ -212,6 +212,10 @@ func (n *NetStatBuffers) GetNetworkInterfaceStats() (int64, int64, error) {
|
||||
totalTx += counter.BytesSent
|
||||
}
|
||||
|
||||
// Convert bytes to bits
|
||||
// Convert bytes to bits with overflow check
|
||||
const maxInt64 = int64(^uint64(0) >> 1)
|
||||
if totalRx*8 > uint64(maxInt64) || totalTx*8 > uint64(maxInt64) {
|
||||
return 0, 0, errors.New("overflow detected when converting uint64 to int64")
|
||||
}
|
||||
return int64(totalRx * 8), int64(totalTx * 8), nil
|
||||
}
|
||||
|
26
src/mod/plugins/forwarder.go
Normal file
26
src/mod/plugins/forwarder.go
Normal file
@ -0,0 +1,26 @@
|
||||
package plugins
|
||||
|
||||
import "net/http"
|
||||
|
||||
/*
|
||||
Forwarder.go
|
||||
|
||||
This file handles the dynamic proxy routing forwarding
|
||||
request to plugin capture path that handles the matching
|
||||
request path registered when the plugin started
|
||||
*/
|
||||
|
||||
func (m *Manager) GetHandlerPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
func (m *Manager) GetHandlerPluginsSubsets(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
func (p *Plugin) HandlePluginRoute(w http.ResponseWriter, r *http.Request) {
|
||||
//Find the plugin that matches the request path
|
||||
//If no plugin found, return 404
|
||||
//If found, forward the request to the plugin
|
||||
|
||||
}
|
89
src/mod/plugins/handler.go
Normal file
89
src/mod/plugins/handler.go
Normal file
@ -0,0 +1,89 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"imuslab.com/zoraxy/mod/utils"
|
||||
)
|
||||
|
||||
// HandleListPlugins handles the request to list all loaded plugins
|
||||
func (m *Manager) HandleListPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
plugins, err := m.ListLoadedPlugins()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
//Sort the plugin by its name
|
||||
sort.Slice(plugins, func(i, j int) bool {
|
||||
return plugins[i].Spec.Name < plugins[j].Spec.Name
|
||||
})
|
||||
|
||||
js, err := json.Marshal(plugins)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
}
|
||||
|
||||
func (m *Manager) HandleLoadPluginIcon(w http.ResponseWriter, r *http.Request) {
|
||||
pluginID, err := utils.GetPara(r, "plugin_id")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, "plugin_id not found")
|
||||
return
|
||||
}
|
||||
|
||||
plugin, err := m.GetPluginByID(pluginID)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
//Check if the icon.png exists under plugin root directory
|
||||
expectedIconPath := filepath.Join(plugin.RootDir, "icon.png")
|
||||
if !utils.FileExists(expectedIconPath) {
|
||||
http.ServeContent(w, r, "no_img.png", time.Now(), bytes.NewReader(noImg))
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, expectedIconPath)
|
||||
}
|
||||
|
||||
func (m *Manager) HandleEnablePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
pluginID, err := utils.PostPara(r, "plugin_id")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, "plugin_id not found")
|
||||
return
|
||||
}
|
||||
|
||||
err = m.EnablePlugin(pluginID)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendOK(w)
|
||||
}
|
||||
|
||||
func (m *Manager) HandleDisablePlugin(w http.ResponseWriter, r *http.Request) {
|
||||
pluginID, err := utils.PostPara(r, "plugin_id")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, "plugin_id not found")
|
||||
return
|
||||
}
|
||||
|
||||
err = m.DisablePlugin(pluginID)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
utils.SendOK(w)
|
||||
}
|
68
src/mod/plugins/introspect.go
Normal file
68
src/mod/plugins/introspect.go
Normal file
@ -0,0 +1,68 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin"
|
||||
)
|
||||
|
||||
// LoadPlugin loads a plugin from the plugin directory
|
||||
func (m *Manager) IsValidPluginFolder(path string) bool {
|
||||
_, err := m.GetPluginEntryPoint(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
/*
|
||||
LoadPluginSpec loads a plugin specification from the plugin directory
|
||||
Zoraxy will start the plugin binary or the entry point script
|
||||
with -introspect flag to get the plugin specification
|
||||
*/
|
||||
func (m *Manager) LoadPluginSpec(pluginPath string) (*Plugin, error) {
|
||||
pluginEntryPoint, err := m.GetPluginEntryPoint(pluginPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pluginSpec, err := m.GetPluginSpec(pluginEntryPoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = validatePluginSpec(pluginSpec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Plugin{
|
||||
Spec: pluginSpec,
|
||||
Enabled: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPluginEntryPoint returns the plugin entry point
|
||||
func (m *Manager) GetPluginSpec(entryPoint string) (*zoraxyPlugin.IntroSpect, error) {
|
||||
pluginSpec := zoraxyPlugin.IntroSpect{}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, entryPoint, "-introspect")
|
||||
output, err := cmd.Output()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return nil, fmt.Errorf("plugin introspect timed out")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Assuming the output is JSON and needs to be unmarshaled into pluginSpec
|
||||
err = json.Unmarshal(output, &pluginSpec)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal plugin spec: %v", err)
|
||||
}
|
||||
|
||||
return &pluginSpec, nil
|
||||
}
|
226
src/mod/plugins/lifecycle.go
Normal file
226
src/mod/plugins/lifecycle.go
Normal file
@ -0,0 +1,226 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"imuslab.com/zoraxy/mod/dynamicproxy/dpcore"
|
||||
zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin"
|
||||
)
|
||||
|
||||
func (m *Manager) StartPlugin(pluginID string) error {
|
||||
plugin, ok := m.LoadedPlugins.Load(pluginID)
|
||||
if !ok {
|
||||
return errors.New("plugin not found")
|
||||
}
|
||||
|
||||
thisPlugin := plugin.(*Plugin)
|
||||
|
||||
//Get the plugin Entry point
|
||||
pluginEntryPoint, err := m.GetPluginEntryPoint(thisPlugin.RootDir)
|
||||
if err != nil {
|
||||
//Plugin removed after introspect?
|
||||
return err
|
||||
}
|
||||
|
||||
//Get the absolute path of the plugin entry point to prevent messing up with the cwd
|
||||
absolutePath, err := filepath.Abs(pluginEntryPoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//Prepare plugin start configuration
|
||||
pluginConfiguration := zoraxyPlugin.ConfigureSpec{
|
||||
Port: getRandomPortNumber(),
|
||||
RuntimeConst: *m.Options.SystemConst,
|
||||
}
|
||||
js, _ := json.Marshal(pluginConfiguration)
|
||||
|
||||
m.Log("Starting plugin "+thisPlugin.Spec.Name+" at :"+strconv.Itoa(pluginConfiguration.Port), nil)
|
||||
cmd := exec.Command(absolutePath, "-configure="+string(js))
|
||||
cmd.Dir = filepath.Dir(absolutePath)
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 1)
|
||||
lineBuf := ""
|
||||
for {
|
||||
n, err := stdoutPipe.Read(buf)
|
||||
if n > 0 {
|
||||
lineBuf += string(buf[:n])
|
||||
for {
|
||||
if idx := strings.IndexByte(lineBuf, '\n'); idx != -1 {
|
||||
m.handlePluginSTDOUT(pluginID, lineBuf[:idx])
|
||||
lineBuf = lineBuf[idx+1:]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
m.handlePluginSTDOUT(pluginID, lineBuf) // handle any remaining data
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
//Create a UI forwarder if the plugin has UI
|
||||
err = m.StartUIHandlerForPlugin(thisPlugin, pluginConfiguration.Port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store the cmd object so it can be accessed later for stopping the plugin
|
||||
plugin.(*Plugin).process = cmd
|
||||
plugin.(*Plugin).Enabled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartUIHandlerForPlugin starts a UI handler for the plugin
|
||||
func (m *Manager) StartUIHandlerForPlugin(targetPlugin *Plugin, pluginListeningPort int) error {
|
||||
// Create a dpcore object to reverse proxy the plugin ui
|
||||
pluginUIRelPath := targetPlugin.Spec.UIPath
|
||||
if !strings.HasPrefix(pluginUIRelPath, "/") {
|
||||
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
|
||||
}
|
||||
|
||||
func (m *Manager) handlePluginSTDOUT(pluginID string, line string) {
|
||||
thisPlugin, err := m.GetPluginByID(pluginID)
|
||||
processID := -1
|
||||
if thisPlugin.process != nil && thisPlugin.process.Process != nil {
|
||||
// Get the process ID of the plugin
|
||||
processID = thisPlugin.process.Process.Pid
|
||||
}
|
||||
if err != nil {
|
||||
m.Log("[unknown:"+strconv.Itoa(processID)+"] "+line, err)
|
||||
return
|
||||
}
|
||||
m.Log("["+thisPlugin.Spec.Name+":"+strconv.Itoa(processID)+"] "+line, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) StopPlugin(pluginID string) error {
|
||||
plugin, ok := m.LoadedPlugins.Load(pluginID)
|
||||
if !ok {
|
||||
return errors.New("plugin not found")
|
||||
}
|
||||
|
||||
thisPlugin := plugin.(*Plugin)
|
||||
var err error
|
||||
|
||||
//Make a GET request to plugin ui path /term to gracefully stop the plugin
|
||||
if thisPlugin.uiProxy != nil {
|
||||
requestURI := "http://127.0.0.1:" + strconv.Itoa(thisPlugin.AssignedPort) + "/" + thisPlugin.Spec.UIPath + "/term"
|
||||
resp, err := http.Get(requestURI)
|
||||
if err != nil {
|
||||
//Plugin do not support termination request, do it the hard way
|
||||
m.Log("Plugin "+thisPlugin.Spec.ID+" termination request failed. Force shutting down", nil)
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
m.Log("Plugin "+thisPlugin.Spec.ID+" does not support termination request", nil)
|
||||
} else {
|
||||
m.Log("Plugin "+thisPlugin.Spec.ID+" termination request returned status: "+resp.Status, nil)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" && thisPlugin.process != nil {
|
||||
//There is no SIGTERM in windows, kill the process directly
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
thisPlugin.process.Process.Kill()
|
||||
} else {
|
||||
//Send SIGTERM to the plugin process, if it is still running
|
||||
err = thisPlugin.process.Process.Signal(syscall.SIGTERM)
|
||||
if err != nil {
|
||||
m.Log("Failed to send Interrupt signal to plugin "+thisPlugin.Spec.Name+": "+err.Error(), nil)
|
||||
}
|
||||
|
||||
//Wait for the plugin to stop
|
||||
for range 5 {
|
||||
time.Sleep(1 * time.Second)
|
||||
if thisPlugin.process.ProcessState != nil && thisPlugin.process.ProcessState.Exited() {
|
||||
m.Log("Plugin "+thisPlugin.Spec.Name+" background process stopped", nil)
|
||||
break
|
||||
}
|
||||
}
|
||||
if thisPlugin.process.ProcessState == nil || !thisPlugin.process.ProcessState.Exited() {
|
||||
m.Log("Plugin "+thisPlugin.Spec.Name+" failed to stop gracefully, killing it", nil)
|
||||
thisPlugin.process.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
//Remove the UI proxy
|
||||
thisPlugin.uiProxy = nil
|
||||
plugin.(*Plugin).Enabled = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the plugin is still running
|
||||
func (m *Manager) PluginStillRunning(pluginID string) bool {
|
||||
plugin, ok := m.LoadedPlugins.Load(pluginID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if plugin.(*Plugin).process == nil {
|
||||
return false
|
||||
}
|
||||
return plugin.(*Plugin).process.ProcessState == nil
|
||||
}
|
||||
|
||||
// BlockUntilAllProcessExited blocks until all the plugins processes have exited
|
||||
func (m *Manager) BlockUntilAllProcessExited() {
|
||||
m.LoadedPlugins.Range(func(key, value interface{}) bool {
|
||||
plugin := value.(*Plugin)
|
||||
if m.PluginStillRunning(value.(*Plugin).Spec.ID) {
|
||||
//Wait for the plugin to exit
|
||||
plugin.process.Wait()
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
BIN
src/mod/plugins/no_img.png
Normal file
BIN
src/mod/plugins/no_img.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 43 KiB |
BIN
src/mod/plugins/no_img.psd
Normal file
BIN
src/mod/plugins/no_img.psd
Normal file
Binary file not shown.
136
src/mod/plugins/plugins.go
Normal file
136
src/mod/plugins/plugins.go
Normal file
@ -0,0 +1,136 @@
|
||||
package plugins
|
||||
|
||||
/*
|
||||
Zoraxy Plugin Manager
|
||||
|
||||
This module is responsible for managing plugins
|
||||
loading plugins from the disk
|
||||
enable / disable plugins
|
||||
and forwarding traffic to plugins
|
||||
*/
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"imuslab.com/zoraxy/mod/utils"
|
||||
)
|
||||
|
||||
// NewPluginManager creates a new plugin manager
|
||||
func NewPluginManager(options *ManagerOptions) *Manager {
|
||||
//Create plugin directory if not exists
|
||||
if options.PluginDir == "" {
|
||||
options.PluginDir = "./plugins"
|
||||
}
|
||||
if !utils.FileExists(options.PluginDir) {
|
||||
os.MkdirAll(options.PluginDir, 0755)
|
||||
}
|
||||
|
||||
//Create database table
|
||||
options.Database.NewTable("plugins")
|
||||
|
||||
return &Manager{
|
||||
LoadedPlugins: sync.Map{},
|
||||
Options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadPluginsFromDisk loads all plugins from the plugin directory
|
||||
func (m *Manager) LoadPluginsFromDisk() error {
|
||||
// Load all plugins from the plugin directory
|
||||
foldersInPluginDir, err := os.ReadDir(m.Options.PluginDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, folder := range foldersInPluginDir {
|
||||
if folder.IsDir() {
|
||||
pluginPath := filepath.Join(m.Options.PluginDir, folder.Name())
|
||||
thisPlugin, err := m.LoadPluginSpec(pluginPath)
|
||||
if err != nil {
|
||||
m.Log("Failed to load plugin: "+filepath.Base(pluginPath), err)
|
||||
continue
|
||||
}
|
||||
thisPlugin.RootDir = filepath.ToSlash(pluginPath)
|
||||
m.LoadedPlugins.Store(thisPlugin.Spec.ID, thisPlugin)
|
||||
m.Log("Loaded plugin: "+thisPlugin.Spec.Name, nil)
|
||||
|
||||
// If the plugin was enabled, start it now
|
||||
if m.GetPluginPreviousEnableState(thisPlugin.Spec.ID) {
|
||||
err = m.StartPlugin(thisPlugin.Spec.ID)
|
||||
if err != nil {
|
||||
m.Log("Failed to enable plugin: "+thisPlugin.Spec.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPluginByID returns a plugin by its ID
|
||||
func (m *Manager) GetPluginByID(pluginID string) (*Plugin, error) {
|
||||
plugin, ok := m.LoadedPlugins.Load(pluginID)
|
||||
if !ok {
|
||||
return nil, errors.New("plugin not found")
|
||||
}
|
||||
return plugin.(*Plugin), nil
|
||||
}
|
||||
|
||||
// EnablePlugin enables a plugin
|
||||
func (m *Manager) EnablePlugin(pluginID string) error {
|
||||
err := m.StartPlugin(pluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Options.Database.Write("plugins", pluginID, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisablePlugin disables a plugin
|
||||
func (m *Manager) DisablePlugin(pluginID string) error {
|
||||
err := m.StopPlugin(pluginID)
|
||||
m.Options.Database.Write("plugins", pluginID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPluginPreviousEnableState returns the previous enable state of a plugin
|
||||
func (m *Manager) GetPluginPreviousEnableState(pluginID string) bool {
|
||||
enableState := true
|
||||
err := m.Options.Database.Read("plugins", pluginID, &enableState)
|
||||
if err != nil {
|
||||
//Default to true
|
||||
return true
|
||||
}
|
||||
return enableState
|
||||
}
|
||||
|
||||
// ListLoadedPlugins returns a list of loaded plugins
|
||||
func (m *Manager) ListLoadedPlugins() ([]*Plugin, error) {
|
||||
var plugins []*Plugin = []*Plugin{}
|
||||
m.LoadedPlugins.Range(func(key, value interface{}) bool {
|
||||
plugin := value.(*Plugin)
|
||||
plugins = append(plugins, plugin)
|
||||
return true
|
||||
})
|
||||
return plugins, nil
|
||||
}
|
||||
|
||||
// Terminate all plugins and exit
|
||||
func (m *Manager) Close() {
|
||||
m.LoadedPlugins.Range(func(key, value interface{}) bool {
|
||||
plugin := value.(*Plugin)
|
||||
if plugin.Enabled {
|
||||
m.StopPlugin(plugin.Spec.ID)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
//Wait until all loaded plugin process are terminated
|
||||
m.BlockUntilAllProcessExited()
|
||||
}
|
40
src/mod/plugins/typdef.go
Normal file
40
src/mod/plugins/typdef.go
Normal file
@ -0,0 +1,40 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
//go:embed no_img.png
|
||||
var noImg []byte
|
||||
|
||||
type Plugin struct {
|
||||
RootDir string //The root directory of the plugin
|
||||
Spec *zoraxyPlugin.IntroSpect //The plugin specification
|
||||
Enabled bool //Whether the plugin is enabled
|
||||
|
||||
//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 *zoraxyPlugin.RuntimeConstantValue
|
||||
Database *database.Database
|
||||
Logger *logger.Logger
|
||||
CSRFTokenGen func(*http.Request) string //The CSRF token generator function
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
LoadedPlugins sync.Map //Storing *Plugin
|
||||
Options *ManagerOptions
|
||||
}
|
55
src/mod/plugins/uirouter.go
Normal file
55
src/mod/plugins/uirouter.go
Normal file
@ -0,0 +1,55 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"imuslab.com/zoraxy/mod/dynamicproxy/dpcore"
|
||||
"imuslab.com/zoraxy/mod/utils"
|
||||
)
|
||||
|
||||
// HandlePluginUI handles the request to the plugin UI
|
||||
// This function will route the request to the correct plugin UI handler
|
||||
func (m *Manager) HandlePluginUI(pluginID string, w http.ResponseWriter, r *http.Request) {
|
||||
plugin, err := m.GetPluginByID(pluginID)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
//Check if the plugin has UI
|
||||
if plugin.Spec.UIPath == "" {
|
||||
utils.SendErrorResponse(w, "Plugin does not have UI")
|
||||
return
|
||||
}
|
||||
|
||||
//Check if the plugin has UI handler
|
||||
if plugin.uiProxy == nil {
|
||||
utils.SendErrorResponse(w, "Plugin does not have UI handler")
|
||||
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: upstreamOrigin,
|
||||
NoCache: true,
|
||||
PathPrefix: matchingPath,
|
||||
Version: m.Options.SystemConst.ZoraxyVersion,
|
||||
UpstreamHeaders: [][]string{
|
||||
{"X-Zoraxy-Csrf", m.Options.CSRFTokenGen(r)},
|
||||
},
|
||||
})
|
||||
}
|
82
src/mod/plugins/utils.go
Normal file
82
src/mod/plugins/utils.go
Normal file
@ -0,0 +1,82 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"imuslab.com/zoraxy/mod/netutils"
|
||||
zoraxyPlugin "imuslab.com/zoraxy/mod/plugins/zoraxy_plugin"
|
||||
)
|
||||
|
||||
/*
|
||||
Check if the folder contains a valid plugin in either one of the forms
|
||||
|
||||
1. Contain a file that have the same name as its parent directory, either executable or .exe on Windows
|
||||
2. Contain a start.sh or start.bat file
|
||||
|
||||
Return the path of the plugin entry point if found
|
||||
*/
|
||||
func (m *Manager) GetPluginEntryPoint(folderpath string) (string, error) {
|
||||
info, err := os.Stat(folderpath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", errors.New("path is not a directory")
|
||||
}
|
||||
expectedBinaryPath := filepath.Join(folderpath, filepath.Base(folderpath))
|
||||
if runtime.GOOS == "windows" {
|
||||
expectedBinaryPath += ".exe"
|
||||
}
|
||||
|
||||
if _, err := os.Stat(expectedBinaryPath); err == nil {
|
||||
return expectedBinaryPath, nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(folderpath, "start.sh")); err == nil {
|
||||
return filepath.Join(folderpath, "start.sh"), nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(folderpath, "start.bat")); err == nil {
|
||||
return filepath.Join(folderpath, "start.bat"), nil
|
||||
}
|
||||
|
||||
return "", errors.New("No valid entry point found")
|
||||
}
|
||||
|
||||
// Log logs a message with an optional error
|
||||
func (m *Manager) Log(message string, err error) {
|
||||
m.Options.Logger.PrintAndLog("plugin-manager", message, err)
|
||||
}
|
||||
|
||||
// getRandomPortNumber generates a random port number between 49152 and 65535
|
||||
func getRandomPortNumber() int {
|
||||
portNo := rand.Intn(65535-49152) + 49152
|
||||
//Check if the port is already in use
|
||||
for netutils.CheckIfPortOccupied(portNo) {
|
||||
portNo = rand.Intn(65535-49152) + 49152
|
||||
}
|
||||
return portNo
|
||||
}
|
||||
|
||||
func validatePluginSpec(pluginSpec *zoraxyPlugin.IntroSpect) error {
|
||||
if pluginSpec.Name == "" {
|
||||
return errors.New("plugin name is empty")
|
||||
}
|
||||
if pluginSpec.Description == "" {
|
||||
return errors.New("plugin description is empty")
|
||||
}
|
||||
if pluginSpec.Author == "" {
|
||||
return errors.New("plugin author is empty")
|
||||
}
|
||||
if pluginSpec.UIPath == "" {
|
||||
return errors.New("plugin UI path is empty")
|
||||
}
|
||||
if pluginSpec.ID == "" {
|
||||
return errors.New("plugin ID is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
19
src/mod/plugins/zoraxy_plugin/README.txt
Normal file
19
src/mod/plugins/zoraxy_plugin/README.txt
Normal file
@ -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
|
128
src/mod/plugins/zoraxy_plugin/embed_webserver.go
Normal file
128
src/mod/plugins/zoraxy_plugin/embed_webserver.go
Normal file
@ -0,0 +1,128 @@
|
||||
package zoraxy_plugin
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"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
|
||||
|
||||
terminateHandler func() //The handler to be called when the plugin is terminated
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
|
||||
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
|
||||
// if mux is nil, the handler will be registered to http.DefaultServeMux
|
||||
func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
|
||||
p.terminateHandler = termFunc
|
||||
if mux == nil {
|
||||
mux = http.DefaultServeMux
|
||||
}
|
||||
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
|
||||
p.terminateHandler()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
go func() {
|
||||
//Make sure the response is sent before the plugin is terminated
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
})
|
||||
}
|
174
src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go
Normal file
174
src/mod/plugins/zoraxy_plugin/zoraxy_plugin.go
Normal file
@ -0,0 +1,174 @@
|
||||
package zoraxy_plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
/*
|
||||
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
|
||||
|
||||
*/
|
||||
GlobalCapturePaths []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
|
||||
*/
|
||||
AlwaysCapturePaths []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)
|
||||
|
||||
/* 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()
|
||||
}
|
@ -174,9 +174,15 @@ func ReverseProxtInit() {
|
||||
}()
|
||||
}
|
||||
|
||||
// Toggle the reverse proxy service on and off
|
||||
func ReverseProxyHandleOnOff(w http.ResponseWriter, r *http.Request) {
|
||||
enable, _ := utils.PostPara(r, "enable") //Support root, vdir and subd
|
||||
if enable == "true" {
|
||||
enable, err := utils.PostBool(r, "enable")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, "enable not defined")
|
||||
return
|
||||
}
|
||||
|
||||
if enable {
|
||||
err := dynamicProxyRouter.StartProxyService()
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
|
@ -58,6 +58,19 @@ func FSHandler(handler http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
//For Plugin Routing
|
||||
if strings.HasPrefix(r.URL.Path, "/plugin.ui/") {
|
||||
//Extract the plugin ID from the request path
|
||||
parts := strings.Split(r.URL.Path, "/")
|
||||
if len(parts) > 2 {
|
||||
pluginID := parts[2]
|
||||
pluginManager.HandlePluginUI(pluginID, w, r)
|
||||
} else {
|
||||
http.Error(w, "Invalid Usage", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
//For WebSSH Routing
|
||||
//Example URL Path: /web.ssh/{{instance_uuid}}/*
|
||||
if strings.HasPrefix(r.URL.Path, "/web.ssh/") {
|
||||
|
29
src/start.go
29
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"
|
||||
@ -26,6 +27,8 @@ import (
|
||||
"imuslab.com/zoraxy/mod/mdns"
|
||||
"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"
|
||||
@ -317,6 +320,28 @@ func startupSequence() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
/*
|
||||
Plugin Manager
|
||||
*/
|
||||
|
||||
pluginManager = plugins.NewPluginManager(&plugins.ManagerOptions{
|
||||
PluginDir: "./plugins",
|
||||
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()
|
||||
if err != nil {
|
||||
SystemWideLogger.PrintAndLog("Plugin Manager", "Failed to load plugins", err)
|
||||
}
|
||||
|
||||
/* Docker UX Optimizer */
|
||||
if runtime.GOOS == "windows" && *runningInDocker {
|
||||
SystemWideLogger.PrintAndLog("warning", "Invalid start flag combination: docker=true && runtime.GOOS == windows. Running in docker UX development mode.", nil)
|
||||
@ -364,6 +389,10 @@ func ShutdownSeq() {
|
||||
if acmeAutoRenewer != nil {
|
||||
acmeAutoRenewer.Close()
|
||||
}
|
||||
//Close the plugin manager
|
||||
SystemWideLogger.Println("Shutting down plugin manager")
|
||||
pluginManager.Close()
|
||||
|
||||
//Remove the tmp folder
|
||||
SystemWideLogger.Println("Cleaning up tmp files")
|
||||
os.RemoveAll("./tmp")
|
||||
|
@ -694,6 +694,7 @@
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Access Count</th>
|
||||
<th>Country of Origin</th>
|
||||
<th>Blacklist</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -1489,15 +1490,30 @@
|
||||
|
||||
//Load the summary to ip access table
|
||||
function initBlacklistQuickBanTable(){
|
||||
$.get("/api/stats/summary", function(data){
|
||||
initIpAccessTable(data.RequestClientIp);
|
||||
$.get("/api/quickban/list", function(data){
|
||||
//Convert the data to a dictionary
|
||||
var ipAccessCounts = {};
|
||||
access_ip_country_map = {};
|
||||
data.forEach(function(entry){
|
||||
ipAccessCounts[entry.IpAddr] = entry.Count
|
||||
access_ip_country_map[entry.IpAddr] = entry.CountryCode;
|
||||
});
|
||||
initIpAccessTable(ipAccessCounts);
|
||||
})
|
||||
}
|
||||
initBlacklistQuickBanTable();
|
||||
|
||||
function getCountryISOFromQuickBan(ip){
|
||||
if (access_ip_country_map[ip] === "") {
|
||||
return "LAN / Reserved";
|
||||
}
|
||||
return access_ip_country_map[ip];
|
||||
}
|
||||
|
||||
var blacklist_entriesPerPage = 30;
|
||||
var blacklist_currentPage = 1;
|
||||
var blacklist_totalPages = 0;
|
||||
var access_ip_country_map = {};
|
||||
|
||||
function initIpAccessTable(ipAccessCounts){
|
||||
blacklist_totalPages = Math.ceil(Object.keys(ipAccessCounts).length / blacklist_entriesPerPage);
|
||||
@ -1533,6 +1549,7 @@
|
||||
var row = $("<tr>").appendTo(tableBody);
|
||||
$("<td>").text(ip).appendTo(row);
|
||||
$("<td>").text(accessCount).appendTo(row);
|
||||
$("<td>").text(getCountryISOFromQuickBan(ip)).appendTo(row);
|
||||
if (ipInBlacklist(ip)){
|
||||
$("<td>").html(`<button class="ui basic green tiny icon button" title"Unban IP" onclick="handleUnban('${ip}');"><i class="green check icon"></i></button>`).appendTo(row);
|
||||
}else{
|
||||
@ -1542,7 +1559,7 @@
|
||||
|
||||
if (slicedEntries.length == 0){
|
||||
var row = $("<tr>").appendTo(tableBody);
|
||||
$("<td colspan='3'>").html(`
|
||||
$("<td colspan='4'>").html(`
|
||||
<i class="ui green circle check icon"></i> There are no HTTP requests recorded today
|
||||
`).appendTo(row);
|
||||
|
||||
|
@ -3,6 +3,10 @@
|
||||
<h2>Global Area Network</h2>
|
||||
<p>Virtual Network Hub that allows all networked devices to communicate as if they all reside in the same physical data center or cloud region</p>
|
||||
</div>
|
||||
<div class="ui yellow message">
|
||||
<b>Deprecation Notice</b>
|
||||
<p>Global Area Network will be deprecating in v3.2.x and moved to Plugin</p>
|
||||
</div>
|
||||
<div class="gansnetworks">
|
||||
<div class="ganstats ui basic segment">
|
||||
<div style="float: right; max-width: 300px; margin-top: 0.4em;">
|
||||
|
62
src/web/components/plugincontext.html
Normal file
62
src/web/components/plugincontext.html
Normal file
@ -0,0 +1,62 @@
|
||||
<div class="">
|
||||
<iframe id="pluginContextLoader" src="" style="width: 100%; border: none;">
|
||||
|
||||
</iframe>
|
||||
</div>
|
||||
<script>
|
||||
function initPluginUIView(forceOverwritePluginID = undefined){
|
||||
if (typeof(forceOverwritePluginID) != "undefined"){
|
||||
let pluginID = forceOverwritePluginID;
|
||||
console.log("Launching plugin UI for plugin with ID:", pluginID);
|
||||
loadPluginContext(pluginID);
|
||||
return;
|
||||
}
|
||||
let pluginID = getPluginIDFromWindowHash();
|
||||
if (pluginID == ""){
|
||||
return;
|
||||
}
|
||||
console.log("Launching plugin UI for plugin with ID:", pluginID);
|
||||
loadPluginContext(pluginID);
|
||||
}
|
||||
|
||||
function loadPluginContext(pluginID){
|
||||
//Check if the iframe is currently visable
|
||||
let pluginContextURL = `/plugin.ui/${pluginID}/`;
|
||||
$("#pluginContextLoader").attr("src", pluginContextURL);
|
||||
}
|
||||
|
||||
function getPluginIDFromWindowHash(){
|
||||
let tabID = window.location.hash.substr(1);
|
||||
let pluginID = "";
|
||||
if (tabID.startsWith("{")) {
|
||||
tabID = decodeURIComponent(tabID);
|
||||
try {
|
||||
let parsedData = JSON.parse(tabID);
|
||||
if (typeof(parsedData.pluginID) != "undefined"){
|
||||
pluginID = parsedData.pluginID;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Invalid JSON data:", e);
|
||||
}
|
||||
}
|
||||
return pluginID;
|
||||
}
|
||||
|
||||
function resizeIframe() {
|
||||
let iframe = document.getElementById('pluginContextLoader');
|
||||
let mainMenuHeight = document.getElementById('mainmenu').offsetHeight;
|
||||
iframe.style.height = mainMenuHeight + 'px';
|
||||
}
|
||||
|
||||
$(window).on("resize", function(){
|
||||
resizeIframe();
|
||||
});
|
||||
|
||||
//Bind event to tab switch
|
||||
tabSwitchEventBind["pluginContextWindow"] = function(){
|
||||
//On switch over to this page, load info
|
||||
resizeIframe();
|
||||
}
|
||||
|
||||
initPluginUIView();
|
||||
</script>
|
167
src/web/components/plugins.html
Normal file
167
src/web/components/plugins.html
Normal file
@ -0,0 +1,167 @@
|
||||
<div class="standardContainer">
|
||||
<div class="ui basic segment">
|
||||
<h2>Plugins</h2>
|
||||
<p>Add custom features to your Zoraxy!</p>
|
||||
</div>
|
||||
<div class="ui yellow message">
|
||||
<div class="header">Experimental Feature</div>
|
||||
<p>This feature is experimental and may not work as expected. Use with caution.</p>
|
||||
</div>
|
||||
<table class="ui basic celled table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plugin Name</th>
|
||||
<th>Descriptions</th>
|
||||
<th>Catergory</th>
|
||||
<th>Action</th>
|
||||
</tr></thead>
|
||||
<tbody id="pluginTable">
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
function initPluginSideMenu(){
|
||||
$.get(`/api/plugins/list`, function(data){
|
||||
$("#pluginMenu").html("");
|
||||
let enabledPluginCount = 0;
|
||||
data.forEach(plugin => {
|
||||
if (!plugin.Enabled){
|
||||
return;
|
||||
}
|
||||
$("#pluginMenu").append(`
|
||||
<a class="item" tag="pluginContextWindow" pluginid="${plugin.Spec.id}">
|
||||
<img style="width: 20px;" class="ui mini right spaced image" src="/api/plugins/icon?plugin_id=${plugin.Spec.id}"> ${plugin.Spec.name}
|
||||
</a>
|
||||
`);
|
||||
enabledPluginCount++;
|
||||
});
|
||||
if (enabledPluginCount == 0){
|
||||
$("#pluginMenu").append(`
|
||||
<a class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
|
||||
<i class="green circle check icon"></i> No Plugins Installed
|
||||
</a>
|
||||
`);
|
||||
}
|
||||
|
||||
//Rebind events for the plugin menu
|
||||
$("#pluginMenu").find(".item").each(function(){
|
||||
$(this).off("click").on("click", function(event){
|
||||
let tabid = $(this).attr("tag");
|
||||
openTabById(tabid, $(this));
|
||||
loadPluginUIContextIfAvailable();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
initPluginSideMenu();
|
||||
|
||||
function loadPluginUIContextIfAvailable(){
|
||||
if(typeof(initPluginUIView) != "undefined"){
|
||||
initPluginUIView();
|
||||
}
|
||||
}
|
||||
|
||||
function initiatePluginList(){
|
||||
$.get(`/api/plugins/list`, function(data){
|
||||
$("#pluginTable").html("");
|
||||
|
||||
data.forEach(plugin => {
|
||||
let authorContact = plugin.Spec.author_contact;
|
||||
if(!authorContact.startsWith('http')){
|
||||
authorContact = `mailto:${authorContact}`;
|
||||
}
|
||||
|
||||
let versionString = `v${plugin.Spec.version_major}.${plugin.Spec.version_minor}.${plugin.Spec.version_patch}`;
|
||||
const row = `
|
||||
<tr>
|
||||
<td data-label="PluginName">
|
||||
<h4 class="ui header">
|
||||
<img src="/api/plugins/icon?plugin_id=${plugin.Spec.id}" class="ui image">
|
||||
<div class="content">
|
||||
${plugin.Spec.name}
|
||||
<div class="sub header">${versionString} by <a href="${authorContact}" target="_blank">${plugin.Spec.author}</a></div>
|
||||
</div>
|
||||
</h4>
|
||||
</td>
|
||||
<td data-label="Descriptions">${plugin.Spec.description}<br>
|
||||
<a href="${plugin.Spec.url}" target="_blank">${plugin.Spec.url}</a></td>
|
||||
<td data-label="Category">${plugin.Spec.type==0?"Router":"Utilities"}</td>
|
||||
<td data-label="Action">
|
||||
<div class="ui small basic buttons">
|
||||
<button onclick="stopPlugin('${plugin.Spec.id}', this);" class="ui button pluginEnableButton" pluginid="${plugin.Spec.id}" ${plugin.Enabled ? '' : 'style="display:none;"'}>
|
||||
<i class="red stop circle icon"></i> Stop
|
||||
</button>
|
||||
<button onclick="startPlugin('${plugin.Spec.id}', this);" class="ui button pluginDisableButton" pluginid="${plugin.Spec.id}" ${plugin.Enabled ? 'style="display:none;"' : ''}>
|
||||
<i class="green play circle icon"></i> Start
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
$("#pluginTable").append(row);
|
||||
});
|
||||
|
||||
if (data.length == 0){
|
||||
$("#pluginTable").append(`
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center;"><i class="ui green circle check icon"></i> No plugins installed</td>
|
||||
</tr>
|
||||
`);
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
});
|
||||
}
|
||||
|
||||
initiatePluginList();
|
||||
|
||||
function startPlugin(pluginId, btn=undefined){
|
||||
if (btn) {
|
||||
$(btn).html('<i class="spinner loading icon"></i> Starting');
|
||||
$(btn).addClass('disabled');
|
||||
}
|
||||
$.cjax({
|
||||
url: '/api/plugins/enable',
|
||||
type: 'POST',
|
||||
data: {plugin_id: pluginId},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false);
|
||||
}else{
|
||||
msgbox("Plugin started", true);
|
||||
}
|
||||
initiatePluginList();
|
||||
initPluginSideMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function stopPlugin(pluginId, btn=undefined){
|
||||
if (btn) {
|
||||
$(btn).html('<i class="spinner loading icon"></i> Stopping');
|
||||
$(btn).addClass('disabled');
|
||||
}
|
||||
$.cjax({
|
||||
url: '/api/plugins/disable',
|
||||
type: 'POST',
|
||||
data: {plugin_id: pluginId},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false);
|
||||
}else{
|
||||
msgbox("Plugin stopped", true);
|
||||
}
|
||||
initiatePluginList();
|
||||
initPluginSideMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
@ -93,6 +93,15 @@
|
||||
<a class="item" tag="utils">
|
||||
<i class="simplistic paperclip icon"></i> Utilities
|
||||
</a>
|
||||
<a class="item" tag="plugins">
|
||||
<i class="simplistic puzzle piece icon"></i> Plugins Manager
|
||||
</a>
|
||||
<div class="ui divider menudivider">Plugins</div>
|
||||
<cx id="pluginMenu"></container>
|
||||
<a class="item" style="pointer-events: none; user-select: none; opacity: 0.6;">
|
||||
<i class="green circle check icon"></i> No Plugins Installed
|
||||
</a>
|
||||
</cx>
|
||||
<!-- Add more components here -->
|
||||
</div>
|
||||
</div>
|
||||
@ -138,7 +147,10 @@
|
||||
<!-- Web Server -->
|
||||
<div id="webserv" class="functiontab" target="webserv.html"></div>
|
||||
|
||||
<!-- Up Time Monitor -->
|
||||
<!-- Plugins -->
|
||||
<div id="plugins" class="functiontab" target="plugins.html"></div>
|
||||
|
||||
<!-- Up Time Monitor -->
|
||||
<div id="utm" class="functiontab" target="uptime.html"></div>
|
||||
|
||||
<!-- Network Tools -->
|
||||
@ -149,6 +161,12 @@
|
||||
|
||||
<!-- Utilities -->
|
||||
<div id="utils" class="functiontab" target="utils.html"></div>
|
||||
|
||||
<!-- Plugin Context Menu -->
|
||||
<div id="pluginContextWindow" class="functiontab" target="plugincontext.html"></div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -240,7 +258,26 @@
|
||||
|
||||
if (window.location.hash.length > 1){
|
||||
let tabID = window.location.hash.substr(1);
|
||||
openTabById(tabID);
|
||||
if (tabID.startsWith("{")) {
|
||||
tabID = decodeURIComponent(tabID);
|
||||
//Zoraxy v3.2.x plugin context window
|
||||
try {
|
||||
let parsedData = JSON.parse(tabID);
|
||||
tabID = parsedData.tabID;
|
||||
|
||||
//Open the plugin context window
|
||||
if (tabID == "pluginContextWindow"){
|
||||
let pluginID = parsedData.pluginID;
|
||||
let button = $("#pluginMenu").find(`[pluginid="${pluginID}"]`);
|
||||
openTabById(tabID, button);
|
||||
loadPluginUIContextIfAvailable();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Invalid JSON data:", e);
|
||||
}
|
||||
}else{
|
||||
openTabById(tabID);
|
||||
}
|
||||
}else{
|
||||
openTabById("status");
|
||||
}
|
||||
@ -251,7 +288,7 @@
|
||||
$("#mainmenu").find(".item").each(function(){
|
||||
$(this).on("click", function(event){
|
||||
let tabid = $(this).attr("tag");
|
||||
openTabById(tabid);
|
||||
openTabById(tabid, $(this));
|
||||
});
|
||||
});
|
||||
|
||||
@ -276,13 +313,19 @@
|
||||
if ($(".sideWrapper").is(":visible")){
|
||||
$(".sideWrapper iframe")[0].contentWindow.setDarkTheme(false);
|
||||
}
|
||||
|
||||
if ($("#pluginContextLoader").is(":visible")){
|
||||
$("#pluginContextLoader")[0].contentWindow.setDarkTheme(false);
|
||||
}
|
||||
}else{
|
||||
setDarkTheme(true);
|
||||
//Check if the snippet iframe is opened. If yes, set the dark theme to the iframe
|
||||
if ($(".sideWrapper").is(":visible")){
|
||||
$(".sideWrapper iframe")[0].contentWindow.setDarkTheme(true);
|
||||
}
|
||||
|
||||
if ($("#pluginContextLoader").is(":visible")){
|
||||
$("#pluginContextLoader")[0].contentWindow.setDarkTheme(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -301,8 +344,12 @@
|
||||
|
||||
//Select and open a tab by its tag id
|
||||
let tabSwitchEventBind = {}; //Bind event to tab switch by tabid
|
||||
function openTabById(tabID){
|
||||
let targetBtn = getTabButtonById(tabID);
|
||||
function openTabById(tabID, object=undefined){
|
||||
let targetBtn = object;
|
||||
if (object == undefined){
|
||||
//Search tab by its tap id
|
||||
targetBtn = getTabButtonById(tabID);
|
||||
}
|
||||
if (targetBtn == undefined){
|
||||
alert("Invalid tabid given");
|
||||
return;
|
||||
@ -323,7 +370,19 @@
|
||||
},100)
|
||||
});
|
||||
$('html,body').animate({scrollTop: 0}, 'fast');
|
||||
window.location.hash = tabID;
|
||||
|
||||
if (tabID == "pluginContextWindow"){
|
||||
let statePayload = {
|
||||
tabID: tabID,
|
||||
pluginID: $(targetBtn).attr("pluginid")
|
||||
}
|
||||
|
||||
window.location.hash = JSON.stringify(statePayload);
|
||||
loadPluginUIContextIfAvailable();
|
||||
}else{
|
||||
window.location.hash = tabID;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$(window).on("resize", function(){
|
||||
@ -408,7 +467,7 @@
|
||||
Toggles for side wrapper
|
||||
*/
|
||||
|
||||
function showSideWrapper(scriptpath=""){
|
||||
function showSideWrapper(scriptpath="", extendedMode=false){
|
||||
if (scriptpath != ""){
|
||||
$(".sideWrapper iframe").attr("src", scriptpath);
|
||||
}
|
||||
@ -416,6 +475,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);
|
||||
|
@ -188,6 +188,10 @@ body{
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sideWrapper.extendedMode{
|
||||
max-width: calc(80% - 1em);
|
||||
}
|
||||
|
||||
.sideWrapper .content{
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
Loading…
x
Reference in New Issue
Block a user