This commit is contained in:
commit
434916ed71
45
.gitea/workflows/release.yaml
Normal file
45
.gitea/workflows/release.yaml
Normal file
@ -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}}'
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
data/**
|
6
README.md
Normal file
6
README.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
### sati bridge
|
||||||
|
прослойка, эмулирующая API других сервисов. для использования готового софта с [sati.ac](https://sati.ac)
|
||||||
|
|
||||||
|
на данный момент реализованы:
|
||||||
|
- RuCaptcha
|
||||||
|
- AntiGateV2
|
228
api/AntiGateV2.go
Normal file
228
api/AntiGateV2.go
Normal file
@ -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
|
||||||
|
}
|
267
api/RuCaptcha.go
Normal file
267
api/RuCaptcha.go
Normal file
@ -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
|
||||||
|
}
|
108
api/Stats.go
Normal file
108
api/Stats.go
Normal file
@ -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(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>sati bridge</title>
|
||||||
|
<style>
|
||||||
|
.highlight {
|
||||||
|
color: #FF7143;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
font-size: 14px;
|
||||||
|
background: #0f0805;
|
||||||
|
color: #ddd;
|
||||||
|
font-family: "Segoe UI","Noto Sans",sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
table, th, td {
|
||||||
|
border: 1px solid #592D25;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 2px 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<span class="highlight">it works!</span> statistics of this instance:
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<tr><th>domain</th><th>handler</th></tr>
|
||||||
|
{{range $domain, $handler := .Domains}}
|
||||||
|
<tr><td>{{$domain}}</td><td>{{$handler}}</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
<br/>
|
||||||
|
<table>
|
||||||
|
<tr><th>task registry</th></tr>
|
||||||
|
<tr><td>Total</td><td>{{.Registry.Total}}</td></tr>
|
||||||
|
<tr><td>Success</td><td>{{.Registry.Success}}</td></tr>
|
||||||
|
<tr><td>Error</td><td>{{.Registry.Error}}</td></tr>
|
||||||
|
<tr><td>Processing</td><td>{{.Registry.Processing}}</td></tr>
|
||||||
|
</table>
|
||||||
|
</pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
195
api/api.go
Normal file
195
api/api.go
Normal file
@ -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
|
||||||
|
}
|
88
config/config.go
Normal file
88
config/config.go
Normal file
@ -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)
|
||||||
|
}
|
13
go.mod
Normal file
13
go.mod
Normal file
@ -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
|
||||||
|
)
|
21
go.sum
Normal file
21
go.sum
Normal file
@ -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=
|
332
main.go
Normal file
332
main.go
Normal file
@ -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)
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user