diff options
Diffstat (limited to 'internal/api/subscribe.go')
| -rw-r--r-- | internal/api/subscribe.go | 288 |
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 +} |
