diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..542b9b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +captures/ +main diff --git a/cert.pem b/cert.pem new file mode 100644 index 0000000..5c5b8cf --- /dev/null +++ b/cert.pem @@ -0,0 +1,34 @@ +-----BEGIN CERTIFICATE----- +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 +0/XDAobaxw== +-----END CERTIFICATE----- diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d0a647 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module git.gulenok.ru/greenhaze/gaslight + +go 1.25.5 + +require github.com/elazarl/goproxy v1.8.3 + +require ( + golang.org/x/net v0.43.0 // indirect + golang.org/x/text v0.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d96502e --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.8.3 h1:XhiZpzW0NvsGOqSv/F3v4+1F29842yYaJNN+In5Fnuc= +github.com/elazarl/goproxy v1.8.3/go.mod h1:b5xm6W48AUHNpRTCvlnd0YVh+JafCCtsLsJZvvNTz+E= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/capture/images.go b/internal/capture/images.go new file mode 100644 index 0000000..7c393fd --- /dev/null +++ b/internal/capture/images.go @@ -0,0 +1,80 @@ +package capture + +import ( + "fmt" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "git.gulenok.ru/greenhaze/gaslight/internal/domain" +) + +type ImageSaver struct { + outputDir string + counter uint64 +} + +func NewImageSaver(outputDir string) *ImageSaver { + return &ImageSaver{outputDir: outputDir} +} + +func (s *ImageSaver) Save(resp *http.Response, body []byte) error { + n := atomic.AddUint64(&s.counter, 1) + ts := time.Now().UTC().Format("20060102T150405.000000000Z") + host := sanitizeName(domain.HostWithoutPort(resp.Request.Host)) + ext := imageExtension(resp.Header.Get("Content-Type"), resp.Request.URL.Path) + name := fmt.Sprintf("%s_%06d_%s%s", ts, n, host, ext) + return os.WriteFile(filepath.Join(s.outputDir, name), body, 0o644) +} + +func IsImageResponse(contentType, path string) bool { + if strings.HasPrefix(strings.ToLower(contentType), "image/") { + return true + } + switch strings.ToLower(filepath.Ext(path)) { + case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".ico", ".avif": + return true + default: + return false + } +} + +func imageExtension(contentType, requestPath string) string { + if ext := strings.ToLower(filepath.Ext(requestPath)); ext != "" && ext != "." { + return ext + } + + base := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + if base != "" { + if exts, err := mime.ExtensionsByType(base); err == nil && len(exts) > 0 { + return exts[0] + } + } + return ".img" +} + +func sanitizeName(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return "unknown-host" + } + + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '.', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return b.String() +} diff --git a/internal/cheating/determine_question_type.go b/internal/cheating/determine_question_type.go new file mode 100644 index 0000000..6737aa3 --- /dev/null +++ b/internal/cheating/determine_question_type.go @@ -0,0 +1,12 @@ +package cheating + +type QuestionType int + +const ( + QuestionWithPicture QuestionType = iota + FullTextQuestion QuestionType = iota +) + +func DetermineQuestionType(html string) QuestionType { + return FullTextQuestion +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..aff227d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,25 @@ +package config + +import "flag" + +type Config struct { + ListenAddr string + OutputDir string + CertPath string + KeyPath string +} + +func ParseFlags() Config { + listenAddr := flag.String("listen", ":1199", "proxy listen address") + outputDir := flag.String("out-dir", "./captures/images", "directory for captured image responses") + certPath := flag.String("cert", "./cert.pem", "MITM CA certificate path (PEM)") + keyPath := flag.String("key", "./key.pem", "MITM CA private key path (PEM)") + flag.Parse() + + return Config{ + ListenAddr: *listenAddr, + OutputDir: *outputDir, + CertPath: *certPath, + KeyPath: *keyPath, + } +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go new file mode 100644 index 0000000..2cde110 --- /dev/null +++ b/internal/domain/domain.go @@ -0,0 +1,16 @@ +package domain + +import "strings" + +func HostWithoutPort(host string) string { + host = strings.TrimSpace(strings.ToLower(host)) + if i := strings.IndexByte(host, ':'); i >= 0 { + return host[:i] + } + return host +} + +func IsMckoDomain(host string) bool { + host = HostWithoutPort(host) + return strings.Contains(host, ".mcko.") || strings.HasPrefix(host, "mcko.") +} diff --git a/internal/latex/latex2ascii.go b/internal/latex/latex2ascii.go new file mode 100644 index 0000000..49d9c3f --- /dev/null +++ b/internal/latex/latex2ascii.go @@ -0,0 +1,57 @@ +package latex + +import ( + "regexp" + "strings" +) + +func ConvertLaTeXToASCII(input string) string { + s := input + + replacements := []struct { + pattern *regexp.Regexp + replace string + }{ + // Fractions: \frac{a}{b} -> (a)/(b) + {regexp.MustCompile(`\\frac\{([^{}]+)\}\{([^{}]+)\}`), `($1)/($2)`}, + + // Square roots: \sqrt{x} -> sqrt(x) + {regexp.MustCompile(`\\sqrt\{([^{}]+)\}`), `sqrt($1)`}, + + // Powers: x^{y} -> x^y + {regexp.MustCompile(`\{?\^\{([^{}]+)\}\}?`), `^$1`}, + + // Subscripts: x_{i} -> x_i + {regexp.MustCompile(`_\{([^{}]+)\}`), `_$1`}, + + // Greek letters (common subset) + {regexp.MustCompile(`\\alpha`), "alpha"}, + {regexp.MustCompile(`\\beta`), "beta"}, + {regexp.MustCompile(`\\gamma`), "gamma"}, + {regexp.MustCompile(`\\delta`), "delta"}, + {regexp.MustCompile(`\\theta`), "theta"}, + {regexp.MustCompile(`\\pi`), "pi"}, + {regexp.MustCompile(`\\lambda`), "lambda"}, + + // Common symbols + {regexp.MustCompile(`\\times`), "*"}, + {regexp.MustCompile(`\\cdot`), "*"}, + {regexp.MustCompile(`\\pm`), "+/-"}, + {regexp.MustCompile(`\\leq`), "<="}, + {regexp.MustCompile(`\\geq`), ">="}, + {regexp.MustCompile(`\\neq`), "!="}, + + // Remove LaTeX math wrappers + {regexp.MustCompile(`\\left|\\right`), ""}, + {regexp.MustCompile(`\\[|\\]|\$`), ""}, + } + + for _, r := range replacements { + s = r.pattern.ReplaceAllString(s, r.replace) + } + + // Clean extra spaces + s = strings.TrimSpace(strings.Join(strings.Fields(s), " ")) + + return s +} diff --git a/internal/openrouter/openrouter.go b/internal/openrouter/openrouter.go new file mode 100644 index 0000000..399414c --- /dev/null +++ b/internal/openrouter/openrouter.go @@ -0,0 +1,176 @@ +package openrouter + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" +) + +var Orclient OpenRouterClient + +type OpenRouterClient struct { + APIKey string + Model string +} +type OpenRouterRequest struct { + Model string `json:"model"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` +} + +type OpenRouterResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +func AskOpenRouter(prompt string) (string, error) { + url := "https://openrouter.ai/api/v1/chat/completions" + apiKey := Orclient.APIKey + model := Orclient.Model + reqBody := OpenRouterRequest{ + Model: model, + } + + reqBody.Messages = append(reqBody.Messages, struct { + Role string `json:"role"` + Content string `json:"content"` + }{ + Role: "user", + Content: prompt, + }) + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("HTTP-Referer", "https://yourapp.com") // optional but recommended + req.Header.Set("X-Title", "My App") // optional + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("error: %s", string(body)) + } + + var result OpenRouterResponse + err = json.Unmarshal(body, &result) + if err != nil { + return "", err + } + + if len(result.Choices) == 0 { + return "", fmt.Errorf("no response from model") + } + + return result.Choices[0].Message.Content, nil +} + +func AskOpenRouterWithLocalImage(prompt, imagePath string) (string, error) { + url := "https://openrouter.ai/api/v1/chat/completions" + apiKey := Orclient.APIKey + model := Orclient.Model + // Read image file + imageBytes, err := os.ReadFile(imagePath) + if err != nil { + return "", err + } + + // Detect mime type (optional but good practice) + mimeType := http.DetectContentType(imageBytes) + + // Encode to base64 + base64Image := base64.StdEncoding.EncodeToString(imageBytes) + + imageData := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Image) + + payload := map[string]interface{}{ + "model": model, + "messages": []map[string]interface{}{ + { + "role": "user", + "content": []map[string]interface{}{ + { + "type": "text", + "text": prompt, + }, + { + "type": "image_url", + "image_url": map[string]string{ + "url": imageData, + }, + }, + }, + }, + }, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return "", err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("HTTP-Referer", "https://yourapp.com") + req.Header.Set("X-Title", "My App") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != 200 { + return "", fmt.Errorf("error: %s", string(body)) + } + + var result OpenRouterResponse + err = json.Unmarshal(body, &result) + if err != nil { + return "", err + } + + if len(result.Choices) == 0 { + return "", fmt.Errorf("no response") + } + + return result.Choices[0].Message.Content, nil +} diff --git a/internal/proxy/server.go b/internal/proxy/server.go new file mode 100644 index 0000000..64f7813 --- /dev/null +++ b/internal/proxy/server.go @@ -0,0 +1,87 @@ +package proxy + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + + "github.com/elazarl/goproxy" + + "git.gulenok.ru/greenhaze/gaslight/internal/capture" + "git.gulenok.ru/greenhaze/gaslight/internal/config" + "git.gulenok.ru/greenhaze/gaslight/internal/domain" + "git.gulenok.ru/greenhaze/gaslight/internal/rewrite" + "git.gulenok.ru/greenhaze/gaslight/internal/tlsutil" +) + +func Run(cfg config.Config) error { + if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + caCert, err := tlsutil.LoadCACert(cfg.CertPath, cfg.KeyPath) + if err != nil { + return fmt.Errorf("failed to load CA certificate/key: %w", err) + } + goproxy.GoproxyCa = *caCert + + imageSaver := capture.NewImageSaver(cfg.OutputDir) + rewriter := rewrite.NewProcessor(rewrite.DefaultQuestionTestHook) + + p := goproxy.NewProxyHttpServer() + p.Verbose = false + p.OnRequest().HandleConnect(goproxy.AlwaysMitm) + p.OnResponse().DoFunc(func(resp *http.Response, _ *goproxy.ProxyCtx) *http.Response { + if resp == nil || resp.Request == nil || resp.Body == nil { + return resp + } + + if !domain.IsMckoDomain(resp.Request.Host) { + return resp + } + + original, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + log.Printf("read body failed for %s: %v", resp.Request.URL.String(), err) + return resp + } + + contentType := strings.ToLower(resp.Header.Get("Content-Type")) + if capture.IsImageResponse(contentType, resp.Request.URL.Path) { + if err := imageSaver.Save(resp, original); err != nil { + log.Printf("save image failed for %s: %v", resp.Request.URL.String(), err) + } + setResponseBody(resp, original) + return resp + } + + if !rewrite.IsHTMLResponse(contentType) { + setResponseBody(resp, original) + return resp + } + + modified, err := rewriter.RewriteIfNeeded(resp.Request, resp.Header.Get("Content-Encoding"), original) + if err != nil { + log.Printf("html rewrite failed for %s: %v", resp.Request.URL.String(), err) + setResponseBody(resp, original) + return resp + } + + setResponseBody(resp, modified) + return resp + }) + + log.Printf("proxy listening on %s", cfg.ListenAddr) + return http.ListenAndServe(cfg.ListenAddr, p) +} + +func setResponseBody(resp *http.Response, body []byte) { + resp.Body = io.NopCloser(bytes.NewReader(body)) + resp.ContentLength = int64(len(body)) + resp.Header.Set("Content-Length", fmt.Sprint(len(body))) +} diff --git a/internal/rewrite/html.go b/internal/rewrite/html.go new file mode 100644 index 0000000..a0020e4 --- /dev/null +++ b/internal/rewrite/html.go @@ -0,0 +1,124 @@ +package rewrite + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strings" + + "git.gulenok.ru/greenhaze/gaslight/internal/cheating" + "git.gulenok.ru/greenhaze/gaslight/internal/latex" + "git.gulenok.ru/greenhaze/gaslight/internal/openrouter" +) + +var questionTestDivRe = regexp.MustCompile(`(?is)