initial commit

This commit is contained in:
2026-03-05 12:21:18 +03:00
commit 992db80ea3
16 changed files with 623 additions and 0 deletions

5
.env Normal file
View File

@@ -0,0 +1,5 @@
PSQL_HOST=127.0.0.1
PSQL_NAME=hyst2
PSQL_USER=hyst2
PSQL_PASS=123456
JWT_SECRET=CHANGEME

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.idea/
provider.json

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
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"]
USER nobody

3
Makefile Normal file
View File

@@ -0,0 +1,3 @@
build:
docker build -t git.nix13.pw/scuroneko/h2master:latest .
docker push git.nix13.pw/scuroneko/h2master:latest

79
app/api.go Normal file
View File

@@ -0,0 +1,79 @@
package app
import (
"encoding/json"
"io"
"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 io.ReadCloser) (T, error) {
dst := new(T)
data, err := io.ReadAll(r)
if err != nil {
return *dst, err
}
err = json.Unmarshal(data, dst)
return *dst, err
}
func ReadResponse[T any](r io.ReadCloser) (T, error) {
var zero T
resp, err := ReadBody[Response[T]](r)
if err != nil {
return zero, err
}
return resp.Data, nil
}
func CheckToken(r *http.Request) bool {
auth := r.Header.Get("Authorization")
if auth != cfg.JWTSecret {
return false
}
return true
}

13
app/config.go Normal file
View File

@@ -0,0 +1,13 @@
package app
import "os"
type Config struct {
JWTSecret string
}
var cfg *Config
func LoadConfig() {
cfg = &Config{JWTSecret: os.Getenv("JWT_SECRET")}
}

44
app/database.go Normal file
View File

@@ -0,0 +1,44 @@
package app
import (
"fmt"
"log"
"os"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
"github.com/vinovest/sqlx"
)
var db *sqlx.DB = nil
func init() {
if err := godotenv.Load(); err != nil {
log.Println("Error loading .env file. If you use docker, then you can ignore this.")
}
if db == nil {
connectPsql()
}
}
func getDSN() string {
user := os.Getenv("PSQL_USER")
password := os.Getenv("PSQL_PASS")
database := os.Getenv("PSQL_NAME")
host, exists := os.LookupEnv("PSQL_HOST")
if !exists {
host = "localhost"
}
return fmt.Sprintf("postgresql://%s:%s@%s/%s?sslmode=disable", user, password, host, database)
}
func connectPsql() {
var err error
db, err = sqlx.Connect("postgres", getDSN())
if err != nil {
panic(err)
}
}
func ClosePsql() error {
return db.Close()
}

81
app/manager.go Normal file
View File

@@ -0,0 +1,81 @@
package app
import (
"fmt"
"io"
"net/http"
"strings"
"time"
)
type NodeManager struct {
client *http.Client
token string
url string
}
func NewNodeManager(node Node) NodeManager {
client := &http.Client{Timeout: time.Second * 15}
return NodeManager{
client, node.Token, node.URL,
}
}
func (m *NodeManager) NewRequest(method, path string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequest(method, fmt.Sprintf("%s%s", m.url, path), body)
if err != nil {
return nil, err
}
req.Header.Add("Authorization", m.token)
return req, nil
}
func (m *NodeManager) Do(req *http.Request) (*http.Response, error) {
return m.client.Do(req)
}
func (m *NodeManager) Request(method, path string, body io.Reader) (*http.Response, error) {
req, err := m.NewRequest(method, path, body)
if err != nil {
return nil, err
}
return m.Do(req)
}
func (m *NodeManager) LoadConfig() (NodeConfig, HysteriaConfig, error) {
var _cfg NodeConfig
var hysteriaCfg HysteriaConfig
resp, err := m.Request("GET", "/config", nil)
if err != nil {
return _cfg, hysteriaCfg, err
}
defer resp.Body.Close()
data, err := ReadResponse[NodeConfigRsp](resp.Body)
if err != nil {
return _cfg, hysteriaCfg, err
}
return data.Config, data.HysteriaConfig, nil
}
func (m *NodeManager) AddUser(username, password string) (User, error) {
body := strings.NewReader(fmt.Sprintf(`{"username":"%s","password":"%s"}`, username, password))
resp, err := m.Request("POST", "/users", body)
if err != nil {
return User{}, err
}
defer resp.Body.Close()
return ReadResponse[User](resp.Body)
}
func (m *NodeManager) GetUser(id uint64) (User, error) {
var user User
resp, err := m.Request("GET", fmt.Sprintf("/users/%d", id), nil)
if err != nil {
return user, err
}
defer resp.Body.Close()
return ReadResponse[User](resp.Body)
}
func (m *NodeManager) GetUserKey(id uint64) (string, error) {
resp, err := m.Request("GET", fmt.Sprintf("/users/%d/key", id), nil)
if err != nil {
return "", err
}
defer resp.Body.Close()
return ReadResponse[string](resp.Body)
}

32
app/nodes.go Normal file
View File

@@ -0,0 +1,32 @@
package app
import (
"github.com/vinovest/sqlx"
)
type NodeRepository struct {
db *sqlx.DB
}
func NewNodeRepository(db *sqlx.DB) *NodeRepository { return &NodeRepository{db} }
func (r *NodeRepository) All() ([]Node, error) {
nodes := make([]Node, 0)
sql := "SELECT * FROM nodes;"
err := r.db.Select(&nodes, sql)
return nodes, err
}
func (r *NodeRepository) Find(id int) (Node, error) {
var node Node
sql := "SELECT * FROM nodes WHERE id=?;"
sql = r.db.Rebind(sql)
err := r.db.Select(&node, sql, id)
return node, err
}
func (r *NodeRepository) Create(token, url string) (Node, error) {
sql := "INSERT INTO nodes (token, url) VALUES (?, ?) RETURNING *;"
sql = r.db.Rebind(sql)
var node Node
err := r.db.Get(&node, sql, token, url)
return node, err
}

125
app/routes.go Normal file
View File

@@ -0,0 +1,125 @@
package app
import (
"encoding/base64"
"log"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
)
var hosts = []string{
"185.231.245.25",
"46.243.6.125",
}
func AddNode(w http.ResponseWriter, r *http.Request) {
type AddNodeReq struct {
Token string `json:"token"`
URL string `json:"url"`
}
req, err := ReadBody[AddNodeReq](r.Body)
if err != nil {
log.Println(err)
}
rep := NewNodeRepository(db)
node, err := rep.Create(req.Token, req.URL)
WriteResponseOrError(w, node, err)
}
func GetNodes(w http.ResponseWriter, r *http.Request) {
rep := NewNodeRepository(db)
nodes, err := rep.All()
WriteResponseOrError(w, nodes, err)
}
func GetNodeById(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
nodeId, err := strconv.Atoi(id)
if err != nil {
WriteError(w, err)
return
}
rep := NewNodeRepository(db)
node, err := rep.Find(nodeId)
WriteResponseOrError(w, node, err)
}
func AddUser(w http.ResponseWriter, r *http.Request) {
rep := NewNodeRepository(db)
nodes, err := rep.All()
if err != nil {
WriteError(w, err)
return
}
type AddUserReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
req, err := ReadBody[AddUserReq](r.Body)
if err != nil {
WriteError(w, err)
return
}
var user User
for _, node := range nodes {
manager := NewNodeManager(node)
user, err = manager.AddUser(req.Username, req.Password)
if err != nil {
WriteError(w, err)
return
}
}
WriteResponseOrError(w, user, err)
}
func Sub(w http.ResponseWriter, r *http.Request) {
idS := chi.URLParam(r, "id")
userId, err := strconv.ParseUint(idS, 10, 32)
if err != nil {
log.Println(err)
return
}
rep := NewNodeRepository(db)
nodes, err := rep.All()
if err != nil {
WriteError(w, err)
return
}
subs := make([]string, 0)
for _, node := range nodes {
manager := NewNodeManager(node)
conf, hystConf, err := manager.LoadConfig()
if err != nil {
WriteError(w, err)
continue
}
key, err := manager.GetUserKey(userId)
if err != nil {
WriteError(w, err)
return
}
user, err := manager.GetUser(userId)
if err != nil {
WriteError(w, err)
return
}
hystUrl := buildSubUrl(conf, hystConf, key, user)
subs = append(subs, hystUrl)
}
sub := []byte(base64.StdEncoding.EncodeToString([]byte(strings.Join(subs, "\n"))))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(sub)))
w.Header().Set("Content-Disposition", `attachment; filename="ScuroNeko"`)
w.Header().Set("profile-web-page-url", "http://185.231.245.25:9997/sub/1")
w.Header().Set("support-url", "https://t.me/scuroneko")
w.Header().Set("profile-title", "base64:YmFuLm5peDEzLnB3")
w.Header().Set("profile-update-interval", "12")
w.Header().Set("subscription-userinfo", "upload=0; download=197782510450; total=0; expire=0")
w.WriteHeader(200)
w.Write(sub)
}

133
app/types.go Normal file
View File

@@ -0,0 +1,133 @@
package app
type User struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
}
type Node struct {
ID int `json:"id"`
Token string `json:"token"`
URL string `json:"url"`
}
type NodeConfig struct {
JWTSecret string
Host string
SNI string
NameFormat string
}
type HysteriaTLSConfig struct {
Cert string `yaml:"cert"`
Key string `yaml:"key"`
}
type HysteriaObfsConfig struct {
Type string `yaml:"type"`
Salamander struct {
Password string `yaml:"password"`
} `yaml:"salamander"`
}
type HysteriaQuicConfig struct {
InitStreamReceiveWindow *int64 `yaml:"initStreamReceiveWindow"`
MaxStreamReceiveWindow *int64 `yaml:"maxStreamReceiveWindow"`
InitConnReceiveWindow *int64 `yaml:"initConnReceiveWindow"`
MaxConnReceiveWindow *int64 `yaml:"maxConnReceiveWindow"`
MaxIdleTimeout *string `yaml:"maxIdleTimeout"`
MaxIncomingStream *int64 `yaml:"maxIncomingStream"`
DisablePathMTUDiscovery *bool `yaml:"disablePathMTUDiscovery"`
}
type HysteriaBandwidthConfig struct {
UP *string `yaml:"up"`
Down *string `yaml:"down"`
IgnoreClientBandwidth *bool `yaml:"ignoreClientBandwidth"`
}
type HysteriaAuthType string
type HysteriaAuthConfig struct {
Type HysteriaAuthType `yaml:"type"`
Password string `yaml:"password,omitempty"`
Userpass map[string]string `yaml:"userpass,omitempty"`
Http struct {
URL string `yaml:"url"`
Insecure bool `yaml:"insecure"`
} `yaml:"http,omitempty"`
Command string `yaml:"command,omitempty"`
}
type HysteriaTCPUDPResolverConfig struct {
Address string `yaml:"addr"`
Timeout string `yaml:"timeout"`
}
type HysteriaTLSResolverConfig struct {
Address string `yaml:"addr"`
Timeout string `yaml:"timeout"`
SNI string `yaml:"sni"`
Insecure bool `yaml:"insecure"`
}
type HysteriaResolverType string
type HysteriaResolverConfig struct {
Type HysteriaResolverType `yaml:"type"`
TCP *HysteriaTCPUDPResolverConfig `yaml:"tcp,omitempty"`
UDP *HysteriaTCPUDPResolverConfig `yaml:"udp,omitempty"`
TLS *HysteriaTLSResolverConfig `yaml:"tls,omitempty"`
HTTPS *HysteriaTLSResolverConfig `yaml:"https,omitempty"`
}
type HysteriaSniffConfig struct {
Enable bool `yaml:"enable"`
}
type HysteriaACLConfig struct{}
type HysteriaOutboundConfig struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
}
type HysteriaTrafficStatsConfig struct {
Listen string `yaml:"listen"`
Secret string `yaml:"secret"`
}
type HysteriaMasqueradeType string
type HysteriaMasqueradeFile struct {
Dir string `yaml:"dir"`
}
type HysteriaMasqueradeProxy struct {
URL string `yaml:"url"`
RewriteHost *bool `yaml:"rewriteHost,omitempty"`
Insecure *bool `yaml:"insecure,omitempty"`
}
type HysteriaMasqueradeString struct {
Content string `yaml:"content"`
Headers map[string]string `yaml:"headers,omitempty"`
StatusCode int `yaml:"statusCode"`
}
type HysteriaMasqueradeConfig struct {
Type HysteriaMasqueradeType `yaml:"type"`
File *HysteriaMasqueradeFile `yaml:"file,omitempty"`
Proxy *HysteriaMasqueradeProxy `yaml:"proxy,omitempty"`
String *HysteriaMasqueradeString `yaml:"string,omitempty"`
ListenHTTP *string `yaml:"listenHTTP,omitempty"`
ListenHTTPS *string `yaml:"listenHTTPS,omitempty"`
ForceHTTPS *bool `yaml:"forceHTTPS,omitempty"`
}
type HysteriaConfig struct {
Listen string `yaml:"listen"`
TLS HysteriaTLSConfig `yaml:"tls"`
Obfs *HysteriaObfsConfig `yaml:"obfs,omitempty"`
Quic *HysteriaQuicConfig `yaml:"quic,omitempty"`
SpeedTest *bool `yaml:"speedTest,omitempty"`
Auth HysteriaAuthConfig `yaml:"auth"`
Resolver *HysteriaResolverConfig `yaml:"resolver,omitempty"`
Sniff *HysteriaSniffConfig `yaml:"sniff,omitempty"`
ACL *HysteriaACLConfig `yaml:"acl,omitempty"`
Outbounds []HysteriaOutboundConfig `yaml:"outbounds,omitempty"`
TrafficStats *HysteriaTrafficStatsConfig `yaml:"trafficStats,omitempty"`
}

31
app/utils.go Normal file
View File

@@ -0,0 +1,31 @@
package app
import (
"fmt"
"net/url"
"strings"
)
type NodeConfigRsp struct {
Config NodeConfig `json:"config"`
HysteriaConfig HysteriaConfig `json:"hysteria"`
}
func buildSubUrl(conf NodeConfig, hystConf HysteriaConfig, key string, user User) string {
values := url.Values{}
values.Add("type", "hysteria")
values.Add("security", "tls")
values.Add("alpn", "h3,h2")
values.Add("fp", "chrome")
values.Add("allowInsecure", "0")
values.Add("sni", conf.SNI)
if hystConf.Obfs != nil {
values.Add("obfs", hystConf.Obfs.Type)
values.Add("obfs-password", hystConf.Obfs.Salamander.Password)
}
host := fmt.Sprintf("%s@%s%s", key, conf.Host, hystConf.Listen)
subName := strings.ReplaceAll(conf.NameFormat, "{username}", user.Username)
return fmt.Sprintf("hysteria2://%s?%s#%s", host, values.Encode(), subName)
// hysteria2://pass@185.231.245.25:51052?&type=hysteria&mport&security=tls&sni=nix13.pw&alpn=h3&fp=chrome&allowInsecure=0#🇷🇺ssia (user1)
}

12
go.mod Normal file
View File

@@ -0,0 +1,12 @@
module Hyst2API
go 1.26
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.11.2
github.com/vinovest/sqlx v1.7.2
)
require github.com/muir/sqltoken v0.1.0 // indirect

24
go.sum Normal file
View File

@@ -0,0 +1,24 @@
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
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/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/muir/sqltoken v0.1.0 h1:edosEGsOClOZNfgGQNQSgxR9O6LiVefm2rDRqp2InuI=
github.com/muir/sqltoken v0.1.0/go.mod h1:lgOIORnKekMsuc/ZwdPOfwz/PtWLPCke43cEbT3uDuY=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/vinovest/sqlx v1.7.2 h1:t/IahCJqO71GJYnhOcACiUXlMiiMomMHtxtUthdcBfo=
github.com/vinovest/sqlx v1.7.2/go.mod h1:o49uG4W/ZYZompljKx5GZ7qx6OFklPjSHXP63nSmND8=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

19
main.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"Hyst2API/app"
"log"
"net/http"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
r.Post("/nodes", app.AddNode)
r.Get("/nodes", app.GetNodes)
r.Get("/nodes/{id}", app.GetNodeById)
r.Post("/users", app.AddUser)
r.Get("/sub/{id}", app.Sub)
log.Fatal(http.ListenAndServe(":9997", r))
}

6
scripts/00-init.sql Normal file
View File

@@ -0,0 +1,6 @@
CREATE TABLE nodes(
id SERIAL PRIMARY KEY,
token TEXT NOT NULL,
url TEXT NOT NULL
);
CREATE UNIQUE INDEX nodes_id ON nodes(id);