Skip to main content

vpn_link_serde/
shadowsocks.rs

1//! Shadowsocks protocol parser (SIP002)
2//!
3//! Link format: `ss://[userinfo@]hostname:port[/][?plugin][#tag]`
4//!
5//! **userinfo**: Base64-encoded `method:password` (Stream/AEAD). Hostname and port are plain text.
6//!
7//! **plugin**: Optional; e.g. `plugin-name;opt=value`, URL-encoded. If present, SIP002 requires a `/` after port (e.g. `...port/?plugin=...`).
8//!
9//! **tag**: Optional; fragment used as remark; must be URL-encoded if it contains spaces or non-ASCII.
10//!
11//! ## Parsing rules
12//!
13//! 1. Prefix `ss://` is case-insensitive.
14//! 2. Fragment (tag) is split by `#`; query (plugin) by `?`. Remainder is `[userinfo@]hostname:port`.
15//! 3. If the part before `@` (or the whole body if no `@`) is valid Base64, decode to get `method:password`; hostname and port come from the part after `@` or the whole body. Port must parse as u16.
16
17use crate::ProtocolParser;
18use crate::constants::{error_msg, scheme};
19use crate::error::{ProtocolError, Result};
20use base64::Engine;
21use serde::{Deserialize, Serialize};
22
23/// Shadowsocks configuration structure
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct ShadowsocksConfig {
26    /// Encryption method (aes-256-gcm, chacha20-poly1305, etc.)
27    pub method: String,
28    /// Password
29    pub password: String,
30    /// Server address
31    pub address: String,
32    /// Server port
33    pub port: u16,
34    /// Tag/remark
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub tag: Option<String>,
37    /// Plugin information
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub plugin: Option<String>,
40}
41
42/// Shadowsocks protocol parser
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Shadowsocks {
45    /// Shadowsocks configuration
46    pub config: ShadowsocksConfig,
47}
48
49impl ProtocolParser for Shadowsocks {
50    fn parse(link: &str) -> Result<Self> {
51        if !link.to_lowercase().starts_with(scheme::SHADOWSOCKS) {
52            return Err(ProtocolError::InvalidFormat(format!(
53                "{} {}",
54                error_msg::MUST_START_WITH,
55                scheme::SHADOWSOCKS
56            )));
57        }
58
59        let link_body = &link[scheme::SHADOWSOCKS.len()..];
60
61        // Extract fragment (tag) if present
62        let (main_part, tag) = {
63            if let Some(hash_pos) = link_body.find('#') {
64                let tag_str = &link_body[hash_pos + 1..];
65                let decoded_tag = urlencoding::decode(tag_str).map_err(|e| {
66                    ProtocolError::UrlParseError(format!("Failed to decode tag: {}", e))
67                })?;
68                (&link_body[..hash_pos], Some(decoded_tag.to_string()))
69            } else {
70                (link_body, None)
71            }
72        };
73
74        // Extract query parameters (for plugin)
75        let (address_part, plugin) = {
76            if let Some(query_pos) = main_part.find('?') {
77                let query_str = &main_part[query_pos + 1..];
78                let params: std::collections::HashMap<String, String> =
79                    url::form_urlencoded::parse(query_str.as_bytes())
80                        .into_owned()
81                        .collect();
82                let plugin = params.get("plugin").cloned();
83                (&main_part[..query_pos], plugin)
84            } else {
85                (main_part, None)
86            }
87        };
88
89        // Check if it's base64 encoded or SIP002 format
90        let (method, password, address, port) = if address_part
91            .chars()
92            .all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
93        {
94            // Base64 encoded format: base64(method:password)@host:port
95            let decoded = base64::engine::general_purpose::STANDARD.decode(address_part)?;
96            let decoded_str = String::from_utf8(decoded)
97                .map_err(|e| ProtocolError::InvalidFormat(format!("Invalid UTF-8: {}", e)))?;
98
99            let at_pos = decoded_str
100                .rfind('@')
101                .ok_or_else(|| ProtocolError::InvalidFormat(error_msg::MISSING_AT.to_string()))?;
102
103            let method_password = &decoded_str[..at_pos];
104            let host_port = &decoded_str[at_pos + 1..];
105
106            let colon_pos = method_password.find(':').ok_or_else(|| {
107                ProtocolError::InvalidFormat("Missing ':' in method:password".to_string())
108            })?;
109
110            let method = &method_password[..colon_pos];
111            let password = &method_password[colon_pos + 1..];
112
113            let hp_colon = host_port.find(':').ok_or_else(|| {
114                ProtocolError::InvalidFormat(error_msg::MISSING_COLON_HOST_PORT.to_string())
115            })?;
116
117            let address = &host_port[..hp_colon];
118            let port_str = host_port[hp_colon + 1..].trim_end_matches('/');
119            let port: u16 = port_str.parse().map_err(|e| {
120                ProtocolError::InvalidField(format!("{}: {}", error_msg::INVALID_PORT, e))
121            })?;
122
123            (
124                method.to_string(),
125                password.to_string(),
126                address.to_string(),
127                port,
128            )
129        } else {
130            // SIP002 format: method:password@host:port (URL encoded)
131            let at_pos = address_part
132                .rfind('@')
133                .ok_or_else(|| ProtocolError::InvalidFormat(error_msg::MISSING_AT.to_string()))?;
134
135            let user_info = &address_part[..at_pos];
136            let host_port = address_part[at_pos + 1..].trim_end_matches('/');
137
138            let decoded_user = base64::engine::general_purpose::STANDARD.decode(user_info)?;
139            let user_str = String::from_utf8(decoded_user)
140                .map_err(|e| ProtocolError::InvalidFormat(format!("Invalid UTF-8: {}", e)))?;
141
142            let colon_pos = user_str.find(':').ok_or_else(|| {
143                ProtocolError::InvalidFormat("Missing ':' in method:password".to_string())
144            })?;
145
146            let method = &user_str[..colon_pos];
147            let password = &user_str[colon_pos + 1..];
148
149            let hp_colon = host_port.find(':').ok_or_else(|| {
150                ProtocolError::InvalidFormat(error_msg::MISSING_COLON_HOST_PORT.to_string())
151            })?;
152
153            let address = &host_port[..hp_colon];
154            let port_str = &host_port[hp_colon + 1..];
155            let port: u16 = port_str.parse().map_err(|e| {
156                ProtocolError::InvalidField(format!("{}: {}", error_msg::INVALID_PORT, e))
157            })?;
158
159            (
160                method.to_string(),
161                password.to_string(),
162                address.to_string(),
163                port,
164            )
165        };
166
167        Ok(Shadowsocks {
168            config: ShadowsocksConfig {
169                method,
170                password,
171                address,
172                port,
173                tag,
174                plugin,
175            },
176        })
177    }
178
179    fn to_link(&self) -> Result<String> {
180        // Use SIP002 format: ss://base64(method:password)@host:port
181        let user_info = format!("{}:{}", self.config.method, self.config.password);
182        let encoded_user = base64::engine::general_purpose::STANDARD.encode(user_info.as_bytes());
183
184        // SIP002: port 后应有 / 再接 ?plugin
185        let mut link = format!(
186            "ss://{}@{}:{}",
187            encoded_user, self.config.address, self.config.port
188        );
189        if self.config.plugin.is_some() {
190            link.push('/');
191        }
192
193        // Add plugin query parameter if present
194        if let Some(ref plugin) = self.config.plugin {
195            link.push_str(&format!("?plugin={}", urlencoding::encode(plugin)));
196        }
197
198        // Add tag (fragment) if present
199        if let Some(ref tag) = self.config.tag {
200            link.push_str(&format!("#{}", urlencoding::encode(tag)));
201        }
202
203        Ok(link)
204    }
205}