Merge pull request #113 from jkaninda/feature/extra-routes
Feature/extra routes
This commit is contained in:
@@ -76,7 +76,7 @@ func (GatewayServer) Config(configFile string, ctx context.Context) (*GatewaySer
|
|||||||
|
|
||||||
}
|
}
|
||||||
logger.Info("Generating new configuration file...")
|
logger.Info("Generating new configuration file...")
|
||||||
// check if config directory does exist
|
// check if config Directory does exist
|
||||||
if !util.FolderExists(ConfigDir) {
|
if !util.FolderExists(ConfigDir) {
|
||||||
err := os.MkdirAll(ConfigDir, os.ModePerm)
|
err := os.MkdirAll(ConfigDir, os.ModePerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
46
internal/extra_config.go
Normal file
46
internal/extra_config.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Jonas Kaninda
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package pkg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadExtraFiles loads routes files in .yml and .yaml based on defined directory
|
||||||
|
func loadExtraFiles(routePath string) ([]string, error) {
|
||||||
|
// Slice to store YAML/YML files
|
||||||
|
var yamlFiles []string
|
||||||
|
// Walk through the Directory
|
||||||
|
err := filepath.Walk(routePath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Check for .yaml or .yml file extension
|
||||||
|
if !info.IsDir() && (filepath.Ext(path) == ".yaml" || filepath.Ext(path) == ".yml") {
|
||||||
|
yamlFiles = append(yamlFiles, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error loading extra route files: %v", err)
|
||||||
|
}
|
||||||
|
return yamlFiles, nil
|
||||||
|
}
|
||||||
@@ -34,10 +34,11 @@ type Gateway struct {
|
|||||||
// RateLimit Defines the number of request peer minutes
|
// RateLimit Defines the number of request peer minutes
|
||||||
RateLimit int `yaml:"rateLimit" env:"GOMA_RATE_LIMIT, overwrite"`
|
RateLimit int `yaml:"rateLimit" env:"GOMA_RATE_LIMIT, overwrite"`
|
||||||
// BlockCommonExploits enable, disable block common exploits
|
// BlockCommonExploits enable, disable block common exploits
|
||||||
BlockCommonExploits bool `yaml:"blockCommonExploits"`
|
BlockCommonExploits bool `yaml:"blockCommonExploits"`
|
||||||
AccessLog string `yaml:"accessLog" env:"GOMA_ACCESS_LOG, overwrite"`
|
AccessLog string `yaml:"accessLog" env:"GOMA_ACCESS_LOG, overwrite"`
|
||||||
ErrorLog string `yaml:"errorLog" env:"GOMA_ERROR_LOG=, overwrite"`
|
ErrorLog string `yaml:"errorLog" env:"GOMA_ERROR_LOG=, overwrite"`
|
||||||
LogLevel string `yaml:"logLevel" env:"GOMA_LOG_LEVEL, overwrite"`
|
LogLevel string `yaml:"logLevel" env:"GOMA_LOG_LEVEL, overwrite"`
|
||||||
|
ExtraRoutes ExtraRouteConfig `yaml:"extraRoutes"`
|
||||||
// DisableHealthCheckStatus enable and disable routes health check
|
// DisableHealthCheckStatus enable and disable routes health check
|
||||||
DisableHealthCheckStatus bool `yaml:"disableHealthCheckStatus"`
|
DisableHealthCheckStatus bool `yaml:"disableHealthCheckStatus"`
|
||||||
// DisableRouteHealthCheckError allows enabling and disabling backend healthcheck errors
|
// DisableRouteHealthCheckError allows enabling and disabling backend healthcheck errors
|
||||||
|
|||||||
@@ -35,18 +35,34 @@ func init() {
|
|||||||
// Initialize initializes the routes
|
// Initialize initializes the routes
|
||||||
func (gatewayServer GatewayServer) Initialize() *mux.Router {
|
func (gatewayServer GatewayServer) Initialize() *mux.Router {
|
||||||
gateway := gatewayServer.gateway
|
gateway := gatewayServer.gateway
|
||||||
|
dynamicRoutes = gateway.Routes
|
||||||
|
// Load Extra Routes
|
||||||
|
if len(gateway.ExtraRoutes.Directory) != 0 {
|
||||||
|
extraRoutes, err := loadExtraRoutes(gateway.ExtraRoutes.Directory)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error: %v", err.Error())
|
||||||
|
}
|
||||||
|
dynamicRoutes = append(dynamicRoutes, extraRoutes...)
|
||||||
|
}
|
||||||
|
// find duplicated route name
|
||||||
|
duplicates := findDuplicateRouteNames(dynamicRoutes)
|
||||||
|
if len(duplicates) != 0 {
|
||||||
|
for _, duplicate := range duplicates {
|
||||||
|
logger.Error("Duplicated route name was found: %s ", duplicate)
|
||||||
|
}
|
||||||
|
}
|
||||||
m := gatewayServer.middlewares
|
m := gatewayServer.middlewares
|
||||||
redisBased := false
|
redisBased := false
|
||||||
if len(gateway.Redis.Addr) != 0 {
|
if len(gateway.Redis.Addr) != 0 {
|
||||||
redisBased = true
|
redisBased = true
|
||||||
}
|
}
|
||||||
// Routes background healthcheck
|
// Routes background healthcheck
|
||||||
routesHealthCheck(gateway.Routes)
|
routesHealthCheck(dynamicRoutes)
|
||||||
|
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
heath := HealthCheckRoute{
|
heath := HealthCheckRoute{
|
||||||
DisableRouteHealthCheckError: gateway.DisableRouteHealthCheckError,
|
DisableRouteHealthCheckError: gateway.DisableRouteHealthCheckError,
|
||||||
Routes: gateway.Routes,
|
Routes: dynamicRoutes,
|
||||||
}
|
}
|
||||||
if gateway.EnableMetrics {
|
if gateway.EnableMetrics {
|
||||||
// Prometheus endpoint
|
// Prometheus endpoint
|
||||||
@@ -80,7 +96,7 @@ func (gatewayServer GatewayServer) Initialize() *mux.Router {
|
|||||||
// Add rate limit middlewares
|
// Add rate limit middlewares
|
||||||
r.Use(limiter.RateLimitMiddleware())
|
r.Use(limiter.RateLimitMiddleware())
|
||||||
}
|
}
|
||||||
for rIndex, route := range gateway.Routes {
|
for rIndex, route := range dynamicRoutes {
|
||||||
if len(route.Path) != 0 {
|
if len(route.Path) != 0 {
|
||||||
// Checks if route destination and backend are empty
|
// Checks if route destination and backend are empty
|
||||||
if len(route.Destination) == 0 && len(route.Backends) == 0 {
|
if len(route.Destination) == 0 && len(route.Backends) == 0 {
|
||||||
|
|||||||
70
internal/route_config.go
Normal file
70
internal/route_config.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Jonas Kaninda
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package pkg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/jkaninda/goma-gateway/pkg/logger"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadExtraRoutes loads additional routes
|
||||||
|
func loadExtraRoutes(routePath string) ([]Route, error) {
|
||||||
|
logger.Info("Loading additional routes from %s", routePath)
|
||||||
|
yamlFiles, err := loadExtraFiles(routePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error loading extra files: %v", err)
|
||||||
|
}
|
||||||
|
var extraRoutes []Route
|
||||||
|
for _, yamlFile := range yamlFiles {
|
||||||
|
buf, err := os.ReadFile(yamlFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error loading extra file: %v", err)
|
||||||
|
}
|
||||||
|
ex := &ExtraRoute{}
|
||||||
|
err = yaml.Unmarshal(buf, ex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("in file %q: %w", ConfigFile, err)
|
||||||
|
}
|
||||||
|
extraRoutes = append(extraRoutes, ex.Routes...)
|
||||||
|
|
||||||
|
}
|
||||||
|
if len(extraRoutes) == 0 {
|
||||||
|
return nil, fmt.Errorf("no extra routes found in %s", routePath)
|
||||||
|
} else {
|
||||||
|
logger.Info("Loaded %d extra routes from %s", len(extraRoutes), routePath)
|
||||||
|
|
||||||
|
}
|
||||||
|
return extraRoutes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findDuplicateRouteNames(routes []Route) []string {
|
||||||
|
// Create a map to track occurrences of names
|
||||||
|
nameMap := make(map[string]int)
|
||||||
|
var duplicates []string
|
||||||
|
|
||||||
|
for _, route := range routes {
|
||||||
|
nameMap[route.Name]++
|
||||||
|
// If the count is ==2, it's a duplicate
|
||||||
|
if nameMap[route.Name] == 2 {
|
||||||
|
duplicates = append(duplicates, route.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return duplicates
|
||||||
|
}
|
||||||
178
internal/route_test.go
Normal file
178
internal/route_test.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2024 Jonas Kaninda
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package pkg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/jkaninda/goma-gateway/util"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initExtraRoute create extra routes
|
||||||
|
func initExtraRoute(path string) error {
|
||||||
|
|
||||||
|
conf := &ExtraRoute{
|
||||||
|
Routes: []Route{
|
||||||
|
{
|
||||||
|
Name: "Extra1",
|
||||||
|
Path: "/",
|
||||||
|
Methods: []string{"GET"},
|
||||||
|
Destination: "https://extra-example.com",
|
||||||
|
Rewrite: "/",
|
||||||
|
HealthCheck: RouteHealthCheck{
|
||||||
|
Path: "/",
|
||||||
|
Interval: "30s",
|
||||||
|
Timeout: "10s",
|
||||||
|
HealthyStatuses: []int{200, 404},
|
||||||
|
},
|
||||||
|
DisableHostFording: true,
|
||||||
|
Middlewares: []string{"block-access"},
|
||||||
|
},
|
||||||
|
// Duplicate route name
|
||||||
|
{
|
||||||
|
Name: "Load balancer",
|
||||||
|
Path: "/protected",
|
||||||
|
Backends: []string{
|
||||||
|
"https://example.com",
|
||||||
|
"https://example2.com",
|
||||||
|
"https://example3.com",
|
||||||
|
},
|
||||||
|
Rewrite: "/",
|
||||||
|
HealthCheck: RouteHealthCheck{},
|
||||||
|
Cors: Cors{
|
||||||
|
Origins: []string{"http://localhost:3000", "https://dev.example.com"},
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Access-Control-Allow-Headers": "Origin, Authorization",
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
"Access-Control-Max-Age": "1728000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Middlewares: []string{"basic-auth", "block-access"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yamlData, err := yaml.Marshal(&conf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serializing configuration %v\n", err.Error())
|
||||||
|
}
|
||||||
|
err = os.WriteFile(fmt.Sprintf("%s/extra.yaml", path), yamlData, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to write config file %s\n", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initConfig initializes configs
|
||||||
|
func initConfiguration(configFile string) error {
|
||||||
|
conf := &GatewayConfig{
|
||||||
|
Version: util.ConfigVersion,
|
||||||
|
GatewayConfig: Gateway{
|
||||||
|
WriteTimeout: 15,
|
||||||
|
ReadTimeout: 15,
|
||||||
|
IdleTimeout: 30,
|
||||||
|
AccessLog: "/dev/Stdout",
|
||||||
|
ErrorLog: "/dev/stderr",
|
||||||
|
DisableRouteHealthCheckError: false,
|
||||||
|
DisableDisplayRouteOnStart: false,
|
||||||
|
RateLimit: 0,
|
||||||
|
InterceptErrors: []int{405, 500},
|
||||||
|
ExtraRoutes: ExtraRouteConfig{
|
||||||
|
Directory: extraRoutePath,
|
||||||
|
Watch: false,
|
||||||
|
},
|
||||||
|
Cors: Cors{
|
||||||
|
Origins: []string{"http://localhost:8080", "https://example.com"},
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Access-Control-Allow-Headers": "Origin, Authorization, Accept, Content-Type, Access-Control-Allow-Headers",
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
"Access-Control-Max-Age": "1728000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Routes: []Route{
|
||||||
|
{
|
||||||
|
Name: "Example",
|
||||||
|
Path: "/",
|
||||||
|
Methods: []string{"GET"},
|
||||||
|
Destination: "https://example.com",
|
||||||
|
Rewrite: "/",
|
||||||
|
HealthCheck: RouteHealthCheck{
|
||||||
|
Path: "/",
|
||||||
|
Interval: "30s",
|
||||||
|
Timeout: "10s",
|
||||||
|
HealthyStatuses: []int{200, 404},
|
||||||
|
},
|
||||||
|
DisableHostFording: true,
|
||||||
|
Middlewares: []string{"block-access"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Load balancer",
|
||||||
|
Path: "/protected",
|
||||||
|
Backends: []string{
|
||||||
|
"https://example.com",
|
||||||
|
"https://example2.com",
|
||||||
|
"https://example3.com",
|
||||||
|
},
|
||||||
|
Rewrite: "/",
|
||||||
|
HealthCheck: RouteHealthCheck{},
|
||||||
|
Cors: Cors{
|
||||||
|
Origins: []string{"http://localhost:3000", "https://dev.example.com"},
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Access-Control-Allow-Headers": "Origin, Authorization",
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
"Access-Control-Max-Age": "1728000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Middlewares: []string{"basic-auth", "block-access"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Middlewares: []Middleware{
|
||||||
|
{
|
||||||
|
Name: "basic-auth",
|
||||||
|
Type: BasicAuth,
|
||||||
|
Paths: []string{
|
||||||
|
"/*",
|
||||||
|
},
|
||||||
|
Rule: BasicRuleMiddleware{
|
||||||
|
Username: "admin",
|
||||||
|
Password: "admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "block-access",
|
||||||
|
Type: AccessMiddleware,
|
||||||
|
Paths: []string{
|
||||||
|
"/swagger-ui/*",
|
||||||
|
"/v2/swagger-ui/*",
|
||||||
|
"/api-docs/*",
|
||||||
|
"/actuator/*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yamlData, err := yaml.Marshal(&conf)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serializing configuration %v\n", err.Error())
|
||||||
|
}
|
||||||
|
err = os.WriteFile(configFile, yamlData, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to write config file %s\n", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -56,3 +56,8 @@ type Route struct {
|
|||||||
// Middlewares Defines route middlewares from Middleware names
|
// Middlewares Defines route middlewares from Middleware names
|
||||||
Middlewares []string `yaml:"middlewares"`
|
Middlewares []string `yaml:"middlewares"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExtraRoute struct {
|
||||||
|
// Routes holds proxy routes
|
||||||
|
Routes []Route `yaml:"routes"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (gatewayServer GatewayServer) Start() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !gatewayServer.gateway.DisableDisplayRouteOnStart {
|
if !gatewayServer.gateway.DisableDisplayRouteOnStart {
|
||||||
printRoute(gatewayServer.gateway.Routes)
|
printRoute(dynamicRoutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpServer := gatewayServer.createServer(":8080", route, nil)
|
httpServer := gatewayServer.createServer(":8080", route, nil)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const testPath = "./tests"
|
const testPath = "./tests"
|
||||||
|
const extraRoutePath = "./tests/extra"
|
||||||
|
|
||||||
var configFile = filepath.Join(testPath, "goma.yml")
|
var configFile = filepath.Join(testPath, "goma.yml")
|
||||||
|
|
||||||
@@ -18,11 +19,15 @@ func TestInit(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
|
err = os.MkdirAll(extraRoutePath, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckConfig(t *testing.T) {
|
func TestCheckConfig(t *testing.T) {
|
||||||
TestInit(t)
|
TestInit(t)
|
||||||
err := initConfig(configFile)
|
err := initConfiguration(configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal("Error init config:", err)
|
t.Fatal("Error init config:", err)
|
||||||
}
|
}
|
||||||
@@ -35,10 +40,15 @@ func TestCheckConfig(t *testing.T) {
|
|||||||
|
|
||||||
func TestStart(t *testing.T) {
|
func TestStart(t *testing.T) {
|
||||||
TestInit(t)
|
TestInit(t)
|
||||||
err := initConfig(configFile)
|
err := initConfiguration(configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Error initializing config: %s", err.Error())
|
t.Fatalf("Error initializing config: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = initExtraRoute(extraRoutePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Error creating extra routes file: %s", err.Error())
|
||||||
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
g := GatewayServer{}
|
g := GatewayServer{}
|
||||||
gatewayServer, err := g.Config(configFile, ctx)
|
gatewayServer, err := g.Config(configFile, ctx)
|
||||||
|
|||||||
@@ -168,3 +168,8 @@ type Redis struct {
|
|||||||
Addr string `yaml:"addr"`
|
Addr string `yaml:"addr"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExtraRouteConfig struct {
|
||||||
|
Directory string `yaml:"directory"`
|
||||||
|
Watch bool `yaml:"watch"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ const AccessMiddleware = "access" // access middlewares
|
|||||||
const BasicAuth = "basic" // basic authentication middlewares
|
const BasicAuth = "basic" // basic authentication middlewares
|
||||||
const JWTAuth = "jwt" // JWT authentication middlewares
|
const JWTAuth = "jwt" // JWT authentication middlewares
|
||||||
const OAuth = "oauth" // OAuth authentication middlewares
|
const OAuth = "oauth" // OAuth authentication middlewares
|
||||||
// Round-robin counter
|
var (
|
||||||
var counter uint32
|
// Round-robin counter
|
||||||
|
counter uint32
|
||||||
var Routes *[]Route
|
// dynamicRoutes routes
|
||||||
|
dynamicRoutes []Route
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user