362 lines
11 KiB
Go
362 lines
11 KiB
Go
// Package share implements the guest share-link domain: link/creation repository,
|
|
// renewal codes, guest server catalog building and panel-response bundling for
|
|
// downloads (config files, vpn:// / vless:// links, zip archives).
|
|
package share
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// VpnLinkMaxStoreLen caps the size of vpn_link kept in the DB response_json blob
|
|
// (protects against a bloated page / storage row).
|
|
const VpnLinkMaxStoreLen = 524288
|
|
|
|
var (
|
|
importURIRe = regexp.MustCompile(`(?i)^(vless|vmess|trojan)://`)
|
|
vpnWrapperRe = regexp.MustCompile(`(?i)^vpn://`)
|
|
zipNameBadRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
|
)
|
|
|
|
// IsAWGFamily reports whether protocol is one of the AmneziaWG variants.
|
|
func IsAWGFamily(protocol string) bool {
|
|
switch protocol {
|
|
case "awg", "awg2", "awg_legacy":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// IsVLESSFamily reports whether protocol is VLESS/Xray.
|
|
func IsVLESSFamily(protocol string) bool {
|
|
return protocol == "vless" || protocol == "xray"
|
|
}
|
|
|
|
// UsesImportSlot reports whether the protocol has a dedicated "import link" download
|
|
// slot (AWG uses vpn://, VLESS uses vless://); WireGuard only has the .conf slot.
|
|
func UsesImportSlot(protocol string) bool {
|
|
return IsAWGFamily(protocol) || IsVLESSFamily(protocol)
|
|
}
|
|
|
|
// ProtocolAPI maps a guest-facing protocol id to the Amnezia Web Panel API protocol
|
|
// name used when calling /api/servers/{id}/connections/add|remove (vless -> xray).
|
|
func ProtocolAPI(protocol string) string {
|
|
if protocol == "vless" {
|
|
return "xray"
|
|
}
|
|
return protocol
|
|
}
|
|
|
|
// ProtocolSortRank orders protocols for display: WireGuard first, then AWG family,
|
|
// then VLESS/Xray, then anything else.
|
|
func ProtocolSortRank(protocol string) int {
|
|
switch {
|
|
case protocol == "wireguard":
|
|
return 0
|
|
case IsAWGFamily(protocol):
|
|
return 1
|
|
case IsVLESSFamily(protocol):
|
|
return 2
|
|
default:
|
|
return 3
|
|
}
|
|
}
|
|
|
|
// IsAmneziaVPNWrapper reports whether link is the Amnezia "vpn://base64(...)" wrapper.
|
|
func IsAmneziaVPNWrapper(link string) bool {
|
|
link = strings.TrimSpace(link)
|
|
return link != "" && vpnWrapperRe.MatchString(link)
|
|
}
|
|
|
|
// decodeLooseBase64 tries strict standard base64 first, then falls back to the
|
|
// URL-safe alphabet (mirrors PHP's base64_decode(strict) + strtr('-_','+/') dance).
|
|
func decodeLooseBase64(payload string) (string, bool) {
|
|
if b, err := base64.StdEncoding.DecodeString(payload); err == nil && len(b) > 0 {
|
|
return string(b), true
|
|
}
|
|
if b, err := base64.RawStdEncoding.DecodeString(payload); err == nil && len(b) > 0 {
|
|
return string(b), true
|
|
}
|
|
swapped := strings.NewReplacer("-", "+", "_", "/").Replace(payload)
|
|
if b, err := base64.StdEncoding.DecodeString(swapped); err == nil && len(b) > 0 {
|
|
return string(b), true
|
|
}
|
|
if b, err := base64.RawStdEncoding.DecodeString(swapped); err == nil && len(b) > 0 {
|
|
return string(b), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func decodeVpnWrapperPayload(vpnLink string) (string, bool) {
|
|
payload := vpnWrapperRe.ReplaceAllString(strings.TrimSpace(vpnLink), "")
|
|
if payload == "" {
|
|
return "", false
|
|
}
|
|
return decodeLooseBase64(payload)
|
|
}
|
|
|
|
// VlessImportURI returns the native vless:// (or vmess:// / trojan://) link suitable
|
|
// for import into Happ / v2rayTun / Hiddify style clients. It never returns the
|
|
// Amnezia vpn:// wrapper — only a plain URI, or "" if none could be resolved.
|
|
func VlessImportURI(configText, vpnLink string) string {
|
|
configText = strings.TrimSpace(configText)
|
|
vpnLink = strings.TrimSpace(vpnLink)
|
|
|
|
if configText != "" && importURIRe.MatchString(configText) {
|
|
return configText
|
|
}
|
|
if vpnLink != "" && importURIRe.MatchString(vpnLink) {
|
|
return vpnLink
|
|
}
|
|
if IsAmneziaVPNWrapper(vpnLink) {
|
|
if decoded, ok := decodeVpnWrapperPayload(vpnLink); ok {
|
|
decoded = strings.TrimSpace(decoded)
|
|
if decoded != "" && importURIRe.MatchString(decoded) {
|
|
return decoded
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// AWGImportURI returns the AmneziaWG vpn:// import link from the panel response.
|
|
func AWGImportURI(vpnLink, configText string) string {
|
|
vpnLink = strings.TrimSpace(vpnLink)
|
|
if vpnLink != "" {
|
|
return vpnLink
|
|
}
|
|
return strings.TrimSpace(configText)
|
|
}
|
|
|
|
// ImportDownloadFilename returns the file name used for the "import" download slot
|
|
// (vless family -> .txt, AWG/others -> .vpn).
|
|
func ImportDownloadFilename(protocol, connectionName string) string {
|
|
if IsVLESSFamily(protocol) {
|
|
return connectionName + ".txt"
|
|
}
|
|
return connectionName + ".vpn"
|
|
}
|
|
|
|
// ConfExtension guesses the file extension for the raw "config" text returned by the
|
|
// panel, based on protocol and content sniffing.
|
|
func ConfExtension(protocol, configText string) string {
|
|
hasIface := strings.Contains(configText, "[Interface]")
|
|
hasPeer := strings.Contains(configText, "[Peer]")
|
|
if protocol == "wireguard" && (hasIface || hasPeer) {
|
|
return "conf"
|
|
}
|
|
if IsAWGFamily(protocol) && hasIface {
|
|
return "conf"
|
|
}
|
|
if IsVLESSFamily(protocol) {
|
|
t := strings.TrimLeft(configText, " \t\r\n")
|
|
if t != "" && (t[0] == '{' || t[0] == '[') {
|
|
return "json"
|
|
}
|
|
}
|
|
return "txt"
|
|
}
|
|
|
|
func vlessURIFromPanelJSON(configText, vpnLink string) string {
|
|
// Identical rules to VlessImportURI; kept as a separate name to mirror the PHP
|
|
// split between share_panel_json.php and share_downloads.php.
|
|
return VlessImportURI(configText, vpnLink)
|
|
}
|
|
|
|
// SlimPanelJSON prepares the panel's raw connection-add JSON response for storage:
|
|
// - drops an oversized vpn_link (defensive cap),
|
|
// - for VLESS/Xray, replaces "config" with the resolved vless:// URI and strips
|
|
// the vpn:// wrapper entirely (only vless:// is ever persisted for VLESS).
|
|
//
|
|
// Any other protocol JSON is normalized (re-marshalled) but left otherwise intact.
|
|
// If jsonBody cannot be parsed as an object, it is returned unchanged.
|
|
func SlimPanelJSON(protocol, jsonBody string) string {
|
|
var dec map[string]any
|
|
if err := json.Unmarshal([]byte(jsonBody), &dec); err != nil {
|
|
return jsonBody
|
|
}
|
|
|
|
if v, ok := dec["vpn_link"]; ok {
|
|
if s, ok := v.(string); ok && len(s) > VpnLinkMaxStoreLen {
|
|
delete(dec, "vpn_link")
|
|
}
|
|
}
|
|
|
|
if IsVLESSFamily(protocol) {
|
|
cfg, _ := dec["config"].(string)
|
|
vpn, _ := dec["vpn_link"].(string)
|
|
uri := vlessURIFromPanelJSON(strings.TrimSpace(cfg), strings.TrimSpace(vpn))
|
|
if uri != "" {
|
|
dec["config"] = uri
|
|
}
|
|
delete(dec, "vpn_link")
|
|
}
|
|
|
|
out, err := json.Marshal(dec)
|
|
if err != nil {
|
|
return jsonBody
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// FilePart is a single downloadable artifact (filename + raw body).
|
|
type FilePart struct {
|
|
Filename string
|
|
Body string
|
|
Mime string
|
|
}
|
|
|
|
// Bundle groups all downloadable artifacts derived from one panel connection-add
|
|
// response: the raw config file, the "import" link file (vpn:// / vless://), and
|
|
// (for WireGuard) a convenience .zip archive wrapping the .conf.
|
|
type Bundle struct {
|
|
Base string
|
|
Conf *FilePart
|
|
Vpn *FilePart
|
|
Zip *FilePart
|
|
CreationID int64
|
|
CreatedAt time.Time
|
|
Protocol string
|
|
ConnectionName string
|
|
ServerID int
|
|
}
|
|
|
|
// BundleFromResponseJSON extracts "config" / "vpn_link" from a (possibly already
|
|
// slimmed) panel JSON response and builds the full download bundle: which files to
|
|
// offer, under what names, and — for WireGuard — an in-memory .zip archive.
|
|
func BundleFromResponseJSON(protocol, connectionName, jsonBody string) Bundle {
|
|
var dec map[string]any
|
|
_ = json.Unmarshal([]byte(jsonBody), &dec)
|
|
cfgText, _ := dec["config"].(string)
|
|
vpnLink, _ := dec["vpn_link"].(string)
|
|
|
|
isAwg := IsAWGFamily(protocol)
|
|
isVless := IsVLESSFamily(protocol)
|
|
useImportSlot := UsesImportSlot(protocol)
|
|
|
|
importBody := ""
|
|
switch {
|
|
case isVless:
|
|
importBody = VlessImportURI(cfgText, vpnLink)
|
|
case isAwg:
|
|
importBody = AWGImportURI(vpnLink, cfgText)
|
|
}
|
|
|
|
bundle := Bundle{Base: connectionName, Protocol: protocol, ConnectionName: connectionName}
|
|
|
|
if cfgText != "" {
|
|
ext := ConfExtension(protocol, cfgText)
|
|
if isAwg && ext == "txt" {
|
|
ext = "conf"
|
|
}
|
|
skipConfDup := isVless && importBody != "" && strings.TrimSpace(cfgText) == importBody
|
|
if !skipConfDup {
|
|
bundle.Conf = &FilePart{Filename: connectionName + "." + ext, Body: cfgText}
|
|
}
|
|
}
|
|
|
|
if useImportSlot && importBody != "" {
|
|
bundle.Vpn = &FilePart{Filename: ImportDownloadFilename(protocol, connectionName), Body: importBody}
|
|
} else if bundle.Conf == nil && importBody != "" {
|
|
ext := ConfExtension(protocol, importBody)
|
|
bundle.Conf = &FilePart{Filename: connectionName + "." + ext, Body: importBody}
|
|
}
|
|
|
|
if protocol == "wireguard" && bundle.Conf != nil && bundle.Conf.Body != "" {
|
|
if zipBytes, err := WireGuardZipBytes(bundle.Conf.Filename, bundle.Conf.Body); err == nil && len(zipBytes) > 0 {
|
|
bundle.Zip = &FilePart{Filename: connectionName + ".zip", Body: string(zipBytes), Mime: "application/zip"}
|
|
}
|
|
}
|
|
|
|
return bundle
|
|
}
|
|
|
|
// DownloadPayloadForPart resolves a single download slot ("conf" | "vpn" | "zip")
|
|
// for an existing stored connection, given its protocol/connection name/response JSON.
|
|
func DownloadPayloadForPart(protocol, connectionName, jsonBody, part string) *FilePart {
|
|
part = strings.ToLower(strings.TrimSpace(part))
|
|
if part != "conf" && part != "vpn" && part != "zip" {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(jsonBody) == "" {
|
|
return nil
|
|
}
|
|
bundle := BundleFromResponseJSON(protocol, connectionName, jsonBody)
|
|
switch part {
|
|
case "conf":
|
|
return bundle.Conf
|
|
case "vpn":
|
|
return bundle.Vpn
|
|
case "zip":
|
|
return bundle.Zip
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// WireGuardZipBytes builds an in-memory .zip archive containing a single file
|
|
// (the WireGuard .conf) — handy for sending through messengers that mangle bare
|
|
// .conf attachments.
|
|
func WireGuardZipBytes(innerFilename, confBody string) ([]byte, error) {
|
|
name := sanitizeZipEntryName(innerFilename)
|
|
buf := &bytes.Buffer{}
|
|
zw := zip.NewWriter(buf)
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := w.Write([]byte(confBody)); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func sanitizeZipEntryName(name string) string {
|
|
base := filepath.Base(strings.ReplaceAll(name, "\\", "/"))
|
|
base = zipNameBadRe.ReplaceAllString(base, "_")
|
|
if base == "" || base == "_" {
|
|
base = "amnezia.conf"
|
|
}
|
|
return base
|
|
}
|
|
|
|
// SortBundlesWireguardFirst orders bundles WireGuard -> AWG -> VLESS -> other, then
|
|
// newest first within the same protocol group.
|
|
func SortBundlesWireguardFirst(bundles []Bundle) []Bundle {
|
|
sort.SliceStable(bundles, func(i, j int) bool {
|
|
ri, rj := ProtocolSortRank(bundles[i].Protocol), ProtocolSortRank(bundles[j].Protocol)
|
|
if ri != rj {
|
|
return ri < rj
|
|
}
|
|
return bundles[i].CreatedAt.After(bundles[j].CreatedAt)
|
|
})
|
|
return bundles
|
|
}
|
|
|
|
// DisplayBody truncates a config body for inline display on the guest page (full
|
|
// text remains available via download).
|
|
func DisplayBody(body string, maxLen int) string {
|
|
if maxLen < 256 {
|
|
maxLen = 256
|
|
}
|
|
if len(body) <= maxLen {
|
|
return body
|
|
}
|
|
return body[:maxLen] + "\n\n… (сокращено для отображения; скачайте файл целиком)"
|
|
}
|
|
|
|
// QRMaxPayloadLen is the maximum string length considered safe to render as a QR
|
|
// code on the guest page.
|
|
const QRMaxPayloadLen = 1800
|