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

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