commit 3701af1fd72bfc88e4f47347ebae48cc8019e5b8 Author: ScuroNeko Date: Fri Feb 27 13:23:16 2026 +0300 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c4b128 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.idea/ +.env* +*.json +Makefile +Dockerfile +docker-compose.yml \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..46a29b6 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +JWT_SECRET=CHANGEME +HOST=127.0.0.1 +PORT=443 +OBFS_PASSWORD=test +SNI=vk.com +NAME_FORMAT={username} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aeed1ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +provider.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de61d37 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.26-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY ./app ./app/ +COPY main.go . +RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod go build -v -o api . + +FROM alpine:3.23 AS runner +WORKDIR /app +COPY --from=builder /src/api /app/api +EXPOSE 8080 +CMD ["/app/api"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c70377c --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +build: + docker build -t git.nix13.pw/scuroneko/h2auth:latest . + docker push git.nix13.pw/scuroneko/h2auth:latest \ No newline at end of file diff --git a/app/api.go b/app/api.go new file mode 100644 index 0000000..28b3ff5 --- /dev/null +++ b/app/api.go @@ -0,0 +1,59 @@ +package app + +import ( + "encoding/json" + "log" + "net/http" +) + +type Error struct { + Ok bool `json:"ok"` + Error string `json:"error"` +} + +func WriteError(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + e := json.NewEncoder(w).Encode(Error{ + Ok: false, + Error: err.Error(), + }) + if e != nil { + log.Println(e) + } +} + +type Response[T any] struct { + Ok bool `json:"ok"` + Data T `json:"data"` +} + +func WriteResponse[T any](w http.ResponseWriter, data T) { + WriteResponseCode(w, data, 200) +} +func WriteResponseCode[T any](w http.ResponseWriter, data T, code int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + e := enc.Encode(Response[T]{ + Ok: true, + Data: data, + }) + if e != nil { + log.Println(e) + } +} + +func WriteResponseOrError[T any](w http.ResponseWriter, data T, err error) { + if err != nil { + WriteError(w, err) + } else { + WriteResponse(w, data) + } +} +func ReadBody[T any](r *http.Request) (T, error) { + dst := new(T) + err := json.NewDecoder(r.Body).Decode(dst) + return *dst, err +} diff --git a/app/provider.go b/app/provider.go new file mode 100644 index 0000000..24e56ba --- /dev/null +++ b/app/provider.go @@ -0,0 +1,96 @@ +package app + +import ( + "encoding/json" + "errors" + "os" +) + +type User struct { + ID int `json:"id"` + Username string `json:"username"` + Password string `json:"password"` +} +type Provider struct { + Users []User `json:"users"` + LastUserID int `json:"last_user_id"` + path string +} + +// NewProvider создаёт новый провайдер с пустыми данными (файл не трогает) +func NewProvider() *Provider { + return &Provider{ + Users: make([]User, 0), + LastUserID: 0, + path: "./provider.json", + } +} + +// LoadProvider загружает данные из файла, если файла нет — возвращает новый провайдер +func LoadProvider() (*Provider, error) { + data, err := os.ReadFile("./provider.json") + if err != nil { + if os.IsNotExist(err) { + // файл не существует, возвращаем новый провайдер + return NewProvider(), nil + } + return nil, err + } + + p := &Provider{path: "./provider.json"} + if err := json.Unmarshal(data, p); err != nil { + return nil, err + } + return p, nil +} + +// Save сохраняет текущее состояние в файл +func (p *Provider) Save() error { + data, err := json.MarshalIndent(p, "", " ") // для читаемости + if err != nil { + return err + } + return os.WriteFile(p.path, data, 0644) +} + +func (p *Provider) AddUser(username, password string) error { + p.LastUserID++ + p.Users = append(p.Users, User{ + ID: p.LastUserID, + Username: username, + Password: password, + }) + return p.Save() +} + +func (p *Provider) GetById(id int) (User, error) { + for _, user := range p.Users { + if user.ID == id { + return user, nil + } + } + return User{}, errors.New("user not found") +} +func (p *Provider) GetUser(password string) (User, error) { + for _, user := range p.Users { + if user.Password == password { + return user, nil + } + } + return User{}, errors.New("user not found") +} + +func (p *Provider) DeleteUser(id int) error { + index := -1 + for i, user := range p.Users { + if user.ID == id { + index = i + break + } + } + if index == -1 { + return errors.New("user not found") + } + p.Users = append(p.Users[:index], p.Users[index+1:]...) + return p.Save() +} diff --git a/app/routes.go b/app/routes.go new file mode 100644 index 0000000..6fe7fad --- /dev/null +++ b/app/routes.go @@ -0,0 +1,199 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strconv" +) + +type AddUserReq struct { + Token string `json:"token"` + Username string `json:"username"` + Password string `json:"password"` +} +type AddUserRsp struct { + ID int `json:"id"` + Username string `json:"username"` +} + +func AddUser(w http.ResponseWriter, r *http.Request) { + log.Println("AddUser called") + req, err := ReadBody[AddUserReq](r) + if err != nil { + WriteError(w, err) + return + } + + if req.Token != cfg.JWTSecret { + WriteError(w, errors.New("token required")) + return + } + + provider, err := LoadProvider() + if err != nil { + WriteError(w, err) + return + } + err = provider.AddUser(req.Username, req.Password) + if err != nil { + WriteError(w, err) + return + } + err = provider.Save() + if err != nil { + WriteError(w, err) + } + + user, err := provider.GetUser(req.Password) + if err != nil { + WriteError(w, err) + return + } + WriteResponseCode(w, user, http.StatusCreated) +} + +type DeleteUserReq struct { + Token string `json:"token"` + ID int `json:"id"` +} + +func DeleteUser(w http.ResponseWriter, r *http.Request) { + req, err := ReadBody[DeleteUserReq](r) + if err != nil { + WriteError(w, err) + return + } + if req.Token != cfg.JWTSecret { + WriteError(w, errors.New("invalid token")) + return + } + provider, err := LoadProvider() + if err != nil { + WriteError(w, err) + return + } + err = provider.DeleteUser(req.ID) + if err != nil { + WriteError(w, err) + return + } + err = provider.Save() + if err != nil { + WriteError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +type AllUserReq struct { + Token string `json:"token"` +} + +func AllUsers(w http.ResponseWriter, r *http.Request) { + fmt.Println("AllUsers called") + req, err := ReadBody[AllUserReq](r) + if err != nil { + WriteError(w, err) + return + } + + if req.Token != cfg.JWTSecret { + WriteError(w, errors.New("invalid token")) + return + } + + provider, err := LoadProvider() + if err != nil { + WriteError(w, err) + return + } + WriteResponse(w, provider) +} + +type GetConnectURLReq struct { + ID int `json:"id"` + Pass string `json:"pass"` +} + +func GetUserURL(w http.ResponseWriter, r *http.Request) { + vars := r.URL.Query() + idS := vars.Get("id") + id, err := strconv.Atoi(idS) + if err != nil { + WriteError(w, err) + return + } + + provider, err := LoadProvider() + if err != nil { + WriteError(w, err) + return + } + user, err := provider.GetById(id) + if err != nil { + WriteError(w, err) + return + } + + urlTemplate := "hysteria2://%s@%s:%s?obfs=salamander&obfs-password=%s&type=hysteria&mport&security=tls&sni=%s&alpn=h3&fp=chrome&allowInsecure=0#%s" + authString := encodeURL(user) + u := fmt.Sprintf(urlTemplate, authString, cfg.Host, cfg.Port, cfg.ObfsPassword, cfg.SNI, formatConfigName(cfg.NameFormat, user)) + WriteResponse(w, u) +} + +type AuthReq struct { + Addr string `json:"addr"` + Auth string `json:"auth"` + Tx int `json:"tx"` +} +type AuthRsp struct { + Ok bool `json:"ok"` + ID string `json:"id"` +} + +func DoAuth(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + log.Println(err) + return + } + + req := new(AuthReq) + if err := json.Unmarshal(data, req); err != nil { + w.WriteHeader(http.StatusBadRequest) + log.Println(err) + return + } + log.Printf("New auth request from %s, data %s", req.Addr, string(data)) + + reqUser, err := decodeURL(req.Auth) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + provider, err := LoadProvider() + if err != nil { + log.Println(err) + w.WriteHeader(http.StatusBadRequest) + return + } + user, err := provider.GetUser(reqUser.Password) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + res := &AuthRsp{true, user.Username} + err = json.NewEncoder(w).Encode(res) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + } else { + w.WriteHeader(http.StatusOK) + } +} diff --git a/app/utils.go b/app/utils.go new file mode 100644 index 0000000..5905485 --- /dev/null +++ b/app/utils.go @@ -0,0 +1,91 @@ +package app + +import ( + "encoding/base64" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +func encodeURL(user User) string { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "id": user.ID, "name": user.Username, "pass": user.Password, + }) + tokenString, err := token.SignedString([]byte(os.Getenv("JWT_SECRET"))) + if err != nil { + return "" + } + tokenString = base64.RawURLEncoding.EncodeToString([]byte(tokenString)) + return tokenString +} +func decodeURL(tokenString string) (User, error) { + var zero User + tokenBytes, err := base64.RawURLEncoding.DecodeString(tokenString) + if err != nil { + return zero, err + } + token, err := jwt.Parse(string(tokenBytes), func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(os.Getenv("JWT_SECRET")), nil + }) + if err != nil { + return zero, err + } + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + id := int(claims["id"].(float64)) + name := claims["name"].(string) + pass := claims["pass"].(string) + return User{ID: id, Username: name, Password: pass}, nil + } + return zero, nil +} + +type Config struct { + JWTSecret string + Host string + Port string + ObfsPassword string + SNI string + NameFormat string +} + +var cfg *Config + +func LoadConfig() { + cfg = &Config{ + JWTSecret: os.Getenv("JWT_SECRET"), + Host: os.Getenv("HOST"), + Port: os.Getenv("PORT"), + ObfsPassword: os.Getenv("OBFS_PASSWORD"), + SNI: os.Getenv("SNI"), + NameFormat: os.Getenv("NAME_FORMAT"), + } +} + +var ip string + +func LoadIP() { + res, err := http.Get("https://api.ipify.org") + if err != nil { + panic(err) + } + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + if err != nil { + panic(err) + } + ip = string(data) + log.Println(ip) +} +func formatConfigName(format string, user User) string { + s := strings.ReplaceAll(format, "{username}", user.Username) + s = strings.ReplaceAll(s, "{host}", ip) + return s +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d0afb53 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module Hyst2Auth + +go 1.26 + +require github.com/golang-jwt/jwt/v5 v5.3.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c0f7290 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= diff --git a/main.go b/main.go new file mode 100644 index 0000000..28f117b --- /dev/null +++ b/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "Hyst2Auth/app" + "log" + "net/http" +) + +func main() { + app.LoadIP() + app.LoadConfig() + _, err := app.LoadProvider() + if err != nil { + panic(err) + } + + r := http.NewServeMux() + r.HandleFunc("/add", app.AddUser) + r.HandleFunc("/delete", app.DeleteUser) + r.HandleFunc("/users", app.AllUsers) + + r.HandleFunc("/connect_url", app.GetUserURL) + + r.HandleFunc("/auth", app.DoAuth) + + log.Println("Listening on :8080") + if err := http.ListenAndServe(":8080", r); err != nil { + log.Fatal(err) + } +}