diff --git a/.gitignore b/.gitignore index ba84196..6209ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,6 @@ web/build/* npm-debug.log* yarn-debug.log* yarn-error.log* -test-config.json -test-config.yml -.vscode/ \ No newline at end of file +easy_gate_test_* +.vscode/ +example.json diff --git a/Dockerfile b/Dockerfile index aaabe9d..aa08f49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ COPY . . COPY --from=web-builder ./easy-gate-web/build ./web/build RUN make easy-gate -FROM scratch AS easy-gate +FROM alpine:3.16 AS easy-gate ENV EASY_GATE_CONFIG_PATH="/etc/easy-gate/easy-gate.json" WORKDIR /etc/easy-gate COPY ./assets/easy-gate.json . diff --git a/README.md b/README.md index db545d2..ddcf893 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ --- - +
Easy Gate is a simple web application built in Go and React that acts as the home page for your self-hosted infrastructure. Services and notes are parsed from a configuration file in real-time (without restarting the application). Items can also be assigned to one or more groups to show them only to specific users (based on their IP addresses). @@ -27,7 +27,7 @@ Easy Gate is a simple web application built in Go and React that acts as the hom - Service and note parsing from a configuration file (JSON/YAML) in real-time (without restarting the application). - Service and note assignment to one or more groups to show items only to specific users (based on their IP addresses). -- Customizable theme. +- Customizable theme and icons. - Run as dependecy free standalone executable or as a Docker container. ## Deployment @@ -193,8 +193,6 @@ Easy gate can be configured by a JSON or a YAML configuration file. An example c - **key_file:** Path to the SSL key file (if TLS is enabled) - **behind_proxy:** If true, the application will use the X-Forwarded-For header to determine the IP address of the client - **title:** Title of the application -- **icon:** Font-awesome icon to use as the application icon -- **motd:** Message to display on home page ### Theme @@ -209,7 +207,7 @@ Example of a dark mode theme: ```json "theme": { "background": "#1d1d1d", - "foreground": "#ffffff" + "foreground": "#ffffff", } ``` @@ -224,7 +222,8 @@ theme: ### Groups
-Group entries are used to define which users can see which items, by providing the user subnet: +Group entries are used to define which users can see which items, by providing the user subnet. Group functionality is useful when dealing with both internal network and VPN users. +
#### JSON @@ -255,24 +254,29 @@ groups: ### Services-A service entry is used to define a service that is available in the infrastructure. Each service has a name, an url, an icon and the groups that can see it (defined in the groups section). If no group is provided the item can be seen by all users: +A service entry is used to define a service that is available in the infrastructure. Each service has the following configurable parameters: + +- **name:** the name of the service (ex. Internal Git, Jenkins, ...) +- **url:** the service url (must be a valid url starting with http(s)://) +- **groups:** list of groups associated to this service (defined in the groups section). If no group is provided the item can be seen by all users: +- **icon (optional):** the icon parameter accepts image URLs or data URI. If the icon parameter is not provided or empty, Easy Gate will try to fetch the service favicon and display it or fallback to a default icon. +
#### JSON ```json { - "icon": "fa-brands fa-git-square", "name": "Git", - "url": "https://git.example.vpn", + "url": "https://git.example.internal", "groups": [ "vpn" ] }, { - "icon": "fa-brands fa-docker", "name": "Portainer", - "url": "https://portainer.example.internal", + "url": "https://portainer.example.all", + "icon": "data:image/png;base64,[...]", "groups": [] } ``` @@ -280,14 +284,13 @@ A service entry is used to define a service that is available in the infrastruct #### YAML ```yml -- icon: fa-brands fa-git-square - name: Git - url: https://git.example.vpn +- name: Git + url: https://git.example.internal groups: - vpn -- icon: fa-brands fa-docker - name: Portainer - url: https://portainer.example.internal +- name: Portainer + url: https://portainer.example.all + icon: data:image/png;base64,[...] groups: [] ``` @@ -326,10 +329,6 @@ A note entry is used to define a simple text note which has a title and a conten groups: [] ``` -### Icons - -Icons are provided by the [Font Awesome](https://fontawesome.com/icons?d=gallery) library. Get the appropriate icon name by using the Font Awesome website (only free icons are available). - ### Environment Variables - **EASY_GATE_CONFIG_PATH:** Easy Gate configuration file path can be provided by this environment variable. The value will have precedence over the configuration file path provided in the command line. diff --git a/assets/demo.png b/assets/demo.png new file mode 100644 index 0000000..9d00e6d Binary files /dev/null and b/assets/demo.png differ diff --git a/assets/easy-gate.json b/assets/easy-gate.json index 538e046..c433e53 100644 --- a/assets/easy-gate.json +++ b/assets/easy-gate.json @@ -5,8 +5,6 @@ "key_file": "", "behind_proxy": false, "title": "Easy Gate", - "icon": "fa-solid fa-cubes", - "motd": "Welcome to Easy Gate", "theme": { "background": "#FFFFFF", "foreground": "#000000" diff --git a/assets/screenshot.png b/assets/screenshot.png deleted file mode 100644 index 6b87a14..0000000 Binary files a/assets/screenshot.png and /dev/null differ diff --git a/cmd/easy-gate/main.go b/cmd/easy-gate/main.go index 8109cbe..50d3971 100644 --- a/cmd/easy-gate/main.go +++ b/cmd/easy-gate/main.go @@ -28,19 +28,23 @@ import ( "time" "github.com/r7wx/easy-gate/internal/config" + "github.com/r7wx/easy-gate/internal/routine" "github.com/r7wx/easy-gate/internal/service" ) func main() { + log.SetPrefix("[Easy Gate] ") + cfgFilePath, err := config.GetConfigPath(os.Args) if err != nil { - log.Fatal("[Easy Gate] No configuration file provided") + log.Fatal("No configuration file provided") } - log.Println("[Easy Gate] Loading configuration file:", - cfgFilePath) - cfgRoutine := config.NewRoutine(cfgFilePath, - 1*time.Second) + log.Println("Loading configuration file:", cfgFilePath) + cfgRoutine, err := routine.NewRoutine(cfgFilePath, 1*time.Second) + if err != nil { + log.Fatal(err) + } go cfgRoutine.Start() service := service.NewService(cfgRoutine) diff --git a/easy-gate.json b/easy-gate.json index 6e7f6c3..3956367 100644 --- a/easy-gate.json +++ b/easy-gate.json @@ -5,8 +5,6 @@ "key_file": "", "behind_proxy": false, "title": "Easy Gate", - "icon": "fa-solid fa-cubes", - "motd": "Welcome to Easy Gate", "theme": { "background": "#FFFFFF", "foreground": "#000000" @@ -23,31 +21,14 @@ ], "services": [ { - "icon": "fa-brands fa-git-square", "name": "Git", "url": "https://git.example.internal", "groups": [ - "internal" - ] - }, - { - "icon": "fa-brands fa-git-square", - "name": "Git", - "url": "https://git.example.vpn", - "groups": [ + "internal", "vpn" ] }, { - "icon": "fa-brands fa-docker", - "name": "Portainer", - "url": "https://portainer.example.internal", - "groups": [ - "internal" - ] - }, - { - "icon": "fa-solid fa-folder-open", "name": "Files", "url": "https://files.example.internal", "groups": [ @@ -55,95 +36,14 @@ ] }, { - "icon": "fa-solid fa-box-archive", - "name": "Archive", - "url": "https://archive.example.internal", - "groups": [ - "internal" - ] - }, - { - "icon": "fa-solid fa-chart-line", - "name": "Kibana", - "url": "https://kibana.example.internal", - "groups": [ - "internal" - ] - }, - { - "icon": "fa-solid fa-download", - "name": "Transmission", - "url": "https://transmission.example.internal", - "groups": [ - "internal" - ] - }, - { - "icon": "fa-solid fa-bookmark", - "name": "Bookmarks", - "url": "https://bookmarks.example.internal", - "groups": [ - "internal" - ] - }, - { - "icon": "fa-solid fa-book", - "name": "Calibre", - "url": "https://calibre.example.internal", - "groups": [ - "internal" - ] - }, - { - "icon": "fa-solid fa-comment", - "name": "Webchat", - "url": "https://chat.example.internal", - "groups": [] - }, - { - "icon": "fa-solid fa-cloud", - "name": "Owncloud", - "url": "https://owncloud.example.internal", - "groups": [ - "internal", - "vpn" - ] - }, - { - "icon": "fa-brands fa-wikipedia-w", - "name": "Wiki", - "url": "https://wiki.example.internal", - "groups": [ - "internal", - "vpn" - ] - }, - { - "icon": "fa-brands fa-mastodon", - "name": "Mastodon", - "url": "https://mastodon.example.internal", - "groups": [ - "internal", - "vpn" - ] - }, - { - "icon": "fa-brands fa-google", "name": "Google", "url": "https://www.google.com", "groups": [] }, { - "icon": "fa-brands fa-youtube", "name": "Youtube", "url": "https://www.youtube.com", "groups": [] - }, - { - "icon": "fa-brands fa-stack-overflow", - "name": "StackOverflow", - "url": "https://stackoverflow.com", - "groups": [] } ], "notes": [ @@ -154,19 +54,9 @@ "vpn" ] }, - { - "name": "Global note", - "text": "This note will be visible to everyone", - "groups": [] - }, - { - "name": "How to use our internal services", - "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec nec arcu purus. Maecenas ut erat ut tellus vulputate pellentesque sit amet quis metus. Praesent sollicitudin ultricies leo. Sed ornare libero non vehicula cursus. Aliquam vulputate pulvinar elit, sit amet tempus justo condimentum in. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus", - "groups": [] - }, { "name": "Another note", - "text": "Another note for internal network users only", + "text": "Another note for internal users only", "groups": [ "internal" ] diff --git a/easy-gate.yml b/easy-gate.yml index 5369490..b3541f4 100644 --- a/easy-gate.yml +++ b/easy-gate.yml @@ -4,8 +4,6 @@ cert_file: "" key_file: "" behind_proxy: false title: Easy Gate -icon: fa-solid fa-cubes -motd: Welcome to Easy Gate theme: background: "#FFFFFF" foreground: "#000000" @@ -15,102 +13,27 @@ groups: - name: vpn subnet: 10.8.1.1/24 services: - - icon: fa-brands fa-git-square - name: Git + - name: Git url: https://git.example.internal groups: - internal - - icon: fa-brands fa-git-square - name: Git - url: https://git.example.vpn - groups: - vpn - - icon: fa-brands fa-docker - name: Portainer - url: https://portainer.example.internal - groups: - - internal - - icon: fa-solid fa-folder-open - name: Files + - name: Files url: https://files.example.internal groups: - internal - - icon: fa-solid fa-box-archive - name: Archive - url: https://archive.example.internal - groups: - - internal - - icon: fa-solid fa-chart-line - name: Kibana - url: https://kibana.example.internal - groups: - - internal - - icon: fa-solid fa-download - name: Transmission - url: https://transmission.example.internal - groups: - - internal - - icon: fa-solid fa-bookmark - name: Bookmarks - url: https://bookmarks.example.internal - groups: - - internal - - icon: fa-solid fa-book - name: Calibre - url: https://calibre.example.internal - groups: - - internal - - icon: fa-solid fa-comment - name: Webchat - url: https://chat.example.internal - groups: [] - - icon: fa-solid fa-cloud - name: Owncloud - url: https://owncloud.example.internal - groups: - - internal - - vpn - - icon: fa-brands fa-wikipedia-w - name: Wiki - url: https://wiki.example.internal - groups: - - internal - - vpn - - icon: fa-brands fa-mastodon - name: Mastodon - url: https://mastodon.example.internal - groups: - - internal - - vpn - - icon: fa-brands fa-google - name: Google + - name: Google url: https://www.google.com groups: [] - - icon: fa-brands fa-youtube - name: Youtube + - name: Youtube url: https://www.youtube.com groups: [] - - icon: fa-brands fa-stack-overflow - name: Stackoverflow - url: https://stackoverflow.com - groups: [] notes: - name: Simple note text: This is a simple note for vpn users groups: - vpn - - name: Global note - text: This note will be visible to everyone - groups: [] - - name: How to use our internal services - text: - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec nec arcu purus. - Maecenas ut erat ut tellus vulputate pellentesque sit amet quis metus. Praesent - sollicitudin ultricies leo. Sed ornare libero non vehicula cursus. Aliquam vulputate - pulvinar elit, sit amet tempus justo condimentum in. Orci varius natoque penatibus - et magnis dis parturient montes, nascetur ridiculus mus - groups: [] - name: Another note - text: Another note for internal network users only + text: Another note for internal users only groups: - internal diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml index e423047..ce3c8b7 100644 --- a/examples/docker-compose.yml +++ b/examples/docker-compose.yml @@ -1,6 +1,7 @@ services: easy-gate: image: r7wx/easy-gate:latest + build: ../. container_name: easy-gate expose: - 8080 diff --git a/internal/config/config.go b/internal/config/config.go index 8de6158..ede7dce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,15 +26,10 @@ import ( "encoding/json" "github.com/r7wx/easy-gate/internal/errors" + "github.com/r7wx/easy-gate/internal/models" "gopkg.in/yaml.v3" ) -// Group - Easy Gate group configuration struct -type Group struct { - Name string `json:"name" yaml:"name"` - Subnet string `json:"subnet" yaml:"subnet"` -} - // Service - Easy Gate service configuration struct type Service struct { Icon string `json:"icon" yaml:"icon"` @@ -50,26 +45,18 @@ type Note struct { Groups []string `json:"groups" yaml:"groups"` } -// Theme - Easy Gate theme configuration struct -type Theme struct { - Background string `json:"background" yaml:"background"` - Foreground string `json:"foreground" yaml:"foreground"` -} - // Config - Easy Gate configuration struct type Config struct { - Theme Theme `json:"theme" yaml:"theme"` - Addr string `json:"addr" yaml:"addr"` - Title string `json:"title" yaml:"title"` - CertFile string `json:"cert_file" yaml:"cert_file"` - KeyFile string `json:"key_file" yaml:"key_file"` - Icon string `json:"icon" yaml:"icon"` - Motd string `json:"motd" yaml:"motd"` - Groups []Group `json:"groups" yaml:"groups"` - Services []Service `json:"services" yaml:"services"` - Notes []Note `json:"notes" yaml:"notes"` - BehindProxy bool `json:"behind_proxy" yaml:"behind_proxy"` - UseTLS bool `json:"use_tls" yaml:"use_tls"` + Theme models.Theme `json:"theme" yaml:"theme"` + Addr string `json:"addr" yaml:"addr"` + Title string `json:"title" yaml:"title"` + CertFile string `json:"cert_file" yaml:"cert_file"` + KeyFile string `json:"key_file" yaml:"key_file"` + Groups []models.Group `json:"groups" yaml:"groups"` + Services []Service `json:"services" yaml:"services"` + Notes []Note `json:"notes" yaml:"notes"` + BehindProxy bool `json:"behind_proxy" yaml:"behind_proxy"` + UseTLS bool `json:"use_tls" yaml:"use_tls"` } type format int diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 87e7dea..d37d34d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -23,297 +23,176 @@ SOFTWARE. package config import ( - "encoding/json" "io/ioutil" "os" "testing" "github.com/r7wx/easy-gate/internal/share" - "gopkg.in/yaml.v3" ) -var testCfg Config = Config{ - Addr: ":8080", - UseTLS: false, - CertFile: "", - KeyFile: "", - BehindProxy: false, - Title: "Test", - Icon: "fa-solid fa-cubes", - Motd: "", - Theme: Theme{ - Background: "#ffffff", - Foreground: "#000000", - }, - Groups: []Group{ - { - Name: "Group 1", - Subnet: "127.0.0.1/32", - }, - }, - Services: []Service{ - { - Name: "Service 1", - Icon: "fa-solid fa-cube", - URL: "http://test:8080", - Groups: []string{}, - }, - { - Name: "Service 2", - Icon: "fa-solid fa-cube", - URL: "http://test2:8080", - Groups: []string{"Group 1"}, - }, - }, - Notes: []Note{ - { - Name: "Note 1", - Text: "This is a test note", - Groups: []string{}, - }, - { - Name: "Note 2", - Text: "This is another test note", - Groups: []string{"Group 1"}, - }, - }, -} - -const ( - testJSONPath = "./test-config.json" - testYAMLPath = "./test-config.yml" -) - -func TestMain(m *testing.M) { - cfgJSON, err := json.Marshal(testCfg) +var sampleJSONConfig string = `{ +"addr": "0.0.0.0:8080", +"use_tls": false, +"cert_file": "", +"key_file": "", +"behind_proxy": false, +"title": "Easy Gate", +"theme": { + "background": "#FFFFFF", + "foreground": "#000000" +}, +"groups": [], +"services": [], +"notes": [] +}` + +var sampleYMLConfig string = `addr: 0.0.0.0:8080 +use_tls: false +cert_file: '' +key_file: '' +behind_proxy: false +title: Easy Gate +theme: + background: '#FFFFFF' + foreground: '#000000' +groups: [] +services: [] +notes: []` + +func TestUnmarshal(t *testing.T) { + _, err := Unmarshal([]byte(sampleJSONConfig)) if err != nil { - panic(err) + t.Fatal(err) } - err = ioutil.WriteFile(testJSONPath, - cfgJSON, 0644) + _, err = Unmarshal([]byte(sampleYMLConfig)) if err != nil { - panic(err) + t.Fatal(err) } - - cfgYAML, err := yaml.Marshal(testCfg) - err = ioutil.WriteFile(testYAMLPath, - cfgYAML, 0644) - if err != nil { - panic(err) + _, err = Unmarshal([]byte("X")) + if err == nil { + t.Fatal() } - - exitCode := m.Run() - os.Remove(testJSONPath) - os.Remove(testYAMLPath) - os.Exit(exitCode) } -func TestPath(t *testing.T) { - args := []string{"test"} - _, err := GetConfigPath(args) - if err == nil { - t.Fatal("Expected error, got nil") +func TestLoadEnv(t *testing.T) { + os.Setenv(share.CFGEnv, sampleJSONConfig) + _, _, err := Load("") + if err != nil { + t.Fatal(err) } + os.Unsetenv(share.CFGEnv) +} - args = []string{"", testJSONPath} - cfg, err := GetConfigPath(args) +func TestLoadFile(t *testing.T) { + cfgFile, err := ioutil.TempFile(".", "easy_gate_test_") if err != nil { t.Fatal(err) } - if cfg != testJSONPath { - t.Fatalf("Expected %s, got %s", - testJSONPath, cfg) + cfgFile.WriteString(sampleJSONConfig) + _, _, err = Load(cfgFile.Name()) + if err != nil { + t.Fatal(err) } + defer os.Remove(cfgFile.Name()) - os.Setenv(share.CFGPathEnv, testYAMLPath) - cfg, err = GetConfigPath([]string{""}) + cfgFile, err = ioutil.TempFile(".", "easy_gate_test_") if err != nil { t.Fatal(err) } - if cfg != testYAMLPath { - t.Fatalf("Expected %s, got %s", - testYAMLPath, cfg) - } - - os.Unsetenv(share.CFGPathEnv) -} - -func TestInvalidFormat(t *testing.T) { - cfgRaw := []byte("invalid") - _, err := Unmarshal(cfgRaw) + cfgFile.WriteString("XXXX") + _, _, err = Load(cfgFile.Name()) if err == nil { - t.Fatal("Expected error") + t.Fatal() } -} + defer os.Remove(cfgFile.Name()) -func TestInvalidLoad(t *testing.T) { - _, _, err := LoadConfig("") - if err == nil { - t.Fatal("Expected error") + cfgFile, err = ioutil.TempFile(".", "easy_gate_test_") + if err != nil { + t.Fatal(err) } - - _, _, err = loadConfig([]byte("invalid")) + wrongConfig := `{ +"addr": "0.0.0.0:8080", +"use_tls": false, +"cert_file": "", +"key_file": "", +"behind_proxy": false, +"title": "Easy Gate", +"theme": { + "background": "TEST123", + "foreground": "TEST" +}, +"groups": [], +"services": [], +"notes": [] +}` + cfgFile.WriteString(wrongConfig) + _, _, err = Load(cfgFile.Name()) if err == nil { - t.Fatal("Expected error") + t.Fatal() } + defer os.Remove(cfgFile.Name()) - invalidCfg, _ := json.Marshal(Config{}) - _, _, err = loadConfig(invalidCfg) + _, _, err = Load("H()2NOTEXISTENT.NOT") if err == nil { - t.Fatal("Expected error") + t.Fatal() } } -func TestInvalidConfigElements(t *testing.T) { - err := validateConfig(&Config{ - Icon: "xxx", - }) - if err == nil { - t.Fatal("Expected error") +func TestGetPath(t *testing.T) { + os.Setenv(share.CFGPathEnv, "test.json") + _, err := GetConfigPath([]string{}) + if err != nil { + t.Fatal(err) } + os.Unsetenv(share.CFGPathEnv) - err = validateConfig(&Config{ - Icon: "fa-solid fa-cubes", - Services: []Service{ - { - Icon: "xxx", - }, - }, - }) + _, err = GetConfigPath([]string{}) if err == nil { - t.Fatal("Expected error") + t.Fatal() } - err = validateConfig(&Config{ - Icon: "fa-solid fa-cubes", - Services: []Service{ - { - Icon: "fa-solid fa-cubes", - URL: "xxx", - }, - }, - }) - if err == nil { - t.Fatal("Expected error") + path, err := GetConfigPath([]string{"", "test.json"}) + if err != nil { + t.Fatal() + } + if path != "test.json" { + t.Fatal() } +} - err = validateConfig(&Config{ - Icon: "fa-solid fa-cubes", - Services: []Service{ - { - Icon: "fa-solid fa-cubes", - URL: "http://test", - }, - }, - Theme: Theme{ - Background: "xxx", - }, - }) +func TestValidate(t *testing.T) { + cfg := Config{} + err := validateConfig(&cfg) if err == nil { - t.Fatal("Expected error") + t.Fatal() } - err = validateConfig(&Config{ - Icon: "fa-solid fa-cubes", - Services: []Service{ - { - Icon: "fa-solid fa-cubes", - URL: "http://test", - }, - }, - Theme: Theme{ - Background: "#000000", - Foreground: "xxx", - }, - }) + cfg.Theme.Background = "#FFF" + err = validateConfig(&cfg) if err == nil { - t.Fatal("Expected error") + t.Fatal() } -} -func TestHexColors(t *testing.T) { - if !isHexColor("#ff0000") { - t.Fatal("Expected true, got false") - } - if !isHexColor("#f00") { - t.Fatal("Expected true, got false") - } - if !isHexColor("#ffff") { - t.Fatal("Expected true, got false") - } - if isHexColor("FFFFFF") { - t.Fatal("Expected false, got true") - } - if isHexColor("#FFFFFFF") { - t.Fatal("Expected false, got true") - } - if isHexColor("#") { - t.Fatal("Expected false, got true") - } - if isHexColor("#FFG") { - t.Fatal("Expected false, got true") - } - if isHexColor("32984327493827@@@AA") { - t.Fatal("Expected false, got true") + cfg.Theme.Foreground = "#____" + err = validateConfig(&cfg) + if err == nil { + t.Fatal() } -} -func TestURLs(t *testing.T) { - if !isURL("http://example.com") { - t.Fatal("Expected true, got false") - } - if !isURL("https://example.com") { - t.Fatal("Expected true, got false") - } - if !isURL("https://example.com/test/test.xy") { - t.Fatal("Expected true, got false") + cfg.Theme.Foreground = "#FFF" + cfg.Services = []Service{ + {URL: "XXX"}, } - if !isURL("https://example.com/test/test.xy?test=test") { - t.Fatal("Expected true, got false") - } - if !isURL("https://example.com/test/test.xy?test=test#test") { - t.Fatal("Expected true, got false") - } - if !isURL("ftp://example.com") { - t.Fatal("Expected true, got false") - } - if isURL("example.internal.priv") { - t.Fatal("Expected false, got true") - } - if isURL("test.test") { - t.Fatal("Expected false, got true") - } - if isURL("example") { - t.Fatal("Expected false, got true") - } - if isURL("javascript:void(0)") { - t.Fatal("Expected false, got true") - } - if isURL("javascript:alert(1)") { - t.Fatal("Expected false, got true") - } - if isURL("javascript: alert(1)") { - t.Fatal("Expected false, got true") + err = validateConfig(&cfg) + if err == nil { + t.Fatal() } -} -func TestIcons(t *testing.T) { - if !isIcon("fa-brands fa-github") { - t.Fatal("Expected true, got false") - } - if !isIcon("fa-regular fa-cube") { - t.Fatal("Expected true, got false") - } - if !isIcon("fa-solid fa-flask-vial") { - t.Fatal("Expected true, got false") + cfg.Services = []Service{ + {URL: "https://www.google.com"}, } - if isIcon("") { - t.Fatal("Expected false, got true") - } - if isIcon("bg-white text-red") { - t.Fatal("Expected false, got true") - } - if isIcon("fa-brands fa-github fa-brands fa-github") { - t.Fatal("Expected false, got true") + err = validateConfig(&cfg) + if err != nil { + t.Fatal() } } diff --git a/internal/config/load.go b/internal/config/load.go index 35f8abf..882d8be 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -31,8 +31,8 @@ import ( "github.com/r7wx/easy-gate/internal/share" ) -// LoadConfig - Load configuration from environment or file -func LoadConfig(filePath string) (*Config, string, error) { +// Load - Load configuration from environment or file +func Load(filePath string) (*Config, string, error) { envCfg := os.Getenv(share.CFGEnv) if envCfg != "" { return loadConfig([]byte(envCfg)) @@ -44,11 +44,7 @@ func LoadConfig(filePath string) (*Config, string, error) { } defer cfgFile.Close() - fileData, err := ioutil.ReadAll(cfgFile) - if err != nil { - return nil, "", err - } - + fileData, _ := ioutil.ReadAll(cfgFile) return loadConfig(fileData) } diff --git a/internal/config/validate.go b/internal/config/validate.go index 18f0f73..527181f 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -55,51 +55,31 @@ func isURL(url string) bool { return r.MatchString(url) } -func isIcon(icon string) bool { - r, _ := regexp.Compile( - `^(fa-(solid|regular|brands) (fa-[A-Za-z0-9--]+))$`, - ) - return r.MatchString(icon) -} - func validateConfig(cfg *Config) error { - if !isIcon(cfg.Icon) { - return errors.NewEasyGateError( - errors.InvalidIcon, - errors.Root, "") - } - - for _, service := range cfg.Services { - if !isIcon(service.Icon) { - return errors.NewEasyGateError( - errors.InvalidIcon, - errors.Service, - service.Name, - ) - } - if !isURL(service.URL) { - return errors.NewEasyGateError( - errors.InvalidURL, - errors.Service, - service.Name, - ) - } - } - if !isHexColor(cfg.Theme.Background) { return errors.NewEasyGateError( errors.InvalidColor, - errors.Root, + errors.Theme, "background", ) } if !isHexColor(cfg.Theme.Foreground) { return errors.NewEasyGateError( errors.InvalidColor, - errors.Root, + errors.Theme, "foreground", ) } + for _, service := range cfg.Services { + if !isURL(service.URL) { + return errors.NewEasyGateError( + errors.InvalidURL, + errors.Service, + service.Name, + ) + } + } + return nil } diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 79e0574..fa01ffb 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -30,7 +30,6 @@ type ErrorType string // Easy Gate errors enum const ( InvalidFormat ErrorType = "format" - InvalidIcon ErrorType = "icon" InvalidURL ErrorType = "url" InvalidColor ErrorType = "color" ) @@ -40,7 +39,7 @@ type ErrorElement string // Easy Gate error context enum const ( - Root ErrorElement = "root" + Theme ErrorElement = "theme" Service ErrorElement = "service" ConfigurationFile ErrorElement = "configuration file" ) diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 82e8f8a..b0c6c1b 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -25,33 +25,18 @@ package errors import "testing" func TestErrors(t *testing.T) { - err := NewEasyGateError(InvalidIcon, Root, "") - if err.Error() != "Invalid icon for root" { - t.Fatalf("Expected 'Invalid icon for root', got '%s'", - err.Error()) - } - - err = NewEasyGateError(InvalidIcon, Service, "service1") - if err.Error() != "Invalid icon for service: service1" { - t.Fatalf("Expected 'Invalid icon for service: service1', got '%s'", - err.Error()) - } - - err = NewEasyGateError(InvalidURL, Service, "service1") + err := NewEasyGateError(InvalidURL, Service, "service1") if err.Error() != "Invalid url for service: service1" { - t.Fatalf("Expected 'Invalid url for service: service1', got '%s'", - err.Error()) + t.Fatal() } - err = NewEasyGateError(InvalidColor, Root, "background") - if err.Error() != "Invalid color for root: background" { - t.Fatalf("Expected 'Invalid color for root: background', got '%s'", - err.Error()) + err = NewEasyGateError(InvalidColor, Theme, "background") + if err.Error() != "Invalid color for theme: background" { + t.Fatal() } err = NewEasyGateError(InvalidFormat, ConfigurationFile, "") if err.Error() != "Invalid format for configuration file" { - t.Fatalf("Expected 'Invalid format for configuration file', got '%s'", - err.Error()) + t.Fatal() } } diff --git a/internal/service/models.go b/internal/models/models.go similarity index 62% rename from internal/service/models.go rename to internal/models/models.go index 055ccf6..44fd843 100644 --- a/internal/service/models.go +++ b/internal/models/models.go @@ -20,30 +20,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package service +package models -type service struct { - Icon string `json:"icon"` - Name string `json:"name"` - URL string `json:"url"` +// Group - Easy Gate group configuration struct +type Group struct { + Name string `json:"name" yaml:"name"` + Subnet string `json:"subnet" yaml:"subnet"` } -type note struct { - Name string `json:"name"` - Text string `json:"text"` +// Service - Service model +type Service struct { + Icon string `json:"icon"` + Name string `json:"name"` + URL string `json:"url"` + Groups []string `json:"-"` } -type theme struct { - Background string `json:"background"` - Foreground string `json:"foreground"` +// Note - Note model +type Note struct { + Name string `json:"name"` + Text string `json:"text"` + Groups []string `json:"-"` } -type response struct { - Error string `json:"error"` - Theme theme `json:"theme"` - Title string `json:"title"` - Icon string `json:"icon"` - Motd string `json:"motd"` - Services []service `json:"services"` - Notes []note `json:"notes"` +// Theme - Easy Gate theme model +type Theme struct { + Background string `json:"background" yaml:"background"` + Foreground string `json:"foreground" yaml:"foreground"` } diff --git a/internal/routine/icon.go b/internal/routine/icon.go new file mode 100644 index 0000000..bb5b921 --- /dev/null +++ b/internal/routine/icon.go @@ -0,0 +1,102 @@ +/* +MIT License + +Copyright (c) 2022 r7wx + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package routine + +import ( + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/r7wx/easy-gate/internal/config" +) + +func (r *Routine) getIconData(service config.Service) string { + if strings.HasPrefix(service.Icon, "data:image") { + return service.Icon + } + + u, err := url.Parse(service.Icon) + if err == nil && u.IsAbs() { + return r.downloadIconFromURL(service.Icon) + } + + u, err = url.Parse(service.URL) + if err != nil { + return "" + } + return r.downloadFavicon(fmt.Sprintf("%s://%s/%s", u.Scheme, + u.Host, "favicon.ico")) +} + +func (r *Routine) downloadIconFromURL(url string) string { + resp, err := r.Client.Get(url) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + mimeType := http.DetectContentType(respBytes) + if !strings.HasPrefix(mimeType, "image/") { + return "" + } + + return fmt.Sprintf( + "data:%s;base64,%s", mimeType, + base64.StdEncoding.EncodeToString(respBytes), + ) +} + +func (r *Routine) downloadFavicon(url string) string { + resp, err := r.Client.Get(url) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + mimeType := http.DetectContentType(respBytes) + if mimeType != "image/x-icon" { + return "" + } + + return fmt.Sprintf( + "data:image/x-icon;base64,%s", + base64.StdEncoding.EncodeToString(respBytes), + ) +} diff --git a/internal/config/routine.go b/internal/routine/routine.go similarity index 65% rename from internal/config/routine.go rename to internal/routine/routine.go index 9e487c2..4347121 100644 --- a/internal/config/routine.go +++ b/internal/routine/routine.go @@ -20,50 +20,64 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package config +package routine import ( + "crypto/tls" "log" + "net/http" "sync" "time" + + "github.com/r7wx/easy-gate/internal/config" ) -// Routine - Config routine struct +// Routine - Routine struct type Routine struct { sync.Mutex Error error - Config *Config + Status *Status + Client *http.Client FilePath string LastChecksum string Interval time.Duration } // NewRoutine - Create new config routine -func NewRoutine(filePath string, interval time.Duration) *Routine { - cfg, checksum, err := LoadConfig(filePath) - if err != nil { - log.Fatal(err) +func NewRoutine(filePath string, interval time.Duration) (*Routine, error) { + routine := Routine{ + FilePath: filePath, + Client: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + }, + Interval: interval, } - return &Routine{ - FilePath: filePath, - Config: cfg, - Interval: interval, - LastChecksum: checksum, + cfg, checksum, err := config.Load(filePath) + if err != nil { + return nil, err } + routine.Status = routine.toStatus(cfg) + routine.LastChecksum = checksum + + return &routine, nil } -// GetConfiguration - Get current configuration -func (r *Routine) GetConfiguration() (*Config, error) { +// GetStatus - Get current status +func (r *Routine) GetStatus() (*Status, error) { defer r.Unlock() r.Lock() - return r.Config, r.Error + return r.Status, r.Error } // Start - Start config routine func (r *Routine) Start() { for { - cfg, checksum, err := LoadConfig(r.FilePath) + cfg, checksum, err := config.Load(r.FilePath) if err != nil { r.Lock() r.Error = err @@ -74,8 +88,8 @@ func (r *Routine) Start() { r.Lock() r.Error = nil if checksum != r.LastChecksum { - log.Println("[Easy Gate] Detected configuration change, reloading...") - r.Config = cfg + log.Println("Detected configuration change, reloading...") + r.Status = r.toStatus(cfg) } r.LastChecksum = checksum r.Unlock() diff --git a/internal/routine/routine_test.go b/internal/routine/routine_test.go new file mode 100644 index 0000000..69cfcea --- /dev/null +++ b/internal/routine/routine_test.go @@ -0,0 +1,160 @@ +/* +MIT License + +Copyright (c) 2022 r7wx + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package routine + +import ( + "io/ioutil" + "log" + "net/http" + "os" + "testing" + "time" + + "github.com/r7wx/easy-gate/internal/config" +) + +var cfgFilePath string + +func TestMain(m *testing.M) { + configContent := `{ +"addr": "127.0.0.1:8080", +"use_tls": false, +"cert_file": "", +"key_file": "", +"behind_proxy": false, +"title": "Easy Gate", +"theme": { + "background": "#FFFFFF", + "foreground": "#000000", +}, +"groups": [], +"services": [], +"notes": [] +}` + cfgFile, err := ioutil.TempFile(".", "easy_gate_test_") + if err != nil { + log.Fatal("Unable to write tmp files for test") + } + cfgFile.WriteString(configContent) + cfgFilePath = cfgFile.Name() + + exitCode := m.Run() + + os.Remove(cfgFilePath) + + os.Exit(exitCode) +} + +func TestRoutine(t *testing.T) { + _, err := NewRoutine("", 1*time.Millisecond) + if err == nil { + t.Fatal() + } + + testRoutine, err := NewRoutine(cfgFilePath, 1*time.Millisecond) + if err != nil { + t.Fatal(err) + } + _, err = testRoutine.GetStatus() + if err != nil { + t.Fatal() + } +} + +func TestGetServices(t *testing.T) { + testRoutine := Routine{ + Client: http.DefaultClient, + } + + cfg := config.Config{ + Services: []config.Service{ + { + Icon: "", + Name: "Test 1", + URL: "https://xxxxxxxx.xxxxxx", + }, + }, + } + + services := testRoutine.getServices(&cfg) + if services[0].Name != "Test 1" { + t.Fatal() + } +} + +func TestGetNotes(t *testing.T) { + testRoutine := Routine{ + Client: http.DefaultClient, + } + + cfg := config.Config{ + Notes: []config.Note{ + { + Name: "Test 1", + Text: "...", + }, + }, + } + + notes := testRoutine.getNotes(&cfg) + if notes[0].Name != "Test 1" { + t.Fatal() + } +} + +func TestIcon(t *testing.T) { + testRoutine := Routine{ + Client: http.DefaultClient, + } + + service := config.Service{ + Icon: "data:image/png;base64,TEST", + } + icon := testRoutine.getIconData(service) + if icon != "data:image/png;base64,TEST" { + t.Fail() + } + + service = config.Service{ + Icon: "data:XXXXX", + } + icon = testRoutine.getIconData(service) + if icon != "" { + t.Fail() + } + + service = config.Service{ + Icon: "https://xxxxxxxx.xxxxxx", + } + icon = testRoutine.getIconData(service) + if icon != "" { + t.Fail() + } + service = config.Service{ + URL: "https://xxxxxxxx.xxxxxx", + } + icon = testRoutine.getIconData(service) + if icon != "" { + t.Fail() + } +} diff --git a/internal/routine/status.go b/internal/routine/status.go new file mode 100644 index 0000000..e144dfd --- /dev/null +++ b/internal/routine/status.go @@ -0,0 +1,105 @@ +/* +MIT License + +Copyright (c) 2022 r7wx + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package routine + +import ( + "github.com/r7wx/easy-gate/internal/config" + "github.com/r7wx/easy-gate/internal/models" +) + +// Status - Status struct +type Status struct { + Theme models.Theme + Addr string + Title string + CertFile string + KeyFile string + Groups []models.Group + Services []models.Service + Notes []models.Note + BehindProxy bool + UseTLS bool +} + +type serviceWrapper struct { + service models.Service + index int +} + +func (r *Routine) toStatus(cfg *config.Config) *Status { + return &Status{ + Theme: cfg.Theme, + Addr: cfg.Addr, + Title: cfg.Title, + CertFile: cfg.CertFile, + KeyFile: cfg.KeyFile, + Groups: cfg.Groups, + Services: r.getServices(cfg), + Notes: r.getNotes(cfg), + BehindProxy: cfg.BehindProxy, + UseTLS: cfg.UseTLS, + } +} + +func (r *Routine) getServices(cfg *config.Config) []models.Service { + servicePChan := make(chan serviceWrapper) + for index, cfgService := range cfg.Services { + go func(index int, cfgService config.Service) { + servicePChan <- serviceWrapper{ + service: models.Service{ + Icon: r.getIconData(cfgService), + Name: cfgService.Name, + URL: cfgService.URL, + Groups: cfgService.Groups, + }, + index: index, + } + }(index, cfgService) + } + + processedServices := map[int]models.Service{} + for i := 1; i <= len(cfg.Services); i++ { + processedService := <-servicePChan + processedServices[processedService.index] = processedService.service + } + + services := []models.Service{} + for index := range cfg.Services { + services = append(services, + processedServices[index]) + } + + return services +} + +func (r *Routine) getNotes(cfg *config.Config) []models.Note { + notes := []models.Note{} + for _, cfgNote := range cfg.Notes { + notes = append(notes, models.Note{ + Name: cfgNote.Name, + Text: cfgNote.Text, + Groups: cfgNote.Groups, + }) + } + return notes +} diff --git a/internal/service/data.go b/internal/service/data.go new file mode 100644 index 0000000..f9beb8b --- /dev/null +++ b/internal/service/data.go @@ -0,0 +1,82 @@ +/* +MIT License + +Copyright (c) 2022 r7wx + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package service + +import ( + "encoding/json" + "log" + "net" + "net/http" + + "github.com/r7wx/easy-gate/internal/models" +) + +type response struct { + Error string `json:"error"` + Theme models.Theme `json:"theme"` + Title string `json:"title"` + Services []models.Service `json:"services"` + Notes []models.Note `json:"notes"` +} + +func (s Service) data(w http.ResponseWriter, req *http.Request) { + status, cfgError := s.Routine.GetStatus() + + reqIP, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + log.Println("Service error:", err) + http.Error(w, "", http.StatusInternalServerError) + return + } + + if status.BehindProxy { + reqIP = req.Header.Get("X-Forwarded-For") + if reqIP == "" { + log.Println("400 Bad Request: X-Forwarded-For header is missing") + http.Error(w, "", http.StatusBadRequest) + return + } + } + log.Printf("[%s] %s", reqIP, req.URL.Path) + + response := response{ + Title: status.Title, + Services: s.getServices(status, reqIP), + Notes: s.getNotes(status, reqIP), + Theme: models.Theme(status.Theme), + Error: "", + } + if cfgError != nil { + response.Error = cfgError.Error() + } + + res, err := json.Marshal(response) + if err != nil { + log.Println("Service error:", err) + http.Error(w, "", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(res) +} diff --git a/internal/service/notes.go b/internal/service/notes.go index 74e8ce6..a1f38a5 100644 --- a/internal/service/notes.go +++ b/internal/service/notes.go @@ -22,15 +22,18 @@ SOFTWARE. package service -import "github.com/r7wx/easy-gate/internal/config" +import ( + "github.com/r7wx/easy-gate/internal/models" + "github.com/r7wx/easy-gate/internal/routine" +) -func (s *Service) getNotes(cfg *config.Config, addr string) []note { - notes := []note{} - for _, cfgNote := range cfg.Notes { - if isAllowed(cfg.Groups, cfgNote.Groups, addr) { - note := note{ - Name: cfgNote.Name, - Text: cfgNote.Text, +func (s *Service) getNotes(status *routine.Status, addr string) []models.Note { + notes := []models.Note{} + for _, statusNote := range status.Notes { + if isAllowed(status.Groups, statusNote.Groups, addr) { + note := models.Note{ + Name: statusNote.Name, + Text: statusNote.Text, } notes = append(notes, note) } diff --git a/internal/service/service.go b/internal/service/service.go index 692c961..a74186f 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -23,81 +23,38 @@ SOFTWARE. package service import ( - "encoding/json" "log" - "net" "net/http" - "github.com/r7wx/easy-gate/internal/config" - "github.com/r7wx/easy-gate/web" + "github.com/r7wx/easy-gate/internal/routine" ) // Service - Easy Gate service struct type Service struct { - ConfigRoutine *config.Routine + Routine *routine.Routine } // NewService - Create a new service -func NewService(cfgRoutine *config.Routine) *Service { - return &Service{ - ConfigRoutine: cfgRoutine, - } +func NewService(routine *routine.Routine) *Service { + return &Service{routine} } // Serve - Serve application func (s Service) Serve() { - cfg, _ := s.ConfigRoutine.GetConfiguration() + status, _ := s.Routine.GetStatus() http.HandleFunc("/api/data", s.data) - http.Handle("/", http.FileServer(web.GetWebFS())) + http.HandleFunc("/", s.webFS) - if cfg.UseTLS { - log.Println("[Easy Gate] Listening for connections on", cfg.Addr, "(HTTPS)") - if err := http.ListenAndServeTLS(cfg.Addr, cfg.CertFile, - cfg.KeyFile, nil); err != nil { + if status.UseTLS { + log.Println("Listening for connections on", status.Addr, "(HTTPS)") + if err := http.ListenAndServeTLS(status.Addr, status.CertFile, + status.KeyFile, nil); err != nil { log.Fatal(err) } } - log.Println("[Easy Gate] Listening for connections on", cfg.Addr) - if err := http.ListenAndServe(cfg.Addr, nil); err != nil { + log.Println("Listening for connections on", status.Addr) + if err := http.ListenAndServe(status.Addr, nil); err != nil { log.Fatal(err) } } - -func (s Service) data(w http.ResponseWriter, req *http.Request) { - cfg, cfgError := s.ConfigRoutine.GetConfiguration() - - reqIP, _, err := net.SplitHostPort(req.RemoteAddr) - if cfg.BehindProxy { - reqIP = req.Header.Get("X-Forwarded-For") - if reqIP == "" { - log.Println("[Easy Gate] 400 Bad Request: X-Forwarded-For header is missing") - http.Error(w, "", http.StatusBadRequest) - return - } - } - log.Println("[Easy Gate] Request from", reqIP) - - response := response{ - Title: cfg.Title, - Icon: cfg.Icon, - Motd: cfg.Motd, - Services: s.getServices(cfg, reqIP), - Notes: s.getNotes(cfg, reqIP), - Theme: theme(cfg.Theme), - Error: "", - } - if cfgError != nil { - response.Error = cfgError.Error() - } - - res, err := json.Marshal(response) - if err != nil { - log.Println("[Easy Gate] Service error:", err) - http.Error(w, "", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(res) -} diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 29793d0..be6cb91 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -26,6 +26,7 @@ import ( "encoding/json" "fmt" "io/ioutil" + "log" "net/http" "net/http/httptest" "os" @@ -33,27 +34,25 @@ import ( "time" "github.com/r7wx/easy-gate/internal/config" + "github.com/r7wx/easy-gate/internal/models" + "github.com/r7wx/easy-gate/internal/routine" ) -const ( - testConfigFilePath = "./test-config.json" -) +var testConfigFilePath string func TestMain(m *testing.M) { testCfg := config.Config{ - Addr: ":8080", + Addr: "127.0.0.1:8080", UseTLS: false, CertFile: "", KeyFile: "", BehindProxy: false, Title: "Test", - Icon: "fa-solid fa-cubes", - Motd: "", - Theme: config.Theme{ + Theme: models.Theme{ Background: "#ffffff", Foreground: "#000000", }, - Groups: []config.Group{ + Groups: []models.Group{ { Name: "test", Subnet: "192.168.1.1/24", @@ -101,82 +100,95 @@ func TestMain(m *testing.M) { cfgJSON, err := json.Marshal(testCfg) if err != nil { - panic(err) + log.Fatal(err) + } + cfgFile, err := ioutil.TempFile(".", "easy_gate_test_") + if err != nil { + log.Fatal("Unable to write tmp files for test") } - err = ioutil.WriteFile(testConfigFilePath, - cfgJSON, 0644) if err != nil { - panic(err) + log.Fatal(err) } + testConfigFilePath = cfgFile.Name() + cfgFile.Write(cfgJSON) + exitCode := m.Run() + os.Remove(testConfigFilePath) os.Exit(exitCode) } func TestService(t *testing.T) { - routine := config.NewRoutine(testConfigFilePath, - 1*time.Second) + routine, err := routine.NewRoutine(testConfigFilePath, 1*time.Second) + if err != nil { + t.Fatal(err) + } go routine.Start() - service := NewService(routine) req := httptest.NewRequest(http.MethodGet, "/api/data", nil) w := httptest.NewRecorder() service.data(w, req) - res := w.Result() defer res.Body.Close() - if res.StatusCode != http.StatusOK { - t.Fatal("Expected status code 200, got", - res.StatusCode) + t.Fatal() + } + service.webFS(w, req) + res = w.Result() + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatal() } routine.Lock() - routine.Config.BehindProxy = true + routine.Status.BehindProxy = true routine.Unlock() req = httptest.NewRequest(http.MethodGet, "/api/data", nil) w = httptest.NewRecorder() service.data(w, req) - res = w.Result() defer res.Body.Close() - if res.StatusCode != http.StatusBadRequest { - t.Fatalf("Expected status code 400, got %d", - res.StatusCode) + t.Fatal() + } + service.webFS(w, req) + res = w.Result() + defer res.Body.Close() + if res.StatusCode != http.StatusBadRequest { + t.Fatal() } req = httptest.NewRequest(http.MethodGet, "/api/data", nil) req.Header.Set("X-Forwarded-For", "127.0.0.1") w = httptest.NewRecorder() service.data(w, req) - res = w.Result() defer res.Body.Close() - if res.StatusCode != http.StatusOK { - t.Fatalf("Expected status code 200, got %d", - res.StatusCode) + t.Fatal() + } + service.webFS(w, req) + res = w.Result() + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatal() } routine.Lock() - routine.Config.BehindProxy = false + routine.Status.BehindProxy = false routine.Error = fmt.Errorf("Test error") routine.Unlock() req = httptest.NewRequest(http.MethodGet, "/api/data", nil) w = httptest.NewRecorder() service.data(w, req) - res = w.Result() defer res.Body.Close() - if res.StatusCode != http.StatusOK { - t.Fatalf("Expected status code 200, got %d", - res.StatusCode) + t.Fatal() } routine.Lock() @@ -185,12 +197,15 @@ func TestService(t *testing.T) { } func TestGetServices(t *testing.T) { - routine := config.NewRoutine(testConfigFilePath, + routine, err := routine.NewRoutine(testConfigFilePath, 8*time.Millisecond) + if err != nil { + t.Fatal(err) + } go routine.Start() service := NewService(routine) - cfg, _ := service.ConfigRoutine.GetConfiguration() + cfg, _ := service.Routine.GetStatus() services := service.getServices(cfg, "192.168.1.1") for _, s := range services { @@ -215,12 +230,15 @@ func TestGetServices(t *testing.T) { } func TestGetNotes(t *testing.T) { - routine := config.NewRoutine(testConfigFilePath, + routine, err := routine.NewRoutine(testConfigFilePath, 8*time.Millisecond) + if err != nil { + t.Fatal(err) + } go routine.Start() service := NewService(routine) - cfg, _ := service.ConfigRoutine.GetConfiguration() + cfg, _ := service.Routine.GetStatus() notes := service.getNotes(cfg, "192.168.1.1") for _, n := range notes { @@ -245,21 +263,21 @@ func TestGetNotes(t *testing.T) { } func TestIsAllowed(t *testing.T) { - if !isAllowed([]config.Group{{ + if !isAllowed([]models.Group{{ Name: "test", Subnet: "127.0.0.1/32", }}, []string{"test"}, "127.0.0.1") { t.Fail() } - if isAllowed([]config.Group{{ + if isAllowed([]models.Group{{ Name: "test", Subnet: "127.0.0.1/32", }}, []string{"test"}, "xxxxxx") { t.Fail() } - if isAllowed([]config.Group{{ + if isAllowed([]models.Group{{ Name: "test", Subnet: "xxxxxxxxxxx", }}, []string{"test"}, "127.0.0.1") { diff --git a/internal/service/services.go b/internal/service/services.go index 3282d44..02cc6bb 100644 --- a/internal/service/services.go +++ b/internal/service/services.go @@ -23,17 +23,18 @@ SOFTWARE. package service import ( - "github.com/r7wx/easy-gate/internal/config" + "github.com/r7wx/easy-gate/internal/models" + "github.com/r7wx/easy-gate/internal/routine" ) -func (s *Service) getServices(cfg *config.Config, addr string) []service { - services := []service{} - for _, cfgService := range cfg.Services { - if isAllowed(cfg.Groups, cfgService.Groups, addr) { - service := service{ - Icon: cfgService.Icon, - Name: cfgService.Name, - URL: cfgService.URL, +func (s *Service) getServices(status *routine.Status, addr string) []models.Service { + services := []models.Service{} + for _, statusService := range status.Services { + if isAllowed(status.Groups, statusService.Groups, addr) { + service := models.Service{ + Icon: statusService.Icon, + Name: statusService.Name, + URL: statusService.URL, } services = append(services, service) } diff --git a/internal/service/utils.go b/internal/service/utils.go index 66d1027..9fb8e64 100644 --- a/internal/service/utils.go +++ b/internal/service/utils.go @@ -25,10 +25,10 @@ package service import ( "net" - "github.com/r7wx/easy-gate/internal/config" + "github.com/r7wx/easy-gate/internal/models" ) -func isAllowed(groups []config.Group, allowedGroups []string, addr string) bool { +func isAllowed(groups []models.Group, allowedGroups []string, addr string) bool { if len(allowedGroups) == 0 { return true } diff --git a/internal/service/web.go b/internal/service/web.go new file mode 100644 index 0000000..e1dbbb8 --- /dev/null +++ b/internal/service/web.go @@ -0,0 +1,59 @@ +/* +MIT License + +Copyright (c) 2022 r7wx + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +package service + +import ( + "log" + "net" + "net/http" + "strings" + + "github.com/r7wx/easy-gate/web" +) + +func (s Service) webFS(w http.ResponseWriter, req *http.Request) { + webFS := web.GetWebFS() + if _, err := webFS.Open(strings.TrimLeft(req.URL.Path, "/")); err != nil { + req.URL.Path = "/" + } + + reqIP, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + log.Println("WebFS error:", err) + http.Error(w, "", http.StatusInternalServerError) + return + } + + status, _ := s.Routine.GetStatus() + if status.BehindProxy { + reqIP = req.Header.Get("X-Forwarded-For") + if reqIP == "" { + log.Println("400 Bad Request: X-Forwarded-For header is missing") + http.Error(w, "", http.StatusBadRequest) + return + } + } + + log.Printf("[%s] %s", reqIP, req.URL.Path) + http.FileServer(webFS).ServeHTTP(w, req) +} diff --git a/internal/share/env.go b/internal/share/env.go index 04b8c46..e4b61ce 100644 --- a/internal/share/env.go +++ b/internal/share/env.go @@ -1,3 +1,25 @@ +/* +MIT License + +Copyright (c) 2022 r7wx + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + package share // Easy Gate environment variable names diff --git a/web/.env b/web/.env new file mode 100644 index 0000000..4f79a0f --- /dev/null +++ b/web/.env @@ -0,0 +1 @@ +GENERATE_SOURCEMAP=false \ No newline at end of file diff --git a/web/package.json b/web/package.json index b70fc64..7c4cf87 100644 --- a/web/package.json +++ b/web/package.json @@ -3,13 +3,10 @@ "version": "0.1.0", "private": true, "dependencies": { - "@fontsource/roboto": "^4.5.5", - "@fortawesome/fontawesome-free": "^6.1.1", - "@fortawesome/fontawesome-svg-core": "^6.1.1", - "@fortawesome/free-brands-svg-icons": "^6.1.1", - "@fortawesome/free-regular-svg-icons": "^6.1.1", - "@fortawesome/free-solid-svg-icons": "^6.1.1", - "@fortawesome/react-fontawesome": "^0.1.18", + "@fontsource/roboto": "^4.5.7", + "@fortawesome/fontawesome-svg-core": "^6.1.2", + "@fortawesome/free-solid-svg-icons": "^6.1.2", + "@fortawesome/react-fontawesome": "^0.2.0", "autoprefixer": "^10.4.4", "axios": "^0.26.1", "postcss": "^8.4.12", diff --git a/web/public/favicon.ico b/web/public/favicon.ico index 4c874cb..b0e7c94 100644 Binary files a/web/public/favicon.ico and b/web/public/favicon.ico differ diff --git a/web/src/App.js b/web/src/App.js index dcd20da..4344fd0 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -22,11 +22,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { library } from "@fortawesome/fontawesome-svg-core"; -import { far } from "@fortawesome/free-regular-svg-icons"; -import { fab } from "@fortawesome/free-brands-svg-icons"; -import { fas } from "@fortawesome/free-solid-svg-icons"; import React, { useEffect, useState } from "react"; import Loading from "./components/Loading"; import Service from "./components/Service"; @@ -35,19 +30,16 @@ import { Helmet } from "react-helmet"; import Note from "./components/Note"; import axios from "axios"; -library.add(fas, far, fab); - function App() { const [loading, setLoading] = useState(true); const [data, setData] = useState({ - title: "", - icon: "", - motd: "", + title: "Easy Gate", + error: "", services: [], notes: [], theme: { - background: "#FFFFFF", - foreground: "#000000", + background: "#ffffff", + foreground: "#1d1d1d", }, }); @@ -79,18 +71,18 @@ function App() { )} -{data.motd}
{data.error.length > 0 &&