commit 434916ed71bd655f06a2a8907f88111c3195b3fc Author: sati.ac Date: Sun Jul 2 19:00:59 2023 +0300 initial commit diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..be22187 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,45 @@ +name: release-tag + +on: + push: + tags: + - '*' + +env: + GOPATH: /go_path + GOCACHE: /go_cache + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: https://gitea.com/actions/checkout@v3 + with: + fetch-depth: 0 + - uses: https://gitea.com/actions/setup-go@v3 + with: + go-version: '>=1.20.1' + - uses: https://gitea.com/actions/go-hashfiles@v0.0.1 + id: hash-go + with: + patterns: | + go.mod + go.sum + - name: cache go + id: cache-go + uses: https://github.com/actions/cache@v3 + with: + path: | + /go_path + /go_cache + key: go_path-${{ steps.hash-go.outputs.hash }} + - name: windows build + run: mkdir -p bin && GOOS=windows go build -o bin/bridge-windows-amd64.exe + - name: linux build + run: mkdir -p bin && GOOS=linux go build -o bin/bridge-linux-amd64.exe + - name: publish release + uses: https://gitea.com/actions/release-action@main + with: + files: |- + bin/** + api_key: '${{secrets.RELEASE_TOKEN}}' \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bae06e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +data/** diff --git a/README.md b/README.md new file mode 100644 index 0000000..c87fe70 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +### sati bridge +прослойка, эмулирующая API других сервисов. для использования готового софта с [sati.ac](https://sati.ac) + +на данный момент реализованы: +- RuCaptcha +- AntiGateV2 \ No newline at end of file diff --git a/api/AntiGateV2.go b/api/AntiGateV2.go new file mode 100644 index 0000000..76f582b --- /dev/null +++ b/api/AntiGateV2.go @@ -0,0 +1,228 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "reflect" + + "git.sati.ac/sati.ac/sati-go" + "github.com/mitchellh/mapstructure" + "github.com/sirupsen/logrus" +) + +type antigateV2Api struct { + ctx *ApiContext + mux *http.ServeMux +} + +func (a *antigateV2Api) Name() string { + return "AntiGateV2" +} + +func (a *antigateV2Api) Domains() []string { + return []string{ + "api.anti-captcha.com", + "api.capsolver.com", + } +} + +func (a *antigateV2Api) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.mux.ServeHTTP(w, r) +} + +type antigateError struct { + ErrorId uint32 `json:"erorrId"` + ErrorCode string `json:"errorCode"` + ErrorDescription string `json:"errorDescription"` +} + +var ( + errorNoSuchMethod = &antigateError{ + ErrorId: 14, + ErrorCode: "ERROR_NO_SUCH_METHOD", + ErrorDescription: "Request made to API with a method that does not exist", + } + + errorCaptchaUnsolvable = &antigateError{ + ErrorId: 12, + ErrorCode: "ERROR_CAPTCHA_UNSOLVABLE", + ErrorDescription: "Captcha unsolvable", + } + + errorNoSuchCaptchaId = &antigateError{ + ErrorId: 16, + ErrorCode: "ERROR_NO_SUCH_CAPCHA_ID", + ErrorDescription: "The captcha you are requesting does not exist in your active captchas list or has expired", + } + + errorTaskAbsent = &antigateError{ + ErrorId: 22, + ErrorCode: "ERROR_TASK_ABSENT", + ErrorDescription: `"task" property is empty or not set in the createTask method`, + } + + errorTaskNotSupported = &antigateError{ + ErrorId: 23, + ErrorCode: "ERROR_TASK_NOT_SUPPORTED", + ErrorDescription: "Task type is not supported or typed incorrectly", + } + + errorInternal = &antigateError{ + ErrorId: 1000, + ErrorCode: "ERROR_INTERNAL", + ErrorDescription: "Internal error, check bridge logs", + } + + errorBadRequest = &antigateError{ + ErrorId: 1001, + ErrorCode: "ERROR_BAD_REQUEST", + ErrorDescription: "Bad request", + } +) + +func (a *antigateV2Api) getTaskResult(request struct { + TaskId uint32 `json:"taskId"` +}) any { + task := a.ctx.Registry.Get(request.TaskId) + if task == nil { + return errorNoSuchCaptchaId + } + + if task.State == StateError { + return errorCaptchaUnsolvable + } + + response := &struct { + ErrorId uint `json:"errorId"` + Status string `json:"status"` + Solution any `json:"solution"` + Cost string `json:"cost"` + Ip string `json:"ip"` + CreateTime int64 `json:"createTime"` + EndTime *int64 `json:"endTime,omitempty"` + SolveCount uint `json:"solveCount"` + }{ + CreateTime: task.CreateTime, + SolveCount: 1, + Ip: a.ctx.Config.AntiGateV2.Ip, + Cost: "0", + } + + if task.State == StateProcessing { + response.Status = "processing" + } else { + response.Status = "ready" + response.EndTime = &task.EndTime + response.Cost = task.Entity.Cost + switch task.Result.(type) { + case *sati.ReCaptcha2Result: + response.Solution = struct { + GRecaptchaResponse string `json:"gRecaptchaResponse"` + }{task.Result.(*sati.ReCaptcha2Result).Token} + case *sati.TurnstileResult: + response.Solution = struct { + Token string `json:"token"` + UserAgent string `json:"userAgent"` + }{ + task.Result.(*sati.TurnstileResult).Token, + a.ctx.Config.AntiGateV2.TurnstileUserAgent, + } + default: + return errorTaskNotSupported + } + } + + return response +} + +func (a *antigateV2Api) createTask(request struct { + Task map[string]any `json:"task"` +}) any { + taskType, ok := request.Task["type"].(string) + if !ok { + return errorTaskAbsent + } + + var id uint32 + + switch taskType { + case "TurnstileTask", "TurnstileTaskProxyless": + var task struct { + WebsiteURL string `json:"websiteURL"` + WebsiteKey string `json:"websiteKey"` + Action *string `json:"action"` + } + mapstructure.Decode(request.Task, &task) + id = a.ctx.Registry.CreateTask(&sati.TurnstileTask{ + PageUrl: task.WebsiteURL, + SiteKey: task.WebsiteKey, + Action: task.Action, + }) + case "RecaptchaV2Task", "RecaptchaV2TaskProxyless": + var task struct { + WebsiteURL string `json:"websiteURL"` + WebsiteKey string `json:"websiteKey"` + } + mapstructure.Decode(request.Task, &task) + id = a.ctx.Registry.CreateTask(&sati.ReCaptcha2Task{ + PageUrl: task.WebsiteURL, + SiteKey: task.WebsiteKey, + }) + default: + return errorTaskNotSupported + } + + return &struct { + ErrorId uint32 `json:"errorId"` + TaskId uint32 `json:"taskId"` + }{0, id} +} + +func (a *antigateV2Api) getBalance(struct{}) any { + balance, err := a.ctx.Api.GetBalance() + if err != nil { + a.ctx.Logger.WithFields(logrus.Fields{ + "handler": a.Name(), + "method": "getBalance", + }).Error(err.Error()) + return errorInternal + } + + return &struct { + ErrorId uint `json:"errorId"` + CaptchaCredits uint `json:"captchaCredits"` + Balance float64 `json:"balance"` + }{0, 0, balance.InexactFloat64()} +} + +func jsonHandler[T any](handler func(data T) any) func(http.ResponseWriter, *http.Request) { + emptyRequest := reflect.Zero(reflect.TypeOf(handler).In(0)).Interface().(T) + + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + + data, _ := io.ReadAll(r.Body) + request := emptyRequest + if err := json.Unmarshal(data, &request); err != nil { + marshaled, _ := json.Marshal(errorBadRequest) + w.Write(marshaled) + return + } + + response := handler(request) + + marshaled, _ := json.Marshal(response) + w.Write(marshaled) + } +} + +func newAntigateV2Api(ctx *ApiContext) ApiHandler { + api := &antigateV2Api{ctx, http.NewServeMux()} + + api.mux.HandleFunc("/createTask", jsonHandler(api.createTask)) + api.mux.HandleFunc("/getTaskResult", jsonHandler(api.getTaskResult)) + api.mux.HandleFunc("/getBalance", jsonHandler(api.getBalance)) + + return api +} diff --git a/api/RuCaptcha.go b/api/RuCaptcha.go new file mode 100644 index 0000000..f1c1f5e --- /dev/null +++ b/api/RuCaptcha.go @@ -0,0 +1,267 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "strings" + + "git.sati.ac/sati.ac/sati-go" + "github.com/sirupsen/logrus" +) + +type ruCaptchaApi struct { + ctx *ApiContext + mux *http.ServeMux +} + +func (a *ruCaptchaApi) Name() string { + return "RuCaptcha" +} + +func (a *ruCaptchaApi) Domains() []string { + return []string{ + "rucaptcha.com", + "2captcha.com", + } +} + +func (a *ruCaptchaApi) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.mux.ServeHTTP(w, r) +} + +type ruCaptchaResponse interface{ text() string } + +func (a *ruCaptchaApi) wrap(handler func(url.Values) ruCaptchaResponse) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + useJson := false + if val := query.Get("json"); val != "" && val != "0" { + useJson = true + } + + if val := query.Get("header_acao"); val != "" && val != "0" { + w.Header().Add("Access-Control-Allow-Origin", "*") + } + + result := handler(query) + if useJson { + w.Header().Add("Content-Type", "application/json") + } else { + w.Header().Add("Content-Type", "text/plain; charset=utf-8") + } + + if useJson { + marshaled, _ := json.Marshal(result) + w.Write(marshaled) + } else { + w.Write([]byte(result.text())) + } + } +} + +type simpleResponse struct { + Status uint `json:"status"` + Request string `json:"request"` +} + +func (r *simpleResponse) text() string { return r.Request } + +type okResponse simpleResponse + +func (r *okResponse) text() string { return "OK|" + r.Request } + +func (a *ruCaptchaApi) endpointIn(params url.Values) ruCaptchaResponse { + var id uint32 + switch params.Get("method") { + case "userrecaptcha": + if val := params.Get("version"); val != "" && val != "v2" { + a.ctx.Logger.WithFields(logrus.Fields{ + "handler": a.Name(), + "method": "in.php", + }).Warn("recaptcha v3 isn't supported yet") + return &simpleResponse{0, "ERROR_BAD_PARAMETERS"} + } + if val := params.Get("enterprise"); val != "" && val != "0" { + a.ctx.Logger.WithFields(logrus.Fields{ + "handler": a.Name(), + "method": "in.php", + }).Warn("recaptcha enterprise isn't supported yet") + return &simpleResponse{0, "ERROR_BAD_PARAMETERS"} + } + + pageUrl := params.Get("pageurl") + siteKey := params.Get("googlekey") + if pageUrl == "" || siteKey == "" { + return &simpleResponse{0, "ERROR_BAD_PARAMETERS"} + } + + id = a.ctx.Registry.CreateTask(&sati.ReCaptcha2Task{ + PageUrl: pageUrl, + SiteKey: siteKey, + }) + case "turnstile": + pageUrl := params.Get("pageurl") + siteKey := params.Get("sitekey") + if pageUrl == "" || siteKey == "" { + return &simpleResponse{0, "ERROR_BAD_PARAMETERS"} + } + + var action *string + var cData *string + if params.Has("action") { + val := params.Get("action") + action = &val + } + if params.Has("data") { + val := params.Get("data") + cData = &val + } + + id = a.ctx.Registry.CreateTask(&sati.TurnstileTask{ + SiteKey: siteKey, + PageUrl: pageUrl, + Action: action, + CData: cData, + }) + default: + return &simpleResponse{0, "ERROR_ZERO_CAPTCHA_FILESIZE"} + } + + return &okResponse{1, strconv.FormatUint(uint64(id), 10)} +} + +type get2Response struct { + Status uint `json:"status"` + Request string `json:"request"` + Price string `json:"price,omitempty"` +} + +func (r *get2Response) text() string { + return "OK|" + r.Request + "|" + r.Price +} + +func (a *ruCaptchaApi) convertTaskResult(task *Task) string { + switch task.Result.(type) { + case *sati.ReCaptcha2Result: + return task.Result.(*sati.ReCaptcha2Result).Token + case *sati.TurnstileResult: + return task.Result.(*sati.TurnstileResult).Token + } + + a.ctx.Logger.WithFields(logrus.Fields{ + "handler": a.Name(), + "method": "convertTaskResult", + }).Warn("unsupported result type") + return "" +} + +func (a *ruCaptchaApi) handleMultiIdRes(rawIds string) ruCaptchaResponse { + responses := []string{} + + for _, rawId := range strings.Split(rawIds, ",") { + id, err := strconv.ParseUint(rawId, 10, 32) + if err != nil { + responses = append(responses, "ERROR_NO_SUCH_CAPCHA_ID") + continue + } + + task := a.ctx.Registry.Get(uint32(id)) + if task == nil { + return &simpleResponse{0, "ERROR_NO_SUCH_CAPCHA_ID"} + } + + switch task.State { + case StateProcessing: + responses = append(responses, "CAPCHA_NOT_READY") + case StateError: + responses = append(responses, "ERROR_CAPTCHA_UNSOLVABLE") + case StateSuccess: + responses = append(responses, a.convertTaskResult(task)) + } + } + + return &simpleResponse{1, strings.Join(responses, "|")} +} + +func (a *ruCaptchaApi) safelyGetTask(rawId string) (*Task, string) { + id, err := strconv.ParseUint(rawId, 10, 32) + if err != nil { + return nil, "ERROR_WRONG_CAPTCHA_ID" + } + + task := a.ctx.Registry.Get(uint32(id)) + if task == nil { + return nil, "ERROR_WRONG_CAPTCHA_ID" + } + + switch task.State { + case StateProcessing: + return nil, "CAPCHA_NOT_READY" + case StateError: + return nil, "ERROR_CAPTCHA_UNSOLVABLE" + case StateSuccess: + return task, "" + } + + return nil, "ERROR_INTERNAL" // this should never happen +} + +func (a *ruCaptchaApi) endpointRes(params url.Values) ruCaptchaResponse { + switch params.Get("action") { + case "getbalance": + balance, err := a.ctx.Api.GetBalance() + if err != nil { + a.ctx.Logger.WithFields(logrus.Fields{ + "handler": a.Name(), + "method": "getbalance", + }).Error(err.Error()) + return &simpleResponse{0, "ERROR_INTERNAL"} + } + return &simpleResponse{1, balance.String()} + case "get": + if rawId := params.Get("id"); rawId != "" && rawId != "0" { + task, err := a.safelyGetTask(rawId) + if err != "" { + return &simpleResponse{0, err} + } + return &okResponse{1, a.convertTaskResult(task)} + } + + if rawIds := params.Get("ids"); rawIds != "" && rawIds != "0" { + return a.handleMultiIdRes(rawIds) + } + + return &simpleResponse{0, "ERROR_WRONG_CAPTCHA_ID"} + case "get2": + if rawId := params.Get("id"); rawId != "" && rawId != "0" { + task, err := a.safelyGetTask(rawId) + if err != "" { + return &simpleResponse{0, err} + } + return &get2Response{1, a.convertTaskResult(task), task.Entity.Cost} + } + + if rawIds := params.Get("ids"); rawIds != "" && rawIds != "0" { + return a.handleMultiIdRes(rawIds) + } + + return &simpleResponse{0, "ERROR_WRONG_CAPTCHA_ID"} + case "reportgood": + fallthrough + case "reportbad": + return &simpleResponse{1, "OK_REPORT_RECORDED"} + } + + return &simpleResponse{0, "ERROR_EMPTY_METHOD"} +} + +func newRuCaptchaApi(ctx *ApiContext) ApiHandler { + api := &ruCaptchaApi{ctx, http.NewServeMux()} + + api.mux.HandleFunc("/in.php", api.wrap(api.endpointIn)) + api.mux.HandleFunc("/res.php", api.wrap(api.endpointRes)) + + return api +} diff --git a/api/Stats.go b/api/Stats.go new file mode 100644 index 0000000..7a8acc0 --- /dev/null +++ b/api/Stats.go @@ -0,0 +1,108 @@ +package api + +import ( + "html/template" + "net/http" +) + +type statsApi struct { + ctx *ApiContext + mux *http.ServeMux +} + +func (a *statsApi) Name() string { + return "Stats" +} + +func (a *statsApi) Domains() []string { + return []string{"bridge.sati.ac"} +} + +func (a *statsApi) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.mux.ServeHTTP(w, r) +} + +var statsTemplate = func() *template.Template { + template, err := template.New("stats").Parse(` + + + sati bridge + + + + +
+ it works! statistics of this instance: +
+ + + {{range $domain, $handler := .Domains}} + + {{end}} +
domainhandler
{{$domain}}{{$handler}}
+
+ + + + + + +
task registry
Total{{.Registry.Total}}
Success{{.Registry.Success}}
Error{{.Registry.Error}}
Processing{{.Registry.Processing}}
+ + + +`) + + if err != nil { + panic(err) + } + + return template +}() + +func (a *statsApi) stats(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/html") + + domains := make(map[string]string, len(a.ctx.Server.domains)) + for domain, handler := range a.ctx.Server.domains { + domains[domain] = handler.Name() + } + + statsTemplate.Execute(w, &struct { + Domains map[string]string + Registry RegistryStats + }{domains, a.ctx.Registry.Stats()}) +} + +func newStatsApi(ctx *ApiContext) ApiHandler { + api := &statsApi{ctx, http.NewServeMux()} + + api.mux.HandleFunc("/", api.stats) + + return api +} diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..818fc96 --- /dev/null +++ b/api/api.go @@ -0,0 +1,195 @@ +package api + +import ( + "net/http" + "sync" + "time" + + "git.sati.ac/sati.ac/bridge/config" + "git.sati.ac/sati.ac/sati-go" + "github.com/sirupsen/logrus" +) + +type TaskState uint + +const ( + StateProcessing TaskState = iota + StateSuccess + StateError +) + +type Task struct { + State TaskState + Entity *sati.TaskEntity + Result any + CreateTime int64 + EndTime int64 +} + +type RegistryStats struct { + Total uint32 + Success uint32 + Error uint32 + Processing uint32 +} + +type TaskRegistry struct { + mu *sync.RWMutex + lifetime time.Duration + tasks map[uint32]*Task + idCounter uint32 + api *sati.Api + stats RegistryStats +} + +func NewTaskRegistry(api *sati.Api, lifetime time.Duration) *TaskRegistry { + return &TaskRegistry{ + mu: &sync.RWMutex{}, + tasks: map[uint32]*Task{}, + api: api, + lifetime: lifetime, + } +} + +func (t *TaskRegistry) CreateTask(task sati.AnyTask) uint32 { + t.mu.Lock() + t.idCounter++ + id := t.idCounter + entry := &Task{ + State: StateProcessing, + CreateTime: time.Now().Unix(), + } + t.stats.Total++ + t.stats.Processing++ + t.tasks[id] = entry + t.mu.Unlock() + + go func() { + result := task.Result() + entity, err := t.api.Solve(task, result) + + t.mu.Lock() + t.stats.Processing-- + if err != nil { + t.stats.Error++ + entry.State = StateError + } else { + t.stats.Success++ + entry.State = StateSuccess + entry.Entity = entity + entry.Result = result + } + entry.EndTime = time.Now().Unix() + t.mu.Unlock() + + time.Sleep(t.lifetime) + t.mu.Lock() + delete(t.tasks, id) + t.mu.Unlock() + }() + + return id +} + +func (t *TaskRegistry) Stats() RegistryStats { + t.mu.RLock() + defer t.mu.RUnlock() + return t.stats +} + +func (t *TaskRegistry) Get(id uint32) *Task { + t.mu.RLock() + defer t.mu.RUnlock() + task := t.tasks[id] + if task == nil { + return nil + } + + cloned := *task + return &cloned +} + +type ApiContext struct { + Config *config.Config + Api *sati.Api + Registry *TaskRegistry + Logger *logrus.Logger + Server *ApiServer +} + +type ApiHandler interface { + Name() string + Domains() []string + ServeHTTP(w http.ResponseWriter, r *http.Request) +} + +type ApiServer struct { + handlers map[string]ApiHandler + domains map[string]ApiHandler + ctx *ApiContext +} + +func (a *ApiServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.ctx.Logger.WithFields(logrus.Fields{ + "method": r.Method, + "host": r.Host, + "path": r.URL.Path, + }).Debug("request") + + handler, ok := a.domains[r.Host] + if !ok { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(400) + w.Write([]byte("domain not found")) + return + } + + handler.ServeHTTP(w, r) +} + +func (a *ApiServer) GetDomains() []string { + domains := make([]string, 0, len(a.domains)) + + for domain := range a.domains { + domains = append(domains, domain) + } + + return domains +} + +func NewApiServer(ctx *ApiContext) *ApiServer { + handlers := []ApiHandler{ + newStatsApi(ctx), + newAntigateV2Api(ctx), + newRuCaptchaApi(ctx), + } + + server := &ApiServer{ + handlers: map[string]ApiHandler{}, + domains: map[string]ApiHandler{}, + ctx: ctx, + } + + for _, handler := range handlers { + server.handlers[handler.Name()] = handler + for _, domain := range handler.Domains() { + server.domains[domain] = handler + } + } + + for domain, handlerName := range ctx.Config.ExtraDomains { + handler, ok := server.handlers[handlerName] + if !ok { + ctx.Logger.WithFields(logrus.Fields{ + "domain": domain, + "handler": handlerName, + }).Warn("extraDomains: handler not found, ignoring it") + continue + } + + server.domains[domain] = handler + } + + ctx.Server = server + return server +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..6d6b252 --- /dev/null +++ b/config/config.go @@ -0,0 +1,88 @@ +package config + +import ( + "bytes" + "encoding/json" + "os" + "strings" +) + +type Config struct { + Path string `json:"-"` + Token string `json:"token"` + Debug bool `json:"debug"` + ExtraDomains map[string]string `json:"extraDomains"` + TlsCertPath string `json:"tlsCertPath"` + TlsKeyPath string `json:"tlsKeyPath"` + Host string `json:"host"` + TlsHost string `json:"tlsHost"` + AntiGateV2 struct { + TurnstileUserAgent string `json:"turnstileUserAgent"` + Ip string `json:"ip"` + } `json:"AntiGateV2"` +} + +func Default() *Config { + return &Config{ + Path: "./data/config.json", + Token: "", + Debug: false, + ExtraDomains: map[string]string{}, + TlsCertPath: "./data/ca.crt", + TlsKeyPath: "./data/ca.key", + Host: "127.0.0.1:80", + TlsHost: "127.0.0.1:443", + AntiGateV2: struct { + TurnstileUserAgent string `json:"turnstileUserAgent"` + Ip string `json:"ip"` + }{ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36", + "127.0.0.1", + }, + } +} + +func (c *Config) Load() error { + raw, err := os.ReadFile(c.Path) + if err != nil { + return err + } + + if err := json.Unmarshal(raw, c); err != nil { + return err + } + + // format config, if it isn't properly formatted + // also it merges user's config with our defaults + marshaled, err := json.MarshalIndent(c, "", "\t") + if err != nil { + return err + } + + if bytes.Compare(raw, marshaled) != 0 { + if err := os.WriteFile(c.Path, marshaled, 0600); err != nil { + return err + } + } + + return nil +} + +func (c *Config) Save() error { + + dir := c.Path + // converts "./user/config.json" to "./user" + if i := strings.LastIndex(dir, "/"); i != -1 { + dir = dir[0:i] + } + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + marshaled, err := json.MarshalIndent(c, "", "\t") + if err != nil { + return err + } + + return os.WriteFile(c.Path, marshaled, 0600) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..64e47fe --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module git.sati.ac/sati.ac/bridge + +go 1.20 + +require git.sati.ac/sati.ac/sati-go v0.0.0-20230629103120-de6961c4f6ec + +require ( + github.com/gorilla/websocket v1.5.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/shopspring/decimal v1.3.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..78f3649 --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +git.sati.ac/sati.ac/sati-go v0.0.0-20230628174309-4eb11030b88c h1:aoyR20enfXVKrka2Y0uTKlaj0asS6K9IicohJlMmN2E= +git.sati.ac/sati.ac/sati-go v0.0.0-20230628174309-4eb11030b88c/go.mod h1:dsLvwV5+2YUjWRAuTYFf/EMvoH/twUu/NWA0t5Yl3pQ= +git.sati.ac/sati.ac/sati-go v0.0.0-20230629103120-de6961c4f6ec h1:DHmxYp62PfGP+jODYIyHQ4G6HES3PJ6rCYF3I3owduw= +git.sati.ac/sati.ac/sati-go v0.0.0-20230629103120-de6961c4f6ec/go.mod h1:dsLvwV5+2YUjWRAuTYFf/EMvoH/twUu/NWA0t5Yl3pQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ec8a7c8 --- /dev/null +++ b/main.go @@ -0,0 +1,332 @@ +package main + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "flag" + "fmt" + "math/big" + "net/http" + "os" + "os/signal" + "regexp" + "runtime" + "strings" + "time" + + "git.sati.ac/sati.ac/bridge/api" + "git.sati.ac/sati.ac/bridge/config" + "git.sati.ac/sati.ac/sati-go" + "github.com/sirupsen/logrus" +) + +type bridge struct { + config *config.Config +} + +func issueCACert(certPath string, keyPath string) error { + cert := &x509.Certificate{ + SerialNumber: big.NewInt(0), + Subject: pkix.Name{ + CommonName: "Bridge CA", + Country: []string{"VA"}, + Organization: []string{"sati.ac"}, + Locality: []string{"Everywhere"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(100, 0, 0), + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + keys, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + der, err := x509.CreateCertificate(rand.Reader, cert, cert, &keys.PublicKey, keys) + if err != nil { + return err + } + + certFile, err := os.OpenFile(certPath, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer certFile.Close() + + if err := pem.Encode(certFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: der, + }); err != nil { + return err + } + + keyFile, err := os.OpenFile(keyPath, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer keyFile.Close() + + if err := pem.Encode(keyFile, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(keys), + }); err != nil { + return err + } + + return nil +} + +func issueCert(domains []string, caCertPath string, caKeyPath string) (string, string, error) { + cert := &x509.Certificate{ + SerialNumber: big.NewInt(time.Now().Unix()), + Subject: pkix.Name{ + Country: []string{"VA"}, + Organization: []string{"sati.ac"}, + OrganizationalUnit: []string{"Bridge ephemeral certificate"}, + Locality: []string{"Everywhere"}, + }, + DNSNames: domains, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(100, 0, 0), + SubjectKeyId: []byte{0}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature, + } + + keys, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", err + } + + caCertPem, err := os.ReadFile(caCertPath) + if err != nil { + return "", "", err + } + block, _ := pem.Decode(caCertPem) + if block == nil || block.Type != "CERTIFICATE" { + return "", "", fmt.Errorf(`certificate: bad pem block "%s"`, block.Type) + } + caCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", "", err + } + + caKeyPem, err := os.ReadFile(caKeyPath) + if err != nil { + return "", "", err + } + block, _ = pem.Decode(caKeyPem) + if block == nil || block.Type != "RSA PRIVATE KEY" { + return "", "", fmt.Errorf(`key: bad pem block "%s"`, block.Type) + } + caKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return "", "", err + } + + der, err := x509.CreateCertificate(rand.Reader, cert, caCert, &keys.PublicKey, caKey) + if err != nil { + return "", "", err + } + + certFile, err := os.CreateTemp("", "bridge*.crt") + if err != nil { + return "", "", err + } + defer certFile.Close() + keyFile, err := os.CreateTemp("", "bridge*.key") + if err != nil { + return "", "", err + } + defer keyFile.Close() + + if err := pem.Encode(certFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: der, + }); err != nil { + return "", "", err + } + + if err := pem.Encode(keyFile, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(keys), + }); err != nil { + return "", "", err + } + + return certFile.Name(), keyFile.Name(), nil +} + +func getHostsPath() (string, error) { + switch runtime.GOOS { + case "freebsd": + fallthrough + case "openbsd": + fallthrough + case "dragonfly": + fallthrough + case "netbsd": + fallthrough + case "darwin": + fallthrough + case "android": + fallthrough + case "linux": + return "/etc/hosts", nil + case "windows": + return `C:\Windows\System32\drivers\etc\hosts`, nil + } + + return "", fmt.Errorf("unknown os: %s", runtime.GOOS) +} + +var hostsModRE = regexp.MustCompile("(?:\n|\r\n|\r)#sati-bridge start, DO NOT MODIFY(?:\n|\r\n|\r)[^#]*(?:\n|\r\n|\r)#sati-bridge end") + +var configPath = flag.String("config", "./data/config.json", "config path") + +func addDomainsToHosts(ctx *api.ApiContext) error { + ctx.Logger.Info("adding domains to hosts") + path, err := getHostsPath() + if err != nil { + ctx.Logger.WithError(err).Warn("unable to get hosts path") + return err + } + + hosts, err := os.ReadFile(path) + if err != nil { + ctx.Logger.WithError(err).Warn("unable to read hosts file") + return err + } + + hosts = hostsModRE.ReplaceAll(hosts, []byte{}) // remove old entries + + hostIp := "127.0.0.1" + suffix := "\r\n#sati-bridge start, DO NOT MODIFY\r\n" + for _, domain := range ctx.Server.GetDomains() { + suffix += hostIp + " " + domain + "\r\n" + } + suffix += "#sati-bridge end\r\n" + hosts = []byte(strings.TrimRight(string(hosts), "\r\n\t ") + suffix) + + err = os.WriteFile(path, hosts, 0644) + if err != nil { + ctx.Logger.WithError(err).Warn("unable to write hosts file") + } + + return err +} + +func removeDomainsFromHosts(ctx *api.ApiContext) error { + ctx.Logger.Info("removing domains from hosts") + path, err := getHostsPath() + if err != nil { + ctx.Logger.WithError(err).Warn("unable to get hosts path") + return err + } + + hosts, err := os.ReadFile(path) + if err != nil { + ctx.Logger.WithError(err).Warn("unable to read hosts file") + return err + } + + hosts = hostsModRE.ReplaceAll(hosts, []byte{}) + hosts = bytes.TrimRight(hosts, "\r\n\t ") + + err = os.WriteFile(path, hosts, 0644) + if err != nil { + ctx.Logger.WithError(err).Warn("unable to write hosts file") + } + + return err +} + +func main() { + logger := logrus.New() + logger.Info("starting") + + cfg := config.Default() + cfg.Path = *configPath + if err := cfg.Load(); err != nil { + logger.Info("failed to load config: ", err.Error(), ". attempting to create new") + if err := cfg.Save(); err != nil { + logger.Panic("failed to create config: ", err.Error()) + } + } + + if cfg.Debug { + logger.SetLevel(logrus.DebugLevel) + } else { + logger.SetLevel(logrus.InfoLevel) + } + + _, certReadErr := os.Stat(cfg.TlsCertPath) + _, keyReadErr := os.Stat(cfg.TlsKeyPath) + if certReadErr != nil || keyReadErr != nil { + logger.Info("CA certificate or key not found, issuing new") + err := issueCACert(cfg.TlsCertPath, cfg.TlsKeyPath) + if err != nil { + logger.Panic("failed to issue CA certificate: ", err.Error()) + } + } + + if cfg.Token == "" { + logger.Fatal("api token not specified, get it at https://sati.ac/dashboard") + } + + if strings.HasPrefix(cfg.Host, "0.0.0.0:") || strings.HasPrefix(cfg.TlsHost, "0.0.0.0:") { + logger.Warn("you are trying to listen on all interfaces, THIS IS INSECURE") + } + + satiConfig := sati.NewConfig(cfg.Token) + satiConfig.Debug = cfg.Debug + satiApi := sati.NewApi(satiConfig) + + registry := api.NewTaskRegistry(satiApi, time.Minute) + + ctx := api.ApiContext{ + Config: cfg, + Api: satiApi, + Registry: registry, + Logger: logger, + } + + server := api.NewApiServer(&ctx) + ctx.Logger.WithFields(logrus.Fields{ + "domains": server.GetDomains(), + }).Debug("api server created") + + logger.Info("issuing ephemeral certificate") + certFile, keyFile, err := issueCert(server.GetDomains(), cfg.TlsCertPath, cfg.TlsKeyPath) + if err != nil { + logger.Panic(err) + } + defer os.Remove(certFile) + defer os.Remove(keyFile) + + if addDomainsToHosts(&ctx) == nil { + defer removeDomainsFromHosts(&ctx) + } + + logger.Info("starting api server") + + terminator := make(chan error) + go func() { terminator <- http.ListenAndServe(cfg.Host, server) }() + go func() { terminator <- http.ListenAndServeTLS(cfg.TlsHost, certFile, keyFile, server) }() + + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + go func() { + <-c + terminator <- fmt.Errorf("interrupted") + }() + + logger.Error(<-terminator) +}