This commit is contained in:
2026-04-23 20:36:37 +03:00
parent 4ecc8973bf
commit 840d7d8e3b
19 changed files with 758 additions and 0 deletions

View File

@@ -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()
}

View File

@@ -0,0 +1,12 @@
package cheating
type QuestionType int
const (
QuestionWithPicture QuestionType = iota
FullTextQuestion QuestionType = iota
)
func DetermineQuestionType(html string) QuestionType {
return FullTextQuestion
}

25
internal/config/config.go Normal file
View File

@@ -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,
}
}

16
internal/domain/domain.go Normal file
View File

@@ -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.")
}

View File

@@ -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
}

View File

@@ -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
}

87
internal/proxy/server.go Normal file
View File

@@ -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)))
}

124
internal/rewrite/html.go Normal file
View File

@@ -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)<div[^>]*\bid\s*=\s*["'][^"']*\bQuestionTest\b[^"']*["'][^>]*>`)
type HTMLHook func(req *http.Request, html []byte) ([]byte, error)
type Processor struct {
hook HTMLHook
}
func NewProcessor(hook HTMLHook) *Processor {
return &Processor{hook: hook}
}
func DefaultQuestionTestHook(_ *http.Request, html []byte) ([]byte, error) {
switch cheating.DetermineQuestionType(string(html)) {
case cheating.FullTextQuestion:
{
aiResponce, err := openrouter.AskOpenRouter(fmt.Sprintf("РЕШИ ЗАДАНИЕ И ДАЙ МАКСИМАЛЬНО КРАТКИЙ ОТВЕТ: %s", string(html)))
if err != nil {
log.Printf("openrouter failed %s", err)
return html, nil
}
return []byte(strings.ReplaceAll(string(html), "generated", latex.ConvertLaTeXToASCII(aiResponce))), nil
}
case cheating.QuestionWithPicture:
{
}
}
return []byte(strings.ReplaceAll(string(html), "generated", "pwned")), nil
}
func IsHTMLResponse(contentType string) bool {
contentType = strings.ToLower(contentType)
return strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml")
}
func (p *Processor) RewriteIfNeeded(req *http.Request, contentEncoding string, body []byte) ([]byte, error) {
decoded, err := decodeBody(contentEncoding, body)
if err != nil {
return nil, err
}
if !questionTestDivRe.Match(decoded) {
return body, nil
}
modifiedDecoded, err := p.hook(req, decoded)
if err != nil {
return nil, err
}
return encodeBody(contentEncoding, modifiedDecoded)
}
func decodeBody(encoding string, body []byte) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "identity":
return body, nil
case "gzip":
r, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
case "deflate":
r := flate.NewReader(bytes.NewReader(body))
defer r.Close()
return io.ReadAll(r)
default:
return nil, fmt.Errorf("unsupported content-encoding: %s", encoding)
}
}
func encodeBody(encoding string, decoded []byte) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "", "identity":
return decoded, nil
case "gzip":
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
if _, err := w.Write(decoded); err != nil {
_ = w.Close()
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
case "deflate":
var buf bytes.Buffer
w, err := flate.NewWriter(&buf, flate.DefaultCompression)
if err != nil {
return nil, err
}
if _, err := w.Write(decoded); err != nil {
_ = w.Close()
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
default:
return nil, fmt.Errorf("unsupported content-encoding: %s", encoding)
}
}

24
internal/tlsutil/ca.go Normal file
View File

@@ -0,0 +1,24 @@
package tlsutil
import (
"crypto/tls"
"crypto/x509"
"fmt"
)
func LoadCACert(certPath, keyPath string) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
if len(cert.Certificate) == 0 {
return nil, fmt.Errorf("empty certificate chain in %s", certPath)
}
leaf, err := x509.ParseCertificate(cert.Certificate[0])
if err != nil {
return nil, err
}
cert.Leaf = leaf
return &cert, nil
}