summaryrefslogtreecommitdiff
path: root/internal/api/subscribe.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/subscribe.go')
-rw-r--r--internal/api/subscribe.go288
1 files changed, 288 insertions, 0 deletions
diff --git a/internal/api/subscribe.go b/internal/api/subscribe.go
new file mode 100644
index 0000000..b4890fa
--- /dev/null
+++ b/internal/api/subscribe.go
@@ -0,0 +1,288 @@
+package api
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "vpnem/internal/models"
+)
+
+func (h *Handler) Subscribe(w http.ResponseWriter, r *http.Request) {
+ links := make([]string, 0)
+
+ catalog, err := h.store.LoadCatalogV2OrLegacy()
+ if err == nil {
+ for _, node := range catalog.Nodes {
+ for _, protocol := range node.Protocols {
+ link, ok := subscriptionLinkV2(node, protocol)
+ if !ok {
+ continue
+ }
+ links = append(links, link)
+ }
+ }
+ } else {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ if r.URL.Query().Get("format") == "plain" {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte(strings.Join(links, "\n")))
+ return
+ }
+
+ payload := base64.StdEncoding.EncodeToString([]byte(strings.Join(links, "\n")))
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte(payload))
+}
+
+func subscriptionLink(server models.Server) (string, bool) {
+ switch server.Type {
+ case "vless":
+ if strings.TrimSpace(server.UUID) == "" {
+ return "", false
+ }
+ query := url.Values{}
+ security := "none"
+ if server.TLS != nil && server.TLS.Enabled {
+ security = "tls"
+ if strings.TrimSpace(server.TLS.ServerName) != "" {
+ query.Set("sni", server.TLS.ServerName)
+ }
+ }
+ query.Set("security", security)
+ if server.Transport != nil {
+ if strings.TrimSpace(server.Transport.Type) != "" {
+ query.Set("type", server.Transport.Type)
+ }
+ if strings.TrimSpace(server.Transport.Path) != "" {
+ query.Set("path", server.Transport.Path)
+ }
+ }
+ return fmt.Sprintf(
+ "vless://%s@%s:%d?%s#%s",
+ server.UUID,
+ server.Server,
+ server.ServerPort,
+ query.Encode(),
+ url.QueryEscape(server.Tag),
+ ), true
+ case "vless-reality":
+ if strings.TrimSpace(server.UUID) == "" || server.TLS == nil || server.TLS.Reality == nil {
+ return "", false
+ }
+ query := url.Values{}
+ query.Set("encryption", "none")
+ query.Set("security", "reality")
+ query.Set("sni", server.TLS.ServerName)
+ query.Set("fp", firstNonEmpty(server.TLS.Reality.Fingerprint, "chrome"))
+ query.Set("pbk", server.TLS.Reality.PublicKey)
+ query.Set("sid", server.TLS.Reality.ShortID)
+ query.Set("type", "tcp")
+ return fmt.Sprintf(
+ "vless://%s@%s:%d?%s#%s",
+ server.UUID,
+ server.Server,
+ server.ServerPort,
+ query.Encode(),
+ url.QueryEscape(server.Tag),
+ ), true
+ case "shadowsocks":
+ if strings.TrimSpace(server.Method) == "" || strings.TrimSpace(server.Password) == "" {
+ return "", false
+ }
+ userInfo := base64.StdEncoding.EncodeToString([]byte(server.Method + ":" + server.Password))
+ return fmt.Sprintf(
+ "ss://%s@%s:%d#%s",
+ userInfo,
+ server.Server,
+ server.ServerPort,
+ url.QueryEscape(server.Tag),
+ ), true
+ case "socks":
+ return fmt.Sprintf(
+ "socks5://%s:%d#%s",
+ server.Server,
+ server.ServerPort,
+ url.QueryEscape(server.Tag),
+ ), true
+ default:
+ return "", false
+ }
+}
+
+func subscriptionLinkV2(node models.CatalogNode, protocol models.CatalogProtocol) (string, bool) {
+ host := node.PublicHost
+ if strings.TrimSpace(host) == "" {
+ if strings.TrimSpace(node.Domain) != "" {
+ host = node.Domain
+ } else {
+ host = node.Host
+ }
+ }
+ tag := subscriptionTag(node, protocol)
+
+ switch protocol.Type {
+ case "vless":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" {
+ return "", false
+ }
+ query := url.Values{}
+ security := "none"
+ if protocol.TLS != nil && protocol.TLS.Enabled {
+ security = "tls"
+ if strings.TrimSpace(protocol.TLS.ServerName) != "" {
+ query.Set("sni", protocol.TLS.ServerName)
+ }
+ }
+ query.Set("security", security)
+ if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" {
+ query.Set("type", transportType)
+ }
+ if path, _ := protocol.Extra["path"].(string); path != "" {
+ query.Set("path", path)
+ }
+ return fmt.Sprintf(
+ "vless://%s@%s:%d?%s#%s",
+ protocol.Auth.UUID,
+ host,
+ protocol.Port,
+ query.Encode(),
+ url.QueryEscape(tag),
+ ), true
+ case "vless-reality":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" || protocol.TLS == nil || protocol.TLS.Reality == nil {
+ return "", false
+ }
+ query := url.Values{}
+ query.Set("encryption", "none")
+ query.Set("security", "reality")
+ query.Set("sni", protocol.TLS.ServerName)
+ query.Set("fp", firstNonEmpty(protocol.TLS.Reality.Fingerprint, "chrome"))
+ query.Set("pbk", protocol.TLS.Reality.PublicKey)
+ query.Set("sid", protocol.TLS.Reality.ShortID)
+ query.Set("type", "tcp")
+ return fmt.Sprintf(
+ "vless://%s@%s:%d?%s#%s",
+ protocol.Auth.UUID,
+ host,
+ protocol.Port,
+ query.Encode(),
+ url.QueryEscape(tag),
+ ), true
+ case "shadowsocks":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Method) == "" || strings.TrimSpace(protocol.Auth.Password) == "" {
+ return "", false
+ }
+ userInfo := base64.StdEncoding.EncodeToString([]byte(protocol.Auth.Method + ":" + protocol.Auth.Password))
+ return fmt.Sprintf(
+ "ss://%s@%s:%d#%s",
+ userInfo,
+ host,
+ protocol.Port,
+ url.QueryEscape(tag),
+ ), true
+ case "socks", "socks5":
+ return fmt.Sprintf(
+ "socks5://%s:%d#%s",
+ host,
+ protocol.Port,
+ url.QueryEscape(tag),
+ ), true
+ case "vmess":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" {
+ return "", false
+ }
+ payload := map[string]string{
+ "v": "2",
+ "ps": tag,
+ "add": host,
+ "port": fmt.Sprintf("%d", protocol.Port),
+ "id": protocol.Auth.UUID,
+ "aid": "0",
+ "scy": "auto",
+ "net": "ws",
+ "type": "none",
+ "host": strings.TrimSpace(protocol.TLS.ServerName),
+ "path": stringFromExtraMap(protocol.Extra, "path", "/vmess"),
+ "tls": vmessTLSValue(protocol.TLS),
+ "sni": strings.TrimSpace(protocol.TLS.ServerName),
+ }
+ if payload["host"] == "" {
+ payload["host"] = host
+ }
+ if payload["sni"] == "" {
+ payload["sni"] = host
+ }
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return "", false
+ }
+ return "vmess://" + base64.StdEncoding.EncodeToString(data), true
+ case "hysteria2":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" {
+ return "", false
+ }
+ query := url.Values{}
+ sni := ""
+ if protocol.TLS != nil && strings.TrimSpace(protocol.TLS.ServerName) != "" {
+ sni = protocol.TLS.ServerName
+ }
+ if sni != "" {
+ query.Set("sni", sni)
+ }
+ query.Set("alpn", "h3")
+ query.Set("insecure", "1")
+ if obfsPassword, _ := protocol.Extra["obfs_password"].(string); obfsPassword != "" {
+ query.Set("obfs", "salamander")
+ query.Set("obfs-password", obfsPassword)
+ }
+ return fmt.Sprintf(
+ "hysteria2://%s@%s:%d/?%s#%s",
+ url.QueryEscape(protocol.Auth.Password),
+ host,
+ protocol.Port,
+ query.Encode(),
+ url.QueryEscape(tag),
+ ), true
+ default:
+ return "", false
+ }
+}
+
+func subscriptionTag(node models.CatalogNode, protocol models.CatalogProtocol) string {
+ if legacy := stringFromExtraMap(protocol.Extra, "legacy_tag", ""); legacy != "" {
+ return legacy
+ }
+ return node.ID + "-" + protocol.Type
+}
+
+func stringFromExtraMap(extra map[string]any, key, fallback string) string {
+ if extra == nil {
+ return fallback
+ }
+ value, _ := extra[key].(string)
+ if strings.TrimSpace(value) == "" {
+ return fallback
+ }
+ return value
+}
+
+func vmessTLSValue(tls *models.TLS) string {
+ if tls != nil && tls.Enabled {
+ return "tls"
+ }
+ return ""
+}
+
+func firstNonEmpty(value, fallback string) string {
+ if strings.TrimSpace(value) != "" {
+ return value
+ }
+ return fallback
+}