diff --git a/config/error_page/CAPTCHA.html b/config/error_page/CAPTCHA.html new file mode 100644 index 0000000..1210f28 --- /dev/null +++ b/config/error_page/CAPTCHA.html @@ -0,0 +1,83 @@ + + + + + + CAPTCHA - ⛩️Server Torii + + + + + +
+
Checking that you are not a robot
+
+ +
+ + + diff --git a/config/rules/CAPTCHA.yml b/config/rules/CAPTCHA.yml new file mode 100644 index 0000000..df022a7 --- /dev/null +++ b/config/rules/CAPTCHA.yml @@ -0,0 +1,3 @@ +secret_key: "0378b0f84c4310279918d71a5647ba5d" +captcha_validate_time: 60 +hcaptcha_secret: "" \ No newline at end of file diff --git a/config/torii.yml b/config/torii.yml index 3403b1e..1e25075 100644 --- a/config/torii.yml +++ b/config/torii.yml @@ -1,8 +1,13 @@ port: "25555" +web_path: "/torii" rule_path: "/www/dev/server_torii/config/rules" error_page: "/www/dev/server_torii/config/error_page" node_name: "Server Torii" +connecting_host_headers: + - "Torii-Real-Host" connecting_ip_headers: - - "X-Real-IP" + - "Torii-Real-IP" connecting_uri_headers: - - "X-Original-URI" \ No newline at end of file + - "Torii-Original-URI" +connecting_captcha_status_headers: + - "Torii-Captcha-Status" \ No newline at end of file diff --git a/internal/action/action.go b/internal/action/action.go index 58c7fad..8b20df2 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -10,25 +10,32 @@ const ( // Decision saves the result of the decision type Decision struct { - HTTPCode string - State checkState - JumpIndex int + HTTPCode []byte + State checkState + ResponseData []byte + JumpIndex int } func NewDecision() *Decision { - return &Decision{HTTPCode: "200", State: Continue, JumpIndex: -1} + return &Decision{HTTPCode: []byte("200"), State: Continue, ResponseData: nil, JumpIndex: -1} } func (d *Decision) Set(state checkState) { d.State = state } -func (d *Decision) SetCode(state checkState, httpCode string) { +func (d *Decision) SetCode(state checkState, httpCode []byte) { d.State = state d.HTTPCode = httpCode } -func (d *Decision) SetJump(state checkState, httpCode string, jumpIndex int) { +func (d *Decision) SetResponse(state checkState, httpCode []byte, responseData []byte) { + d.State = state + d.HTTPCode = httpCode + d.ResponseData = responseData +} + +func (d *Decision) SetJump(state checkState, httpCode []byte, jumpIndex int) { d.State = state d.HTTPCode = httpCode d.JumpIndex = jumpIndex diff --git a/internal/check/Captcha.go b/internal/check/Captcha.go new file mode 100644 index 0000000..aed1835 --- /dev/null +++ b/internal/check/Captcha.go @@ -0,0 +1,121 @@ +package check + +import ( + "crypto/hmac" + "crypto/sha512" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "server_torii/internal/action" + "server_torii/internal/config" + "server_torii/internal/dataType" + "strconv" + "strings" + "time" +) + +type HCaptchaResponse struct { + Success bool `json:"success"` + ChallengeTS string `json:"challenge_ts"` + Hostname string `json:"hostname"` + ErrorCodes []string `json:"error-codes"` +} + +func Captcha(reqData dataType.UserRequest, ruleSet *config.RuleSet, decision *action.Decision) { + if !reqData.Captcha { + decision.Set(action.Continue) + return + } + + if !verifyClearanceCookie(reqData, *ruleSet) { + decision.SetCode(action.Done, []byte("CAPTCHA")) + return + } + + decision.Set(action.Continue) + +} + +func CheckCaptcha(r *http.Request, reqData dataType.UserRequest, ruleSet *config.RuleSet, decision *action.Decision) { + if r.Method != "POST" { + decision.SetResponse(action.Done, []byte("403"), nil) + return + } + + hCaptchaResponse := r.FormValue("h-captcha-response") + if hCaptchaResponse == "" { + decision.SetResponse(action.Done, []byte("200"), []byte("bad")) + return + } + + data := url.Values{} + data.Set("secret", ruleSet.CAPTCHARule.HCaptchaSecret) + data.Set("response", hCaptchaResponse) + data.Set("remoteip", reqData.RemoteIP) + + resp, err := http.PostForm("https://api.hcaptcha.com/siteverify", data) + if err != nil { + decision.SetResponse(action.Done, []byte("500"), []byte("bad")) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + decision.SetResponse(action.Done, []byte("500"), []byte("bad")) + return + } + + var hCaptchaResp HCaptchaResponse + err = json.Unmarshal(body, &hCaptchaResp) + if err != nil { + decision.SetResponse(action.Done, []byte("500"), []byte("bad")) + return + } + + if !hCaptchaResp.Success { + decision.SetResponse(action.Done, []byte("200"), []byte("bad4")) + return + } + + decision.SetResponse(action.Done, []byte("200"), []byte("good")) + return + +} + +func GenClearance(reqData dataType.UserRequest, ruleSet config.RuleSet) []byte { + timeNow := time.Now().Unix() + mac := hmac.New(sha512.New, []byte(ruleSet.CAPTCHARule.SecretKey)) + mac.Write([]byte(fmt.Sprintf("%d%s%s", timeNow, reqData.Host, reqData.UserAgent))) + return []byte(fmt.Sprintf("%s:%s", fmt.Sprintf("%d", time.Now().Unix()), fmt.Sprintf("%x", mac.Sum(nil)))) +} + +func verifyClearanceCookie(reqData dataType.UserRequest, ruleSet config.RuleSet) bool { + if reqData.ToriiClearance == "" { + return false + } + parts := strings.Split(reqData.ToriiClearance, ":") + if len(parts) != 2 { + return false + } + timestamp := parts[0] + expectedHash := parts[1] + + timeNow := time.Now().Unix() + parsedTimestamp, err := strconv.ParseInt(timestamp, 10, 64) + if err != nil { + return false + } + + if timeNow-parsedTimestamp > ruleSet.CAPTCHARule.CaptchaValidateTime { + return false + } + + mac := hmac.New(sha512.New, []byte(ruleSet.CAPTCHARule.SecretKey)) + mac.Write([]byte(fmt.Sprintf("%d%s%s", parsedTimestamp, reqData.Host, reqData.UserAgent))) + computedHash := fmt.Sprintf("%x", mac.Sum(nil)) + + return hmac.Equal([]byte(computedHash), []byte(expectedHash)) + +} diff --git a/internal/check/IPAllow.go b/internal/check/IPAllow.go index 262c915..bc90a2b 100644 --- a/internal/check/IPAllow.go +++ b/internal/check/IPAllow.go @@ -16,7 +16,7 @@ func IPAllowList(reqData dataType.UserRequest, ruleSet *config.RuleSet, decision return } if trie.Search(ip) { - decision.SetCode(action.Done, "200") + decision.SetCode(action.Done, []byte("200")) } else { decision.Set(action.Continue) } diff --git a/internal/check/IPBlock.go b/internal/check/IPBlock.go index 12911b3..291bafc 100644 --- a/internal/check/IPBlock.go +++ b/internal/check/IPBlock.go @@ -15,7 +15,7 @@ func IPBlockList(reqData dataType.UserRequest, ruleSet *config.RuleSet, decision return } if trie.Search(ip) { - decision.SetCode(action.Done, "403") + decision.SetCode(action.Done, []byte("403")) } else { decision.Set(action.Continue) } diff --git a/internal/check/URLAllow.go b/internal/check/URLAllow.go index 6be1f5d..e80cf0c 100644 --- a/internal/check/URLAllow.go +++ b/internal/check/URLAllow.go @@ -10,7 +10,7 @@ func URLAllowList(reqData dataType.UserRequest, ruleSet *config.RuleSet, decisio url := reqData.Uri list := ruleSet.URLAllowList if list.Match(url) { - decision.SetCode(action.Done, "200") + decision.SetCode(action.Done, []byte("200")) } else { decision.Set(action.Continue) } diff --git a/internal/check/URLBlock.go b/internal/check/URLBlock.go index 2f4b9ce..55f6be3 100644 --- a/internal/check/URLBlock.go +++ b/internal/check/URLBlock.go @@ -10,7 +10,7 @@ func URLBlockList(reqData dataType.UserRequest, ruleSet *config.RuleSet, decisio url := reqData.Uri list := ruleSet.URLBlockList if list.Match(url) { - decision.SetCode(action.Done, "403") + decision.SetCode(action.Done, []byte("403")) } else { decision.Set(action.Continue) } diff --git a/internal/config/config.go b/internal/config/config.go index 27ea85c..343a567 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,12 +12,15 @@ import ( ) type MainConfig struct { - Port string `yaml:"port"` - RulePath string `yaml:"rule_path"` - ErrorPage string `yaml:"error_page"` - NodeName string `yaml:"node_name"` - ConnectingIPHeaders []string `yaml:"connecting_ip_headers"` - ConnectingURIHeaders []string `yaml:"connecting_uri_headers"` + Port string `yaml:"port"` + WebPath string `yaml:"web_path"` + RulePath string `yaml:"rule_path"` + ErrorPage string `yaml:"error_page"` + NodeName string `yaml:"node_name"` + ConnectingHostHeaders []string `yaml:"connecting_host_headers"` + ConnectingIPHeaders []string `yaml:"connecting_ip_headers"` + ConnectingURIHeaders []string `yaml:"connecting_uri_headers"` + ConnectingCaptchaStatusHeaders []string `yaml:"connecting_captcha_status_headers"` } // LoadMainConfig Read the configuration file and return the configuration object @@ -50,6 +53,7 @@ type RuleSet struct { IPBlockTrie *dataType.TrieNode URLAllowList *dataType.URLRuleList URLBlockList *dataType.URLRuleList + CAPTCHARule *dataType.CaptchaRule } // LoadRules Load all rules from the specified path @@ -59,6 +63,7 @@ func LoadRules(rulePath string) (*RuleSet, error) { IPBlockTrie: &dataType.TrieNode{}, URLAllowList: &dataType.URLRuleList{}, URLBlockList: &dataType.URLRuleList{}, + CAPTCHARule: &dataType.CaptchaRule{}, } // Load IP Allow List @@ -85,9 +90,29 @@ func LoadRules(rulePath string) (*RuleSet, error) { return nil, err } + // Load CAPTCHA Rule + captchaFile := rulePath + "/CAPTCHA.yml" + if err := loadCAPTCHARule(captchaFile, rs.CAPTCHARule); err != nil { + return nil, err + } + return &rs, nil } +func loadCAPTCHARule(file string, rule *dataType.CaptchaRule) error { + data, err := os.ReadFile(file) + if err != nil { + return err + } + + if err := yaml.Unmarshal(data, &rule); err != nil { + return err + } + + return nil + +} + // loadIPRules read the IP rule file and insert the rules into the trie func loadIPRules(filePath string, trie *dataType.TrieNode) error { file, err := os.Open(filePath) diff --git a/internal/dataType/type.go b/internal/dataType/type.go index af30942..db83d66 100644 --- a/internal/dataType/type.go +++ b/internal/dataType/type.go @@ -1,6 +1,17 @@ package dataType type UserRequest struct { - RemoteIP string - Uri string + RemoteIP string + Uri string + Captcha bool + ToriiClearance string + ToriiSessionID string + UserAgent string + Host string +} + +type CaptchaRule struct { + SecretKey string `yaml:"secret_key"` + CaptchaValidateTime int64 `yaml:"captcha_validate_time"` + HCaptchaSecret string `yaml:"hcaptcha_secret"` } diff --git a/internal/server/checker.go b/internal/server/checker.go new file mode 100644 index 0000000..632cd9f --- /dev/null +++ b/internal/server/checker.go @@ -0,0 +1,77 @@ +package server + +import ( + "bytes" + "html/template" + "net/http" + "server_torii/internal/action" + "server_torii/internal/check" + "server_torii/internal/config" + "server_torii/internal/dataType" + "time" +) + +type CheckFunc func(dataType.UserRequest, *config.RuleSet, *action.Decision) + +func CheckMain(w http.ResponseWriter, userRequestData dataType.UserRequest, ruleSet *config.RuleSet, cfg *config.MainConfig) { + decision := action.NewDecision() + + checkFuncs := make([]CheckFunc, 0) + checkFuncs = append(checkFuncs, check.IPAllowList) + checkFuncs = append(checkFuncs, check.IPBlockList) + checkFuncs = append(checkFuncs, check.URLAllowList) + checkFuncs = append(checkFuncs, check.URLBlockList) + checkFuncs = append(checkFuncs, check.Captcha) + + for _, checkFunc := range checkFuncs { + checkFunc(userRequestData, ruleSet, decision) + if decision.State == action.Done { + break + } + } + + if bytes.Compare(decision.HTTPCode, []byte("200")) == 0 { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + } else if bytes.Compare(decision.HTTPCode, []byte("403")) == 0 { + tpl, err := template.ParseFiles(cfg.ErrorPage + "/403.html") + if err != nil { + http.Error(w, "500 - Internal Server Error", http.StatusInternalServerError) + return + } + + data := struct { + EdgeTag string + ConnectIP string + Date string + }{ + EdgeTag: cfg.NodeName, + ConnectIP: userRequestData.RemoteIP, + Date: time.Now().Format("2006-01-02 15:04:05"), + } + w.WriteHeader(http.StatusForbidden) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err = tpl.Execute(w, data); err != nil { + http.Error(w, "500 - Internal Server Error", http.StatusInternalServerError) + return + } + + } else if bytes.Compare(decision.HTTPCode, []byte("CAPTCHA")) == 0 { + tpl, err := template.ParseFiles(cfg.ErrorPage + "/CAPTCHA.html") + if err != nil { + http.Error(w, "500 - Internal Server Error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err = tpl.Execute(w, nil); err != nil { + http.Error(w, "500 - Internal Server Error", http.StatusInternalServerError) + return + } + + } else { + //should never happen + http.Error(w, "500 - Internal Server Error", http.StatusInternalServerError) + return + } +} diff --git a/internal/server/server.go b/internal/server/server.go index a5a6b3e..2f1188a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,71 +1,26 @@ package server import ( - "html/template" "log" "net" "net/http" - "server_torii/internal/action" - "server_torii/internal/check" "server_torii/internal/config" "server_torii/internal/dataType" "strings" - "time" ) -type CheckFunc func(dataType.UserRequest, *config.RuleSet, *action.Decision) - // StartServer starts the HTTP server func StartServer(cfg *config.MainConfig, ruleSet *config.RuleSet) error { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { userRequestData := processRequestData(cfg, r) - decision := action.NewDecision() - - checkFuncs := make([]CheckFunc, 0) - checkFuncs = append(checkFuncs, check.IPAllowList) - checkFuncs = append(checkFuncs, check.IPBlockList) - checkFuncs = append(checkFuncs, check.URLAllowList) - checkFuncs = append(checkFuncs, check.URLBlockList) - - for _, checkFunc := range checkFuncs { - checkFunc(userRequestData, ruleSet, decision) - if decision.State == action.Done { - break - } - } - - if decision.HTTPCode == "200" { - w.WriteHeader(http.StatusOK) - w.Write([]byte("OK")) - } else if decision.HTTPCode == "403" { - tpl, err := template.ParseFiles(cfg.ErrorPage + "/" + decision.HTTPCode + ".html") - if err != nil { - http.Error(w, "500 - Internal Server Error", http.StatusInternalServerError) - return - } - - data := struct { - EdgeTag string - ConnectIP string - Date string - }{ - EdgeTag: cfg.NodeName, - ConnectIP: userRequestData.RemoteIP, - Date: time.Now().Format("2006-01-02 15:04:05"), - } - w.WriteHeader(http.StatusForbidden) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err = tpl.Execute(w, data); err != nil { - http.Error(w, "500 - Internal Server Error", http.StatusInternalServerError) - return - } - + if strings.HasPrefix(userRequestData.Uri, cfg.WebPath) { + CheckTorii(w, r, userRequestData, ruleSet, cfg) } else { - // should not reach here - w.WriteHeader(http.StatusInternalServerError) + CheckMain(w, userRequestData, ruleSet, cfg) } + }) log.Printf("HTTP Server listening on :%s ...", cfg.Port) @@ -74,6 +29,55 @@ func StartServer(cfg *config.MainConfig, ruleSet *config.RuleSet) error { func processRequestData(cfg *config.MainConfig, r *http.Request) dataType.UserRequest { + userRequest := dataType.UserRequest{ + RemoteIP: getClientIP(cfg, r), + Uri: getReqURI(cfg, r), + Captcha: getCaptchaStatus(cfg, r), + ToriiClearance: getHeader(r, "__torii_clearance"), + ToriiSessionID: getHeader(r, "__torii_session_id"), + UserAgent: r.UserAgent(), + Host: getReqHost(cfg, r), + } + return userRequest +} + +func getHeader(r *http.Request, headerName string) string { + cookie, err := r.Cookie(headerName) + if err != nil { + return "" + } + return cookie.Value +} + +func getCaptchaStatus(cfg *config.MainConfig, r *http.Request) bool { + captchaStatus := false + for _, headerName := range cfg.ConnectingCaptchaStatusHeaders { + if captchaVal := r.Header.Get(headerName); captchaVal != "" { + if captchaVal == "on" { + captchaStatus = true + } + break + } + } + return captchaStatus + +} + +func getReqURI(cfg *config.MainConfig, r *http.Request) string { + var clientURI string + for _, headerName := range cfg.ConnectingURIHeaders { + if uriVal := r.Header.Get(headerName); uriVal != "" { + clientURI = uriVal + break + } + } + if clientURI == "" { + clientURI = r.RequestURI + } + return clientURI +} + +func getClientIP(cfg *config.MainConfig, r *http.Request) string { var clientIP string for _, headerName := range cfg.ConnectingIPHeaders { if ipVal := r.Header.Get(headerName); ipVal != "" { @@ -96,21 +100,16 @@ func processRequestData(cfg *config.MainConfig, r *http.Request) dataType.UserRe clientIP = ipStr } } + return clientIP +} - var clientURI string - for _, headerName := range cfg.ConnectingURIHeaders { - if uriVal := r.Header.Get(headerName); uriVal != "" { - clientURI = uriVal +func getReqHost(cfg *config.MainConfig, r *http.Request) string { + var clientHost = "" + for _, headerName := range cfg.ConnectingHostHeaders { + if hostVal := r.Header.Get(headerName); hostVal != "" { + clientHost = hostVal break } } - if clientURI == "" { - clientURI = r.RequestURI - } - - userRequest := dataType.UserRequest{ - RemoteIP: clientIP, - Uri: clientURI, - } - return userRequest + return clientHost } diff --git a/internal/server/torii.go b/internal/server/torii.go new file mode 100644 index 0000000..2d566e4 --- /dev/null +++ b/internal/server/torii.go @@ -0,0 +1,58 @@ +package server + +import ( + "bytes" + "html/template" + "net/http" + "server_torii/internal/action" + "server_torii/internal/check" + "server_torii/internal/config" + "server_torii/internal/dataType" + "time" +) + +func CheckTorii(w http.ResponseWriter, r *http.Request, reqData dataType.UserRequest, ruleSet *config.RuleSet, cfg *config.MainConfig) { + decision := action.NewDecision() + + decision.SetCode(action.Continue, []byte("403")) + if reqData.Uri == cfg.WebPath+"/captcha" { + check.CheckCaptcha(r, reqData, ruleSet, decision) + } + if bytes.Compare(decision.HTTPCode, []byte("200")) == 0 { + if bytes.Compare(decision.ResponseData, []byte("bad")) == 0 { + w.WriteHeader(http.StatusOK) + w.Write([]byte("bad")) + return + } else if bytes.Compare(decision.ResponseData, []byte("good")) == 0 { + w.Header().Set("Set-Cookie", "__torii_clearance="+string(check.GenClearance(reqData, *ruleSet))+"; Path=/; HttpOnly") + w.WriteHeader(http.StatusOK) + w.Write(decision.ResponseData) + } else { + //should not be here + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("500 - Internal Server Error")) + } + } else { + tpl, err := template.ParseFiles(cfg.ErrorPage + "/403.html") + if err != nil { + http.Error(w, "500 - Internal Server Error", http.StatusInternalServerError) + return + } + + data := struct { + EdgeTag string + ConnectIP string + Date string + }{ + EdgeTag: cfg.NodeName, + ConnectIP: reqData.RemoteIP, + Date: time.Now().Format("2006-01-02 15:04:05"), + } + w.WriteHeader(http.StatusForbidden) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err = tpl.Execute(w, data); err != nil { + http.Error(w, "500 - Internal Server Error", http.StatusInternalServerError) + return + } + } +}