Initial import: vojo proxy — native Android proxy client

Kotlin + Jetpack Compose VPN proxy client (Shadowsocks AEAD SIP004,
SOCKS5 RFC1928/1929, HTTP/HTTPS CONNECT) with per-app whitelist via
VpnService and the vendored hev-socks5-tunnel engine (JNI). Includes
the post-review fixes to the connection lifecycle, reconnect teardown,
journal, app-filter auto-reconnect, and the tab/timeline layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
v.lagerev 2026-06-13 12:51:38 +03:00
commit c3c10c92d6
599 changed files with 151414 additions and 0 deletions

30
.gitignore vendored Normal file
View file

@ -0,0 +1,30 @@
# Gradle / build output
.gradle/
build/
app/build/
**/build/
.kotlin/
# Android / IDE
local.properties
*.iml
.idea/
captures/
.externalNativeBuild/
.cxx/
# Native build intermediates
obj/
app/src/main/jni/**/obj/
# Editor / OS
.vscode/
.DS_Store
# Local agent state
.claude/
# Keystores / secrets (never commit signing material)
*.jks
*.keystore
keystore.properties

75
README.md Normal file
View file

@ -0,0 +1,75 @@
# vojo proxy
Нативный Android-клиент для прокси: **Shadowsocks**, **SOCKS5**, **HTTP**, **HTTPS**.
Per-app whitelist, минимальный расход батареи, маленький размер (release APK ≈ 2.4 МБ).
Дизайн — в стиле мессенджера vojo (Dawn/Fleet: тёмные поверхности, hairline-границы,
один фиолетовый акцент, monospace для технических данных).
## Стек
- **Kotlin + Jetpack Compose + Material3**, Coroutines, DataStore, kotlinx.serialization.
- **VpnService** для создания tun-интерфейса и per-app whitelist (`addAllowedApplication`).
- **tun2socks**: нативный движок [`hev-socks5-tunnel`](https://github.com/heiher/hev-socks5-tunnel)
(C, event-loop, ~320 КБ) — самый экономичный по CPU/батарее способ конвертировать
IP-пакеты из tun в потоки.
- minSdk 28, targetSdk 35, только `arm64-v8a` (Samsung S23).
## Архитектура потока данных
```
Выбранные приложения
│ (per-app whitelist через VpnService.addAllowedApplication)
tun-интерфейс ──fd──► hev-socks5-tunnel (нативный, JNI)
│ говорит SOCKS5 на 127.0.0.1:<port>
LocalSocks5Server (Kotlin, loopback)
│ мост на нужный апстрим
┌─────────────────────┼─────────────────────┬───────────────┐
▼ ▼ ▼ ▼
Shadowsocks SOCKS5 HTTP CONNECT HTTPS CONNECT
(AEAD SIP004) (RFC 1928/1929) (RFC 7231) (TLS + CONNECT)
```
- **TCP** проксируется для всех 4 протоколов. Цель приходит уже как IP/домен в SOCKS5-форме.
- **UDP/DNS**: `hev` отдаёт UDP через SOCKS5 UDP ASSOCIATE. Для SS/SOCKS5 — полноценный
UDP-релей; для HTTP/HTTPS (UDP не поддерживается протоколом) — DNS-over-TCP fallback (порт 53).
- Все апстрим-сокеты защищены `VpnService.protect()`, чтобы не зацикливаться обратно в tun.
## Ключевые модули
| Путь | Назначение |
|------|-----------|
| `core/Tun2Socks.kt`, `jni/tun2socks.c` | JNI-мост к нативному движку |
| `core/crypto/ShadowsocksCrypto.kt`, `ShadowsocksStream.kt` | AEAD (HKDF-SHA1, AES-GCM, ChaCha20-Poly1305), TCP-framing, UDP seal/open |
| `core/socks/LocalSocks5Server.kt` | Локальный SOCKS5-сервер: CONNECT + UDP ASSOCIATE |
| `core/upstream/*` | Апстримы SS / SOCKS5 / HTTP(S) |
| `service/ProxyVpnService.kt` | VpnService, whitelist, foreground-уведомление, статистика, журнал событий |
| `service/ProxyTileService.kt` | Тайл быстрых настроек (вкл/выкл из шторки) |
| `data/ConfigStore.kt`, `AppRepository.kt` | Персист конфигов (DataStore) и список приложений |
| `core/ConfigImport.kt` | Импорт ссылок `ss://` (SIP002/legacy), `socks5://`, `http(s)://` |
| `ui/*` | Compose-экраны: Туннель (журнал событий на timeline rail), Серверы, Приложения, Опции |
## Сборка
```bash
./gradlew :app:assembleRelease # → app/build/outputs/apk/release/app-release.apk (~2.4 МБ)
./gradlew :app:assembleDebug
```
Требуется Android SDK (platform-35, build-tools 35), NDK 27.2.x. Путь к SDK — в `local.properties`.
## Оптимизация батареи
- Нативный event-loop tun2socks вместо userspace TCP/IP-стека на JVM (нет GC-давления на пакет).
- Блокирующий I/O на эластичном пуле потоков — простаивающие соединения паркуют поток (0% CPU).
- `setUnderlyingNetworks` + отслеживание смены сети, `setMetered(false)`.
- Низкоприоритетное foreground-уведомление, поллинг статистики раз в 2 с (без wakelock).
- Секундный тикер UI работает только при CONNECTED и только пока активити RESUMED.
## Безопасность / ограничения
- Не реализованы VLESS/Reality (намеренно).
- Shadowsocks: AEAD-шифры (`aes-128-gcm`, `aes-256-gcm`, `chacha20-ietf-poly1305`).
Legacy stream-шифры и SS-2022 не поддерживаются.
- HTTP/HTTPS-прокси не могут передавать UDP — для них работает только DNS-over-TCP.

102
app/build.gradle.kts Normal file
View file

@ -0,0 +1,102 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "chat.vojo.proxy"
compileSdk = 35
ndkVersion = "27.2.12479018"
defaultConfig {
applicationId = "chat.vojo.proxy"
minSdk = 28
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
ndk {
abiFilters += "arm64-v8a"
}
vectorDrawables { useSupportLibrary = true }
resourceConfigurations += setOf("en", "ru")
}
externalNativeBuild {
ndkBuild {
path = file("src/main/jni/Android.mk")
}
}
buildFeatures {
compose = true
buildConfig = true
}
signingConfigs {
create("release") {
// Debug keystore so CI/dev builds are installable; replace for production.
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
debug {
applicationIdSuffix = ".debug"
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"DebugProbesKt.bin",
"kotlin-tooling-metadata.json",
)
}
// hev needs 16KB-aligned shared libs (kept uncompressed for mmap on modern Android)
jniLibs { useLegacyPackaging = false }
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.01")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
}

29
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,29 @@
# Keep JNI bridge the native symbol name is derived from the full class+method
# name, so the class must not be renamed and the native methods must be kept.
-keep class chat.vojo.proxy.core.Tun2Socks {
native <methods>;
*;
}
# Vendored libhev's JNI_OnLoad does FindClass + RegisterNatives on this exact
# class/signature at load time; renaming or removing it aborts the process.
-keep class hev.htproxy.TProxyService {
native <methods>;
*;
}
# kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class **$$serializer { *; }
-keepclasseswithmembers,allowshrinking,allowobfuscation class chat.vojo.proxy.** {
@kotlinx.serialization.Serializable <methods>;
}
-if @kotlinx.serialization.Serializable class chat.vojo.proxy.**
-keep,allowobfuscation class <1> {
static <1>$Companion Companion;
*** Companion;
}
-keepclassmembers class chat.vojo.proxy.** {
*** serializer(...);
}

View file

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Needed to enumerate installed apps for the per-app proxy whitelist. -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.VojoProxy"
tools:targetApi="34">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode|smallestScreenSize|density"
android:theme="@style/Theme.VojoProxy"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.ProxyVpnService"
android:exported="false"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="specialUse">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="per-app proxy VPN tunnel" />
</service>
<service
android:name=".service.ProxyTileService"
android:exported="true"
android:icon="@drawable/ic_stat_shield"
android:label="@string/app_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -0,0 +1,93 @@
package chat.vojo.proxy
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.VpnService
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
import chat.vojo.proxy.service.ProxyVpnService
import chat.vojo.proxy.service.VpnState
import chat.vojo.proxy.ui.MainScreen
import chat.vojo.proxy.ui.MainViewModel
import chat.vojo.proxy.ui.theme.VojoTheme
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private var pendingConnect = false
private val vpnPermissionLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
ProxyVpnService.start(this)
} else {
// Make the refusal visible instead of silently doing nothing.
VpnState.setStopped("Доступ к VPN не разрешён")
}
}
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {
// Best-effort; the tunnel works without it.
if (pendingConnect) {
pendingConnect = false
launchVpnFlow()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// The app is dark-only: force light icons even on a light system theme.
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT),
)
setContent {
VojoTheme {
MainScreen(
vm = viewModel,
onConnect = ::requestConnect,
onDisconnect = { ProxyVpnService.stop(this) },
onReconnect = { viewModel.reconnect() },
)
}
}
}
/**
* Asks for POST_NOTIFICATIONS in context right before the first connect,
* when the user understands why the tunnel notification matters.
*/
private fun requestConnect() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) {
pendingConnect = true
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
return
}
}
launchVpnFlow()
}
private fun launchVpnFlow() {
val prepare = VpnService.prepare(this)
if (prepare != null) {
vpnPermissionLauncher.launch(prepare)
} else {
ProxyVpnService.start(this)
}
}
}

View file

@ -0,0 +1,126 @@
package chat.vojo.proxy.core
import android.util.Base64
import java.net.URI
import java.net.URLDecoder
/**
* Parses a proxy share link into a [ProxyConfig]:
* - `ss://` SIP002 (`ss://base64url(method:password)@host:port#name`)
* - `ss://` legacy (`ss://base64(method:password@host:port)#name`)
* - `socks5://[user:pass@]host:port`
* - `http(s)://[user:pass@]host:port`
* Returns null when the text is not a recognisable link.
*/
fun parseProxyUri(raw: String): ProxyConfig? {
val s = raw.trim()
return runCatching {
when {
s.startsWith("ss://", ignoreCase = true) -> parseSs(s)
s.startsWith("socks5://", ignoreCase = true) || s.startsWith("socks://", ignoreCase = true) -> parseUri(s, ProxyType.SOCKS5, 1080)
s.startsWith("https://", ignoreCase = true) -> parseUri(s, ProxyType.HTTPS, 443)
s.startsWith("http://", ignoreCase = true) -> parseUri(s, ProxyType.HTTP, 8080)
else -> null
}
}.getOrNull()
}
/**
* True if [raw] is an `ss://` link that depends on an unsupported transport
* plugin (obfs / v2ray-plugin). Lets the UI explain *why* a recognisable link
* was rejected instead of falling back to the generic "no link" message.
*/
fun ssRequiresPlugin(raw: String): Boolean {
val s = raw.trim()
if (!s.startsWith("ss://", ignoreCase = true)) return false
val query = s.substringBefore('#').substringAfter('?', "")
return Regex("(^|&)plugin=[^&]+").containsMatchIn(query)
}
private fun decodeBase64(s: String): String? {
val flagsList = listOf(
Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP,
Base64.DEFAULT,
)
for (flags in flagsList) {
runCatching { return String(Base64.decode(s, flags), Charsets.UTF_8) }
}
return null
}
private fun splitHostPort(hostPort: String): Pair<String, Int>? {
val hp = hostPort.trim()
return if (hp.startsWith("[")) { // [v6]:port
val host = hp.substringAfter('[').substringBefore(']')
val port = hp.substringAfterLast(':').toIntOrNull() ?: return null
host to port
} else {
val host = hp.substringBeforeLast(':')
val port = hp.substringAfterLast(':').toIntOrNull() ?: return null
host to port
}
}
private fun parseSs(s: String): ProxyConfig? {
val name = s.substringAfter('#', "").let { runCatching { URLDecoder.decode(it, "UTF-8") }.getOrDefault(it) }
// SIP002 transport plugins (v2ray-plugin, obfs, …) aren't supported — a
// config that needs one won't tunnel, so reject it rather than import a
// silently-broken server.
if (ssRequiresPlugin(s)) return null
// The scheme is matched case-insensitively at dispatch, so strip it the same
// way — a mixed-case `Ss://` must not survive into the body and break parsing.
val body = s.replaceFirst(Regex("^ss://", RegexOption.IGNORE_CASE), "")
.substringBefore('#').substringBefore('?').trimEnd('/')
val method: String
val password: String
val host: String
val port: Int
if ('@' in body) {
// SIP002: userinfo is base64url(method:password) or plain method:password
val userinfo = body.substringBeforeLast('@')
val decoded = decodeBase64(userinfo)?.takeIf { ':' in it } ?: userinfo
if (':' !in decoded) return null
method = decoded.substringBefore(':')
password = decoded.substringAfter(':')
val hp = splitHostPort(body.substringAfterLast('@')) ?: return null
host = hp.first; port = hp.second
} else {
// Legacy: whole body is base64(method:password@host:port)
val decoded = decodeBase64(body) ?: return null
if ('@' !in decoded || ':' !in decoded) return null
val cred = decoded.substringBeforeLast('@')
method = cred.substringBefore(':')
password = cred.substringAfter(':')
val hp = splitHostPort(decoded.substringAfterLast('@')) ?: return null
host = hp.first; port = hp.second
}
val cipher = SsCipher.entries.firstOrNull { it.id.equals(method, ignoreCase = true) } ?: return null
if (host.isBlank() || port !in 1..65535) return null
return ProxyConfig(
name = name.ifBlank { host },
type = ProxyType.SHADOWSOCKS,
host = host,
port = port,
password = password,
cipher = cipher,
)
}
private fun parseUri(s: String, type: ProxyType, defaultPort: Int): ProxyConfig? {
val uri = URI(s)
val host = uri.host ?: return null
val port = if (uri.port > 0) uri.port else defaultPort
val user = uri.userInfo?.substringBefore(':').orEmpty()
val pass = uri.userInfo?.substringAfter(':', "").orEmpty()
val name = uri.fragment?.let { runCatching { URLDecoder.decode(it, "UTF-8") }.getOrDefault(it) }.orEmpty()
return ProxyConfig(
name = name.ifBlank { host },
type = type,
host = host,
port = port,
username = user,
password = pass,
)
}

View file

@ -0,0 +1,29 @@
package chat.vojo.proxy.core
import java.util.Calendar
import java.util.Locale
fun formatBytes(b: Long): String {
if (b < 1024) return "$b Б"
val units = listOf("КБ", "МБ", "ГБ", "ТБ")
var v = b / 1024.0
var i = 0
while (v >= 1024 && i < units.size - 1) { v /= 1024; i++ }
return String.format(Locale.US, "%.1f %s", v, units[i])
}
fun formatUptime(since: Long, nowMs: Long): String {
if (since <= 0) return ""
val s = ((nowMs - since) / 1000).coerceAtLeast(0)
val h = s / 3600
val m = (s % 3600) / 60
val sec = s % 60
return if (h > 0) String.format(Locale.US, "%d:%02d:%02d", h, m, sec)
else String.format(Locale.US, "%02d:%02d", m, sec)
}
/** HH:MM for the timeline rail, matching the design's tabular-nums time column. */
fun formatClock(epochMs: Long): String {
val c = Calendar.getInstance().apply { timeInMillis = epochMs }
return String.format(Locale.US, "%02d:%02d", c.get(Calendar.HOUR_OF_DAY), c.get(Calendar.MINUTE))
}

View file

@ -0,0 +1,79 @@
package chat.vojo.proxy.core
import kotlinx.serialization.Serializable
import java.util.UUID
/** Supported upstream proxy protocols. No VLESS/Reality by design. */
@Serializable
enum class ProxyType {
SHADOWSOCKS,
SOCKS5,
HTTP,
HTTPS;
val label: String
get() = when (this) {
SHADOWSOCKS -> "Shadowsocks"
SOCKS5 -> "SOCKS5"
HTTP -> "HTTP"
HTTPS -> "HTTPS"
}
/** Two-letter badge used by the avatar tile in the UI. */
val badge: String
get() = when (this) {
SHADOWSOCKS -> "SS"
SOCKS5 -> "S5"
HTTP -> "HT"
HTTPS -> "HS"
}
}
/** Shadowsocks AEAD ciphers backed by platform JCA. */
@Serializable
enum class SsCipher(val id: String, val keySize: Int, val saltSize: Int, val tagSize: Int = 16) {
AES_128_GCM("aes-128-gcm", 16, 16),
AES_256_GCM("aes-256-gcm", 32, 32),
CHACHA20_IETF_POLY1305("chacha20-ietf-poly1305", 32, 32);
}
/**
* A single saved proxy endpoint. One of these is "active" at a time.
*
* For HTTP/HTTPS/SOCKS5, [password]/[username] are the proxy credentials (optional).
* For Shadowsocks, [password] is the SS password and [cipher] the AEAD method.
*/
@Serializable
data class ProxyConfig(
val id: String = UUID.randomUUID().toString(),
val name: String,
val type: ProxyType,
val host: String,
val port: Int,
val username: String = "",
val password: String = "",
val cipher: SsCipher = SsCipher.AES_256_GCM,
) {
/** `host:port` shown in monospace, mirroring the `@user:server` handle in the design. */
val handle: String get() = "$host:$port"
fun validationError(): String? = when {
name.isBlank() -> "Укажите имя"
host.isBlank() -> "Укажите адрес сервера"
port !in 1..65535 -> "Порт должен быть 165535"
type == ProxyType.SHADOWSOCKS && password.isEmpty() -> "Укажите пароль Shadowsocks"
else -> null
}
}
/** App-wide settings persisted alongside the server list. */
@Serializable
data class AppSettings(
/** Packages whose traffic is routed through the proxy (whitelist). Empty = all apps routed. */
val allowedApps: Set<String> = emptySet(),
val activeConfigId: String? = null,
val ipv6: Boolean = false,
/** DNS server queried by routed apps; sent through the tunnel for privacy. */
val dns: String = "1.1.1.1",
val mtu: Int = 8500,
)

View file

@ -0,0 +1,28 @@
package chat.vojo.proxy.core
/**
* Kotlin facade over the native hev-socks5-tunnel engine.
*
* The engine reads IP packets from the VPN tun fd and converts every TCP/UDP
* flow into a SOCKS5 connection towards a local address (our [chat.vojo.proxy.core.socks.LocalSocks5Server]).
* [nativeStart] blocks for the lifetime of the tunnel, so callers must run it on
* a dedicated thread and stop it via [nativeStop].
*/
object Tun2Socks {
init {
// libtun2socks depends on libhev-socks5-tunnel; load the dependency first.
// libhev's JNI_OnLoad fires on load and RegisterNatives onto the stub
// [hev.htproxy.TProxyService] — which must exist or the process aborts.
// We then drive the engine through our own C bridge below.
System.loadLibrary("hev-socks5-tunnel")
System.loadLibrary("tun2socks")
}
/** Blocks until [nativeStop] is called or the engine fails. Returns 0 on clean exit. */
external fun nativeStart(config: String, tunFd: Int): Int
external fun nativeStop()
/** @return [txPackets, txBytes, rxPackets, rxBytes] as observed by the engine. */
external fun nativeStats(): LongArray
}

View file

@ -0,0 +1,144 @@
package chat.vojo.proxy.core.crypto
import chat.vojo.proxy.core.SsCipher
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Shadowsocks AEAD primitives (SIP004).
*
* Master key derivation, per-session subkey derivation (HKDF-SHA1), and the
* AEAD seal/open operations. Implemented entirely on platform JCA so we pull in
* no extra crypto dependency. ChaCha20-Poly1305 requires API 28+ (our minSdk).
*/
object ShadowsocksCrypto {
private val SUBKEY_INFO = "ss-subkey".toByteArray(Charsets.US_ASCII)
/**
* OpenSSL EVP_BytesToKey(MD5) the legacy KDF Shadowsocks uses to turn a
* password into the master key. No salt, single iteration per block.
*/
fun deriveMasterKey(password: String, keyLen: Int): ByteArray {
val pw = password.toByteArray(Charsets.UTF_8)
val md5 = MessageDigest.getInstance("MD5")
val out = ByteArray(keyLen)
var generated = 0
var prev = ByteArray(0)
while (generated < keyLen) {
md5.reset()
md5.update(prev)
md5.update(pw)
prev = md5.digest()
val n = minOf(prev.size, keyLen - generated)
System.arraycopy(prev, 0, out, generated, n)
generated += n
}
return out
}
/** HKDF-SHA1 expand+extract producing [outLen] bytes of session subkey. */
fun hkdfSha1(key: ByteArray, salt: ByteArray, info: ByteArray, outLen: Int): ByteArray {
val mac = Mac.getInstance("HmacSHA1")
// Extract
mac.init(SecretKeySpec(salt, "HmacSHA1"))
val prk = mac.doFinal(key)
// Expand
val out = ByteArray(outLen)
var t = ByteArray(0)
var generated = 0
var counter = 1
while (generated < outLen) {
mac.reset()
mac.init(SecretKeySpec(prk, "HmacSHA1"))
mac.update(t)
mac.update(info)
mac.update(counter.toByte())
t = mac.doFinal()
val n = minOf(t.size, outLen - generated)
System.arraycopy(t, 0, out, generated, n)
generated += n
counter++
}
return out
}
fun sessionSubkey(masterKey: ByteArray, salt: ByteArray, keyLen: Int): ByteArray =
hkdfSha1(masterKey, salt, SUBKEY_INFO, keyLen)
/** Little-endian increment of the 12-byte AEAD nonce, in place. */
fun incrementNonce(nonce: ByteArray) {
var i = 0
while (i < nonce.size) {
val v = (nonce[i].toInt() and 0xff) + 1
nonce[i] = v.toByte()
if (v <= 0xff) return
i++
}
}
}
/** Abstracts the two AEAD families behind one seal/open surface. */
sealed class Aead(val cipher: SsCipher) {
val keySize: Int get() = cipher.keySize
val saltSize: Int get() = cipher.saltSize
val tagSize: Int get() = cipher.tagSize
abstract fun seal(subkey: ByteArray, nonce: ByteArray, plaintext: ByteArray, off: Int, len: Int): ByteArray
abstract fun open(subkey: ByteArray, nonce: ByteArray, ciphertext: ByteArray, off: Int, len: Int): ByteArray
fun seal(subkey: ByteArray, nonce: ByteArray, plaintext: ByteArray): ByteArray =
seal(subkey, nonce, plaintext, 0, plaintext.size)
fun open(subkey: ByteArray, nonce: ByteArray, ciphertext: ByteArray): ByteArray =
open(subkey, nonce, ciphertext, 0, ciphertext.size)
companion object {
fun of(cipher: SsCipher): Aead = when (cipher) {
SsCipher.AES_128_GCM, SsCipher.AES_256_GCM -> AesGcmAead(cipher)
SsCipher.CHACHA20_IETF_POLY1305 -> ChaChaAead(cipher)
}
}
}
private class AesGcmAead(cipher: SsCipher) : Aead(cipher) {
// Cipher.getInstance is a provider lookup + allocation; the relay hot path
// runs four AEAD ops per 16 KB of traffic, so cache one instance per thread.
private val local = ThreadLocal.withInitial { Cipher.getInstance("AES/GCM/NoPadding") }
override fun seal(subkey: ByteArray, nonce: ByteArray, plaintext: ByteArray, off: Int, len: Int): ByteArray {
val c = local.get()!!
c.init(Cipher.ENCRYPT_MODE, SecretKeySpec(subkey, "AES"), GCMParameterSpec(tagSize * 8, nonce))
return c.doFinal(plaintext, off, len)
}
override fun open(subkey: ByteArray, nonce: ByteArray, ciphertext: ByteArray, off: Int, len: Int): ByteArray {
val c = local.get()!!
c.init(Cipher.DECRYPT_MODE, SecretKeySpec(subkey, "AES"), GCMParameterSpec(tagSize * 8, nonce))
return c.doFinal(ciphertext, off, len)
}
}
private class ChaChaAead(cipher: SsCipher) : Aead(cipher) {
// "ChaCha20/Poly1305/NoPadding" is registered by Conscrypt since API 28;
// the hyphenated alias only exists in newer Mainline Conscrypt builds.
private val local = ThreadLocal.withInitial { Cipher.getInstance("ChaCha20/Poly1305/NoPadding") }
override fun seal(subkey: ByteArray, nonce: ByteArray, plaintext: ByteArray, off: Int, len: Int): ByteArray {
val c = local.get()!!
c.init(Cipher.ENCRYPT_MODE, SecretKeySpec(subkey, "ChaCha20"), IvParameterSpec(nonce))
return c.doFinal(plaintext, off, len)
}
override fun open(subkey: ByteArray, nonce: ByteArray, ciphertext: ByteArray, off: Int, len: Int): ByteArray {
val c = local.get()!!
c.init(Cipher.DECRYPT_MODE, SecretKeySpec(subkey, "ChaCha20"), IvParameterSpec(nonce))
return c.doFinal(ciphertext, off, len)
}
}

View file

@ -0,0 +1,135 @@
package chat.vojo.proxy.core.crypto
import chat.vojo.proxy.core.net.readExact
import chat.vojo.proxy.core.net.u16be
import java.io.ByteArrayOutputStream
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.SecureRandom
/** Max plaintext bytes per Shadowsocks AEAD chunk (14-bit length field). */
private const val MAX_PAYLOAD = 0x3FFF
private val rng = SecureRandom()
/**
* Encrypts a byte stream into the Shadowsocks AEAD TCP framing:
* `[salt][len-chunk][payload-chunk]...`, prepending the salt on the first write.
*/
class SsOutputStream(
private val out: OutputStream,
private val aead: Aead,
masterKey: ByteArray,
) : OutputStream() {
private val salt = ByteArray(aead.saltSize).also { rng.nextBytes(it) }
private val subkey = ShadowsocksCrypto.sessionSubkey(masterKey, salt, aead.keySize)
private val nonce = ByteArray(12)
private var saltSent = false
override fun write(b: Int) = write(byteArrayOf(b.toByte()), 0, 1)
@Synchronized
override fun write(b: ByteArray, off: Int, len: Int) {
if (len <= 0) return
val buf = ByteArrayOutputStream(len + 64)
if (!saltSent) {
buf.write(salt)
saltSent = true
}
var p = off
var rem = len
while (rem > 0) {
val n = minOf(rem, MAX_PAYLOAD)
val lenBytes = byteArrayOf((n ushr 8).toByte(), n.toByte())
buf.write(aead.seal(subkey, nonce, lenBytes))
ShadowsocksCrypto.incrementNonce(nonce)
buf.write(aead.seal(subkey, nonce, b, p, n))
ShadowsocksCrypto.incrementNonce(nonce)
p += n
rem -= n
}
out.write(buf.toByteArray())
}
override fun flush() = out.flush()
override fun close() = out.close()
}
/**
* Decrypts a Shadowsocks AEAD TCP stream back into plaintext. Reads the salt
* lazily on first access. A clean upstream close surfaces as normal EOF (-1).
*/
class SsInputStream(
private val inp: InputStream,
private val aead: Aead,
private val masterKey: ByteArray,
) : InputStream() {
private var subkey: ByteArray? = null
private val nonce = ByteArray(12)
private var plain: ByteArray = ByteArray(0)
private var plainPos = 0
private fun fill(): Boolean {
try {
if (subkey == null) {
val salt = inp.readExact(aead.saltSize)
subkey = ShadowsocksCrypto.sessionSubkey(masterKey, salt, aead.keySize)
}
val key = subkey!!
val encLen = inp.readExact(2 + aead.tagSize)
val lenPlain = aead.open(key, nonce, encLen)
ShadowsocksCrypto.incrementNonce(nonce)
val payloadLen = u16be(lenPlain[0].toInt(), lenPlain[1].toInt())
if (payloadLen <= 0 || payloadLen > MAX_PAYLOAD) {
throw IOException("invalid shadowsocks chunk length $payloadLen")
}
val encPayload = inp.readExact(payloadLen + aead.tagSize)
plain = aead.open(key, nonce, encPayload)
ShadowsocksCrypto.incrementNonce(nonce)
plainPos = 0
return true
} catch (e: EOFException) {
return false
}
}
@Synchronized
override fun read(b: ByteArray, off: Int, len: Int): Int {
if (len <= 0) return 0
while (plainPos >= plain.size) {
if (!fill()) return -1
}
val n = minOf(len, plain.size - plainPos)
System.arraycopy(plain, plainPos, b, off, n)
plainPos += n
return n
}
override fun read(): Int {
val one = ByteArray(1)
val n = read(one, 0, 1)
return if (n <= 0) -1 else one[0].toInt() and 0xff
}
override fun close() = inp.close()
}
/** Seals a single Shadowsocks UDP datagram: `[salt][AEAD(payload, nonce=0)]`. */
fun ssUdpSeal(aead: Aead, masterKey: ByteArray, plaintext: ByteArray, off: Int, len: Int): ByteArray {
val salt = ByteArray(aead.saltSize).also { rng.nextBytes(it) }
val subkey = ShadowsocksCrypto.sessionSubkey(masterKey, salt, aead.keySize)
val ct = aead.seal(subkey, ByteArray(12), plaintext, off, len)
return salt + ct
}
/** Opens a single Shadowsocks UDP datagram; returns the decrypted `[ADDR][PAYLOAD]`. */
fun ssUdpOpen(aead: Aead, masterKey: ByteArray, packet: ByteArray, off: Int, len: Int): ByteArray {
require(len >= aead.saltSize + aead.tagSize) { "short ss udp packet" }
val salt = packet.copyOfRange(off, off + aead.saltSize)
val subkey = ShadowsocksCrypto.sessionSubkey(masterKey, salt, aead.keySize)
return aead.open(subkey, ByteArray(12), packet, off + aead.saltSize, len - aead.saltSize)
}

View file

@ -0,0 +1,86 @@
package chat.vojo.proxy.core.net
import java.io.InputStream
import java.net.InetAddress
const val ATYP_IPV4 = 0x01
const val ATYP_DOMAIN = 0x03
const val ATYP_IPV6 = 0x04
/**
* A connection target in SOCKS5 address form. [raw] is the exact wire encoding
* `[ATYP][ADDR][PORT]`, reusable verbatim as a Shadowsocks address header or an
* upstream SOCKS5 request body.
*/
class Destination(
val host: String,
val port: Int,
val atyp: Int,
val raw: ByteArray,
) {
override fun toString(): String = if (atyp == ATYP_IPV6) "[$host]:$port" else "$host:$port"
}
/** Parses `[ATYP][ADDR][PORT]` from [input] (already positioned at ATYP). */
fun readSocksAddress(input: InputStream): Destination {
val atyp = input.read()
if (atyp < 0) throw java.io.EOFException("eof reading atyp")
val host: String
val addrBytes: ByteArray
when (atyp) {
ATYP_IPV4 -> {
addrBytes = input.readExact(4)
host = InetAddress.getByAddress(addrBytes).hostAddress!!
}
ATYP_DOMAIN -> {
val len = input.read()
if (len < 0) throw java.io.EOFException("eof reading domain len")
addrBytes = ByteArray(1 + len)
addrBytes[0] = len.toByte()
input.readFully(addrBytes, 1, len)
host = String(addrBytes, 1, len, Charsets.US_ASCII)
}
ATYP_IPV6 -> {
addrBytes = input.readExact(16)
host = InetAddress.getByAddress(addrBytes).hostAddress!!
}
else -> throw IllegalArgumentException("unknown atyp $atyp")
}
val portBytes = input.readExact(2)
val port = u16be(portBytes[0].toInt(), portBytes[1].toInt())
val raw = ByteArray(1 + addrBytes.size + 2)
raw[0] = atyp.toByte()
System.arraycopy(addrBytes, 0, raw, 1, addrBytes.size)
raw[raw.size - 2] = portBytes[0]
raw[raw.size - 1] = portBytes[1]
return Destination(host, port, atyp, raw)
}
/** Parses a SOCKS5 address from a raw datagram payload starting at [off]; returns dest + header length. */
fun parseSocksAddress(data: ByteArray, off: Int): Pair<Destination, Int> {
var p = off
val atyp = data[p++].toInt() and 0xff
val host: String
when (atyp) {
ATYP_IPV4 -> {
host = InetAddress.getByAddress(data.copyOfRange(p, p + 4)).hostAddress!!
p += 4
}
ATYP_DOMAIN -> {
val len = data[p++].toInt() and 0xff
host = String(data, p, len, Charsets.US_ASCII)
p += len
}
ATYP_IPV6 -> {
host = InetAddress.getByAddress(data.copyOfRange(p, p + 16)).hostAddress!!
p += 16
}
else -> throw IllegalArgumentException("unknown atyp $atyp")
}
val port = u16be(data[p].toInt(), data[p + 1].toInt())
p += 2
val headerLen = p - off
val raw = data.copyOfRange(off, off + headerLen)
return Destination(host, port, atyp, raw) to headerLen
}

View file

@ -0,0 +1,23 @@
package chat.vojo.proxy.core.net
import java.io.EOFException
import java.io.InputStream
/** Reads exactly [len] bytes into [buf] starting at [off] or throws [EOFException]. */
fun InputStream.readFully(buf: ByteArray, off: Int = 0, len: Int = buf.size) {
var read = 0
while (read < len) {
val n = read(buf, off + read, len - read)
if (n < 0) throw EOFException("stream closed after $read/$len bytes")
read += n
}
}
/** Reads exactly [len] bytes into a fresh array. */
fun InputStream.readExact(len: Int): ByteArray {
val b = ByteArray(len)
readFully(b, 0, len)
return b
}
fun u16be(hi: Int, lo: Int): Int = ((hi and 0xff) shl 8) or (lo and 0xff)

View file

@ -0,0 +1,322 @@
package chat.vojo.proxy.core.socks
import android.util.Log
import chat.vojo.proxy.core.net.parseSocksAddress
import chat.vojo.proxy.core.net.readExact
import chat.vojo.proxy.core.net.readFully
import chat.vojo.proxy.core.net.readSocksAddress
import chat.vojo.proxy.core.net.u16be
import chat.vojo.proxy.core.upstream.UdpAssociation
import chat.vojo.proxy.core.upstream.Upstream
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.BufferedInputStream
import java.io.Closeable
import java.io.InputStream
import java.io.OutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketAddress
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
private const val TAG = "LocalSocks5"
private const val RELAY_BUFFER = 32 * 1024
private const val SOCKS_VERSION = 0x05
private const val CMD_CONNECT = 0x01
private const val CMD_UDP_ASSOCIATE = 0x03
private const val DNS_TCP_TIMEOUT_MS = 10_000
/**
* A loopback SOCKS5 server the native tun2socks engine connects to. Each inbound
* SOCKS5 connection is bridged to the configured [upstream] proxy. Supports TCP
* CONNECT and UDP ASSOCIATE (with a DNS-over-TCP fallback for HTTP upstreams).
*/
class LocalSocks5Server(
private val upstream: Upstream,
private val onUpstreamError: (() -> Unit)? = null,
) {
private val serverSocket = ServerSocket().apply {
reuseAddress = true
bind(InetSocketAddress("127.0.0.1", 0), 256)
}
/** Ephemeral loopback port the engine should target. */
val port: Int get() = serverSocket.localPort
// Elastic IO threads; blocking reads park their thread, so idle costs nothing.
private val dispatcher = Dispatchers.IO.limitedParallelism(512)
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val open = ConcurrentHashMap.newKeySet<Closeable>()
fun start() {
scope.launch { acceptLoop() }
}
fun stop() {
runCatching { serverSocket.close() }
open.forEach { runCatching { it.close() } }
open.clear()
scope.cancel()
}
private fun track(c: Closeable) { open.add(c) }
private fun untrack(c: Closeable) { open.remove(c) }
private suspend fun acceptLoop() {
while (scope.isActive) {
val client = try {
serverSocket.accept()
} catch (e: Exception) {
// Transient failures (e.g. EMFILE) must not kill the listener;
// only a closed server socket ends the loop.
if (serverSocket.isClosed || !scope.isActive) break
Log.w(TAG, "accept failed: ${e.message}")
delay(100)
continue
}
scope.launch { handleClient(client) }
}
}
private suspend fun handleClient(client: Socket) {
track(client)
try {
client.tcpNoDelay = true
val cin = BufferedInputStream(client.getInputStream())
val cout = client.getOutputStream()
// Greeting: VER, NMETHODS, METHODS...
if (cin.read() != SOCKS_VERSION) return
val nMethods = cin.read()
if (nMethods < 0) return
cin.readFully(ByteArray(nMethods))
cout.write(byteArrayOf(SOCKS_VERSION.toByte(), 0x00)) // no-auth
cout.flush()
// Request: VER, CMD, RSV, [ATYP+ADDR+PORT]
val reqHead = cin.readExact(3)
if (reqHead[0].toInt() != SOCKS_VERSION) return
val cmd = reqHead[1].toInt() and 0xff
val dest = try {
readSocksAddress(cin)
} catch (e: IllegalArgumentException) {
runCatching { writeReply(cout, rep = 0x08) } // address type not supported
return
}
when (cmd) {
CMD_CONNECT -> handleConnect(client, cin, cout, dest)
CMD_UDP_ASSOCIATE -> handleUdpAssociate(client, cin, cout)
else -> writeReply(cout, rep = 0x07) // command not supported
}
} catch (e: Exception) {
// fallthrough to cleanup
} finally {
closeQuietly(client)
untrack(client)
}
}
private suspend fun handleConnect(
client: Socket,
cin: InputStream,
cout: OutputStream,
dest: chat.vojo.proxy.core.net.Destination,
) {
val conn = try {
upstream.connectTcp(dest)
} catch (e: Exception) {
Log.w(TAG, "upstream connect failed for $dest: ${e.message}")
onUpstreamError?.invoke()
runCatching { writeReply(cout, rep = 0x05) } // connection refused
return
}
track(conn)
try {
writeReply(cout, rep = 0x00) // success
// Half-close aware relay: EOF in one direction only shuts down the
// peer's write side (hev forwards SHUT_WR from apps); the connection
// dies once both directions finish or anything errors out.
val finished = AtomicInteger(0)
val closeBoth = {
closeQuietly(client)
runCatching { conn.close() }
Unit
}
val doneOne = { clean: Boolean, shutdownPeer: () -> Unit ->
if (clean) {
// SSLSocket can't half-close — fall back to full close.
runCatching { shutdownPeer() }.onFailure { closeBoth() }
if (finished.incrementAndGet() >= 2) closeBoth()
} else closeBoth()
}
val up = scope.launch {
val clean = runCatching { pump(cin, conn.output) }.isSuccess
doneOne(clean) { conn.shutdownOutput() }
}
val down = scope.launch {
val clean = runCatching { pump(conn.input, cout) }.isSuccess
doneOne(clean) { cout.flush(); client.shutdownOutput() }
}
up.join()
down.join()
} finally {
runCatching { conn.close() }
untrack(conn)
}
}
private fun handleUdpAssociate(client: Socket, cin: InputStream, cout: OutputStream) {
val relay = DatagramSocket(InetSocketAddress("127.0.0.1", 0))
track(relay)
val session = UdpSession(relay)
try {
// Tell the engine where to send UDP: 127.0.0.1:relayPort
val rp = relay.localPort
cout.write(
byteArrayOf(
SOCKS_VERSION.toByte(), 0x00, 0x00, 0x01,
127, 0, 0, 1,
(rp ushr 8).toByte(), rp.toByte(),
)
)
cout.flush()
session.start()
// Hold the association open until the engine closes the control connection.
val drain = ByteArray(512)
while (cin.read(drain) >= 0) { /* keep-alive */ }
} catch (e: Exception) {
// fallthrough to cleanup
} finally {
session.stop()
closeQuietly(relay)
untrack(relay)
}
}
/** Bridges UDP datagrams between the engine's relay socket and the upstream proxy. */
private inner class UdpSession(private val relay: DatagramSocket) {
private val assoc: UdpAssociation? = runCatching { upstream.openUdpAssociation() }.getOrNull()
private val jobs = mutableListOf<Job>()
@Volatile private var engineSource: SocketAddress? = null
@Volatile private var running = true
fun start() {
assoc?.let { track(it) }
jobs += scope.launch { forwardLoop() }
if (assoc != null) jobs += scope.launch { replyLoop() }
}
fun stop() {
running = false
assoc?.let { runCatching { it.close() }; untrack(it) }
jobs.forEach { it.cancel() }
}
private fun forwardLoop() {
val buf = ByteArray(64 * 1024)
while (running) {
val dp = DatagramPacket(buf, buf.size)
try { relay.receive(dp) } catch (e: Exception) { break }
if (engineSource == null) {
engineSource = dp.socketAddress
// Lock the relay to the engine's source so another local app
// can't inject datagrams or hijack the reply destination.
runCatching { relay.connect(dp.socketAddress) }
} else {
engineSource = dp.socketAddress
}
try {
// [RSV(2)][FRAG(1)][ATYP+ADDR+PORT][DATA]
if (dp.length < 7 || buf[2].toInt() != 0) continue // FRAG unsupported
val (dest, headerLen) = parseSocksAddress(buf, 3)
val dataStart = 3 + headerLen
val dataLen = dp.length - dataStart
if (dataLen < 0) continue
if (assoc != null) {
assoc.send(dest, buf, dataStart, dataLen)
} else if (dest.port == 53) {
val q = buf.copyOfRange(dataStart, dp.length)
scope.launch { dnsOverTcp(dest, q) }
}
} catch (e: Exception) {
// malformed datagram; skip
}
}
}
private fun replyLoop() {
val a = assoc ?: return
while (running) {
val reply = try { a.receive() } catch (e: Exception) { break } ?: break
sendToEngine(reply.from.raw, reply.data)
}
}
/** HTTP-upstream DNS fallback: tunnel a single query as DNS-over-TCP (RFC 7766). */
private fun dnsOverTcp(dest: chat.vojo.proxy.core.net.Destination, query: ByteArray) {
val conn = try { upstream.connectTcp(dest) } catch (e: Exception) { return }
track(conn)
try {
conn.socket.soTimeout = DNS_TCP_TIMEOUT_MS
val out = conn.output
out.write((query.size ushr 8) and 0xff)
out.write(query.size and 0xff)
out.write(query)
out.flush()
val lenHdr = conn.input.readExact(2)
val rlen = u16be(lenHdr[0].toInt(), lenHdr[1].toInt())
if (rlen <= 0 || rlen > 65535) return
val resp = conn.input.readExact(rlen)
sendToEngine(dest.raw, resp)
} catch (e: Exception) {
// drop on failure
} finally {
runCatching { conn.close() }
untrack(conn)
}
}
private fun sendToEngine(addrRaw: ByteArray, data: ByteArray) {
val src = engineSource ?: return
val pkt = ByteArray(3 + addrRaw.size + data.size)
System.arraycopy(addrRaw, 0, pkt, 3, addrRaw.size)
System.arraycopy(data, 0, pkt, 3 + addrRaw.size, data.size)
runCatching { relay.send(DatagramPacket(pkt, pkt.size, src)) }
}
}
private fun writeReply(out: OutputStream, rep: Int) {
// VER, REP, RSV, ATYP=IPv4, BND.ADDR=0.0.0.0, BND.PORT=0
out.write(byteArrayOf(SOCKS_VERSION.toByte(), rep.toByte(), 0x00, 0x01, 0, 0, 0, 0, 0, 0))
out.flush()
}
private fun pump(input: InputStream, output: OutputStream) {
val buf = ByteArray(RELAY_BUFFER)
while (true) {
val n = input.read(buf)
if (n < 0) break
if (n > 0) {
output.write(buf, 0, n)
output.flush()
}
}
}
private fun closeQuietly(c: Closeable) {
runCatching { c.close() }
}
}

View file

@ -0,0 +1,90 @@
package chat.vojo.proxy.core.upstream
import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.ProxyType
import chat.vojo.proxy.core.net.ATYP_IPV6
import chat.vojo.proxy.core.net.Destination
import java.io.BufferedInputStream
import java.io.IOException
import java.io.InputStream
import java.net.Socket
import java.util.Base64
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
/** Upstream that tunnels through an HTTP(S) proxy using the CONNECT method. */
class HttpUpstream(
private val cfg: ProxyConfig,
private val protector: SocketProtector,
) : Upstream {
private val tls: Boolean get() = cfg.type == ProxyType.HTTPS
override fun connectTcp(dest: Destination): UpstreamConn {
var socket = protector.connectedSocket(cfg.host, cfg.port)
try {
socket.soTimeout = HANDSHAKE_TIMEOUT_MS
if (tls) {
val factory = SSLSocketFactory.getDefault() as SSLSocketFactory
val ssl = factory.createSocket(socket, cfg.host, cfg.port, true) as SSLSocket
ssl.soTimeout = HANDSHAKE_TIMEOUT_MS
// Raw SSLSocket validates the chain but NOT the hostname; without
// this a valid cert for any domain could impersonate the proxy.
val params = ssl.sslParameters
params.endpointIdentificationAlgorithm = "HTTPS"
ssl.sslParameters = params
ssl.startHandshake()
socket = ssl
}
val out = socket.getOutputStream()
val inp = BufferedInputStream(socket.getInputStream())
val authority = if (dest.atyp == ATYP_IPV6) "[${dest.host}]:${dest.port}" else "${dest.host}:${dest.port}"
val sb = StringBuilder()
sb.append("CONNECT ").append(authority).append(" HTTP/1.1\r\n")
sb.append("Host: ").append(authority).append("\r\n")
if (cfg.username.isNotEmpty()) {
val token = Base64.getEncoder()
.encodeToString("${cfg.username}:${cfg.password}".toByteArray(Charsets.UTF_8))
sb.append("Proxy-Authorization: Basic ").append(token).append("\r\n")
}
sb.append("Proxy-Connection: keep-alive\r\n")
sb.append("\r\n")
out.write(sb.toString().toByteArray(Charsets.US_ASCII))
out.flush()
val status = readStatusLine(inp)
if (status !in 200..299) throw IOException("http proxy CONNECT failed: $status")
socket.soTimeout = 0
return UpstreamConn(socket, inp, out)
} catch (e: Throwable) {
runCatching { socket.close() }
throw e
}
}
/** Reads the response head up to the blank line and returns the status code. */
private fun readStatusLine(inp: InputStream): Int {
val head = StringBuilder()
var last3 = 0 // rolling match for "\r\n\r\n"
while (true) {
val c = inp.read()
if (c < 0) throw IOException("eof during http proxy response")
head.append(c.toChar())
last3 = when {
c == '\r'.code -> if (last3 == 2) 3 else 1
c == '\n'.code -> if (last3 == 1) 2 else if (last3 == 3) 4 else 0
else -> 0
}
if (last3 == 4) break // saw \r\n\r\n
if (head.length > 16 * 1024) throw IOException("http proxy response head too large")
}
val firstLine = head.lineSequence().firstOrNull()?.trim().orEmpty()
val parts = firstLine.split(' ')
return parts.getOrNull(1)?.toIntOrNull() ?: throw IOException("malformed status line: $firstLine")
}
/** HTTP proxies can't carry UDP; the local server falls back to DNS-over-TCP. */
override fun openUdpAssociation(): UdpAssociation? = null
}

View file

@ -0,0 +1,86 @@
package chat.vojo.proxy.core.upstream
import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.crypto.Aead
import chat.vojo.proxy.core.crypto.ShadowsocksCrypto
import chat.vojo.proxy.core.crypto.SsInputStream
import chat.vojo.proxy.core.crypto.SsOutputStream
import chat.vojo.proxy.core.crypto.ssUdpOpen
import chat.vojo.proxy.core.crypto.ssUdpSeal
import chat.vojo.proxy.core.net.Destination
import chat.vojo.proxy.core.net.parseSocksAddress
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
/** Upstream that tunnels through a Shadowsocks AEAD server (SIP004). */
class ShadowsocksUpstream(
private val cfg: ProxyConfig,
private val protector: SocketProtector,
) : Upstream {
private val aead: Aead = Aead.of(cfg.cipher)
private val masterKey: ByteArray = ShadowsocksCrypto.deriveMasterKey(cfg.password, cfg.cipher.keySize)
override fun connectTcp(dest: Destination): UpstreamConn {
val socket = protector.connectedSocket(cfg.host, cfg.port)
try {
val ssOut = SsOutputStream(socket.getOutputStream(), aead, masterKey)
val ssIn = SsInputStream(socket.getInputStream(), aead, masterKey)
// First plaintext bytes are the target address header.
ssOut.write(dest.raw)
ssOut.flush()
return UpstreamConn(socket, ssIn, ssOut)
} catch (e: Throwable) {
runCatching { socket.close() }
throw e
}
}
override fun openUdpAssociation(): UdpAssociation = ShadowsocksUdpAssociation(cfg, protector, aead, masterKey)
}
/**
* Shadowsocks UDP relay. One socket to the SS server carries every destination;
* each datagram is independently `[salt][AEAD([ADDR][DATA], nonce=0)]`.
*/
private class ShadowsocksUdpAssociation(
cfg: ProxyConfig,
protector: SocketProtector,
private val aead: Aead,
private val masterKey: ByteArray,
) : UdpAssociation {
private val server = InetSocketAddress(InetAddress.getByName(cfg.host), cfg.port)
private val udp = DatagramSocket().also { protector.protect(it) }
override fun send(dest: Destination, data: ByteArray, off: Int, len: Int) {
val plain = ByteArray(dest.raw.size + len)
System.arraycopy(dest.raw, 0, plain, 0, dest.raw.size)
System.arraycopy(data, off, plain, dest.raw.size, len)
val sealed = ssUdpSeal(aead, masterKey, plain, 0, plain.size)
udp.send(DatagramPacket(sealed, sealed.size, server))
}
override fun receive(): UdpReply? {
val buf = ByteArray(64 * 1024)
// Skip undecryptable/malformed datagrams (spoofed or corrupt) instead of
// letting one bad packet kill the reply loop.
while (true) {
val dp = DatagramPacket(buf, buf.size)
udp.receive(dp)
try {
val plain = ssUdpOpen(aead, masterKey, buf, 0, dp.length)
val (dest, headerLen) = parseSocksAddress(plain, 0)
return UdpReply(dest, plain.copyOfRange(headerLen, plain.size))
} catch (e: Exception) {
continue
}
}
}
override fun close() {
runCatching { udp.close() }
}
}

View file

@ -0,0 +1,206 @@
package chat.vojo.proxy.core.upstream
import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.net.ATYP_DOMAIN
import chat.vojo.proxy.core.net.ATYP_IPV4
import chat.vojo.proxy.core.net.ATYP_IPV6
import chat.vojo.proxy.core.net.Destination
import chat.vojo.proxy.core.net.readExact
import chat.vojo.proxy.core.net.readFully
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.InetSocketAddress
/** Upstream that speaks standard SOCKS5 (RFC 1928 / 1929) to the proxy server. */
class Socks5Upstream(
private val cfg: ProxyConfig,
private val protector: SocketProtector,
) : Upstream {
private val auth: Boolean get() = cfg.username.isNotEmpty()
override fun connectTcp(dest: Destination): UpstreamConn {
val socket = protector.connectedSocket(cfg.host, cfg.port)
try {
socket.soTimeout = HANDSHAKE_TIMEOUT_MS
val out = socket.getOutputStream()
val inp = socket.getInputStream()
negotiate(inp, out)
// CONNECT request: VER=5, CMD=1, RSV=0, then [ATYP][ADDR][PORT]
val req = ByteArray(3 + dest.raw.size)
req[0] = 0x05
req[1] = 0x01
req[2] = 0x00
System.arraycopy(dest.raw, 0, req, 3, dest.raw.size)
out.write(req)
out.flush()
readReply(inp)
socket.soTimeout = 0
return UpstreamConn(socket, inp, out)
} catch (e: Throwable) {
runCatching { socket.close() }
throw e
}
}
private fun negotiate(inp: InputStream, out: OutputStream) {
if (auth) {
out.write(byteArrayOf(0x05, 0x02, 0x00, 0x02))
} else {
out.write(byteArrayOf(0x05, 0x01, 0x00))
}
out.flush()
val sel = inp.readExact(2)
if (sel[0].toInt() != 0x05) throw IOException("bad socks5 version ${sel[0]}")
when (sel[1].toInt() and 0xff) {
0x00 -> {} // no auth
0x02 -> userPassAuth(inp, out)
0xFF -> throw IOException("socks5 proxy rejected auth methods")
else -> throw IOException("unsupported socks5 method ${sel[1]}")
}
}
private fun userPassAuth(inp: InputStream, out: OutputStream) {
val u = cfg.username.toByteArray(Charsets.UTF_8)
val p = cfg.password.toByteArray(Charsets.UTF_8)
// RFC 1929 encodes ULEN/PLEN as a single byte each — refuse anything that
// would overflow rather than silently truncate the length prefix.
if (u.size > 255 || p.size > 255) throw IOException("socks5 credentials exceed 255 bytes")
val msg = ByteArray(3 + u.size + p.size)
msg[0] = 0x01
msg[1] = u.size.toByte()
System.arraycopy(u, 0, msg, 2, u.size)
msg[2 + u.size] = p.size.toByte()
System.arraycopy(p, 0, msg, 3 + u.size, p.size)
out.write(msg)
out.flush()
val res = inp.readExact(2)
if (res[1].toInt() != 0x00) throw IOException("socks5 auth failed")
}
private fun readReply(inp: InputStream) {
val head = inp.readExact(4) // VER, REP, RSV, ATYP
if (head[1].toInt() != 0x00) throw IOException("socks5 connect failed, rep=${head[1].toInt() and 0xff}")
// consume BND.ADDR + BND.PORT
when (head[3].toInt() and 0xff) {
ATYP_IPV4 -> inp.readFully(ByteArray(4 + 2))
ATYP_IPV6 -> inp.readFully(ByteArray(16 + 2))
ATYP_DOMAIN -> {
val len = inp.read()
if (len < 0) throw IOException("eof reading bnd addr")
inp.readFully(ByteArray(len + 2))
}
else -> throw IOException("bad atyp in socks5 reply")
}
}
override fun openUdpAssociation(): UdpAssociation? = try {
Socks5UdpAssociation(cfg, protector, ::negotiate)
} catch (e: Throwable) {
null
}
}
/**
* SOCKS5 UDP ASSOCIATE: a TCP control connection holds the association open while
* datagrams flow over a side UDP socket to the relay endpoint the proxy returns.
*/
private class Socks5UdpAssociation(
cfg: ProxyConfig,
protector: SocketProtector,
negotiate: (InputStream, OutputStream) -> Unit,
) : UdpAssociation {
private val control = protector.connectedSocket(cfg.host, cfg.port)
private val udp = DatagramSocket().also { protector.protect(it) }
private val relay: InetSocketAddress
init {
try {
control.soTimeout = HANDSHAKE_TIMEOUT_MS
val out = control.getOutputStream()
val inp = control.getInputStream()
negotiate(inp, out)
// UDP ASSOCIATE with wildcard endpoint
out.write(byteArrayOf(0x05, 0x03, 0x00, ATYP_IPV4.toByte(), 0, 0, 0, 0, 0, 0))
out.flush()
val head = inp.readExact(4)
if (head[1].toInt() != 0x00) throw IOException("udp associate failed rep=${head[1].toInt() and 0xff}")
val addr: InetAddress
when (head[3].toInt() and 0xff) {
ATYP_IPV4 -> addr = InetAddress.getByAddress(inp.readExact(4))
ATYP_IPV6 -> addr = InetAddress.getByAddress(inp.readExact(16))
ATYP_DOMAIN -> {
val len = inp.read()
if (len < 0) throw IOException("eof reading bnd domain len")
addr = InetAddress.getByName(String(inp.readExact(len), Charsets.US_ASCII))
}
else -> throw IOException("bad atyp in udp associate reply")
}
val portBytes = inp.readExact(2)
val port = ((portBytes[0].toInt() and 0xff) shl 8) or (portBytes[1].toInt() and 0xff)
// Some proxies return 0.0.0.0 — fall back to the proxy host.
val target = if (addr.isAnyLocalAddress) InetAddress.getByName(cfg.host) else addr
relay = InetSocketAddress(target, port)
control.soTimeout = 0
watchControl()
} catch (e: Throwable) {
close()
throw e
}
}
/**
* RFC 1928: the association lives only while the control connection does.
* When the proxy drops it, closing the UDP socket unblocks receive() so the
* session ends instead of silently black-holing datagrams.
*/
private fun watchControl() {
Thread({
runCatching {
val inp = control.getInputStream()
val sink = ByteArray(64)
while (inp.read(sink) >= 0) { /* drain */ }
}
close()
}, "socks5-udp-control").apply { isDaemon = true; start() }
}
override fun send(dest: Destination, data: ByteArray, off: Int, len: Int) {
// [RSV(2)=0][FRAG(1)=0][ATYP+ADDR+PORT][DATA]
val pkt = ByteArray(3 + dest.raw.size + len)
System.arraycopy(dest.raw, 0, pkt, 3, dest.raw.size)
System.arraycopy(data, off, pkt, 3 + dest.raw.size, len)
udp.send(DatagramPacket(pkt, pkt.size, relay))
}
override fun receive(): UdpReply? {
val buf = ByteArray(64 * 1024)
// Skip malformed/fragmented datagrams instead of letting one bad packet
// kill the whole reply loop; only socket-level errors propagate.
while (true) {
val dp = DatagramPacket(buf, buf.size)
udp.receive(dp)
try {
if (dp.length < 7 || buf[2].toInt() != 0) continue // FRAG unsupported
val (dest, headerLen) = chat.vojo.proxy.core.net.parseSocksAddress(buf, 3)
val dataStart = 3 + headerLen
if (dataStart > dp.length) continue
return UdpReply(dest, buf.copyOfRange(dataStart, dp.length))
} catch (e: Exception) {
continue
}
}
}
override fun close() {
runCatching { udp.close() }
runCatching { control.close() }
}
}

View file

@ -0,0 +1,75 @@
package chat.vojo.proxy.core.upstream
import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.ProxyType
import chat.vojo.proxy.core.net.Destination
import java.io.Closeable
import java.io.InputStream
import java.io.OutputStream
import java.net.DatagramSocket
import java.net.Socket
/** Excludes app-owned sockets from the VPN so upstream traffic doesn't loop back into the tun. */
interface SocketProtector {
fun protect(socket: Socket): Boolean
fun protect(socket: DatagramSocket): Boolean
}
/** A live TCP tunnel to a target, with the proxy framing already negotiated. */
class UpstreamConn(
val socket: Socket,
val input: InputStream,
val output: OutputStream,
) : Closeable {
/**
* Half-closes the write side so the peer sees EOF while reads continue.
* SSLSocket does not support shutdownOutput throws, caller falls back to close().
*/
fun shutdownOutput() {
output.flush()
socket.shutdownOutput()
}
override fun close() {
runCatching { socket.close() }
}
}
class UdpReply(val from: Destination, val data: ByteArray)
/** A UDP relay through the proxy that multiplexes all destinations over one path. */
interface UdpAssociation : Closeable {
fun send(dest: Destination, data: ByteArray, off: Int, len: Int)
/** Blocks for the next inbound datagram; returns null when the association closes. */
fun receive(): UdpReply?
}
/** One configured proxy endpoint, able to open TCP tunnels (and maybe UDP) for targets. */
interface Upstream {
/** Connects to [dest] through the proxy. Throws on handshake failure. */
fun connectTcp(dest: Destination): UpstreamConn
/** A UDP relay, or null if this proxy type can't carry UDP (HTTP/HTTPS). */
fun openUdpAssociation(): UdpAssociation?
companion object {
fun create(cfg: ProxyConfig, protector: SocketProtector): Upstream = when (cfg.type) {
ProxyType.SHADOWSOCKS -> ShadowsocksUpstream(cfg, protector)
ProxyType.SOCKS5 -> Socks5Upstream(cfg, protector)
ProxyType.HTTP, ProxyType.HTTPS -> HttpUpstream(cfg, protector)
}
}
}
const val CONNECT_TIMEOUT_MS = 10_000
const val HANDSHAKE_TIMEOUT_MS = 15_000
/** Creates an unconnected, VPN-protected TCP socket and connects it to the proxy server. */
internal fun SocketProtector.connectedSocket(host: String, port: Int): Socket {
val socket = Socket()
protect(socket)
socket.tcpNoDelay = true
socket.connect(java.net.InetSocketAddress(host, port), CONNECT_TIMEOUT_MS)
return socket
}

View file

@ -0,0 +1,62 @@
package chat.vojo.proxy.data
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.util.LruCache
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** A user-facing installed app that can use the network. */
data class AppInfo(
val packageName: String,
val label: String,
)
/** Lists installable apps for the whitelist picker and lazily renders their icons. */
class AppRepository(context: Context) {
private val appContext = context.applicationContext
private val pm: PackageManager = appContext.packageManager
private val iconCache = LruCache<String, ImageBitmap>(256)
suspend fun installedApps(): List<AppInfo> = withContext(Dispatchers.IO) {
val self = appContext.packageName
val packages = pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)
packages.asSequence()
.filter { it.packageName != self }
.filter { it.requestedPermissions?.contains(android.Manifest.permission.INTERNET) == true }
.map { pkg ->
val ai = pkg.applicationInfo
AppInfo(
packageName = pkg.packageName,
label = ai?.let { pm.getApplicationLabel(it).toString() } ?: pkg.packageName,
)
}
.sortedBy { it.label.lowercase() }
.toList()
}
/** Synchronous cache hit for composition-time initial values (no thread hop). */
fun cachedIcon(packageName: String): ImageBitmap? = iconCache.get(packageName)
suspend fun icon(packageName: String): ImageBitmap? = withContext(Dispatchers.IO) {
iconCache.get(packageName)?.let { return@withContext it }
val drawable = runCatching { pm.getApplicationIcon(packageName) }.getOrNull() ?: return@withContext null
val bmp = drawable.toImageBitmap()
iconCache.put(packageName, bmp)
bmp
}
private fun Drawable.toImageBitmap(size: Int = 96): ImageBitmap {
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
setBounds(0, 0, size, size)
draw(canvas)
return bitmap.asImageBitmap()
}
}

View file

@ -0,0 +1,76 @@
package chat.vojo.proxy.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import java.io.IOException
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import chat.vojo.proxy.core.AppSettings
import chat.vojo.proxy.core.ProxyConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/** Everything we persist: the saved servers plus app-wide settings. */
@Serializable
data class ProxyState(
val configs: List<ProxyConfig> = emptyList(),
val settings: AppSettings = AppSettings(),
) {
val activeConfig: ProxyConfig?
get() = configs.firstOrNull { it.id == settings.activeConfigId }
?: configs.firstOrNull()
}
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "vojo_proxy")
private val STATE_KEY = stringPreferencesKey("state")
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
/** Single source of truth for persisted configuration, backed by Preferences DataStore. */
class ConfigStore(context: Context) {
private val store = context.applicationContext.dataStore
val state: Flow<ProxyState> = store.data
// Only swallow IO/corruption (DataStore's documented failure mode);
// rethrow anything else so a real bug isn't hidden as "no data".
.catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e }
.map { prefs ->
prefs[STATE_KEY]?.let { runCatching { json.decodeFromString<ProxyState>(it) }.getOrNull() }
?: ProxyState()
}
suspend fun current(): ProxyState = state.first()
suspend fun update(transform: (ProxyState) -> ProxyState) {
store.edit { prefs ->
val cur = prefs[STATE_KEY]?.let {
runCatching { json.decodeFromString<ProxyState>(it) }.getOrNull()
} ?: ProxyState()
prefs[STATE_KEY] = json.encodeToString(transform(cur))
}
}
suspend fun upsertConfig(config: ProxyConfig) = update { state ->
val others = state.configs.filterNot { it.id == config.id }
state.copy(configs = others + config)
}
suspend fun deleteConfig(id: String) = update { state ->
val configs = state.configs.filterNot { it.id == id }
val active = if (state.settings.activeConfigId == id) configs.firstOrNull()?.id else state.settings.activeConfigId
state.copy(configs = configs, settings = state.settings.copy(activeConfigId = active))
}
suspend fun setActive(id: String) = update { it.copy(settings = it.settings.copy(activeConfigId = id)) }
suspend fun updateSettings(transform: (AppSettings) -> AppSettings) =
update { it.copy(settings = transform(it.settings)) }
}

View file

@ -0,0 +1,77 @@
package chat.vojo.proxy.service
import android.app.PendingIntent
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import chat.vojo.proxy.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/** Quick Settings tile: one-tap connect/disconnect from the shade. */
class ProxyTileService : TileService() {
private var scope: CoroutineScope? = null
override fun onStartListening() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { sc ->
sc.launch { VpnState.status.collect { render(it) } }
}
}
override fun onStopListening() {
scope?.cancel()
scope = null
}
private fun render(status: VpnState.Status) {
val tile = qsTile ?: return
tile.state = when (status) {
VpnState.Status.CONNECTED, VpnState.Status.CONNECTING -> Tile.STATE_ACTIVE
else -> Tile.STATE_INACTIVE
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
tile.subtitle = when (status) {
VpnState.Status.CONNECTED -> "online"
VpnState.Status.CONNECTING -> "connecting"
VpnState.Status.STOPPING -> "stopping"
VpnState.Status.ERROR -> "error"
VpnState.Status.DISCONNECTED -> null
}
}
tile.updateTile()
}
override fun onClick() {
when (VpnState.status.value) {
VpnState.Status.CONNECTED, VpnState.Status.CONNECTING ->
ProxyVpnService.stop(this)
else -> {
if (VpnService.prepare(this) == null) {
ProxyVpnService.start(this)
} else {
// VPN consent needed — open the app to run the system dialog.
openApp()
}
}
}
}
private fun openApp() {
val intent = Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(
PendingIntent.getActivity(this, 2, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
)
} else {
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
startActivityAndCollapse(intent)
}
}
}

View file

@ -0,0 +1,486 @@
package chat.vojo.proxy.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import chat.vojo.proxy.MainActivity
import chat.vojo.proxy.R
import chat.vojo.proxy.core.AppSettings
import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.Tun2Socks
import chat.vojo.proxy.core.formatBytes
import chat.vojo.proxy.core.formatUptime
import chat.vojo.proxy.core.socks.LocalSocks5Server
import chat.vojo.proxy.core.upstream.SocketProtector
import chat.vojo.proxy.core.upstream.Upstream
import chat.vojo.proxy.data.ConfigStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.net.DatagramSocket
import java.net.Socket
import java.util.concurrent.Executors
class ProxyVpnService : VpnService(), SocketProtector {
// Every lifecycle transition (start/stop/restart/engine-exit) runs on this
// single thread: no start/stop races, no blocking joins on the main thread.
private val lifecycle = Executors.newSingleThreadExecutor { r -> Thread(r, "vojo-vpn-lifecycle") }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var vpnInterface: ParcelFileDescriptor? = null
private var server: LocalSocks5Server? = null
private var engineThread: Thread? = null
private var statsJob: Job? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private var lastNetwork: Network? = null
// Mutated only on the lifecycle thread, but read from IO threads via
// onUpstreamUnreachable, so it must be visible across threads.
@Volatile private var running = false
private var tornDown = true // idempotency guard for shutdown()
@Volatile private var stopRequested = false
@Volatile private var engineGen = 0
@Volatile private var lastUpstreamWarn = 0L
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
lifecycle.execute {
shutdown(errorMessage = null)
stopSelf()
}
return START_NOT_STICKY
}
ACTION_RESTART -> {
if (!goForeground()) return START_NOT_STICKY
lifecycle.execute {
if (running) {
VpnState.event("Переподключение")
shutdown(errorMessage = null, keepNotification = true)
}
startTunnel()
}
}
else -> {
if (!goForeground()) return START_NOT_STICKY
lifecycle.execute { startTunnel() }
}
}
return START_STICKY
}
/** Promotes to foreground immediately (the 5-second FGS window starts at onStartCommand). */
private fun goForeground(): Boolean {
return try {
startForegroundNotification(getString(R.string.notif_connecting))
true
} catch (e: Throwable) {
// ForegroundServiceStartNotAllowedException on a background sticky
// restart (Android 12+) must not crash the process. Tear down on the
// lifecycle thread (an in-flight RESTART may still have a live engine)
// and only then mark the tunnel stopped.
Log.e(TAG, "startForeground rejected", e)
lifecycle.execute {
shutdown(errorMessage = null)
VpnState.setStopped(null)
stopSelf()
}
false
}
}
private fun startTunnel() { // lifecycle thread
if (running) return
stopRequested = false
tornDown = false
lastUpstreamWarn = 0L
val gen = ++engineGen
// A new attempt supersedes any prior failure — clear its lingering notification.
runCatching { getSystemService(NotificationManager::class.java)?.cancel(NOTIF_ERROR_ID) }
VpnState.setConnecting()
try {
val state = runBlocking { ConfigStore(this@ProxyVpnService).current() }
val config = state.activeConfig
if (config == null) {
fail(getString(R.string.error_no_server))
return
}
VpnState.event("Подключение", "${config.name} · ${config.type.label}")
updateNotification(getString(R.string.notif_connecting))
val upstream = Upstream.create(config, this)
val socks = LocalSocks5Server(upstream, ::onUpstreamUnreachable).also { it.start() }
server = socks
val pfd = establish(config, state.settings, socks.port)
vpnInterface = pfd
VpnState.event("Интерфейс поднят", "$TUN_IPV4 · mtu ${state.settings.mtu}")
VpnState.event("SOCKS5-мост", "127.0.0.1:${socks.port}")
VpnState.event("DNS через туннель", state.settings.dns)
val hevConfig = buildEngineConfig(socks.port, state.settings)
val fd = pfd.fd
engineThread = Thread({
val ret = Tun2Socks.nativeStart(hevConfig, fd)
Log.i(TAG, "engine returned $ret")
lifecycle.execute { onEngineExit(gen, ret) }
}, "vojo-tun2socks").apply { start() }
running = true
VpnState.setConnected(VpnState.Applied(config.id, state.settings))
VpnState.event("Подключено", "${config.name} · ${config.type.label}", VpnState.TunnelEvent.Tone.OK)
updateNotification(getString(R.string.notif_connected, config.name))
startStatsLoop()
registerNetworkCallback()
} catch (e: Throwable) {
Log.e(TAG, "failed to start tunnel", e)
fail(e.message ?: "Не удалось запустить туннель")
}
}
/** Throttled warning surfaced to the journal when upstream connects fail. */
private fun onUpstreamUnreachable() {
if (!running) return
val now = System.currentTimeMillis()
if (now - lastUpstreamWarn > 30_000) {
lastUpstreamWarn = now
VpnState.event("Прокси не отвечает", "проверьте сервер или сеть", VpnState.TunnelEvent.Tone.WARN)
}
}
/** Engine thread exited on its own (not via a requested stop) — tear down loudly. */
private fun onEngineExit(gen: Int, ret: Int) { // lifecycle thread
if (gen != engineGen || stopRequested) return
fail("Движок туннеля неожиданно завершился (код $ret)")
}
private fun establish(config: ProxyConfig, settings: AppSettings, socksPort: Int): ParcelFileDescriptor {
val builder = Builder()
.setSession("vojo · ${config.name}")
.setMtu(settings.mtu)
.addAddress(TUN_IPV4, 32)
.addRoute("0.0.0.0", 0)
.addDnsServer(settings.dns.ifBlank { "1.1.1.1" })
.setBlocking(false)
if (settings.ipv6) {
builder.addAddress(TUN_IPV6, 128).addRoute("::", 0)
}
applyAppFilter(builder, settings.allowedApps)
builder.setUnderlyingNetworks(activeNetworkArray())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(false)
}
builder.setConfigureIntent(activityPendingIntent())
return builder.establish() ?: throw IllegalStateException("VPN permission not granted")
}
/** Whitelist mode when apps are selected; otherwise route everything except ourselves. */
private fun applyAppFilter(builder: Builder, allowed: Set<String>) {
val self = packageName
if (allowed.isNotEmpty()) {
var added = 0
for (pkg in allowed) {
if (pkg == self) continue
runCatching { builder.addAllowedApplication(pkg); added++ }
.onFailure { Log.w(TAG, "skip missing package $pkg") }
}
// All selected packages are gone: routing *everything* through the
// proxy would silently violate the whitelist contract.
if (added == 0) {
throw IllegalStateException("Выбранные приложения не установлены — обновите список")
}
} else {
runCatching { builder.addDisallowedApplication(self) }
}
}
private fun buildEngineConfig(socksPort: Int, settings: AppSettings): String = buildString {
append("tunnel:\n")
append(" mtu: ${settings.mtu}\n")
append(" ipv4: $TUN_IPV4\n")
if (settings.ipv6) append(" ipv6: '$TUN_IPV6'\n")
append("socks5:\n")
append(" address: 127.0.0.1\n")
append(" port: $socksPort\n")
append(" udp: 'udp'\n")
append("misc:\n")
append(" connect-timeout: 8000\n")
append(" log-level: warn\n")
}
private fun startStatsLoop() {
statsJob?.cancel()
statsJob = scope.launch {
var tick = 0
while (isActive) {
runCatching {
val s = Tun2Socks.nativeStats()
if (s.size >= 4) {
VpnState.setStats(VpnState.Stats(txBytes = s[1], rxBytes = s[3]))
}
}
// Refresh the notification with live traffic once a minute.
if (++tick % 30 == 0 && VpnState.status.value == VpnState.Status.CONNECTED) {
runCatching { updateNotification(trafficSummary()) }
}
delay(2000)
}
}
}
private fun trafficSummary(): String {
val st = VpnState.stats.value
val up = formatUptime(VpnState.connectedSince.value, System.currentTimeMillis())
return "${formatBytes(st.txBytes)} · ↓ ${formatBytes(st.rxBytes)} · $up"
}
private fun registerNetworkCallback() {
val cm = getSystemService(ConnectivityManager::class.java) ?: return
lastNetwork = cm.activeNetwork
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
runCatching { setUnderlyingNetworks(arrayOf(network)) }
if (network != lastNetwork) {
lastNetwork = network
val caps = runCatching { cm.getNetworkCapabilities(network) }.getOrNull()
val name = when {
caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "Wi-Fi"
caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "мобильная сеть"
caps?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true -> "Ethernet"
else -> "другая сеть"
}
VpnState.event("Сеть изменилась", name, VpnState.TunnelEvent.Tone.WARN)
}
}
override fun onLost(network: Network) {
runCatching { setUnderlyingNetworks(null) }
}
}
networkCallback = cb
runCatching { cm.registerDefaultNetworkCallback(cb) }
}
private fun activeNetworkArray(): Array<Network>? {
val cm = getSystemService(ConnectivityManager::class.java) ?: return null
return cm.activeNetwork?.let { arrayOf(it) }
}
// --- SocketProtector ---
override fun protect(socket: Socket): Boolean = super<VpnService>.protect(socket)
override fun protect(socket: DatagramSocket): Boolean = super<VpnService>.protect(socket)
private fun fail(message: String) { // lifecycle thread
VpnState.event("Ошибка", message, VpnState.TunnelEvent.Tone.ERROR)
shutdown(errorMessage = message)
stopSelf()
}
/**
* Tears everything down on the lifecycle thread. A non-null [errorMessage]
* keeps the ERROR state (and an explanatory notification) visible to the user.
*/
private fun shutdown(errorMessage: String?, keepNotification: Boolean = false) { // lifecycle thread
// Idempotent: ACTION_STOP tears down then stopSelf() → onDestroy() would
// otherwise run a second teardown (and log a duplicate "Отключено").
if (tornDown) return
tornDown = true
stopRequested = true
engineGen++ // invalidate pending engine-exit callbacks
VpnState.setStopping()
statsJob?.cancel()
statsJob = null
networkCallback?.let { cb ->
runCatching { getSystemService(ConnectivityManager::class.java)?.unregisterNetworkCallback(cb) }
}
networkCallback = null
val engine = engineThread
engineThread = null
if (engine != null) {
// nativeStop only signals the engine's event fd; the native `running`
// flag and the tun fd aren't released until nativeStart() returns and
// the thread body finishes. Wait for that here (re-kicking stop) so a
// following reconnect can't hit the C double-start guard (ret == -2),
// and so we never close the tun fd while the engine still reads it.
var waited = 0L
while (engine.isAlive && waited < ENGINE_JOIN_TIMEOUT_MS) {
runCatching { Tun2Socks.nativeStop() }
runCatching { engine.join(ENGINE_JOIN_STEP_MS) }
waited += ENGINE_JOIN_STEP_MS
}
if (engine.isAlive) Log.w(TAG, "engine thread did not exit in time")
}
server?.stop()
server = null
// Only reclaim the tun fd once the engine has truly stopped touching it;
// closing it out from under live native code is undefined behaviour.
if (engine == null || !engine.isAlive) {
runCatching { vpnInterface?.close() }
vpnInterface = null
} else {
Log.w(TAG, "leaving tun fd open — engine still alive after stop")
}
running = false
VpnState.setStopped(errorMessage)
// Don't log "Отключено" mid-reconnect (keepNotification) — that path
// logs "Переподключение" → "Подключение" → "Подключено" instead.
if (errorMessage == null && !keepNotification && VpnState.events.value.isNotEmpty()) {
VpnState.event("Отключено")
}
if (!keepNotification) {
stopForeground(STOP_FOREGROUND_REMOVE)
if (errorMessage != null) postErrorNotification(errorMessage)
}
}
override fun onRevoke() {
// User revoked VPN permission or another VPN took over.
lifecycle.execute {
VpnState.event("VPN отозван системой", null, VpnState.TunnelEvent.Tone.WARN)
shutdown(errorMessage = null)
stopSelf()
}
}
override fun onDestroy() {
// System killed the service without ACTION_STOP: still release the
// engine, the SOCKS server and the tun fd.
lifecycle.execute { shutdown(errorMessage = null) }
lifecycle.shutdown()
scope.cancel()
super.onDestroy()
}
// --- Notification ---
private fun startForegroundNotification(text: String) {
ensureChannel()
val notification = buildNotification(text)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(NOTIF_ID, notification)
}
}
private fun updateNotification(text: String) {
val nm = getSystemService(NotificationManager::class.java) ?: return
nm.notify(NOTIF_ID, buildNotification(text))
}
private fun postErrorNotification(text: String) {
val nm = getSystemService(NotificationManager::class.java) ?: return
ensureChannel()
val n = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notif_error_title))
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setSmallIcon(R.drawable.ic_stat_shield)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_ERROR)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(activityPendingIntent())
.build()
nm.notify(NOTIF_ERROR_ID, n)
}
private fun buildNotification(text: String): Notification {
val stopIntent = PendingIntent.getService(
this, 1,
Intent(this, ProxyVpnService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(text)
.setSmallIcon(R.drawable.ic_stat_shield)
.setOngoing(true)
.setShowWhen(false)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentIntent(activityPendingIntent())
.addAction(0, getString(R.string.action_disconnect), stopIntent)
.build()
}
private fun activityPendingIntent(): PendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
private fun ensureChannel() {
val nm = getSystemService(NotificationManager::class.java) ?: return
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
nm.createNotificationChannel(
NotificationChannel(CHANNEL_ID, getString(R.string.channel_name), NotificationManager.IMPORTANCE_LOW).apply {
setShowBadge(false)
description = getString(R.string.channel_desc)
}
)
}
}
companion object {
private const val TAG = "ProxyVpnService"
const val ACTION_START = "chat.vojo.proxy.START"
const val ACTION_STOP = "chat.vojo.proxy.STOP"
const val ACTION_RESTART = "chat.vojo.proxy.RESTART"
private const val CHANNEL_ID = "vpn_status"
private const val NOTIF_ID = 0x7C5C
private const val NOTIF_ERROR_ID = 0x7C5D
// Bounded wait for the native engine thread to exit during teardown.
private const val ENGINE_JOIN_TIMEOUT_MS = 5000L
private const val ENGINE_JOIN_STEP_MS = 250L
const val TUN_IPV4 = "198.18.0.1"
private const val TUN_IPV6 = "fc00::1"
fun start(context: Context) {
val intent = Intent(context, ProxyVpnService::class.java).setAction(ACTION_START)
ContextCompat.startForegroundService(context, intent)
}
fun restart(context: Context) {
val intent = Intent(context, ProxyVpnService::class.java).setAction(ACTION_RESTART)
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
val intent = Intent(context, ProxyVpnService::class.java).setAction(ACTION_STOP)
context.startService(intent)
}
}
}

View file

@ -0,0 +1,92 @@
package chat.vojo.proxy.service
import chat.vojo.proxy.core.AppSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicLong
/** Process-wide observable VPN state shared between the service and the UI. */
object VpnState {
enum class Status { DISCONNECTED, CONNECTING, CONNECTED, STOPPING, ERROR }
data class Stats(
val txBytes: Long = 0,
val rxBytes: Long = 0,
)
/** Config + settings snapshot the running tunnel was built from. */
data class Applied(val configId: String, val settings: AppSettings)
/** One row of the connection journal rendered by the timeline rail. */
data class TunnelEvent(
/** Process-unique, monotonic — a stable LazyColumn key even when two events share a millisecond. */
val id: Long,
val at: Long,
val label: String,
val detail: String? = null,
val tone: Tone = Tone.NEUTRAL,
) {
enum class Tone { NEUTRAL, OK, WARN, ERROR }
}
private const val MAX_EVENTS = 30
private val eventSeq = AtomicLong(0)
private val _status = MutableStateFlow(Status.DISCONNECTED)
val status: StateFlow<Status> = _status.asStateFlow()
private val _stats = MutableStateFlow(Stats())
val stats: StateFlow<Stats> = _stats.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
/** Epoch millis when the tunnel reached CONNECTED, or 0. */
private val _connectedSince = MutableStateFlow(0L)
val connectedSince: StateFlow<Long> = _connectedSince.asStateFlow()
private val _applied = MutableStateFlow<Applied?>(null)
val applied: StateFlow<Applied?> = _applied.asStateFlow()
private val _events = MutableStateFlow<List<TunnelEvent>>(emptyList())
val events: StateFlow<List<TunnelEvent>> = _events.asStateFlow()
internal fun setConnecting() {
_error.value = null
_status.value = Status.CONNECTING
}
internal fun setConnected(applied: Applied) {
_status.value = Status.CONNECTED
_applied.value = applied
if (_connectedSince.value == 0L) _connectedSince.value = System.currentTimeMillis()
}
internal fun setStopping() {
_status.value = Status.STOPPING
}
/** Final state after shutdown; a non-null [error] survives until the next connect. */
internal fun setStopped(error: String? = null) {
if (error != null) {
_error.value = error
_status.value = Status.ERROR
} else {
_status.value = Status.DISCONNECTED
}
_connectedSince.value = 0L
_stats.value = Stats()
_applied.value = null
}
internal fun setStats(stats: Stats) {
_stats.value = stats
}
internal fun event(label: String, detail: String? = null, tone: TunnelEvent.Tone = TunnelEvent.Tone.NEUTRAL) {
val ev = TunnelEvent(eventSeq.incrementAndGet(), System.currentTimeMillis(), label, detail, tone)
_events.value = (_events.value + ev).takeLast(MAX_EVENTS)
}
}

View file

@ -0,0 +1,273 @@
package chat.vojo.proxy.ui
import android.annotation.SuppressLint
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.vojo.proxy.data.AppInfo
import chat.vojo.proxy.ui.theme.Vojo
@Composable
fun AppsScreen(
apps: List<AppInfo>,
loading: Boolean,
selected: Set<String>,
loadIcon: suspend (String) -> ImageBitmap?,
cachedIcon: (String) -> ImageBitmap?,
onToggle: (String, Boolean) -> Unit,
onClear: () -> Unit,
onLoad: () -> Unit,
) {
LaunchedEffect(Unit) { onLoad() }
var query by rememberSaveable { mutableStateOf("") }
// All network-capable apps are shown — no system/user split.
val filtered = remember(apps, query) {
apps.asSequence()
.filter { query.isBlank() || it.label.contains(query, true) || it.packageName.contains(query, true) }
.toList()
}
// Selected apps pin to the top — the whitelist must be reviewable at a glance.
val pinned = remember(apps, selected) { apps.filter { it.packageName in selected } }
val missing = remember(apps, selected, loading) {
if (apps.isEmpty()) emptyList()
else (selected - apps.map { it.packageName }.toSet()).sorted()
}
val rest = remember(filtered, selected) { filtered.filter { it.packageName !in selected } }
Column(Modifier.fillMaxSize()) {
Column(Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (selected.isEmpty()) "Проксируются: все приложения" else "Проксируются",
color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
)
if (selected.isNotEmpty()) {
Spacer(Modifier.width(8.dp))
Box(
Modifier
.clip(RoundedCornerShape(99.dp))
.background(Vojo.fleet)
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text("${selected.size}", color = Color(0xFF0C0C0E), fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
}
Spacer(Modifier.weight(1f))
if (selected.isNotEmpty()) {
Text(
"Сбросить",
color = Vojo.fleetSoft, fontSize = 13.sp,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { onClear() }
.padding(horizontal = 10.dp, vertical = 8.dp),
)
}
}
Text(
"Пустой список = весь трафик идёт через прокси.",
color = Vojo.faint, fontSize = 12.sp,
)
Spacer(Modifier.height(12.dp))
// Embedded search field (Fleet composer style)
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Vojo.bgPanel)
.border(1.dp, Vojo.divider, RoundedCornerShape(12.dp))
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(VojoIcons.Search, contentDescription = null, tint = Vojo.faint, modifier = Modifier.size(17.dp))
Spacer(Modifier.width(10.dp))
BasicTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = TextStyle(color = Vojo.text, fontSize = 15.sp),
cursorBrush = SolidColor(Vojo.fleet),
decorationBox = { inner ->
Box(Modifier.padding(vertical = 14.dp)) {
if (query.isEmpty()) Text("Поиск приложений…", color = Vojo.faint, fontSize = 15.sp)
inner()
}
},
)
}
}
if (loading && apps.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Vojo.fleet, strokeWidth = 2.dp)
}
} else {
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 14.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
if (query.isBlank() && (pinned.isNotEmpty() || missing.isNotEmpty())) {
item(key = "hdr-selected") {
SectionLabel("Выбранные · ${selected.size}", Modifier.padding(start = 6.dp, top = 6.dp, bottom = 6.dp))
}
items(pinned, key = { "sel:" + it.packageName }) { app ->
AppRow(app, true, loadIcon, cachedIcon) { on -> onToggle(app.packageName, on) }
}
items(missing, key = { "miss:$it" }) { pkg ->
MissingAppRow(pkg) { onToggle(pkg, false) }
}
item(key = "hdr-all") {
SectionLabel("Все приложения", Modifier.padding(start = 6.dp, top = 14.dp, bottom = 6.dp))
}
}
val list = if (query.isBlank()) rest else filtered
items(list, key = { it.packageName }) { app ->
AppRow(app, app.packageName in selected, loadIcon, cachedIcon) { on -> onToggle(app.packageName, on) }
}
if (filtered.isEmpty() && query.isNotBlank()) {
item(key = "empty") {
Box(Modifier.fillMaxWidth().padding(vertical = 32.dp), contentAlignment = Alignment.Center) {
Text("Ничего не найдено по «$query»", color = Vojo.faint, fontSize = 14.sp)
}
}
}
}
}
}
}
@SuppressLint("ProduceStateDoesNotAssignValue") // false positive: value is assigned via suspend lambda
@Composable
private fun AppRow(
app: AppInfo,
checked: Boolean,
loadIcon: suspend (String) -> ImageBitmap?,
cachedIcon: (String) -> ImageBitmap?,
onToggle: (Boolean) -> Unit,
) {
// Cache hit renders instantly — no placeholder flash on fast scroll.
val icon by produceState(initialValue = cachedIcon(app.packageName), app.packageName) {
if (value == null) value = loadIcon(app.packageName)
}
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(if (checked) Vojo.surface else Color.Transparent)
.toggleable(value = checked, role = Role.Checkbox) { onToggle(it) }
.padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// The active-channel idiom: a 2dp accent bar marks selected rows.
Box(
Modifier
.width(2.dp)
.height(34.dp)
.clip(RoundedCornerShape(1.dp))
.background(if (checked) Vojo.fleet else Color.Transparent)
)
Spacer(Modifier.width(9.dp))
Box(Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)).background(if (checked) Vojo.surface2 else Vojo.surface), contentAlignment = Alignment.Center) {
val img = icon
if (img != null) {
Image(img, contentDescription = null, modifier = Modifier.size(34.dp))
} else {
Text(app.label.take(1).uppercase(), color = Vojo.muted, fontSize = 15.sp)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(app.label, color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(app.packageName, color = Vojo.faint, fontFamily = Vojo.mono, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Spacer(Modifier.width(10.dp))
Checkbox(checked)
}
}
/** A selected package that is no longer installed — visible and removable. */
@Composable
private fun MissingAppRow(packageName: String, onRemove: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Vojo.surface.copy(alpha = 0.5f))
.clickable { onRemove() }
.padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.width(2.dp)
.height(34.dp)
.background(Vojo.amber.copy(alpha = 0.6f))
)
Spacer(Modifier.width(9.dp))
Box(Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)).background(Vojo.surface), contentAlignment = Alignment.Center) {
Text("?", color = Vojo.faint, fontSize = 15.sp)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(packageName, color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("не установлено · нажмите, чтобы убрать", color = Vojo.faint, fontSize = 11.sp)
}
}
}
@Composable
private fun Checkbox(checked: Boolean) {
Box(
Modifier
.size(24.dp)
.clip(RoundedCornerShape(7.dp))
.background(if (checked) Vojo.fleet else Color.Transparent)
.border(1.dp, if (checked) Vojo.fleet else Vojo.hairline, RoundedCornerShape(7.dp)),
contentAlignment = Alignment.Center,
) {
if (checked) Icon(VojoIcons.Check, contentDescription = null, tint = Color(0xFF0C0C0E), modifier = Modifier.size(14.dp))
}
}

View file

@ -0,0 +1,191 @@
package chat.vojo.proxy.ui
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import chat.vojo.proxy.ui.theme.Vojo
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
/** Darkens a colour for the avatar gradient, mirroring the design's `shade(c, -22)`. */
fun Color.shade(amount: Float = 0.78f): Color = Color(red * amount, green * amount, blue * amount, alpha)
/** Square protocol tile with a 2-letter badge and a soft diagonal gradient. */
@Composable
fun ProtocolBadge(badge: String, color: Color, size: Int = 38) {
Box(
modifier = Modifier
.size(size.dp)
.clip(RoundedCornerShape((size * 0.32f).dp))
.background(Brush.linearGradient(listOf(color, color.shade()))),
contentAlignment = Alignment.Center,
) {
Text(
badge,
color = Color(0xFF0C0C0E),
fontWeight = FontWeight.Bold,
fontSize = (size * 0.36f).sp,
)
}
}
/** Uppercase, letter-spaced section label — the design's one monospace chrome accent. */
@Composable
fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text.uppercase(),
modifier = modifier,
color = Vojo.faint,
fontFamily = Vojo.mono,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.4.sp,
)
}
/**
* The whole top chrome: the four tabs sit on the static backdrop (the row does
* NOT move with the panes). Tabs are sized to their content and distributed with
* even gaps equal-width quarters left the centred labels with ragged spacing.
* The active tab carries a violet underline that slides AND resizes between the
* measured tab bounds; both the bounds and the pager position are read in the
* layout phase so a swipe glides the line without recomposing the row.
*/
@Composable
fun SegmentTabs(
tabs: List<String>,
pagerState: PagerState,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
// [left, width] of each tab in px, captured in the layout phase. Updated only
// on real change so onGloballyPositioned can't drive an endless relayout.
val bounds = remember(tabs.size) {
mutableStateListOf<IntArray>().apply { repeat(tabs.size) { add(intArrayOf(0, 0)) } }
}
Box(modifier.background(Vojo.bgPanel)) {
Row(
Modifier.fillMaxWidth().height(54.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
tabs.forEachIndexed { i, label ->
val active = pagerState.currentPage == i
Box(
Modifier
.onGloballyPositioned { c ->
val r = c.boundsInParent()
val x = r.left.roundToInt()
val w = r.width.roundToInt()
val cur = bounds[i]
if (cur[0] != x || cur[1] != w) bounds[i] = intArrayOf(x, w)
}
.fillMaxHeight()
.clip(RoundedCornerShape(10.dp))
.clickable { onSelect(i) }
.padding(horizontal = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = if (active) Vojo.text else Vojo.muted,
fontSize = 14.5.sp,
fontWeight = if (active) FontWeight.SemiBold else FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
// Underline spanning the active tab, interpolated toward the next tab by
// the live swipe fraction. The outer node fills the width so the inner bar
// can be placed at any x; both reads happen in the layout phase.
Box(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.height(2.5.dp),
) {
Box(
Modifier
.layout { measurable, constraints ->
val last = tabs.lastIndex.coerceAtLeast(0)
val posF = (pagerState.currentPage + pagerState.currentPageOffsetFraction)
.coerceIn(0f, last.toFloat())
val lo = floor(posF).toInt().coerceIn(0, last)
val hi = ceil(posF).toInt().coerceIn(0, last)
val t = posF - lo
val from = bounds[lo]
val to = bounds[hi]
val barW = lerp(from[1].toFloat(), to[1].toFloat(), t).roundToInt().coerceAtLeast(0)
val left = lerp(from[0].toFloat(), to[0].toFloat(), t).roundToInt()
val placeable = measurable.measure(constraints.copy(minWidth = barW, maxWidth = barW))
layout(constraints.maxWidth, placeable.height) {
placeable.place(left, 0)
}
}
.height(2.5.dp)
.clip(RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp))
.background(Vojo.fleet),
)
}
}
}
/** A flat Fleet-style card: panel surface, hairline border, rounded. */
@Composable
fun PanelCard(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
accent: Color? = null,
background: Color = Vojo.bgPanel,
content: @Composable () -> Unit,
) {
val shape = RoundedCornerShape(14.dp)
var m = modifier
.clip(shape)
.background(background)
.border(BorderStroke(1.dp, accent ?: Vojo.divider), shape)
if (onClick != null) m = m.clickable { onClick() }
Box(m) { content() }
}
@Composable
fun OnlineDot(color: Color, size: Int = 8) {
Box(
Modifier
.size(size.dp)
.clip(CircleShape)
.background(color)
)
}

View file

@ -0,0 +1,494 @@
package chat.vojo.proxy.ui
import android.annotation.SuppressLint
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import chat.vojo.proxy.core.AppSettings
import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.ProxyType
import chat.vojo.proxy.core.formatBytes
import chat.vojo.proxy.core.formatClock
import chat.vojo.proxy.core.formatUptime
import chat.vojo.proxy.service.VpnState
import chat.vojo.proxy.ui.theme.Vojo
import kotlinx.coroutines.delay
import java.util.Calendar
private fun protoColor(config: ProxyConfig?): Color = when (config?.type) {
ProxyType.SHADOWSOCKS -> Vojo.fleet
ProxyType.SOCKS5 -> Vojo.blue
ProxyType.HTTP -> Vojo.amber
ProxyType.HTTPS -> Vojo.teal
null -> Vojo.fleetSoft
}
/** The Dawn hour palette: night → dawn → noon → dusk, colour lives on the time axis. */
private fun hourColor(h: Int): Color = when {
h < 5 -> Color(0xFF3A3D57)
h < 7 -> Color(0xFF4A4A6B)
h < 10 -> Color(0xFFC39A7A)
h < 13 -> Color(0xFFD4B88A)
h < 16 -> Color(0xFFA8B39A)
h < 19 -> Color(0xFFB8A187)
h < 21 -> Color(0xFFC08E7B)
h < 23 -> Color(0xFF6E6587)
else -> Color(0xFF3A3D57)
}
private fun eventHour(at: Long): Int =
Calendar.getInstance().apply { timeInMillis = at }.get(Calendar.HOUR_OF_DAY)
@Composable
fun ConnectionScreen(
activeConfig: ProxyConfig?,
configs: List<ProxyConfig>,
status: VpnState.Status,
stats: VpnState.Stats,
connectedSince: Long,
error: String?,
events: List<VpnState.TunnelEvent>,
applied: VpnState.Applied?,
settings: AppSettings,
liveId: String?,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onReconnect: () -> Unit,
onSelectServer: (String) -> Unit,
) {
var pickerOpen by remember { mutableStateOf(false) }
val connected = status == VpnState.Status.CONNECTED
val active = status == VpnState.Status.CONNECTING || status == VpnState.Status.CONNECTED || status == VpnState.Status.STOPPING
val live = activeConfig != null && activeConfig.id == liveId
Column(Modifier.fillMaxSize()) {
// ── Fixed header — never reflows on connect/disconnect ──
Column(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 16.dp)) {
if (error != null && status == VpnState.Status.ERROR) {
ErrorBanner(error)
Spacer(Modifier.height(14.dp))
}
ActiveServerCard(
config = activeConfig,
live = live,
active = active,
onPick = if (configs.size > 1 && !active) ({ pickerOpen = true }) else null,
onDisconnect = onDisconnect,
)
val stale = connected && applied != null &&
(applied.configId != activeConfig?.id || applied.settings != settings)
if (stale) {
Spacer(Modifier.height(10.dp))
PanelCard(
Modifier.fillMaxWidth(),
onClick = onReconnect,
accent = Vojo.amber.copy(alpha = 0.45f),
) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically) {
Text("Настройки изменены", color = Vojo.muted, fontSize = 13.sp, modifier = Modifier.weight(1f))
Text("переподключить", color = Vojo.fleetSoft, fontSize = 13.sp)
}
}
}
Spacer(Modifier.height(16.dp))
// Fixed-height slot: button when down, stats when up — swapping in
// place means the journal below never jumps on connect/disconnect.
Box(Modifier.fillMaxWidth().height(72.dp), contentAlignment = Alignment.Center) {
when {
connected -> StatsRow(stats, connectedSince)
status == VpnState.Status.CONNECTING || status == VpnState.Status.STOPPING ->
ConnectingButton(status)
else -> ConnectButton(status, onConnect, enabled = activeConfig != null)
}
}
}
// ── Scrollable journal — the only thing that scrolls; autoscrolls down ──
Column(Modifier.fillMaxWidth().weight(1f).padding(horizontal = 18.dp)) {
SectionLabel("Журнал", Modifier.padding(start = 2.dp, bottom = 10.dp))
JournalList(
events = events,
pulseLast = status == VpnState.Status.CONNECTING,
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
if (pickerOpen) {
ServerPickerSheet(
configs = configs,
activeId = activeConfig?.id,
liveId = liveId,
onSelect = { onSelectServer(it); pickerOpen = false },
onDismiss = { pickerOpen = false },
)
}
}
@Composable
private fun ErrorBanner(message: String) {
PanelCard(
Modifier.fillMaxWidth(),
accent = Vojo.danger.copy(alpha = 0.5f),
background = Vojo.danger.copy(alpha = 0.08f),
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(8.dp).clip(CircleShape).background(Vojo.danger))
Spacer(Modifier.width(12.dp))
Text(message, color = Vojo.danger, fontSize = 14.sp, modifier = Modifier.weight(1f))
}
}
}
@Composable
private fun ActiveServerCard(
config: ProxyConfig?,
live: Boolean,
active: Boolean,
onPick: (() -> Unit)?,
onDisconnect: () -> Unit,
) {
PanelCard(
Modifier.fillMaxWidth(),
onClick = onPick,
accent = if (live) protoColor(config).copy(alpha = 0.45f) else null,
) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
ProtocolBadge(config?.type?.badge ?: "", protoColor(config), size = 48)
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
config?.name ?: "Сервер не выбран",
color = if (live) Vojo.green else Vojo.text,
fontSize = 17.sp, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (live) {
Spacer(Modifier.width(8.dp))
OnlineDot(Vojo.green, 7)
}
}
Spacer(Modifier.height(3.dp))
Text(
config?.handle ?: "выберите на вкладке «Серверы»",
color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 13.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
if (active) {
// Disconnect lives inside the card as a square ✕.
Spacer(Modifier.width(10.dp))
Box(
Modifier
.size(46.dp)
.clip(RoundedCornerShape(12.dp))
.background(Vojo.danger.copy(alpha = 0.14f))
.clickable { onDisconnect() },
contentAlignment = Alignment.Center,
) {
Icon(VojoIcons.Cross, contentDescription = "Отключить", tint = Vojo.danger, modifier = Modifier.size(18.dp))
}
} else if (config != null && onPick != null) {
Spacer(Modifier.width(8.dp))
Text("сменить", color = Vojo.fleetSoft, fontSize = 13.sp)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ServerPickerSheet(
configs: List<ProxyConfig>,
activeId: String?,
liveId: String?,
onSelect: (String) -> Unit,
onDismiss: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
containerColor = Vojo.bgPanel,
contentColor = Vojo.text,
dragHandle = {
Box(
Modifier
.padding(top = 12.dp, bottom = 6.dp)
.size(width = 38.dp, height = 4.dp)
.clip(RoundedCornerShape(2.dp))
.background(Vojo.hairline)
)
},
) {
Column(Modifier.padding(horizontal = 14.dp, vertical = 4.dp)) {
SectionLabel("Сменить сервер", Modifier.padding(start = 4.dp, bottom = 10.dp))
configs.forEach { cfg ->
val isActive = cfg.id == activeId
val isLive = cfg.id == liveId
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(if (isActive) Vojo.surface else Color.Transparent)
.clickable { onSelect(cfg.id) }
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ProtocolBadge(cfg.type.badge, protoColor(cfg), size = 38)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
cfg.name,
color = if (isLive) Vojo.green else Vojo.text,
fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Text(cfg.handle, color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (isLive) OnlineDot(Vojo.green, 7)
else if (isActive) OnlineDot(Vojo.fleetSoft, 7)
}
}
Spacer(Modifier.height(20.dp))
}
}
}
@Composable
private fun ConnectButton(status: VpnState.Status, onConnect: () -> Unit, enabled: Boolean) {
val bg = if (enabled) Vojo.fleet else Vojo.surface
val fg = if (enabled) Color(0xFF0C0C0E) else Vojo.faint
Box(
Modifier
.fillMaxWidth()
.height(58.dp)
.clip(RoundedCornerShape(15.dp))
.background(bg)
.clickable(enabled = enabled) { onConnect() },
contentAlignment = Alignment.Center,
) {
Text("Подключиться", color = fg, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
/** Non-interactive transition state shown while the tunnel comes up or tears down. */
@Composable
private fun ConnectingButton(status: VpnState.Status) {
val label = if (status == VpnState.Status.STOPPING) "Отключение…" else "Подключение…"
Box(
Modifier
.fillMaxWidth()
.height(58.dp)
.clip(RoundedCornerShape(15.dp))
.background(Vojo.surface),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(color = Vojo.fleetSoft, strokeWidth = 2.dp, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(10.dp))
Text(label, color = Vojo.muted, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
}
@Composable
private fun StatsRow(stats: VpnState.Stats, connectedSince: Long) {
// Only this leaf subscribes to the 1 Hz tick, so the rest of the screen (and
// the pager) doesn't recompose every second while connected.
val nowMs = rememberNowTicker()
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
StatTile("Отправлено", formatBytes(stats.txBytes), Modifier.weight(1f))
StatTile("Получено", formatBytes(stats.rxBytes), Modifier.weight(1f))
StatTile("Время", formatUptime(connectedSince, nowMs), Modifier.weight(1f))
}
}
@SuppressLint("ProduceStateDoesNotAssignValue") // false positive: value is assigned inside repeatOnLifecycle
@Composable
private fun rememberNowTicker(): Long {
val lifecycle = LocalLifecycleOwner.current.lifecycle
val now by produceState(initialValue = System.currentTimeMillis(), lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (true) {
value = System.currentTimeMillis()
delay(1000)
}
}
}
return now
}
@Composable
private fun StatTile(label: String, value: String, modifier: Modifier = Modifier) {
PanelCard(modifier) {
Column(Modifier.padding(14.dp)) {
Text(label, color = Vojo.faint, fontSize = 12.sp)
Spacer(Modifier.height(5.dp))
Text(value, color = Vojo.text, fontFamily = Vojo.mono, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, maxLines = 1)
}
}
}
/** Connection journal: a scrollable, auto-scrolling timeline rail of events. */
@Composable
private fun JournalList(
events: List<VpnState.TunnelEvent>,
pulseLast: Boolean,
modifier: Modifier = Modifier,
) {
if (events.isEmpty()) {
Row(modifier) {
Text(
"",
Modifier.width(50.dp).padding(end = 12.dp),
color = Vojo.faint, fontFamily = Vojo.mono, fontSize = 11.sp, textAlign = TextAlign.End,
)
Text("Готов к подключению", color = Vojo.muted, fontSize = 14.sp)
}
return
}
val listState = rememberLazyListState()
// Auto-scroll to the newest event as it lands. Keyed on the last event's id,
// NOT events.size — the ring buffer saturates at MAX_EVENTS, so size stops
// changing while new events keep arriving.
LaunchedEffect(events.lastOrNull()?.id) {
if (events.isNotEmpty()) listState.animateScrollToItem(events.lastIndex)
}
LazyColumn(modifier, state = listState) {
itemsIndexed(events, key = { _, e -> e.id }) { i, e ->
val color = when (e.tone) {
VpnState.TunnelEvent.Tone.OK -> Vojo.green
VpnState.TunnelEvent.Tone.WARN -> Vojo.amber
VpnState.TunnelEvent.Tone.ERROR -> Vojo.danger
VpnState.TunnelEvent.Tone.NEUTRAL -> hourColor(eventHour(e.at))
}
RailRow(
time = formatClock(e.at),
label = e.label,
sub = e.detail,
color = color,
error = e.tone == VpnState.TunnelEvent.Tone.ERROR,
drawTop = i > 0,
drawBottom = i < events.lastIndex,
pulse = pulseLast && i == events.lastIndex,
)
}
}
}
@Composable
private fun RailRow(
time: String,
label: String,
sub: String?,
color: Color,
error: Boolean,
drawTop: Boolean,
drawBottom: Boolean,
pulse: Boolean,
) {
Row(Modifier.fillMaxWidth().height(if (sub.isNullOrBlank()) 42.dp else 50.dp)) {
Text(
time,
Modifier.width(50.dp).padding(end = 12.dp, top = 3.dp),
color = Vojo.faint, fontFamily = Vojo.mono, fontSize = 11.sp,
textAlign = TextAlign.End, maxLines = 1,
)
Box(Modifier.width(16.dp).fillMaxHeight()) {
// The rail line and the dot must share the same centre. The Column
// fills the cell WIDTH so CenterHorizontally actually centres its 1dp
// children; with only fillMaxHeight it shrink-wraps to 1dp and pins to
// the cell's start, leaving the dot floating ~7dp to its right.
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(Modifier.width(1.dp).height(9.dp).background(if (drawTop) Vojo.rail else Color.Transparent))
Box(Modifier.weight(1f).width(1.dp).background(if (drawBottom) Vojo.rail else Color.Transparent))
}
RailDot(color, pulse, Modifier.align(Alignment.TopCenter).padding(top = 2.dp))
}
Spacer(Modifier.width(14.dp))
Column {
Text(
label,
color = if (error) Vojo.danger else Vojo.text,
fontSize = 14.5.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (!sub.isNullOrBlank()) {
Text(sub, color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
@Composable
private fun RailDot(color: Color, pulse: Boolean, modifier: Modifier = Modifier) {
Box(
modifier.size(15.dp).clip(CircleShape).background(Vojo.bgApp),
contentAlignment = Alignment.Center,
) {
var dot = Modifier.size(9.dp)
if (pulse) {
val t = rememberInfiniteTransition(label = "rail-pulse")
val scale by t.animateFloat(
initialValue = 0.85f, targetValue = 1.2f,
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Reverse),
label = "dot",
)
dot = dot.graphicsLayer { scaleX = scale; scaleY = scale }
}
Box(dot.clip(CircleShape).background(color))
}
}

View file

@ -0,0 +1,152 @@
package chat.vojo.proxy.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.vojo.proxy.ui.theme.Vojo
@Composable
fun VojoTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
placeholder: String = "",
modifier: Modifier = Modifier,
keyboardType: KeyboardType = KeyboardType.Text,
mono: Boolean = false,
isPassword: Boolean = false,
error: String? = null,
) {
var reveal by rememberSaveable { mutableStateOf(false) }
Column(modifier) {
SectionLabel(label)
Spacer(Modifier.height(6.dp))
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Vojo.bgApp)
.border(1.dp, if (error != null) Vojo.danger.copy(alpha = 0.55f) else Vojo.hairline, RoundedCornerShape(10.dp))
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = TextStyle(
color = Vojo.text,
fontSize = 14.sp,
fontFamily = if (mono) Vojo.mono else FontFamily.Default,
),
cursorBrush = SolidColor(Vojo.fleet),
keyboardOptions = KeyboardOptions(
keyboardType = if (isPassword) KeyboardType.Password else keyboardType,
),
visualTransformation = if (isPassword && !reveal) PasswordVisualTransformation() else VisualTransformation.None,
decorationBox = { inner ->
Box(Modifier.padding(vertical = 15.dp)) {
if (value.isEmpty()) {
Text(
placeholder,
color = Vojo.faint,
fontSize = 14.sp,
fontFamily = if (mono) Vojo.mono else FontFamily.Default,
)
}
inner()
}
},
)
if (isPassword) {
Icon(
if (reveal) VojoIcons.EyeOff else VojoIcons.Eye,
contentDescription = if (reveal) "Скрыть пароль" else "Показать пароль",
tint = Vojo.faint,
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.clickable { reveal = !reveal }
.padding(6.dp)
.size(16.dp),
)
}
}
if (error != null) {
Spacer(Modifier.height(4.dp))
Text(error, color = Vojo.danger, fontSize = 11.sp)
}
}
}
@Composable
fun <T> VojoDropdown(
label: String,
options: List<T>,
selected: T,
optionLabel: (T) -> String,
onSelect: (T) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
Column(modifier) {
SectionLabel(label)
Spacer(Modifier.height(6.dp))
Box {
Row(
Modifier
.fillMaxWidth()
.height(52.dp)
.clip(RoundedCornerShape(10.dp))
.background(Vojo.bgApp)
.border(1.dp, Vojo.hairline, RoundedCornerShape(10.dp))
.clickable { expanded = true }
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(optionLabel(selected), color = Vojo.text, fontSize = 15.sp, modifier = Modifier.weight(1f))
Icon(VojoIcons.ChevronDown, contentDescription = null, tint = Vojo.muted, modifier = Modifier.size(14.dp))
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { opt ->
DropdownMenuItem(
text = { Text(optionLabel(opt), color = Vojo.text, fontSize = 14.sp) },
onClick = { onSelect(opt); expanded = false },
)
}
}
}
}
}

View file

@ -0,0 +1,140 @@
package chat.vojo.proxy.ui
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
/**
* Thin stroke icons (1.7dp, round caps) matching the design bundle's shared.jsx
* icon set unicode glyphs render inconsistently across system fallback fonts.
* Tinted via Icon(..., tint = ...).
*/
object VojoIcons {
private inline fun stroke(name: String, crossinline body: PathBuilder.() -> Unit): ImageVector =
ImageVector.Builder(
name = name, defaultWidth = 24.dp, defaultHeight = 24.dp,
viewportWidth = 24f, viewportHeight = 24f,
).apply {
path(
fill = null,
stroke = SolidColor(Color.White),
strokeLineWidth = 1.7f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
) { body() }
}.build()
private inline fun fill(name: String, crossinline body: PathBuilder.() -> Unit): ImageVector =
ImageVector.Builder(
name = name, defaultWidth = 24.dp, defaultHeight = 24.dp,
viewportWidth = 24f, viewportHeight = 24f,
).apply {
path(fill = SolidColor(Color.White)) { body() }
}.build()
private fun PathBuilder.circle(cx: Float, cy: Float, r: Float) {
moveTo(cx + r, cy)
arcTo(r, r, 0f, isMoreThanHalf = false, isPositiveArc = true, x1 = cx - r, y1 = cy)
arcTo(r, r, 0f, isMoreThanHalf = false, isPositiveArc = true, x1 = cx + r, y1 = cy)
close()
}
val Search: ImageVector by lazy {
stroke("search") {
moveTo(18f, 11f)
arcTo(7f, 7f, 0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 4f, y1 = 11f)
arcTo(7f, 7f, 0f, isMoreThanHalf = false, isPositiveArc = true, x1 = 18f, y1 = 11f)
moveTo(16f, 16f)
lineTo(20f, 20f)
}
}
val More: ImageVector by lazy {
fill("more") {
circle(5f, 12f, 1.6f)
circle(12f, 12f, 1.6f)
circle(19f, 12f, 1.6f)
}
}
val ChevronDown: ImageVector by lazy {
stroke("chevron-down") {
moveTo(6f, 9.5f)
lineTo(12f, 15.5f)
lineTo(18f, 9.5f)
}
}
val Check: ImageVector by lazy {
stroke("check") {
moveTo(5f, 12.5f)
lineTo(10f, 17.5f)
lineTo(19f, 6.5f)
}
}
val Plus: ImageVector by lazy {
stroke("plus") {
moveTo(12f, 5f)
verticalLineTo(19f)
moveTo(5f, 12f)
horizontalLineTo(19f)
}
}
val Eye: ImageVector by lazy {
stroke("eye") {
moveTo(2f, 12f)
curveTo(5f, 5.8f, 19f, 5.8f, 22f, 12f)
curveTo(19f, 18.2f, 5f, 18.2f, 2f, 12f)
close()
circle(12f, 12f, 3f)
}
}
val EyeOff: ImageVector by lazy {
stroke("eye-off") {
moveTo(2f, 12f)
curveTo(5f, 5.8f, 19f, 5.8f, 22f, 12f)
curveTo(19f, 18.2f, 5f, 18.2f, 2f, 12f)
close()
moveTo(5f, 3.5f)
lineTo(19f, 20.5f)
}
}
val Cross: ImageVector by lazy {
stroke("cross") {
moveTo(6f, 6f)
lineTo(18f, 18f)
moveTo(18f, 6f)
lineTo(6f, 18f)
}
}
val Clipboard: ImageVector by lazy {
stroke("clipboard") {
moveTo(9f, 5f)
horizontalLineTo(6f)
arcTo(1.5f, 1.5f, 0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 4.5f, y1 = 6.5f)
verticalLineTo(19.5f)
arcTo(1.5f, 1.5f, 0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 6f, y1 = 21f)
horizontalLineTo(18f)
arcTo(1.5f, 1.5f, 0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 19.5f, y1 = 19.5f)
verticalLineTo(6.5f)
arcTo(1.5f, 1.5f, 0f, isMoreThanHalf = false, isPositiveArc = false, x1 = 18f, y1 = 5f)
horizontalLineTo(15f)
moveTo(9f, 3.5f)
horizontalLineTo(15f)
verticalLineTo(6.5f)
horizontalLineTo(9f)
close()
}
}
}

View file

@ -0,0 +1,174 @@
package chat.vojo.proxy.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.tappableElement
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import chat.vojo.proxy.service.VpnState
import chat.vojo.proxy.ui.theme.Vojo
import kotlinx.coroutines.launch
@Composable
fun MainScreen(
vm: MainViewModel,
onConnect: () -> Unit,
onDisconnect: () -> Unit,
onReconnect: () -> Unit,
) {
val proxyState by vm.state.collectAsState()
val status by vm.status.collectAsState()
val stats by vm.stats.collectAsState()
val error by vm.error.collectAsState()
val connectedSince by vm.connectedSince.collectAsState()
val events by vm.events.collectAsState()
val applied by vm.applied.collectAsState()
val apps by vm.apps.collectAsState()
val appsLoading by vm.appsLoading.collectAsState()
val tabs = listOf("Туннель", "Серверы", "Приложения", "Опции")
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()
var editorOpen by rememberSaveable { mutableStateOf(false) }
var editorInitialId by rememberSaveable { mutableStateOf<String?>(null) }
val active = proxyState.activeConfig
val settings = proxyState.settings
val connected = status == VpnState.Status.CONNECTED
// The config the running tunnel was actually built from (null when down).
// Green/live styling keys off this, NOT the merely-selected server.
val liveId = if (connected) applied?.configId else null
// Static backdrop (#181A20) stretched edge-to-edge over the whole window —
// shows through the inter-pane gap during a swipe and behind the system bars.
Box(Modifier.fillMaxSize().background(Vojo.bgPanel)) {
// Only the TOP (status bar / cutout) and SIDES are padded here — the
// sheets reach the very bottom so the backdrop never shows as a strip
// below them. The bottom system inset is applied INSIDE each sheet.
Column(
Modifier
.fillMaxSize()
.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
),
) {
SegmentTabs(
tabs = tabs,
pagerState = pagerState,
onSelect = {
scope.launch { pagerState.animateScrollToPage(it, animationSpec = tween(260, easing = FastOutSlowInEasing)) }
},
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp)) // backdrop strip above the sheets
// The panes are sheets (#0D0E11) with a rounded top, sliding on the
// backdrop like a gallery; pageSpacing surfaces the backdrop gap.
// A short ease-out snap keeps the settle from feeling sluggish.
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
pageSpacing = 18.dp,
beyondViewportPageCount = 1,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = tween(260, easing = FastOutSlowInEasing),
),
) { page ->
Box(
Modifier
.fillMaxSize()
.clip(RoundedCornerShape(topStart = 26.dp, topEnd = 26.dp))
.background(Vojo.bgApp),
) {
// Sheet background reaches the bottom edge; its CONTENT clears
// the 3-button nav bar via tappableElement (0 in gesture mode,
// so gesture nav stays truly edge-to-edge).
Box(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.tappableElement.only(WindowInsetsSides.Bottom)),
) {
when (page) {
0 -> ConnectionScreen(
activeConfig = active,
configs = proxyState.configs,
status = status,
stats = stats,
connectedSince = connectedSince,
error = error,
events = events,
applied = applied,
settings = settings,
liveId = liveId,
onConnect = onConnect,
onDisconnect = onDisconnect,
onReconnect = onReconnect,
onSelectServer = { vm.setActive(it) },
)
1 -> ServersScreen(
configs = proxyState.configs,
activeId = active?.id,
liveId = liveId,
onSelect = { vm.setActive(it) },
onAdd = { editorInitialId = null; editorOpen = true },
onEdit = { editorInitialId = it.id; editorOpen = true },
onDelete = { vm.deleteConfig(it) },
)
2 -> AppsScreen(
apps = apps,
loading = appsLoading,
selected = settings.allowedApps,
loadIcon = { vm.iconFor(it) },
cachedIcon = { vm.cachedIcon(it) },
onToggle = { pkg, on -> vm.toggleApp(pkg, on) },
onClear = { vm.clearApps() },
onLoad = { vm.loadApps() },
)
3 -> SettingsScreen(
settings = settings,
onChange = { transform -> vm.updateSettings(transform) },
)
}
}
}
}
}
}
if (editorOpen) {
ServerEditorDialog(
initial = remember(editorInitialId, proxyState.configs) {
proxyState.configs.firstOrNull { it.id == editorInitialId }
},
onSave = { vm.saveConfig(it); editorOpen = false },
onDismiss = { editorOpen = false },
)
}
}

View file

@ -0,0 +1,98 @@
package chat.vojo.proxy.ui
import android.app.Application
import androidx.compose.ui.graphics.ImageBitmap
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import chat.vojo.proxy.core.AppSettings
import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.data.AppInfo
import chat.vojo.proxy.data.AppRepository
import chat.vojo.proxy.data.ConfigStore
import chat.vojo.proxy.data.ProxyState
import chat.vojo.proxy.service.ProxyVpnService
import chat.vojo.proxy.service.VpnState
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class MainViewModel(app: Application) : AndroidViewModel(app) {
private val store = ConfigStore(app)
private val appRepo = AppRepository(app)
val state: StateFlow<ProxyState> =
store.state.stateIn(viewModelScope, SharingStarted.Eagerly, ProxyState())
val status: StateFlow<VpnState.Status> = VpnState.status
val stats: StateFlow<VpnState.Stats> = VpnState.stats
val error: StateFlow<String?> = VpnState.error
val connectedSince: StateFlow<Long> = VpnState.connectedSince
val events: StateFlow<List<VpnState.TunnelEvent>> = VpnState.events
val applied: StateFlow<VpnState.Applied?> = VpnState.applied
private val _apps = MutableStateFlow<List<AppInfo>>(emptyList())
val apps: StateFlow<List<AppInfo>> = _apps.asStateFlow()
private val _appsLoading = MutableStateFlow(false)
val appsLoading: StateFlow<Boolean> = _appsLoading.asStateFlow()
/** Refreshes on every screen entry so newly installed apps show up. */
fun loadApps() {
if (_appsLoading.value) return
_appsLoading.value = true
viewModelScope.launch {
_apps.value = appRepo.installedApps()
_appsLoading.value = false
}
}
suspend fun iconFor(packageName: String): ImageBitmap? = appRepo.icon(packageName)
fun cachedIcon(packageName: String): ImageBitmap? = appRepo.cachedIcon(packageName)
fun setActive(id: String) = viewModelScope.launch {
val changing = id != state.value.activeConfig?.id
store.setActive(id)
// Switching servers while the tunnel runs must actually move the traffic.
if (changing && VpnState.status.value in RUNNING_STATES) {
ProxyVpnService.restart(getApplication())
}
}
fun reconnect() = ProxyVpnService.restart(getApplication())
fun saveConfig(config: ProxyConfig) = viewModelScope.launch { store.upsertConfig(config) }
fun deleteConfig(id: String) = viewModelScope.launch { store.deleteConfig(id) }
fun toggleApp(packageName: String, enabled: Boolean) = viewModelScope.launch {
store.updateSettings { s ->
val apps = if (enabled) s.allowedApps + packageName else s.allowedApps - packageName
s.copy(allowedApps = apps)
}
// Whitelist changes only take effect at establish() time — re-apply the
// tunnel live so toggling an app on/off routes immediately, mirroring the
// auto-reconnect on an active-server switch.
reconnectIfRunning()
}
fun clearApps() = viewModelScope.launch {
store.updateSettings { it.copy(allowedApps = emptySet()) }
reconnectIfRunning()
}
private fun reconnectIfRunning() {
if (VpnState.status.value in RUNNING_STATES) ProxyVpnService.restart(getApplication())
}
fun updateSettings(transform: (AppSettings) -> AppSettings) = viewModelScope.launch {
store.updateSettings(transform)
}
private companion object {
val RUNNING_STATES = setOf(VpnState.Status.CONNECTED, VpnState.Status.CONNECTING)
}
}

View file

@ -0,0 +1,421 @@
package chat.vojo.proxy.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import chat.vojo.proxy.core.ProxyConfig
import chat.vojo.proxy.core.ProxyType
import chat.vojo.proxy.core.SsCipher
import chat.vojo.proxy.core.parseProxyUri
import chat.vojo.proxy.core.ssRequiresPlugin
import chat.vojo.proxy.ui.theme.Vojo
private fun typeColor(type: ProxyType): Color = when (type) {
ProxyType.SHADOWSOCKS -> Vojo.fleet
ProxyType.SOCKS5 -> Vojo.blue
ProxyType.HTTP -> Vojo.amber
ProxyType.HTTPS -> Vojo.teal
}
@Composable
fun ServersScreen(
configs: List<ProxyConfig>,
activeId: String?,
liveId: String?,
onSelect: (String) -> Unit,
onAdd: () -> Unit,
onEdit: (ProxyConfig) -> Unit,
onDelete: (String) -> Unit,
) {
var confirmDelete by remember { mutableStateOf<ProxyConfig?>(null) }
Box(Modifier.fillMaxSize()) {
if (configs.isEmpty()) {
EmptyServers(onAdd)
} else {
LazyColumn(
Modifier.fillMaxSize().padding(horizontal = 14.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 14.dp, bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
SectionLabel("Серверы · ${configs.size}", Modifier.padding(start = 4.dp, bottom = 4.dp))
}
items(configs, key = { it.id }) { cfg ->
ServerRow(
cfg = cfg,
selected = cfg.id == activeId,
live = cfg.id == liveId,
onClick = { onSelect(cfg.id) },
onEdit = { onEdit(cfg) },
onDelete = { confirmDelete = cfg },
)
}
item {
Row(
Modifier
.fillMaxWidth()
.height(54.dp)
.clip(RoundedCornerShape(14.dp))
.border(1.dp, Vojo.hairline, RoundedCornerShape(14.dp))
.clickable { onAdd() },
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(VojoIcons.Plus, contentDescription = null, tint = Vojo.fleetSoft, modifier = Modifier.size(17.dp))
Spacer(Modifier.width(8.dp))
Text("Добавить сервер", color = Vojo.fleetSoft, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
}
}
}
confirmDelete?.let { cfg ->
ConfirmDeleteDialog(
config = cfg,
onConfirm = { onDelete(cfg.id); confirmDelete = null },
onDismiss = { confirmDelete = null },
)
}
}
@Composable
private fun ServerRow(
cfg: ProxyConfig,
selected: Boolean,
live: Boolean,
onClick: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
var menu by remember { mutableStateOf(false) }
PanelCard(
Modifier.fillMaxWidth(),
onClick = onClick,
accent = when {
live -> Vojo.green.copy(alpha = 0.5f)
selected -> Vojo.fleet.copy(alpha = 0.5f)
else -> null
},
background = if (selected) Vojo.surface else Vojo.bgPanel,
) {
Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
// Radio marks the SELECTED (active) server; the green name marks the
// one that's actually LIVE in the tunnel right now.
SelectRadio(selected)
Spacer(Modifier.width(13.dp))
ProtocolBadge(cfg.type.badge, typeColor(cfg.type), size = 42)
Spacer(Modifier.width(13.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
cfg.name,
color = if (live) Vojo.green else Vojo.text,
fontSize = 15.sp, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
)
if (live) {
Spacer(Modifier.width(8.dp))
OnlineDot(Vojo.green, 7)
}
}
Spacer(Modifier.height(3.dp))
Text(
cfg.handle,
color = Vojo.muted, fontFamily = Vojo.mono, fontSize = 13.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Box {
Icon(
VojoIcons.More,
contentDescription = "Меню сервера",
tint = Vojo.muted,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clickable { menu = true }
.padding(12.dp)
.size(20.dp),
)
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(
text = { Text("Изменить", color = Vojo.text) },
onClick = { menu = false; onEdit() },
)
DropdownMenuItem(
text = { Text("Удалить", color = Vojo.danger) },
onClick = { menu = false; onDelete() },
)
}
}
}
}
}
@Composable
private fun SelectRadio(selected: Boolean) {
Box(
Modifier
.size(22.dp)
.clip(CircleShape)
.border(2.dp, if (selected) Vojo.fleet else Vojo.hairline, CircleShape),
contentAlignment = Alignment.Center,
) {
if (selected) Box(Modifier.size(11.dp).clip(CircleShape).background(Vojo.fleet))
}
}
/** Deleting a server (and its password) is destructive — confirm explicitly. */
@Composable
private fun ConfirmDeleteDialog(config: ProxyConfig, onConfirm: () -> Unit, onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(Vojo.bgPanel)
.border(1.dp, Vojo.hairline, RoundedCornerShape(18.dp))
.padding(20.dp),
) {
Text("Удалить сервер?", color = Vojo.text, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
Text(
"«${config.name}» и его пароль будут удалены безвозвратно.",
color = Vojo.muted, fontSize = 14.sp,
)
Spacer(Modifier.height(4.dp))
Text(config.handle, color = Vojo.faint, fontFamily = Vojo.mono, fontSize = 13.sp)
Spacer(Modifier.height(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(
Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(12.dp))
.border(1.dp, Vojo.hairline, RoundedCornerShape(12.dp))
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) { Text("Отмена", color = Vojo.muted, fontSize = 15.sp) }
Box(
Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(12.dp))
.background(Vojo.danger)
.clickable { onConfirm() },
contentAlignment = Alignment.Center,
) { Text("Удалить", color = Color(0xFF0C0C0E), fontWeight = FontWeight.SemiBold, fontSize = 15.sp) }
}
}
}
}
@Composable
private fun EmptyServers(onAdd: () -> Unit) {
Column(
Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Нет серверов", color = Vojo.text, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
Text(
"Добавьте Shadowsocks, SOCKS5 или HTTP(S) прокси, чтобы начать.",
color = Vojo.muted, fontSize = 14.sp,
modifier = Modifier.padding(horizontal = 16.dp),
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(20.dp))
Row(
Modifier
.clip(RoundedCornerShape(13.dp))
.background(Vojo.fleet)
.clickable { onAdd() }
.padding(horizontal = 24.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(VojoIcons.Plus, contentDescription = null, tint = Color(0xFF0C0C0E), modifier = Modifier.size(17.dp))
Spacer(Modifier.width(8.dp))
Text("Добавить сервер", color = Color(0xFF0C0C0E), fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
}
}
}
/** Add / edit form rendered as a dialog. */
@Composable
fun ServerEditorDialog(
initial: ProxyConfig?,
onSave: (ProxyConfig) -> Unit,
onDismiss: () -> Unit,
) {
var name by rememberSaveable { mutableStateOf(initial?.name ?: "") }
var type by rememberSaveable { mutableStateOf(initial?.type ?: ProxyType.SHADOWSOCKS) }
var host by rememberSaveable { mutableStateOf(initial?.host ?: "") }
var port by rememberSaveable { mutableStateOf(initial?.port?.toString() ?: "") }
var username by rememberSaveable { mutableStateOf(initial?.username ?: "") }
var password by rememberSaveable { mutableStateOf(initial?.password ?: "") }
var cipher by rememberSaveable { mutableStateOf(initial?.cipher ?: SsCipher.AES_256_GCM) }
var errorText by rememberSaveable { mutableStateOf<String?>(null) }
val clipboard = LocalClipboardManager.current
Dialog(
onDismissRequest = onDismiss,
// Typed-in credentials must survive an accidental tap outside.
properties = DialogProperties(dismissOnClickOutside = false),
) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(Vojo.bgPanel)
.border(1.dp, Vojo.hairline, RoundedCornerShape(18.dp))
.padding(20.dp)
.verticalScroll(rememberScrollState()),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (initial == null) "Новый сервер" else "Изменить сервер",
color = Vojo.text, fontSize = 19.sp, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
// Most SS configs travel as ss:// links — fill the form from one.
Row(
Modifier
.clip(RoundedCornerShape(8.dp))
.clickable {
val text = clipboard.getText()?.text ?: ""
val parsed = parseProxyUri(text)
when {
parsed != null -> {
name = parsed.name
type = parsed.type
host = parsed.host
port = parsed.port.toString()
username = parsed.username
password = parsed.password
cipher = parsed.cipher
errorText = null
}
ssRequiresPlugin(text) ->
errorText = "Ссылка ss:// требует плагин (obfs/v2ray) — он не поддерживается"
else ->
errorText = "В буфере нет ссылки ss:// / socks5:// / http(s)://"
}
}
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(VojoIcons.Clipboard, contentDescription = null, tint = Vojo.fleetSoft, modifier = Modifier.size(15.dp))
Spacer(Modifier.width(6.dp))
Text("вставить", color = Vojo.fleetSoft, fontSize = 13.sp)
}
}
Spacer(Modifier.height(18.dp))
VojoTextField(name, { name = it }, "Имя", "Мой сервер")
Spacer(Modifier.height(14.dp))
VojoDropdown(
label = "Протокол",
options = ProxyType.entries,
selected = type,
optionLabel = { it.label },
onSelect = { type = it },
)
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
VojoTextField(host, { host = it }, "Адрес", "example.com", Modifier.weight(2f), mono = true)
VojoTextField(port, { port = it.filter(Char::isDigit).take(5) }, "Порт", "8388", Modifier.weight(1f), keyboardType = androidx.compose.ui.text.input.KeyboardType.Number, mono = true)
}
Spacer(Modifier.height(14.dp))
when (type) {
ProxyType.SHADOWSOCKS -> {
VojoDropdown(
label = "Шифр",
options = SsCipher.entries,
selected = cipher,
optionLabel = { it.id },
onSelect = { cipher = it },
)
Spacer(Modifier.height(14.dp))
VojoTextField(password, { password = it }, "Пароль", "пароль Shadowsocks", isPassword = true, mono = true)
}
else -> {
VojoTextField(username, { username = it }, "Логин (необязательно)", "user", mono = true)
Spacer(Modifier.height(14.dp))
VojoTextField(password, { password = it }, "Пароль (необязательно)", "••••", isPassword = true, mono = true)
}
}
if (errorText != null) {
Spacer(Modifier.height(12.dp))
Text(errorText!!, color = Vojo.danger, fontSize = 13.sp)
}
Spacer(Modifier.height(22.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(
Modifier.weight(1f).height(50.dp).clip(RoundedCornerShape(12.dp))
.border(1.dp, Vojo.hairline, RoundedCornerShape(12.dp))
.clickable { onDismiss() },
contentAlignment = Alignment.Center,
) { Text("Отмена", color = Vojo.muted, fontSize = 15.sp) }
Box(
Modifier.weight(1f).height(50.dp).clip(RoundedCornerShape(12.dp))
.background(Vojo.fleet)
.clickable {
val cfg = ProxyConfig(
id = initial?.id ?: java.util.UUID.randomUUID().toString(),
name = name.trim(),
type = type,
host = host.trim(),
port = port.toIntOrNull() ?: 0,
username = username.trim(),
password = password,
cipher = cipher,
)
val err = cfg.validationError()
if (err != null) errorText = err else onSave(cfg)
},
contentAlignment = Alignment.Center,
) { Text("Сохранить", color = Color(0xFF0C0C0E), fontWeight = FontWeight.SemiBold, fontSize = 15.sp) }
}
}
}
}

View file

@ -0,0 +1,144 @@
package chat.vojo.proxy.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.vojo.proxy.core.AppSettings
import chat.vojo.proxy.ui.theme.Vojo
/** Loose but useful: enough to keep garbage out of VpnService.addDnsServer. */
private fun isValidIp(s: String): Boolean {
if (s.isBlank()) return false
return if (':' in s) {
s.matches(Regex("^[0-9a-fA-F:]{2,45}$")) && s.count { it == ':' } in 1..8
} else {
val parts = s.split('.')
parts.size == 4 && parts.all { p -> p.toIntOrNull()?.let { it in 0..255 } == true && p.length <= 3 }
}
}
@Composable
fun SettingsScreen(
settings: AppSettings,
onChange: ((AppSettings) -> AppSettings) -> Unit,
) {
var dnsEdit by rememberSaveable { mutableStateOf<String?>(null) }
var mtuEdit by rememberSaveable { mutableStateOf<String?>(null) }
val dnsValue = dnsEdit ?: settings.dns
val mtuValue = mtuEdit ?: settings.mtu.toString()
val dnsError = if (dnsEdit != null && !isValidIp(dnsValue.trim())) "Не похоже на IP-адрес — не сохранено" else null
val mtuError = if (mtuEdit != null && (mtuValue.toIntOrNull() == null || mtuValue.toInt() !in 1000..9000)) "Допустимо 10009000 — не сохранено" else null
Column(
// imePadding so the keyboard never covers the DNS/MTU fields under
// edge-to-edge (the window isn't resized when decor fits system windows).
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).imePadding().padding(18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SectionLabel("Сеть")
VojoTextField(
value = dnsValue,
onValueChange = {
dnsEdit = it
val v = it.trim()
if (isValidIp(v)) onChange { s -> s.copy(dns = v) }
},
label = "DNS-сервер",
placeholder = "1.1.1.1",
mono = true,
keyboardType = KeyboardType.Ascii,
error = dnsError,
)
Text("DNS-запросы идут через туннель — провайдер их не видит.", color = Vojo.faint, fontSize = 12.sp)
VojoTextField(
value = mtuValue,
onValueChange = {
val v = it.filter(Char::isDigit).take(4)
mtuEdit = v
v.toIntOrNull()?.let { n -> if (n in 1000..9000) onChange { s -> s.copy(mtu = n) } }
},
label = "MTU",
placeholder = "8500",
mono = true,
keyboardType = KeyboardType.Number,
error = mtuError,
)
ToggleRow(
title = "IPv6",
subtitle = "Маршрутизировать IPv6-трафик через туннель",
checked = settings.ipv6,
onToggle = { onChange { s -> s.copy(ipv6 = it) } },
)
Text(
"Изменения применяются при следующем подключении.",
color = Vojo.faint, fontSize = 12.sp,
)
}
}
@Composable
private fun ToggleRow(title: String, subtitle: String, checked: Boolean, onToggle: (Boolean) -> Unit) {
PanelCard(Modifier.fillMaxWidth()) {
Row(
Modifier
.toggleable(value = checked, role = Role.Switch) { onToggle(it) }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(title, color = Vojo.text, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(subtitle, color = Vojo.muted, fontSize = 12.5.sp)
}
Switch(checked)
}
}
}
@Composable
private fun Switch(checked: Boolean) {
Box(
Modifier
.width(46.dp)
.height(26.dp)
.clip(RoundedCornerShape(13.dp))
.background(if (checked) Vojo.fleet else Vojo.surface2)
.padding(3.dp),
contentAlignment = if (checked) Alignment.CenterEnd else Alignment.CenterStart,
) {
Box(Modifier.size(20.dp).clip(RoundedCornerShape(10.dp)).background(Color.White))
}
}

View file

@ -0,0 +1,84 @@
package chat.vojo.proxy.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* The "Dawn / Fleet" palette ported from the vojo design bundle: near-black
* surfaces, hairline dividers, a single violet accent, colour used only for
* status. Monospace is reserved for technical metadata (host:port, ciphers).
*/
object Vojo {
val bgApp = Color(0xFF0D0E11) // app background
val bgPanel = Color(0xFF181A20) // cards / panels
val surface = Color(0xFF21232B) // raised
val surface2 = Color(0xFF2A2D36) // hover / active
val divider = Color(0x0FFFFFFF) // white 6%
val hairline = Color(0x14FFFFFF) // white 8%
val rail = Color(0x2EE6E6E9) // thin gray rail ~18%
val text = Color(0xFFE6E6E9)
val muted = Color(0x8CE6E6E9) // 55%
val faint = Color(0x52E6E6E9) // 32%
val fleet = Color(0xFF9580FF) // violet accent
val fleetSoft = Color(0xFFA59CFF)
val green = Color(0xFF7DD3A8)
val amber = Color(0xFFD4B88A)
val blue = Color(0xFF7AB6D9)
val teal = Color(0xFF9BD1B3)
val danger = Color(0xFFE0796B)
val mono = FontFamily.Monospace
}
private val VojoColorScheme = darkColorScheme(
primary = Vojo.fleet,
onPrimary = Color(0xFF0C0C0E),
secondary = Vojo.fleetSoft,
background = Vojo.bgApp,
onBackground = Vojo.text,
surface = Vojo.bgPanel,
onSurface = Vojo.text,
surfaceVariant = Vojo.surface,
onSurfaceVariant = Vojo.muted,
error = Vojo.danger,
outline = Vojo.hairline,
// M3 popups (DropdownMenu, ModalBottomSheet) pick surfaceContainer* — keep
// them inside the Vojo palette instead of the default violet-tinted tones.
surfaceContainerLowest = Vojo.bgApp,
surfaceContainerLow = Vojo.bgPanel,
surfaceContainer = Vojo.bgPanel,
surfaceContainerHigh = Vojo.surface,
surfaceContainerHighest = Vojo.surface,
surfaceDim = Vojo.bgApp,
surfaceBright = Vojo.surface2,
)
private val VojoTypography = Typography(
titleLarge = TextStyle(fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = Vojo.text),
titleMedium = TextStyle(fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = Vojo.text),
bodyLarge = TextStyle(fontSize = 14.sp, color = Vojo.text),
bodyMedium = TextStyle(fontSize = 13.sp, color = Vojo.text),
bodySmall = TextStyle(fontSize = 11.5.sp, color = Vojo.muted),
labelLarge = TextStyle(fontSize = 13.sp, fontWeight = FontWeight.Medium),
labelSmall = TextStyle(fontSize = 10.5.sp, color = Vojo.faint),
)
@Composable
fun VojoTheme(content: @Composable () -> Unit) {
// App is dark-only, like the design (no light-theme branch).
MaterialTheme(
colorScheme = VojoColorScheme,
typography = VojoTypography,
content = content,
)
}

View file

@ -0,0 +1,18 @@
package hev.htproxy
/**
* Stub required by the vendored libhev-socks5-tunnel. Its JNI_OnLoad (hev-jni.c)
* runs on library load and does FindClass("hev/htproxy/TProxyService") +
* RegisterNatives against these exact instance methods; if the class is missing
* the pending ClassNotFoundException aborts the whole process (SIGABRT).
*
* We drive the engine through our own [chat.vojo.proxy.core.Tun2Socks] C bridge
* and never call these the class only has to exist with matching signatures.
* Kept verbatim in release by a -keep rule in proguard-rules.pro.
*/
@Suppress("unused", "FunctionName")
class TProxyService {
external fun TProxyStartService(configPath: String, fd: Int)
external fun TProxyStopService()
external fun TProxyGetStats(): LongArray
}

View file

@ -0,0 +1,17 @@
# Capture our directory before including vendored makefiles (they reassign LOCAL_PATH).
VOJO_JNI_PATH := $(call my-dir)
# Build hev-socks5-tunnel as a shared library plus its static dependencies
# (yaml, lwip, hev-task-system). This is the tun2socks engine.
include $(VOJO_JNI_PATH)/hev-socks5-tunnel/Android.mk
# Thin JNI bridge that exposes hev's C API to Kotlin.
include $(CLEAR_VARS)
LOCAL_PATH := $(VOJO_JNI_PATH)
LOCAL_MODULE := tun2socks
LOCAL_SRC_FILES := tun2socks.c
LOCAL_C_INCLUDES := $(VOJO_JNI_PATH)/hev-socks5-tunnel/include
LOCAL_SHARED_LIBRARIES := hev-socks5-tunnel
LOCAL_LDLIBS := -llog
LOCAL_CFLAGS += -O2 -Wall
include $(BUILD_SHARED_LIBRARY)

View file

@ -0,0 +1,115 @@
---
Language: Cpp
AccessModifierOffset: -4
AlignAfterOpenBracket: Align
AlignConsecutiveAssignments: false
AlignConsecutiveDeclarations: false
AlignEscapedNewlines: Left
AlignOperands: true
AlignTrailingComments: false
AllowAllParametersOfDeclarationOnNextLine: false
AllowShortBlocksOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
AlwaysBreakAfterDefinitionReturnType: TopLevel
AlwaysBreakAfterReturnType: None
AlwaysBreakBeforeMultilineStrings: false
AlwaysBreakTemplateDeclarations: MultiLine
BinPackArguments: true
BinPackParameters: true
BraceWrapping:
AfterClass: true
AfterControlStatement: false
AfterEnum: true
AfterFunction: true
AfterNamespace: true
AfterObjCDeclaration: false
AfterStruct: true
AfterUnion: true
AfterExternBlock: false
BeforeCatch: false
BeforeElse: false
IndentBraces: false
SplitEmptyFunction: true
SplitEmptyRecord: true
SplitEmptyNamespace: true
BreakBeforeBinaryOperators: None
BreakBeforeBraces: Custom
BreakBeforeInheritanceComma: false
BreakInheritanceList: BeforeColon
BreakBeforeTernaryOperators: false
BreakConstructorInitializersBeforeComma: false
BreakAfterJavaFieldAnnotations: false
BreakStringLiterals: false
ColumnLimit: 80
CommentPragmas: '^ IWYU pragma:'
CompactNamespaces: false
ConstructorInitializerAllOnOneLineOrOnePerLine: false
ConstructorInitializerIndentWidth: 4
ContinuationIndentWidth: 4
Cpp11BracedListStyle: false
DerivePointerAlignment: false
DisableFormat: false
ExperimentalAutoDetectBinPacking: false
FixNamespaceComments: false
ForEachMacros:
- foreach
- Q_FOREACH
- BOOST_FOREACH
IncludeBlocks: Preserve
IncludeCategories:
- Regex: '.*'
Priority: 1
- Regex: '^(<|"(gtest|gmock|isl|json)/)'
Priority: 3
- Regex: '.*'
Priority: 1
IncludeIsMainRegex: '(Test)?$'
IndentCaseLabels: false
IndentPPDirectives: None
IndentWidth: 4
IndentWrappedFunctionNames: false
JavaScriptQuotes: Leave
JavaScriptWrapImports: true
KeepEmptyLinesAtTheStartOfBlocks: false
MacroBlockBegin: ''
MacroBlockEnd: ''
MaxEmptyLinesToKeep: 1
NamespaceIndentation: Inner
ObjCBinPackProtocolList: Auto
ObjCBlockIndentWidth: 4
ObjCSpaceAfterProperty: true
ObjCSpaceBeforeProtocolList: true
PenaltyBreakAssignment: 10
PenaltyBreakBeforeFirstCallParameter: 30
PenaltyBreakComment: 10
PenaltyBreakFirstLessLess: 0
PenaltyBreakString: 10
PenaltyBreakTemplateDeclaration: 10
PenaltyExcessCharacter: 100
PenaltyReturnTypeOnItsOwnLine: 60
PointerAlignment: Right
ReflowComments: false
SortIncludes: false
SortUsingDeclarations: false
SpaceAfterCStyleCast: false
SpaceAfterTemplateKeyword: true
SpaceBeforeAssignmentOperators: true
SpaceBeforeCpp11BracedList: false
SpaceBeforeCtorInitializerColon: true
SpaceBeforeInheritanceColon: true
SpaceBeforeParens: Always
SpaceBeforeRangeBasedForLoopColon: true
SpaceInEmptyParentheses: false
SpacesBeforeTrailingComments: 1
SpacesInAngles: false
SpacesInContainerLiterals: false
SpacesInCStyleCastParentheses: false
SpacesInParentheses: false
SpacesInSquareBrackets: false
Standard: Cpp03
TabWidth: 4
UseTab: Never
...

View file

@ -0,0 +1,11 @@
/.idea/
/.vscode/
/.git/
/.github/
/README.md
/License
/Dockerfile
/conf/

View file

@ -0,0 +1,408 @@
name: "Build"
on:
push:
branches:
- main
pull_request:
release:
types:
- published
workflow_dispatch:
jobs:
source:
name: Source
runs-on: ubuntu-latest
env:
BRANCH_NAME: ${{ github.base_ref || github.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Gen Source
run: |
REV_ID=$(git rev-parse --short HEAD)
mkdir -p hev-socks5-tunnel-${{ env.BRANCH_NAME }}
git ls-files --recurse-submodules | tar c -O -T- | tar x -C hev-socks5-tunnel-${{ env.BRANCH_NAME }}
echo ${REV_ID} > hev-socks5-tunnel-${{ env.BRANCH_NAME }}/.rev-id
tar cJf hev-socks5-tunnel-${{ env.BRANCH_NAME }}.tar.xz hev-socks5-tunnel-${{ env.BRANCH_NAME }}
- name: Upload source
uses: actions/upload-artifact@v7
with:
name: hev-socks5-tunnel-${{ env.BRANCH_NAME }}.tar.xz
path: hev-socks5-tunnel-${{ env.BRANCH_NAME }}.tar.xz
if-no-files-found: error
retention-days: 1
linux:
name: Linux
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: arm64
tool: aarch64-unknown-linux-musl
- name: arm32
tool: arm-unknown-linux-musleabi
- name: arm32hf
tool: arm-unknown-linux-musleabihf
- name: arm32v7
tool: armv7-unknown-linux-musleabi
- name: arm32v7hf
tool: armv7-unknown-linux-musleabihf
- name: i586
tool: i586-unknown-linux-musl
- name: i686
tool: i686-unknown-linux-musl
- name: loong64
tool: loongarch64-unknown-linux-musl
- name: m68k
tool: m68k-unknown-linux-musl
- name: microblazeel
tool: microblazeel-xilinx-linux-musl
- name: microblaze
tool: microblaze-xilinx-linux-musl
- name: mips64el
tool: mips64el-unknown-linux-musl
- name: mips64
tool: mips64-unknown-linux-musl
- name: mips32el
tool: mipsel-unknown-linux-musl
- name: mips32elsf
tool: mipsel-unknown-linux-muslsf
- name: mips32
tool: mips-unknown-linux-musl
- name: mips32sf
tool: mips-unknown-linux-muslsf
- name: or1k
tool: or1k-unknown-linux-musl
- name: powerpc64le
tool: powerpc64le-unknown-linux-musl
- name: powerpc64
tool: powerpc64-unknown-linux-musl
- name: powerpcle
tool: powerpcle-unknown-linux-musl
- name: powerpc
tool: powerpc-unknown-linux-musl
- name: riscv32
tool: riscv32-unknown-linux-musl
- name: riscv64
tool: riscv64-unknown-linux-musl
- name: s390x
tool: s390x-ibm-linux-musl
- name: sh4
tool: sh4-multilib-linux-musl
- name: x86_64
tool: x86_64-unknown-linux-musl
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Build ${{ matrix.name }}
run: |
sudo mkdir -p /opt/x-tools
wget https://github.com/cross-tools/musl-cross/releases/download/20260515/${{ matrix.tool }}.tar.xz
sudo tar xf ${{ matrix.tool }}.tar.xz -C /opt/x-tools
make CROSS_PREFIX=/opt/x-tools/${{ matrix.tool }}/bin/${{ matrix.tool }}- CFLAGS=${{ matrix.env.CFLAGS }} ENABLE_STATIC=1 -j`nproc`
cp bin/hev-socks5-tunnel hev-socks5-tunnel-linux-${{ matrix.name }}
- name: Upload ${{ matrix.name }}
uses: actions/upload-artifact@v7
with:
name: hev-socks5-tunnel-linux-${{ matrix.name }}
path: hev-socks5-tunnel-linux-${{ matrix.name }}
if-no-files-found: error
retention-days: 1
macos:
name: macOS
runs-on: macos-latest
strategy:
matrix:
include:
- name: arm64
flags: -arch arm64 -mmacosx-version-min=11.0
- name: x86_64
flags: -arch x86_64 -mmacosx-version-min=10.6
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Build ${{ matrix.name }}
run: |
make CC=clang CFLAGS="${{ matrix.flags }}" LFLAGS="${{ matrix.flags }}" -j $(sysctl -n hw.logicalcpu)
cp bin/hev-socks5-tunnel hev-socks5-tunnel-darwin-${{ matrix.name }}
- name: Upload ${{ matrix.name }}
uses: actions/upload-artifact@v7
with:
name: hev-socks5-tunnel-darwin-${{ matrix.name }}
path: hev-socks5-tunnel-darwin-${{ matrix.name }}
if-no-files-found: error
retention-days: 1
freebsd:
name: FreeBSD
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Build
uses: vmactions/freebsd-vm@v1
with:
usesh: true
prepare: |
pkg install -y gmake gcc
run: |
gmake
cp bin/hev-socks5-tunnel hev-socks5-tunnel-freebsd-x86_64
- name: Upload
uses: actions/upload-artifact@v7
with:
name: hev-socks5-tunnel-freebsd-x86_64
path: hev-socks5-tunnel-freebsd-x86_64
if-no-files-found: error
retention-days: 1
windows:
name: Windows
runs-on: windows-latest
defaults:
run:
shell: cmd
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MSYS
location: D:\msys2
update: true
install: >-
gcc
git
make
wget
zip
- name: Build
shell: msys2 {0}
run: |
export MSYS=winsymlinks:native
git clone --depth=1 --recursive file://`pwd` work; cd work
make -j`nproc`
mkdir hev-socks5-tunnel
cp bin/hev-socks5-tunnel* hev-socks5-tunnel
cp third-part/wintun/bin/wintun.dll hev-socks5-tunnel
wget -P hev-socks5-tunnel https://github.com/heiher/msys2/releases/latest/download/msys-2.0.dll
zip -r ../hev-socks5-tunnel-win64.zip hev-socks5-tunnel
- name: Upload ${{ matrix.name }}
uses: actions/upload-artifact@v7
with:
name: hev-socks5-tunnel-win64.zip
path: hev-socks5-tunnel-win64.zip
if-no-files-found: error
retention-days: 1
release:
name: Release
runs-on: ubuntu-latest
needs:
- source
- linux
- macos
- freebsd
- windows
if: github.event_name == 'release'
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Download artifacts
uses: actions/download-artifact@v8
with:
path: release
pattern: "hev-*"
merge-multiple: true
- name: Upload artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for i in release/hev-*; do
gh release upload ${{ github.event.release.tag_name }} $i
done
apple:
name: Apple
runs-on: macos-latest
if: github.event_name != 'release'
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Build
run: |
./build-apple.sh
android:
name: Android
runs-on: ubuntu-latest
if: github.event_name != 'release'
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Prepare
run: |
wget https://dl.google.com/android/repository/android-ndk-r27d-linux.zip
unzip android-ndk-r27d-linux.zip
ln -sf . jni
- name: Build
run: |
./android-ndk-r27d/ndk-build
llvm:
name: LLVM
runs-on: ubuntu-latest
if: github.event_name != 'release'
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Prepare
run: |
sudo apt install -y clang
- name: Build
run: |
make CC=clang ENABLE_STATIC=1 -j`nproc`
docker:
name: Build Docker Image
runs-on: ubuntu-latest
strategy:
matrix:
platform:
- linux/amd64
- linux/386
- linux/arm64/v8
- linux/arm/v7
- linux/arm/v6
- linux/riscv64
permissions:
packages: write
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
submodules: true
- name: Prepare QEMU
uses: docker/setup-qemu-action@v3
- name: Prepare Buildx
uses: docker/setup-buildx-action@v3
- name: Prepare Repo Name
id: repo
run: |
echo "repository=${GITHUB_REPOSITORY@L}" >> $GITHUB_OUTPUT
- name: Prepare Digest
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login GitHub Packages Docker Image Repository
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Docker Image
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
provenance: false
outputs: type=image,name=ghcr.io/${{ steps.repo.outputs.repository }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
- name: Export Digest
if: github.event_name != 'pull_request'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload Digest
uses: actions/upload-artifact@v7
if: github.event_name != 'pull_request'
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-merge:
name: Merge Docker Image Tags
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
needs:
- docker
permissions:
packages: write
contents: read
steps:
- name: Download Digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Prepare Buildx
uses: docker/setup-buildx-action@v3
- name: Prepare Repo Name
id: repo
run: |
echo "repository=${GITHUB_REPOSITORY@L}" >> $GITHUB_OUTPUT
- name: Login GitHub Packages Docker Image Repository
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Format Docker Image Meta
uses: docker/metadata-action@v5
id: docker_meta
with:
images: ghcr.io/${{ steps.repo.outputs.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=nightly,enable={{is_default_branch}}
- name: Create Manifest List and Push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ steps.repo.outputs.repository }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ghcr.io/${{ steps.repo.outputs.repository }}:${{ steps.docker_meta.outputs.version }}

View file

@ -0,0 +1,26 @@
name: "Check code formatting"
on:
push:
branches:
- main
pull_request:
jobs:
checker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Prepare
run: |
sudo apt install -y clang-format
- name: Format
run: |
find src -iname "*.[h,c]" -exec clang-format -i {} \;
- name: Check
run: |
git status
git diff --quiet

View file

@ -0,0 +1,5 @@
bin
build
HevSocks5Tunnel.xcframework
obj
libs

View file

@ -0,0 +1,12 @@
[submodule "third-part/hev-task-system"]
path = third-part/hev-task-system
url = https://github.com/heiher/hev-task-system
[submodule "third-part/yaml"]
path = third-part/yaml
url = https://github.com/heiher/yaml
[submodule "third-part/lwip"]
path = third-part/lwip
url = https://github.com/heiher/lwip
[submodule "src/core"]
path = src/core
url = https://github.com/heiher/hev-socks5-core

View file

@ -0,0 +1,51 @@
# Copyright (C) 2021 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
TOP_PATH := $(call my-dir)
ifeq ($(filter $(modules-get-list),yaml),)
include $(TOP_PATH)/third-part/yaml/Android.mk
endif
ifeq ($(filter $(modules-get-list),lwip),)
include $(TOP_PATH)/third-part/lwip/Android.mk
endif
ifeq ($(filter $(modules-get-list),hev-task-system),)
include $(TOP_PATH)/third-part/hev-task-system/Android.mk
endif
LOCAL_PATH = $(TOP_PATH)
SRCDIR := $(LOCAL_PATH)/src
include $(CLEAR_VARS)
include $(LOCAL_PATH)/build.mk
LOCAL_MODULE := hev-socks5-tunnel
LOCAL_SRC_FILES := $(patsubst $(SRCDIR)/%,src/%,$(SRCFILES))
LOCAL_C_INCLUDES := \
$(LOCAL_PATH)/src \
$(LOCAL_PATH)/src/misc \
$(LOCAL_PATH)/src/core/include \
$(LOCAL_PATH)/third-part/yaml/include \
$(LOCAL_PATH)/third-part/lwip/src/include \
$(LOCAL_PATH)/third-part/lwip/src/ports/include \
$(LOCAL_PATH)/third-part/hev-task-system/include
LOCAL_CFLAGS += -DFD_SET_DEFINED -DSOCKLEN_T_DEFINED -DENABLE_LIBRARY
LOCAL_CFLAGS += $(VERSION_CFLAGS)
ifeq ($(TARGET_ARCH_ABI),armeabi-v7a)
LOCAL_CFLAGS += -mfpu=neon
endif
LOCAL_STATIC_LIBRARIES := yaml lwip hev-task-system
LOCAL_LDFLAGS += -Wl,-z,max-page-size=16384
LOCAL_LDFLAGS += -Wl,-z,common-page-size=16384
include $(BUILD_SHARED_LIBRARY)

View file

@ -0,0 +1,21 @@
# Copyright (C) 2013 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
APP_OPTIM := release
APP_PLATFORM := android-29
APP_ABI := armeabi-v7a arm64-v8a x86 x86_64
APP_CFLAGS := -O3
APP_SUPPORT_FLEXIBLE_PAGE_SIZES := true
NDK_TOOLCHAIN_VERSION := clang

View file

@ -0,0 +1,43 @@
FROM alpine:latest AS builder
RUN apk add --update --no-cache \
make \
git \
gcc \
linux-headers \
musl-dev
WORKDIR /src
COPY . /src
RUN make clean && make
FROM alpine:latest
LABEL org.opencontainers.image.source="https://github.com/heiher/hev-socks5-tunnel"
RUN apk add --update --no-cache \
iproute2
ENV TUN=tun0 \
MTU=8500 \
IPV4=198.18.0.1 \
IPV6='' \
TABLE=20 \
MARK=438 \
SOCKS5_ADDR=172.17.0.1 \
SOCKS5_PORT=1080 \
SOCKS5_USERNAME='' \
SOCKS5_PASSWORD='' \
SOCKS5_UDP_MODE=udp \
SOCKS5_UDP_ADDR='' \
CONFIG_ROUTES=1 \
IPV4_INCLUDED_ROUTES=0.0.0.0/0 \
IPV4_EXCLUDED_ROUTES='' \
LOG_LEVEL=warn
HEALTHCHECK --start-period=5s --interval=5s --timeout=2s --retries=3 CMD ["test", "-f", "/success"]
COPY --chmod=755 docker/entrypoint.sh /entrypoint.sh
COPY --from=builder /src/bin/hev-socks5-tunnel /usr/bin/hev-socks5-tunnel
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -0,0 +1,19 @@
Copyright (c) 2022 hev
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,146 @@
# Makefile for hev-socks5-tunnel
PROJECT=hev-socks5-tunnel
CROSS_PREFIX :=
PP=$(CROSS_PREFIX)cpp
CC=$(CROSS_PREFIX)gcc
AR=$(CROSS_PREFIX)ar
STRIP=$(CROSS_PREFIX)strip
CCFLAGS=-O3 -pipe -Wall -Werror $(CFLAGS) \
-I$(SRCDIR) \
-I$(SRCDIR)/misc \
-I$(SRCDIR)/core/include \
-I$(THIRDPARTDIR)/yaml/include \
-I$(THIRDPARTDIR)/wintun/include \
-I$(THIRDPARTDIR)/lwip/src/include \
-I$(THIRDPARTDIR)/lwip/src/ports/include \
-I$(THIRDPARTDIR)/hev-task-system/include
LDFLAGS=-L$(THIRDPARTDIR)/yaml/bin -lyaml \
-L$(THIRDPARTDIR)/lwip/bin -llwip \
-L$(THIRDPARTDIR)/hev-task-system/bin -lhev-task-system \
-lpthread $(LFLAGS)
SRCDIR=src
BINDIR=bin
CONFDIR=conf
BUILDDIR=build
INSTDIR=/usr/local
THIRDPARTDIR=third-part
CONFIG=$(CONFDIR)/main.yml
EXEC_TARGET=$(BINDIR)/hev-socks5-tunnel
STATIC_TARGET=$(BINDIR)/lib$(PROJECT).a
SHARED_TARGET=$(BINDIR)/lib$(PROJECT).so
THIRDPARTS=$(THIRDPARTDIR)/yaml \
$(THIRDPARTDIR)/lwip \
$(THIRDPARTDIR)/hev-task-system
$(STATIC_TARGET) : CCFLAGS+=-DENABLE_LIBRARY
$(SHARED_TARGET) : CCFLAGS+=-DENABLE_LIBRARY -fPIC
$(SHARED_TARGET) : LDFLAGS+=-shared -pthread
-include build.mk
CCFLAGS+=$(VERSION_CFLAGS)
CCSRCS=$(filter %.c,$(SRCFILES))
ASSRCS=$(filter %.S,$(SRCFILES))
LDOBJS=$(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(CCSRCS)) \
$(patsubst $(SRCDIR)/%.S,$(BUILDDIR)/%.o,$(ASSRCS))
DEPEND=$(LDOBJS:.o=.dep)
BUILDMSG="\e[1;31mBUILD\e[0m %s\n"
LINKMSG="\e[1;34mLINK\e[0m \e[1;32m%s\e[0m\n"
STRIPMSG="\e[1;34mSTRIP\e[0m \e[1;32m%s\e[0m\n"
CLEANMSG="\e[1;34mCLEAN\e[0m %s\n"
INSTMSG="\e[1;34mINST\e[0m %s -> %s\n"
UNINSMSG="\e[1;34mUNINS\e[0m %s\n"
ENABLE_DEBUG :=
ifeq ($(ENABLE_DEBUG),1)
CCFLAGS+=-g -O0 -DENABLE_DEBUG
STRIP=true
endif
ENABLE_STATIC :=
ifeq ($(ENABLE_STATIC),1)
CCFLAGS+=-static
endif
ifeq ($(MSYSTEM),MSYS)
LDFLAGS+=-lmsys-2.0 -lws2_32 -lIphlpapi
endif
V :=
ECHO_PREFIX := @
ifeq ($(V),1)
undefine ECHO_PREFIX
endif
.PHONY: exec static shared clean install uninstall tp-static tp-shared tp-clean
exec : $(EXEC_TARGET)
static : $(STATIC_TARGET)
shared : $(SHARED_TARGET)
tp-static : $(THIRDPARTS)
@$(foreach dir,$^,$(MAKE) --no-print-directory -C $(dir) static;)
tp-shared : $(THIRDPARTS)
@$(foreach dir,$^,$(MAKE) --no-print-directory -C $(dir) shared;)
tp-clean : $(THIRDPARTS)
@$(foreach dir,$^,$(MAKE) --no-print-directory -C $(dir) clean;)
clean : tp-clean
$(ECHO_PREFIX) $(RM) -rf $(BINDIR) $(BUILDDIR)
@printf $(CLEANMSG) $(PROJECT)
install : $(INSTDIR)/bin/$(PROJECT) $(INSTDIR)/etc/$(PROJECT).yml
uninstall :
$(ECHO_PREFIX) $(RM) -rf $(INSTDIR)/bin/$(PROJECT)
@printf $(UNINSMSG) $(INSTDIR)/bin/$(PROJECT)
$(ECHO_PREFIX) $(RM) -rf $(INSTDIR)/etc/$(PROJECT).yml
@printf $(UNINSMSG) $(INSTDIR)/etc/$(PROJECT).yml
$(INSTDIR)/bin/$(PROJECT) : $(EXEC_TARGET)
$(ECHO_PREFIX) install -d -m 0755 $(dir $@)
$(ECHO_PREFIX) install -m 0755 $< $@
@printf $(INSTMSG) $< $@
$(INSTDIR)/etc/$(PROJECT).yml : $(CONFIG)
$(ECHO_PREFIX) install -d -m 0755 $(dir $@)
$(ECHO_PREFIX) install -m 0644 $< $@
@printf $(INSTMSG) $< $@
$(EXEC_TARGET) : $(LDOBJS) tp-static
$(ECHO_PREFIX) mkdir -p $(dir $@)
$(ECHO_PREFIX) $(CC) $(CCFLAGS) -o $@ $(LDOBJS) $(LDFLAGS)
@printf $(LINKMSG) $@
$(ECHO_PREFIX) $(STRIP) $@
@printf $(STRIPMSG) $@
$(STATIC_TARGET) : $(LDOBJS) tp-static
$(ECHO_PREFIX) mkdir -p $(dir $@)
$(ECHO_PREFIX) $(AR) csq $@ $(LDOBJS)
@printf $(LINKMSG) $@
$(SHARED_TARGET) : $(LDOBJS) tp-shared
$(ECHO_PREFIX) mkdir -p $(dir $@)
$(ECHO_PREFIX) $(CC) $(CCFLAGS) -o $@ $(LDOBJS) $(LDFLAGS)
@printf $(LINKMSG) $@
$(BUILDDIR)/%.dep : $(SRCDIR)/%.c
$(ECHO_PREFIX) mkdir -p $(dir $@)
$(ECHO_PREFIX) $(PP) $(CCFLAGS) -MM -MT$(@:.dep=.o) -MF$@ $< 2>/dev/null
$(BUILDDIR)/%.o : $(SRCDIR)/%.c
$(ECHO_PREFIX) mkdir -p $(dir $@)
$(ECHO_PREFIX) $(CC) $(CCFLAGS) -c -o $@ $<
@printf $(BUILDMSG) $<
ifneq ($(MAKECMDGOALS),clean)
-include $(DEPEND)
endif

View file

@ -0,0 +1,398 @@
# HevSocks5Tunnel
[![status](https://github.com/heiher/hev-socks5-tunnel/actions/workflows/build.yaml/badge.svg?branch=main&event=push)](https://github.com/heiher/hev-socks5-tunnel)
A simple, lightweight tunnel over Socks5 proxy (tun2socks).
## Features
* IPv4/IPv6. (dual stack)
* Redirect TCP connections.
* Redirect UDP packets. (Fullcone NAT, UDP-in-UDP and UDP-in-TCP [^1])
* Linux/Android/FreeBSD/macOS/iOS/Windows.
## Benchmarks
See [here](https://github.com/heiher/hev-socks5-tunnel/wiki/Benchmarks) for more details.
### Speed
![](https://github.com/heiher/hev-socks5-tunnel/wiki/res/upload-speed.png)
![](https://github.com/heiher/hev-socks5-tunnel/wiki/res/download-speed.png)
### CPU usage
![](https://github.com/heiher/hev-socks5-tunnel/wiki/res/upload-cpu.png)
![](https://github.com/heiher/hev-socks5-tunnel/wiki/res/download-cpu.png)
### Memory usage
![](https://github.com/heiher/hev-socks5-tunnel/wiki/res/upload-mem.png)
![](https://github.com/heiher/hev-socks5-tunnel/wiki/res/download-mem.png)
## How to Build
### Unix
```bash
git clone --recursive https://github.com/heiher/hev-socks5-tunnel
cd hev-socks5-tunnel
make
```
### Android
```bash
mkdir hev-socks5-tunnel
cd hev-socks5-tunnel
git clone --recursive https://github.com/heiher/hev-socks5-tunnel jni
ndk-build
```
### iOS and macOS
```bash
git clone --recursive https://github.com/heiher/hev-socks5-tunnel
cd hev-socks5-tunnel
# will generate HevSocks5Tunnel.xcframework
./build-apple.sh
```
### Windows (MSYS2)
```bash
export MSYS=winsymlinks:native
git clone --recursive https://github.com/heiher/hev-socks5-tunnel
cd hev-socks5-tunnel
make
```
### Library
```bash
git clone --recursive https://github.com/heiher/hev-socks5-tunnel
cd hev-socks5-tunnel
# Static library
make static
# Shared library
make shared
```
## How to Use
### Config
```yaml
tunnel:
# Interface name
name: tun0
# Interface MTU
mtu: 8500
# Multi-queue
multi-queue: false
# IPv4 address
ipv4: 198.18.0.1
# IPv6 address
ipv6: 'fc00::1'
# Post up script
# post-up-script: up.sh
# Pre down script
# pre-down-script: down.sh
socks5:
# Socks5 server port
port: 1080
# Socks5 server address (ipv4/ipv6)
address: 127.0.0.1
# Socks5 UDP relay mode (tcp|udp)
udp: 'udp'
# Override the UDP address provided by the Socks5 server (ipv4/ipv6)
# udp-address: ''
# Socks5 handshake using pipeline mode
# pipeline: false
# Socks5 server username
# username: 'username'
# Socks5 server password
# password: 'password'
# Socket mark
# mark: 0
# TCP fastopen
# tcp-fastopen: false
#mapdns:
# Mapped DNS address
# address: 198.18.0.2
# Mapped DNS port
# port: 53
# Mapped IP network base
# network: 100.64.0.0
# Mapped IP network mask
# netmask: 255.192.0.0
# Mapped DNS cache size
# cache-size: 10000
#misc:
# task stack size (bytes)
# task-stack-size: 86016
# tcp buffer size (bytes)
# tcp-buffer-size: 65536
# udp socket recv buffer (SO_RCVBUF) size (bytes)
# udp-recv-buffer-size: 524288
# number of udp buffers in splice, 1500 bytes per buffer.
# udp-copy-buffer-nums: 10
# maximum session count (0: unlimited)
# max-session-count: 0
# connect timeout (ms)
# connect-timeout: 10000
# TCP read-write timeout (ms)
# tcp-read-write-timeout: 300000
# UDP read-write timeout (ms)
# udp-read-write-timeout: 60000
# stdout, stderr or file-path
# log-file: stderr
# debug, info, warn or error
# log-level: warn
# If present, run as a daemon with this pid file
# pid-file: /run/hev-socks5-tunnel.pid
# If present, set rlimit nofile; else use default value
# limit-nofile: 65535
```
### Run
#### Linux
```bash
# Set socks5.mark = 438
bin/hev-socks5-tunnel conf/main.yml
# Disable reverse path filter
sudo sysctl -w net.ipv4.conf.all.rp_filter=0
sudo sysctl -w net.ipv4.conf.tun0.rp_filter=0
# Bypass upstream socks5 server
sudo ip rule add fwmark 438 lookup main pref 10
sudo ip -6 rule add fwmark 438 lookup main pref 10
# Route others
sudo ip route add default dev tun0 table 20
sudo ip rule add lookup 20 pref 20
sudo ip -6 route add default dev tun0 table 20
sudo ip -6 rule add lookup 20 pref 20
```
#### FreeBSD/macOS
```zsh
# Bypass upstream socks5 server
# 10.0.0.1: socks5 server
# 10.0.2.2: default gateway
sudo route add -net 10.0.0.1/32 10.0.2.2
# Route others
sudo route change -inet default -interface tun0
sudo route change -inet6 default -interface tun0
```
#### Windows
```zsh
# Bypass upstream socks5 server
# 10.0.0.1: socks5 server
# 10.0.2.2: default gateway
route add 10.0.0.1/32 10.0.2.2
# Route others
route change 0.0.0.0/0 0.0.0.0 if tun-index
route change ::/0 :: if tun-index
```
#### OpenWrt 24.10+
Repo: https://github.com/openwrt/packages/tree/master/net/hev-socks5-tunnel
```sh
# Install package
opkg install hev-socks5-tunnel
# Edit /etc/config/hev-socks5-tunnel
# Restart service
/etc/init.d/hev-socks5-tunnel restart
```
#### Low memory usage
On low-memory systems like iOS, reducing the size of the TCP buffer and
task stack, as well as limiting the maximum session count, can help prevent
out-of-memory issues.
```yaml
misc:
# task stack size (bytes)
task-stack-size: 24576 # 20480 + tcp-buffer-size
# tcp buffer size (bytes)
tcp-buffer-size: 4096
# maximum session count
max-session-count: 1200
```
#### Docker Compose
```yaml
version: "3.9"
services:
client:
image: alpine:latest # just for network testing
tty: true # you can test network in terminal
depends_on:
tun:
condition: service_healthy
network_mode: "service:tun"
tun:
image: ghcr.io/heiher/hev-socks5-tunnel:latest # `latest` for the latest published version; `nightly` for the latest source build; `vX.Y.Z` for the specific version
cap_add:
- NET_ADMIN # needed
devices:
- /dev/net/tun:/dev/net/tun # needed
environment:
TUN: tun0 # optional, tun interface name, default `tun0`
MTU: 8500 # optional, MTU is MTU, default `8500`
IPV4: 198.18.0.1 # optional, tun interface ip, default `198.18.0.1`
TABLE: 20 # optional, ip route table id, default `20`
MARK: 438 # optional, ip route rule mark, dec or hex format, default `438`
SOCKS5_ADDR: a.b.c.d # socks5 proxy server address
SOCKS5_PORT: 1080 # socks5 proxy server port
SOCKS5_USERNAME: user # optional, socks5 proxy username, only set when need to auth
SOCKS5_PASSWORD: pass # optional, socks5 proxy password, only set when need to auth
SOCKS5_UDP_MODE: udp # optional, UDP relay mode, default `udp`, other option `tcp`
SOCKS5_UDP_ADDR: a.b.c.d # optional, override the UDP address provided by the Socks5 server
CONFIG_ROUTES: 1 # optional, set 0 to ignore TABLE, IPV4_INCLUDED_ROUTES and IPV4_EXCLUDED_ROUTES, with MARK defaults to 0
IPV4_INCLUDED_ROUTES: 0.0.0.0/0 # optional, demo means proxy all traffic. for multiple network segments, join with `,` or `\n`
IPV4_EXCLUDED_ROUTES: a.b.c.d # optional, demo means exclude traffic from the proxy itself. for multiple network segments, join with `,` or `\n`
LOG_LEVEL: warn # optional, default `warn`, other option `debug`/`info`/`error`
dns:
- 8.8.8.8
```
You can also set the route rules with multiple network segments like:
```yaml
environment:
IPV4_INCLUDED_ROUTES: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
IPV4_EXCLUDED_ROUTES: |-
a.b.c.d/24
a.b.c.f/24
```
## API
```c
/**
* hev_socks5_tunnel_main:
* @config_path: config file path
* @tun_fd: tunnel file descriptor
*
* Start and run the socks5 tunnel, this function will blocks until the
* hev_socks5_tunnel_quit is called or an error occurs.
*
* Alias of hev_socks5_tunnel_main_from_file
*
* Returns: returns zero on successful, otherwise returns -1.
*
* Since: 2.4.6
*/
int hev_socks5_tunnel_main (const char *config_path, int tun_fd);
/**
* hev_socks5_tunnel_main_from_file:
* @config_path: config file path
* @tun_fd: tunnel file descriptor
*
* Start and run the socks5 tunnel, this function will blocks until the
* hev_socks5_tunnel_quit is called or an error occurs.
*
* Returns: returns zero on successful, otherwise returns -1.
*
* Since: 2.6.7
*/
int hev_socks5_tunnel_main_from_file (const char *config_path, int tun_fd);
/**
* hev_socks5_tunnel_main_from_str:
* @config_str: string config
* @config_len: the byte length of string config
* @tun_fd: tunnel file descriptor
*
* Start and run the socks5 tunnel, this function will blocks until the
* hev_socks5_tunnel_quit is called or an error occurs.
*
* Returns: returns zero on successful, otherwise returns -1.
*
* Since: 2.6.7
*/
int hev_socks5_tunnel_main_from_str (const unsigned char *config_str,
unsigned int config_len, int tun_fd);
/**
* hev_socks5_tunnel_quit:
*
* Stop the socks5 tunnel.
*
* Since: 2.4.6
*/
void hev_socks5_tunnel_quit (void);
/**
* hev_socks5_tunnel_stats:
* @tx_packets (out): transmitted packets
* @tx_bytes (out): transmitted bytes
* @rx_packets (out): received packets
* @rx_bytes (out): received bytes
*
* Retrieve tunnel interface traffic statistics.
*
* Since: 2.6.5
*/
void hev_socks5_tunnel_stats (size_t *tx_packets, size_t *tx_bytes,
size_t *rx_packets, size_t *rx_bytes);
```
## Use Cases
### Android VPN
* [SocksTun](https://github.com/heiher/sockstun)
### iOS
* [Tun2SocksKit](https://github.com/EbrahimTahernejad/Tun2SocksKit)
## Contributors
* **arror** - https://github.com/arror
* **bazuchan** - https://github.com/bazuchan
* **codewithtamim** - https://github.com/codewithtamim
* **dovecoteescapee** - https://github.com/dovecoteescapee
* **ebrahimtahernejad** - https://github.com/ebrahimtahernejad
* **heiby** - https://github.com/heiby
* **hev** - https://hev.cc
* **katana** - https://github.com/officialkatana
* **pronebird** - https://github.com/pronebird
* **saeeddev94** - https://github.com/saeeddev94
* **sskaje** - https://github.com/sskaje
* **wankkoree** - https://github.com/wankkoree
* **xiguagua** - https://github.com/xiguagua
* **xz-dev** - https://github.com/xz-dev
* **yiguous** - https://github.com/yiguous
* **yujinpan** - https://github.com/yujinpan
* **zheshinicheng** - https://github.com/zheshinicheng
## License
MIT
[^1]: See [protocol specification](https://github.com/heiher/hev-socks5-core/tree/main?tab=readme-ov-file#udp-in-tcp). The [hev-socks5-server](https://github.com/heiher/hev-socks5-server) supports UDP relay over TCP.

View file

@ -0,0 +1,76 @@
#!/bin/bash
set -e
XCFRAMEWORK_DIR="./apple_xcframework"
LIB_NAME="HevSocks5Tunnel"
# buildStatic iphoneos -mios-version-min=15.0 arm64
buildStatic()
{
echo "build for $1, $2, min version $3"
local MIN_VERSION="-m$1-version-min=$3"
make PP="xcrun --sdk $1 --toolchain $1 clang" \
CC="xcrun --sdk $1 --toolchain $1 clang" \
CFLAGS="-arch $2 $MIN_VERSION" \
LFLAGS="-arch $2 $MIN_VERSION -Wl,-Bsymbolic-functions" static
local OUTPUT_DIR="$XCFRAMEWORK_DIR/$1-$2"
mkdir -p $OUTPUT_DIR
local OUTPUT_ARCH_FILE="$OUTPUT_DIR/libhev-socks5-tunnel.a"
libtool -static -o $OUTPUT_ARCH_FILE \
bin/libhev-socks5-tunnel.a \
third-part/lwip/bin/liblwip.a \
third-part/yaml/bin/libyaml.a \
third-part/hev-task-system/bin/libhev-task-system.a
make clean
}
mergeStatic()
{
echo "merge for $1, $2, $3"
local FIRST_LIB_FILE="$XCFRAMEWORK_DIR/$1-$2/libhev-socks5-tunnel.a"
local SECOND_LIB_FILE="$XCFRAMEWORK_DIR/$1-$3/libhev-socks5-tunnel.a"
local OUTPUT_DIR="$XCFRAMEWORK_DIR/$1-$2-$3"
mkdir -p $OUTPUT_DIR
local OUTPUT_ARCH_FILE="$OUTPUT_DIR/libhev-socks5-tunnel.a"
lipo -create \
-arch $2 $FIRST_LIB_FILE \
-arch $3 $SECOND_LIB_FILE \
-output $OUTPUT_ARCH_FILE
}
rm -rf $XCFRAMEWORK_DIR
rm -rf HevSocks5Tunnel.xcframework
mkdir $XCFRAMEWORK_DIR
buildStatic iphoneos arm64 15.0
buildStatic iphonesimulator x86_64 15.0
buildStatic iphonesimulator arm64 15.0
mergeStatic iphonesimulator x86_64 arm64
# keep same with flutter
buildStatic macosx x86_64 10.14
buildStatic macosx arm64 10.14
mergeStatic macosx x86_64 arm64
buildStatic appletvos arm64 17.0
buildStatic appletvsimulator x86_64 17.0
buildStatic appletvsimulator arm64 17.0
mergeStatic appletvsimulator x86_64 arm64
INCLUDE_DIR="$XCFRAMEWORK_DIR/include"
mkdir -p $INCLUDE_DIR/$LIB_NAME
cp ./src/hev-main.h $INCLUDE_DIR/$LIB_NAME
cp ./module.modulemap $INCLUDE_DIR/$LIB_NAME
xcodebuild -create-xcframework \
-library ./apple_xcframework/iphoneos-arm64/libhev-socks5-tunnel.a -headers $INCLUDE_DIR \
-library ./apple_xcframework/iphonesimulator-x86_64-arm64/libhev-socks5-tunnel.a -headers $INCLUDE_DIR \
-library ./apple_xcframework/macosx-x86_64-arm64/libhev-socks5-tunnel.a -headers $INCLUDE_DIR \
-library ./apple_xcframework/appletvos-arm64/libhev-socks5-tunnel.a -headers $INCLUDE_DIR \
-library ./apple_xcframework/appletvsimulator-x86_64-arm64/libhev-socks5-tunnel.a -headers $INCLUDE_DIR \
-output ./HevSocks5Tunnel.xcframework
rm -rf ./apple_xcframework

View file

@ -0,0 +1,20 @@
# Build
rwildcard=$(foreach d,$(wildcard $1*), \
$(call rwildcard,$d/,$2) \
$(filter $(subst *,%,$2),$d))
SRCFILES=$(call rwildcard,$(SRCDIR)/,*.c *.S)
ifeq ($(REV_ID),)
ifneq (,$(wildcard .rev-id))
REV_ID=$(shell cat .rev-id)
endif
ifeq ($(REV_ID),)
REV_ID=$(shell git -C $(SRCDIR) rev-parse --short HEAD)
endif
ifeq ($(REV_ID),)
REV_ID=unknown
endif
endif
VERSION_CFLAGS=-DCOMMIT_ID=\"$(REV_ID)\"

View file

@ -0,0 +1,75 @@
# Main configuration for hev-socks5-tunnel
tunnel:
# Interface name
name: tun0
# Interface MTU
mtu: 8500
# Multi-queue
multi-queue: false
# IPv4 address
ipv4: 198.18.0.1
# IPv6 address
ipv6: 'fc00::1'
# Post up script
# post-up-script: up.sh
# Pre down script
# pre-down-script: down.sh
socks5:
# Socks5 server port
port: 1080
# Socks5 server address (ipv4/ipv6)
address: 127.0.0.1
# Socks5 UDP relay mode (tcp|udp)
udp: 'udp'
# Override the UDP address provided by the Socks5 server (ipv4/ipv6)
# udp-address: ''
# Socks5 handshake using pipeline mode
# pipeline: false
# Socks5 server username
# username: 'username'
# Socks5 server password
# password: 'password'
# Socket mark
# mark: 0
# TCP fastopen
# tcp-fastopen: false
#mapdns:
# Mapped DNS address
# address: 198.18.0.2
# Mapped DNS port
# port: 53
# Mapped IP network base
# network: 100.64.0.0
# Mapped IP network mask
# netmask: 255.192.0.0
# Mapped DNS cache size
# cache-size: 10000
#misc:
# task stack size (bytes)
# task-stack-size: 86016
# tcp buffer size (bytes)
# tcp-buffer-size: 65536
# udp socket recv buffer (SO_RCVBUF) size (bytes)
# udp-recv-buffer-size: 524288
# number of udp buffers in splice, 1500 bytes per buffer.
# udp-copy-buffer-nums: 10
# maximum session count (0: unlimited)
# max-session-count: 0
# connect timeout (ms)
# connect-timeout: 10000
# TCP read-write timeout (ms)
# tcp-read-write-timeout: 300000
# UDP read-write timeout (ms)
# udp-read-write-timeout: 60000
# stdout, stderr or file-path
# log-file: stderr
# debug, info, warn or error
# log-level: warn
# If present, run as a daemon with this pid file
# pid-file: /run/hev-socks5-tunnel.pid
# If present, set rlimit nofile; else use default value
# limit-nofile: 65535

View file

@ -0,0 +1,89 @@
#!/bin/sh
TUN="${TUN:-tun0}"
MTU="${MTU:-8500}"
IPV4="${IPV4:-198.18.0.1}"
IPV6="${IPV6:-}"
CONFIG_ROUTES="${CONFIG_ROUTES:-1}"
TABLE="${TABLE:-20}"
if [ "${CONFIG_ROUTES}" == "0" ]; then
MARK="${MARK:-0}"
else
MARK="${MARK:-438}"
fi
SOCKS5_ADDR="${SOCKS5_ADDR:-172.17.0.1}"
SOCKS5_PORT="${SOCKS5_PORT:-1080}"
SOCKS5_USERNAME="${SOCKS5_USERNAME:-}"
SOCKS5_PASSWORD="${SOCKS5_PASSWORD:-}"
SOCKS5_UDP_MODE="${SOCKS5_UDP_MODE:-udp}"
SOCKS5_UDP_ADDR="${SOCKS5_UDP_ADDR:-}"
IPV4_INCLUDED_ROUTES="${IPV4_INCLUDED_ROUTES:-0.0.0.0/0}"
IPV4_EXCLUDED_ROUTES="${IPV4_EXCLUDED_ROUTES:-}"
LOG_LEVEL="${LOG_LEVEL:-warn}"
config_file() {
cat > /hs5t.yml << EOF
misc:
log-level: '${LOG_LEVEL}'
tunnel:
name: '${TUN}'
mtu: ${MTU}
ipv4: '${IPV4}'
ipv6: '${IPV6}'
post-up-script: '/route.sh'
socks5:
address: '${SOCKS5_ADDR}'
port: ${SOCKS5_PORT}
udp: '${SOCKS5_UDP_MODE}'
mark: ${MARK}
EOF
if [ -n "${SOCKS5_USERNAME}" ]; then
echo " username: '${SOCKS5_USERNAME}'" >> /hs5t.yml
fi
if [ -n "${SOCKS5_PASSWORD}" ]; then
echo " password: '${SOCKS5_PASSWORD}'" >> /hs5t.yml
fi
if [ -n "${SOCKS5_UDP_ADDR}" ]; then
echo " udp-address: '${SOCKS5_UDP_ADDR}'" >> /hs5t.yml
fi
}
config_route() {
echo "#!/bin/sh" > /route.sh
chmod +x /route.sh
if [ "${CONFIG_ROUTES}" == "0" ]; then
return
fi
echo "ip route add default dev ${TUN} table ${TABLE}" >> /route.sh
for addr in $(echo ${IPV4_INCLUDED_ROUTES} | tr ',' '\n'); do
echo "ip rule add to ${addr} table ${TABLE}" >> /route.sh
done
echo "ip rule add to $(ip -o -f inet address show eth0 | awk '/scope global/ {print $4}') table main" >> /route.sh
for addr in $(echo ${IPV4_EXCLUDED_ROUTES} | tr ',' '\n'); do
echo "ip rule add to ${addr} table main" >> /route.sh
done
echo "ip rule add fwmark ${MARK} table main pref 1" >> /route.sh
}
run() {
config_file
config_route
echo "echo 1 > /success" >> /route.sh
hev-socks5-tunnel /hs5t.yml
}
run || exit 1

View file

@ -0,0 +1 @@
../src/hev-main.h

View file

@ -0,0 +1,4 @@
module HevSocks5Tunnel {
umbrella header "hev-main.h"
export *
}

View file

@ -0,0 +1,115 @@
---
Language: Cpp
AccessModifierOffset: -4
AlignAfterOpenBracket: Align
AlignConsecutiveAssignments: false
AlignConsecutiveDeclarations: false
AlignEscapedNewlines: Left
AlignOperands: true
AlignTrailingComments: false
AllowAllParametersOfDeclarationOnNextLine: false
AllowShortBlocksOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
AlwaysBreakAfterDefinitionReturnType: TopLevel
AlwaysBreakAfterReturnType: None
AlwaysBreakBeforeMultilineStrings: false
AlwaysBreakTemplateDeclarations: MultiLine
BinPackArguments: true
BinPackParameters: true
BraceWrapping:
AfterClass: true
AfterControlStatement: false
AfterEnum: true
AfterFunction: true
AfterNamespace: true
AfterObjCDeclaration: false
AfterStruct: true
AfterUnion: true
AfterExternBlock: false
BeforeCatch: false
BeforeElse: false
IndentBraces: false
SplitEmptyFunction: true
SplitEmptyRecord: true
SplitEmptyNamespace: true
BreakBeforeBinaryOperators: None
BreakBeforeBraces: Custom
BreakBeforeInheritanceComma: false
BreakInheritanceList: BeforeColon
BreakBeforeTernaryOperators: false
BreakConstructorInitializersBeforeComma: false
BreakAfterJavaFieldAnnotations: false
BreakStringLiterals: false
ColumnLimit: 80
CommentPragmas: '^ IWYU pragma:'
CompactNamespaces: false
ConstructorInitializerAllOnOneLineOrOnePerLine: false
ConstructorInitializerIndentWidth: 4
ContinuationIndentWidth: 4
Cpp11BracedListStyle: false
DerivePointerAlignment: false
DisableFormat: false
ExperimentalAutoDetectBinPacking: false
FixNamespaceComments: false
ForEachMacros:
- foreach
- Q_FOREACH
- BOOST_FOREACH
IncludeBlocks: Preserve
IncludeCategories:
- Regex: '.*'
Priority: 1
- Regex: '^(<|"(gtest|gmock|isl|json)/)'
Priority: 3
- Regex: '.*'
Priority: 1
IncludeIsMainRegex: '(Test)?$'
IndentCaseLabels: false
IndentPPDirectives: None
IndentWidth: 4
IndentWrappedFunctionNames: false
JavaScriptQuotes: Leave
JavaScriptWrapImports: true
KeepEmptyLinesAtTheStartOfBlocks: false
MacroBlockBegin: ''
MacroBlockEnd: ''
MaxEmptyLinesToKeep: 1
NamespaceIndentation: Inner
ObjCBinPackProtocolList: Auto
ObjCBlockIndentWidth: 4
ObjCSpaceAfterProperty: true
ObjCSpaceBeforeProtocolList: true
PenaltyBreakAssignment: 10
PenaltyBreakBeforeFirstCallParameter: 30
PenaltyBreakComment: 10
PenaltyBreakFirstLessLess: 0
PenaltyBreakString: 10
PenaltyBreakTemplateDeclaration: 10
PenaltyExcessCharacter: 100
PenaltyReturnTypeOnItsOwnLine: 60
PointerAlignment: Right
ReflowComments: false
SortIncludes: false
SortUsingDeclarations: false
SpaceAfterCStyleCast: false
SpaceAfterTemplateKeyword: true
SpaceBeforeAssignmentOperators: true
SpaceBeforeCpp11BracedList: false
SpaceBeforeCtorInitializerColon: true
SpaceBeforeInheritanceColon: true
SpaceBeforeParens: Always
SpaceBeforeRangeBasedForLoopColon: true
SpaceInEmptyParentheses: false
SpacesBeforeTrailingComments: 1
SpacesInAngles: false
SpacesInContainerLiterals: false
SpacesInCStyleCastParentheses: false
SpacesInParentheses: false
SpacesInSquareBrackets: false
Standard: Cpp03
TabWidth: 4
UseTab: Never
...

View file

@ -0,0 +1,26 @@
name: "Check code formatting"
on:
push:
branches:
- main
pull_request:
jobs:
checker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Prepare
run: |
sudo apt install -y clang-format
- name: Format
run: |
find src -iname "*.[h,c]" -exec clang-format -i {} \;
- name: Check
run: |
git status
git diff --quiet

View file

@ -0,0 +1,19 @@
Copyright (c) 2022 hev
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,204 @@
# HevSocks5Core
HevSocks5Core is a simple, lightweight socks5 library.
**Features**
* IPv4/IPv6. (dual stack)
* Standard `CONNECT` command.
* Standard `UDP ASSOCIATE` command.
* Extended `FWD UDP` command. (UDP in TCP)
* Multiple username/password authentication.
**Dependencies**
* HevTaskSystem - https://github.com/heiher/hev-task-system
## Examples
### Server
```c
#include <unistd.h>
#include <hev-task.h>
#include <hev-task-io.h>
#include <hev-task-io-socket.h>
#include <hev-task-dns.h>
#include <hev-task-system.h>
#include <hev-socks5-server.h>
static void
server_entry (void *data)
{
HevSocks5Server *server = data;
hev_socks5_server_run (server);
hev_object_unref (HEV_OBJECT (server));
}
static void
listener_entry (void *data)
{
struct addrinfo hints = { 0 };
struct addrinfo *result;
int fd;
hints.ai_family = AF_INET6;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
hev_task_dns_getaddrinfo (NULL, "1080", &hints, &result);
fd = hev_task_io_socket_socket (AF_INET6, SOCK_STREAM, 0);
bind (fd, result->ai_addr, result->ai_addrlen);
freeaddrinfo (result);
listen (fd, 5);
hev_task_add_fd (hev_task_self (), fd, POLLIN);
for (;;) {
HevSocks5Server *server;
HevTask *task;
int nfd;
nfd = hev_task_io_socket_accept (fd, NULL, NULL, NULL, NULL);
task = hev_task_new (-1);
server = hev_socks5_server_new (nfd);
hev_task_run (task, server_entry, server);
}
close (fd);
}
int
main (int argc, char *argv[])
{
HevTask *task;
hev_task_system_init ();
task = hev_task_new (-1);
hev_task_run (task, listener_entry, NULL);
hev_task_system_run ();
hev_task_system_fini ();
return 0;
}
```
### Client
```c
#include <stddef.h>
#include <hev-task.h>
#include <hev-task-system.h>
#include <hev-socks5-client-tcp.h>
#include <hev-socks5-client-udp.h>
static void
tcp_client_entry (void *data)
{
HevSocks5ClientTCP *tcp;
tcp = hev_socks5_client_tcp_new_name ("www.google.com", 443);
hev_socks5_client_connect (HEV_SOCKS5_CLIENT (tcp), "127.0.0.1", 1080);
hev_socks5_client_handshake (HEV_SOCKS5_CLIENT (tcp));
/*
* splice data to/from a socket fd:
* hev_socks5_tcp_splice (HEV_SOCKS5_TCP (tcp), fd);
*/
hev_object_unref (HEV_OBJECT (tcp));
}
static void
udp_client_entry (void *data)
{
HevSocks5ClientUDP *udp;
udp = hev_socks5_client_udp_new (HEV_SOCKS5_TYPE_UDP_IN_TCP);
hev_socks5_client_connect (HEV_SOCKS5_CLIENT (udp), "127.0.0.1", 1080);
hev_socks5_client_handshake (HEV_SOCKS5_CLIENT (udp));
/*
* HevSocks5UDPMsg msgv[num];
*
* send udp packets:
* hev_socks5_udp_sendmmsg (HEV_SOCKS5_UDP (udp), msgv, num);
*
* recv udp packets:
* hev_socks5_udp_recvmmsg (HEV_SOCKS5_UDP (udp), msgv, num, 0);
*/
hev_object_unref (HEV_OBJECT (udp));
}
int
main (int argc, char *argv[])
{
HevTask *task;
hev_task_system_init ();
task = hev_task_new (-1);
hev_task_run (task, tcp_client_entry, NULL);
task = hev_task_new (-1);
hev_task_run (task, udp_client_entry, NULL);
hev_task_system_run ();
hev_task_system_fini ();
return 0;
}
```
## UDP in TCP
UDP-in-TCP mode is a proprietary extension based on RFC 1928, designed to
forward UDP packets within the primary SOCKS5 TCP stream. The protocol is
defined as follows:
### SOCKS5 Requests
```
+----+-----+-------+------+----------+----------+
|VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
+----+-----+-------+------+----------+----------+
| 1 | 1 | X'00' | 1 | Variable | 2 |
+----+-----+-------+------+----------+----------+
```
* CMD
* UDP IN TCP: X'05'
### UDP Relays
```
+--------+--------+------+----------+----------+----------+
| MSGLEN | HDRLEN | ATYP | DST.ADDR | DST.PORT | DATA |
+--------+--------+------+----------+----------+----------+
| 2 | 1 | 1 | Variable | 2 | Variable |
+--------+--------+------+----------+----------+----------+
```
- MSGLEN: The total length of the UDP relay message. `[MSGLEN, DATA]`
- HDRLEN: The header length of the UDP relay message. `[MSGLEN, DST.PORT]`
- ATYPE/DST.ADDR/DST.PORT: Fields follow the definitions specified in RFC 1928.
## Users
* **HevSocks5Server** - https://github.com/heiher/hev-socks5-server
* **HevSocks5TProxy** - https://github.com/heiher/hev-socks5-tproxy
* **HevSocks5Tunnel** - https://github.com/heiher/hev-socks5-tunnel
## Contributors
* **hev** - https://hev.cc
* **spider84** - https://github.com/spider84
## License
MIT

View file

@ -0,0 +1 @@
../src/hev-rbtree.h

View file

@ -0,0 +1 @@
../src/hev-socks5-authenticator.h

View file

@ -0,0 +1 @@
../src/hev-socks5-client-tcp.h

View file

@ -0,0 +1 @@
../src/hev-socks5-client-udp.h

View file

@ -0,0 +1 @@
../src/hev-socks5-client.h

View file

@ -0,0 +1 @@
../src/hev-socks5-logger.h

View file

@ -0,0 +1 @@
../src/hev-socks5-misc.h

View file

@ -0,0 +1 @@
../src/hev-socks5-proto.h

View file

@ -0,0 +1 @@
../src/hev-socks5-server.h

View file

@ -0,0 +1 @@
../src/hev-socks5-tcp.h

View file

@ -0,0 +1 @@
../src/hev-socks5-udp.h

View file

@ -0,0 +1 @@
../src/hev-socks5-user.h

View file

@ -0,0 +1 @@
../src/hev-socks5.h

View file

@ -0,0 +1,106 @@
/*
============================================================================
Name : hev-compiler.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2019 everyone.
Description : Compiler
============================================================================
*/
#ifndef __HEV_COMPILER_H__
#define __HEV_COMPILER_H__
#ifdef __cplusplus
extern "C" {
#endif
#define ALIGN_UP(addr, align) \
((addr + (typeof (addr))align - 1) & ~((typeof (addr))align - 1))
#define ALIGN_DOWN(addr, align) ((addr) & ~((typeof (addr))align - 1))
#ifndef ARRAY_SIZE
#define ARRAY_SIZE(x) (sizeof (x) / sizeof (x[0]))
#endif
#ifndef EXPORT_SYMBOL
#define EXPORT_SYMBOL __attribute__ ((visibility ("default")))
#endif
#define barrier() __asm__ __volatile__ ("" : : : "memory")
static inline void
__read_once_size (void *dst, const volatile void *src, int size)
{
switch (size) {
case sizeof (char):
*(char *)dst = *(volatile char *)src;
break;
case sizeof (short):
*(short *)dst = *(volatile short *)src;
break;
case sizeof (int):
*(int *)dst = *(volatile int *)src;
break;
default:
barrier ();
__builtin_memcpy ((void *)dst, (const void *)src, size);
barrier ();
}
}
static inline void
__write_once_size (volatile void *dst, const void *src, int size)
{
switch (size) {
case sizeof (char):
*(volatile char *)dst = *(char *)src;
break;
case sizeof (short):
*(volatile short *)dst = *(short *)src;
break;
case sizeof (int):
*(volatile int *)dst = *(int *)src;
break;
default:
barrier ();
__builtin_memcpy ((void *)dst, (const void *)src, size);
barrier ();
}
}
#define READ_ONCE(x) \
({ \
union \
{ \
typeof (x) __val; \
char __c[1]; \
} __u; \
__read_once_size (__u.__c, &(x), sizeof (x)); \
__u.__val; \
})
#define WRITE_ONCE(x, val) \
({ \
union \
{ \
typeof (x) __val; \
char __c[1]; \
} __u = { .__val = (typeof (x))(val) }; \
__write_once_size (&(x), __u.__c, sizeof (x)); \
__u.__val; \
})
#ifndef container_of
#define container_of(ptr, type, member) \
({ \
void *__mptr = (void *)(ptr); \
((type *)(__mptr - offsetof (type, member))); \
})
#endif
#ifdef __cplusplus
}
#endif
#endif /* __HEV_COMPILER_H__ */

View file

@ -0,0 +1,635 @@
/*
============================================================================
Name : hev-rbtree.c
Authors : Andrea Arcangeli <andrea@suse.de>
David Woodhouse <dwmw2@infradead.org>
Michel Lespinasse <walken@google.com>
Heiher <r@hev.cc>
Copyright : Copyright (c) 2018 everyone.
Description : RedBlack Tree
============================================================================
*/
#include "hev-rbtree.h"
typedef enum _HevRBTreeColor HevRBTreeColor;
enum _HevRBTreeColor
{
HEV_RBTREE_RED = 0,
HEV_RBTREE_BLACK,
};
static inline int
hev_rbtree_node_color (HevRBTreeNode *node)
{
return node->__parent_color & 1;
}
static inline int
hev_rbtree_node_is_red (HevRBTreeNode *node)
{
return !(hev_rbtree_node_color (node) & HEV_RBTREE_BLACK);
}
static inline int
hev_rbtree_node_is_black (HevRBTreeNode *node)
{
return hev_rbtree_node_color (node) & HEV_RBTREE_BLACK;
}
static inline int
_hev_rbtree_node_is_black (unsigned long pc)
{
return pc & HEV_RBTREE_BLACK;
}
static inline HevRBTreeNode *
_hev_rbtree_node_parent (unsigned long pc)
{
return ((HevRBTreeNode *)(pc & ~3));
}
static inline HevRBTreeNode *
hev_rbtree_node_red_parent (HevRBTreeNode *node)
{
return (HevRBTreeNode *)node->__parent_color;
}
static inline void
hev_rbtree_node_set_black (HevRBTreeNode *node)
{
node->__parent_color |= HEV_RBTREE_BLACK;
}
static inline void
hev_rbtree_node_set_parent (HevRBTreeNode *node, HevRBTreeNode *parent)
{
node->__parent_color = hev_rbtree_node_color (node) | (unsigned long)parent;
}
static inline void
hev_rbtree_node_set_parent_color (HevRBTreeNode *node, HevRBTreeNode *parent,
int color)
{
node->__parent_color = (unsigned long)parent | color;
}
static inline void
hev_rbtree_change_child (HevRBTree *self, HevRBTreeNode *old,
HevRBTreeNode *new, HevRBTreeNode *parent)
{
if (parent) {
if (parent->left == old)
parent->left = new;
else
parent->right = new;
} else {
self->root = new;
}
}
/*
* Helper function for rotations:
* - old's parent and color get assigned to new
* - old gets assigned new as a parent and 'color' as a color.
*/
static inline void
hev_rbtree_rotate_set_parents (HevRBTree *self, HevRBTreeNode *old,
HevRBTreeNode *new, int color)
{
HevRBTreeNode *parent = hev_rbtree_node_parent (old);
new->__parent_color = old->__parent_color;
hev_rbtree_node_set_parent_color (old, new, color);
hev_rbtree_change_child (self, old, new, parent);
}
HevRBTreeNode *
hev_rbtree_node_prev (HevRBTreeNode *node)
{
HevRBTreeNode *parent;
if (hev_rbtree_node_empty (node))
return NULL;
/*
* If we have a left-hand child, go down and then right as far
* as we can.
*/
if (node->left) {
node = node->left;
while (node->right)
node = node->right;
return (HevRBTreeNode *)node;
}
/*
* No left-hand children. Go up till we find an ancestor which
* is a right-hand child of its parent.
*/
while ((parent = hev_rbtree_node_parent (node)) && node == parent->left)
node = parent;
return parent;
}
HevRBTreeNode *
hev_rbtree_node_next (HevRBTreeNode *node)
{
HevRBTreeNode *parent;
if (hev_rbtree_node_empty (node))
return NULL;
/*
* If we have a right-hand child, go down and then left as far
* as we can.
*/
if (node->right) {
node = node->right;
while (node->left)
node = node->left;
return (HevRBTreeNode *)node;
}
/*
* No right-hand children. Everything down and left is smaller than us,
* so any 'next' node must be in the general direction of our parent.
* Go up the tree; any time the ancestor is a right-hand child of its
* parent, keep going up. First time it's a left-hand child of its
* parent, said parent is our 'next' node.
*/
while ((parent = hev_rbtree_node_parent (node)) && node == parent->right)
node = parent;
return parent;
}
HevRBTreeNode *
hev_rbtree_first (HevRBTree *self)
{
HevRBTreeNode *n;
n = self->root;
if (!n)
return NULL;
while (n->left)
n = n->left;
return n;
}
HevRBTreeNode *
hev_rbtree_last (HevRBTree *self)
{
HevRBTreeNode *n;
n = self->root;
if (!n)
return NULL;
while (n->right)
n = n->right;
return n;
}
void
hev_rbtree_insert_color (HevRBTree *self, HevRBTreeNode *node)
{
HevRBTreeNode *parent = hev_rbtree_node_red_parent (node), *gparent, *tmp;
while (1) {
/*
* Loop invariant: node is red.
*/
if (!parent) {
/*
* The inserted node is root. Either this is the
* first node, or we recursed at Case 1 below and
* are no longer violating 4).
*/
hev_rbtree_node_set_parent_color (node, NULL, HEV_RBTREE_BLACK);
break;
}
/*
* If there is a black parent, we are done.
* Otherwise, take some corrective action as,
* per 4), we don't want a red root or two
* consecutive red nodes.
*/
if (hev_rbtree_node_is_black (parent))
break;
gparent = hev_rbtree_node_red_parent (parent);
tmp = gparent->right;
if (parent != tmp) { /* parent == gparent->left */
if (tmp && hev_rbtree_node_is_red (tmp)) {
/*
* Case 1 - node's uncle is red (color flips).
*
* G g
* / \ / \
* p u --> P U
* / /
* n n
*
* However, since g's parent might be red, and
* 4) does not allow this, we need to recurse
* at g.
*/
hev_rbtree_node_set_parent_color (tmp, gparent,
HEV_RBTREE_BLACK);
hev_rbtree_node_set_parent_color (parent, gparent,
HEV_RBTREE_BLACK);
node = gparent;
parent = hev_rbtree_node_parent (node);
hev_rbtree_node_set_parent_color (node, parent, HEV_RBTREE_RED);
continue;
}
tmp = parent->right;
if (node == tmp) {
/*
* Case 2 - node's uncle is black and node is
* the parent's right child (left rotate at parent).
*
* G G
* / \ / \
* p U --> n U
* \ /
* n p
*
* This still leaves us in violation of 4), the
* continuation into Case 3 will fix that.
*/
tmp = node->left;
parent->right = tmp;
node->left = parent;
if (tmp)
hev_rbtree_node_set_parent_color (tmp, parent,
HEV_RBTREE_BLACK);
hev_rbtree_node_set_parent_color (parent, node, HEV_RBTREE_RED);
parent = node;
tmp = node->right;
}
/*
* Case 3 - node's uncle is black and node is
* the parent's left child (right rotate at gparent).
*
* G P
* / \ / \
* p U --> n g
* / \
* n U
*/
gparent->left = tmp; /* == parent->right */
parent->right = gparent;
if (tmp)
hev_rbtree_node_set_parent_color (tmp, gparent,
HEV_RBTREE_BLACK);
hev_rbtree_rotate_set_parents (self, gparent, parent,
HEV_RBTREE_RED);
break;
} else {
tmp = gparent->left;
if (tmp && hev_rbtree_node_is_red (tmp)) {
/* Case 1 - color flips */
hev_rbtree_node_set_parent_color (tmp, gparent,
HEV_RBTREE_BLACK);
hev_rbtree_node_set_parent_color (parent, gparent,
HEV_RBTREE_BLACK);
node = gparent;
parent = hev_rbtree_node_parent (node);
hev_rbtree_node_set_parent_color (node, parent, HEV_RBTREE_RED);
continue;
}
tmp = parent->left;
if (node == tmp) {
/* Case 2 - right rotate at parent */
tmp = node->right;
parent->left = tmp;
node->right = parent;
if (tmp)
hev_rbtree_node_set_parent_color (tmp, parent,
HEV_RBTREE_BLACK);
hev_rbtree_node_set_parent_color (parent, node, HEV_RBTREE_RED);
parent = node;
tmp = node->left;
}
/* Case 3 - left rotate at gparent */
gparent->right = tmp; /* == parent->left */
parent->left = gparent;
if (tmp)
hev_rbtree_node_set_parent_color (tmp, gparent,
HEV_RBTREE_BLACK);
hev_rbtree_rotate_set_parents (self, gparent, parent,
HEV_RBTREE_RED);
break;
}
}
}
void
hev_rbtree_replace (HevRBTree *self, HevRBTreeNode *victim, HevRBTreeNode *new)
{
HevRBTreeNode *parent = hev_rbtree_node_parent (victim);
/* Copy the pointers/colour from the victim to the replacement */
*new = *victim;
/* Set the surrounding nodes to point to the replacement */
if (victim->left)
hev_rbtree_node_set_parent (victim->left, new);
if (victim->right)
hev_rbtree_node_set_parent (victim->right, new);
hev_rbtree_change_child (self, victim, new, parent);
}
static inline HevRBTreeNode *
_hev_rbtree_erase (HevRBTree *self, HevRBTreeNode *node)
{
HevRBTreeNode *child = node->right;
HevRBTreeNode *tmp = node->left;
HevRBTreeNode *parent, *rebalance;
unsigned long pc;
if (!tmp) {
/*
* Case 1: node to erase has no more than 1 child (easy!)
*
* Note that if there is one child it must be red due to 5)
* and node must be black due to 4). We adjust colors locally
* so as to bypass _hev_rbtree_erase_color() later on.
*/
pc = node->__parent_color;
parent = _hev_rbtree_node_parent (pc);
hev_rbtree_change_child (self, node, child, parent);
if (child) {
child->__parent_color = pc;
rebalance = NULL;
} else
rebalance = _hev_rbtree_node_is_black (pc) ? parent : NULL;
tmp = parent;
} else if (!child) {
/* Still case 1, but this time the child is node->left */
tmp->__parent_color = pc = node->__parent_color;
parent = _hev_rbtree_node_parent (pc);
hev_rbtree_change_child (self, node, tmp, parent);
rebalance = NULL;
tmp = parent;
} else {
HevRBTreeNode *successor = child, *child2;
tmp = child->left;
if (!tmp) {
/*
* Case 2: node's successor is its right child
*
* (n) (s)
* / \ / \
* (x) (s) -> (x) (c)
* \
* (c)
*/
parent = successor;
child2 = successor->right;
} else {
/*
* Case 3: node's successor is leftmost under
* node's right child subtree
*
* (n) (s)
* / \ / \
* (x) (y) -> (x) (y)
* / /
* (p) (p)
* / /
* (s) (c)
* \
* (c)
*/
do {
parent = successor;
successor = tmp;
tmp = tmp->left;
} while (tmp);
child2 = successor->right;
parent->left = child2;
successor->right = child;
hev_rbtree_node_set_parent (child, successor);
}
tmp = node->left;
successor->left = tmp;
hev_rbtree_node_set_parent (tmp, successor);
pc = node->__parent_color;
tmp = _hev_rbtree_node_parent (pc);
hev_rbtree_change_child (self, node, successor, tmp);
if (child2) {
hev_rbtree_node_set_parent_color (child2, parent, HEV_RBTREE_BLACK);
rebalance = NULL;
} else {
rebalance = hev_rbtree_node_is_black (successor) ? parent : NULL;
}
successor->__parent_color = pc;
tmp = successor;
}
return rebalance;
}
/*
* Inline version for hev_rbtree_erase() use - we want to be able to inline
* and eliminate the dummy_rotate callback there
*/
static inline void
_hev_rbtree_erase_color (HevRBTree *self, HevRBTreeNode *parent)
{
HevRBTreeNode *node = NULL, *sibling, *tmp1, *tmp2;
while (1) {
/*
* Loop invariants:
* - node is black (or NULL on first iteration)
* - node is not the root (parent is not NULL)
* - All leaf paths going through parent and node have a
* black node count that is 1 lower than other leaf paths.
*/
sibling = parent->right;
if (node != sibling) { /* node == parent->left */
if (hev_rbtree_node_is_red (sibling)) {
/*
* Case 1 - left rotate at parent
*
* P S
* / \ / \
* N s --> p Sr
* / \ / \
* Sl Sr N Sl
*/
tmp1 = sibling->left;
parent->right = tmp1;
sibling->left = parent;
hev_rbtree_node_set_parent_color (tmp1, parent,
HEV_RBTREE_BLACK);
hev_rbtree_rotate_set_parents (self, parent, sibling,
HEV_RBTREE_RED);
sibling = tmp1;
}
tmp1 = sibling->right;
if (!tmp1 || hev_rbtree_node_is_black (tmp1)) {
tmp2 = sibling->left;
if (!tmp2 || hev_rbtree_node_is_black (tmp2)) {
/*
* Case 2 - sibling color flip
* (p could be either color here)
*
* (p) (p)
* / \ / \
* N S --> N s
* / \ / \
* Sl Sr Sl Sr
*
* This leaves us violating 5) which
* can be fixed by flipping p to black
* if it was red, or by recursing at p.
* p is red when coming from Case 1.
*/
hev_rbtree_node_set_parent_color (sibling, parent,
HEV_RBTREE_RED);
if (hev_rbtree_node_is_red (parent))
hev_rbtree_node_set_black (parent);
else {
node = parent;
parent = hev_rbtree_node_parent (node);
if (parent)
continue;
}
break;
}
/*
* Case 3 - right rotate at sibling
* (p could be either color here)
*
* (p) (p)
* / \ / \
* N S --> N sl
* / \ \
* sl Sr S
* \
* Sr
*
* Note: p might be red, and then both
* p and sl are red after rotation(which
* breaks property 4). This is fixed in
* Case 4 (in hev_rbtree_rotate_set_parents()
* which set sl the color of p
* and set p HEV_RBTREE_BLACK)
*
* (p) (sl)
* / \ / \
* N sl --> P S
* \ / \
* S N Sr
* \
* Sr
*/
tmp1 = tmp2->right;
sibling->left = tmp1;
tmp2->right = sibling;
parent->right = tmp2;
if (tmp1)
hev_rbtree_node_set_parent_color (tmp1, sibling,
HEV_RBTREE_BLACK);
tmp1 = sibling;
sibling = tmp2;
}
/*
* Case 4 - left rotate at parent + color flips
* (p and sl could be either color here.
* After rotation, p becomes black, s acquires
* p's color, and sl keeps its color)
*
* (p) (s)
* / \ / \
* N S --> P Sr
* / \ / \
* (sl) sr N (sl)
*/
tmp2 = sibling->left;
parent->right = tmp2;
sibling->left = parent;
hev_rbtree_node_set_parent_color (tmp1, sibling, HEV_RBTREE_BLACK);
if (tmp2)
hev_rbtree_node_set_parent (tmp2, parent);
hev_rbtree_rotate_set_parents (self, parent, sibling,
HEV_RBTREE_BLACK);
break;
} else {
sibling = parent->left;
if (hev_rbtree_node_is_red (sibling)) {
/* Case 1 - right rotate at parent */
tmp1 = sibling->right;
parent->left = tmp1;
sibling->right = parent;
hev_rbtree_node_set_parent_color (tmp1, parent,
HEV_RBTREE_BLACK);
hev_rbtree_rotate_set_parents (self, parent, sibling,
HEV_RBTREE_RED);
sibling = tmp1;
}
tmp1 = sibling->left;
if (!tmp1 || hev_rbtree_node_is_black (tmp1)) {
tmp2 = sibling->right;
if (!tmp2 || hev_rbtree_node_is_black (tmp2)) {
/* Case 2 - sibling color flip */
hev_rbtree_node_set_parent_color (sibling, parent,
HEV_RBTREE_RED);
if (hev_rbtree_node_is_red (parent))
hev_rbtree_node_set_black (parent);
else {
node = parent;
parent = hev_rbtree_node_parent (node);
if (parent)
continue;
}
break;
}
/* Case 3 - left rotate at sibling */
tmp1 = tmp2->left;
sibling->right = tmp1;
tmp2->left = sibling;
parent->left = tmp2;
if (tmp1)
hev_rbtree_node_set_parent_color (tmp1, sibling,
HEV_RBTREE_BLACK);
tmp1 = sibling;
sibling = tmp2;
}
/* Case 4 - right rotate at parent + color flips */
tmp2 = sibling->right;
parent->left = tmp2;
sibling->right = parent;
hev_rbtree_node_set_parent_color (tmp1, sibling, HEV_RBTREE_BLACK);
if (tmp2)
hev_rbtree_node_set_parent (tmp2, parent);
hev_rbtree_rotate_set_parents (self, parent, sibling,
HEV_RBTREE_BLACK);
break;
}
}
}
void
hev_rbtree_erase (HevRBTree *self, HevRBTreeNode *node)
{
HevRBTreeNode *rebalance;
rebalance = _hev_rbtree_erase (self, node);
if (rebalance)
_hev_rbtree_erase_color (self, rebalance);
}

View file

@ -0,0 +1,68 @@
/*
============================================================================
Name : hev-rbtree.h
Authors : Andrea Arcangeli <andrea@suse.de>
David Woodhouse <dwmw2@infradead.org>
Michel Lespinasse <walken@google.com>
Heiher <r@hev.cc>
Copyright : Copyright (c) 2018 everyone.
Description : RedBlack Tree
============================================================================
*/
#ifndef __HEV_RBTREE_H__
#define __HEV_RBTREE_H__
#include <stddef.h>
typedef struct _HevRBTree HevRBTree;
typedef struct _HevRBTreeNode HevRBTreeNode;
struct _HevRBTree
{
HevRBTreeNode *root;
};
struct _HevRBTreeNode
{
unsigned long __parent_color;
HevRBTreeNode *right;
HevRBTreeNode *left;
} __attribute__ ((aligned (sizeof (long))));
static inline int
hev_rbtree_node_empty (HevRBTreeNode *node)
{
return node->__parent_color == (unsigned long)node;
}
static inline HevRBTreeNode *
hev_rbtree_node_parent (HevRBTreeNode *node)
{
return (HevRBTreeNode *)(node->__parent_color & ~3);
}
static inline void
hev_rbtree_node_link (HevRBTreeNode *node, HevRBTreeNode *parent,
HevRBTreeNode **link)
{
node->__parent_color = (unsigned long)parent;
node->left = node->right = NULL;
*link = node;
}
HevRBTreeNode *hev_rbtree_node_prev (HevRBTreeNode *node);
HevRBTreeNode *hev_rbtree_node_next (HevRBTreeNode *node);
HevRBTreeNode *hev_rbtree_first (HevRBTree *self);
HevRBTreeNode *hev_rbtree_last (HevRBTree *self);
void hev_rbtree_insert_color (HevRBTree *self, HevRBTreeNode *node);
void hev_rbtree_replace (HevRBTree *self, HevRBTreeNode *victim,
HevRBTreeNode *new);
void hev_rbtree_erase (HevRBTree *self, HevRBTreeNode *node);
#endif /* __HEV_RBTREE_H__ */

View file

@ -0,0 +1,191 @@
/*
============================================================================
Name : hev-socks5-authenticator.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2023 hev
Description : Socks5 Authenticator
============================================================================
*/
#include <stdlib.h>
#include <string.h>
#include "hev-compiler.h"
#include "hev-socks5-logger-priv.h"
#include "hev-socks5-authenticator.h"
HevSocks5Authenticator *
hev_socks5_authenticator_new (void)
{
HevSocks5Authenticator *self;
int res;
self = calloc (1, sizeof (HevSocks5Authenticator));
if (!self)
return NULL;
res = hev_socks5_authenticator_construct (self);
if (res < 0) {
free (self);
return NULL;
}
LOG_D ("%p socks5 authenticator new", self);
return self;
}
int
hev_socks5_authenticator_add (HevSocks5Authenticator *self, HevSocks5User *user)
{
HevRBTreeNode **new = &self->tree.root, *parent = NULL;
while (*new) {
HevSocks5User *this;
int res;
this = container_of (*new, HevSocks5User, node);
if (this->name_len < user->name_len)
res = -1;
else if (this->name_len > user->name_len)
res = 1;
else
res = memcmp (this->name, user->name, this->name_len);
parent = *new;
if (res < 0)
new = &((*new)->left);
else if (res > 0)
new = &((*new)->right);
else
return -1;
}
hev_rbtree_node_link (&user->node, parent, new);
hev_rbtree_insert_color (&self->tree, &user->node);
return 0;
}
int
hev_socks5_authenticator_del (HevSocks5Authenticator *self, const char *name,
unsigned int name_len)
{
HevRBTreeNode *node = self->tree.root;
while (node) {
HevSocks5User *this;
int res;
this = container_of (node, HevSocks5User, node);
if (this->name_len < name_len)
res = -1;
else if (this->name_len > name_len)
res = 1;
else
res = memcmp (this->name, name, name_len);
if (res < 0) {
node = node->left;
} else if (res > 0) {
node = node->right;
} else {
hev_rbtree_erase (&self->tree, node);
hev_object_unref (HEV_OBJECT (this));
return 0;
}
}
return -1;
}
HevSocks5User *
hev_socks5_authenticator_get (HevSocks5Authenticator *self, const char *name,
unsigned int name_len)
{
HevRBTreeNode *node = self->tree.root;
while (node) {
HevSocks5User *this;
int res;
this = container_of (node, HevSocks5User, node);
if (this->name_len < name_len)
res = -1;
else if (this->name_len > name_len)
res = 1;
else
res = memcmp (this->name, name, name_len);
if (res < 0)
node = node->left;
else if (res > 0)
node = node->right;
else
return this;
}
return NULL;
}
void
hev_socks5_authenticator_clear (HevSocks5Authenticator *self)
{
HevRBTreeNode *n;
while ((n = hev_rbtree_first (&self->tree))) {
HevSocks5User *t;
t = container_of (n, HevSocks5User, node);
hev_rbtree_erase (&self->tree, n);
hev_object_unref (HEV_OBJECT (t));
}
}
int
hev_socks5_authenticator_construct (HevSocks5Authenticator *self)
{
int res;
res = hev_object_atomic_construct (&self->base);
if (res < 0)
return res;
LOG_D ("%p socks5 authenticator construct", self);
HEV_OBJECT (self)->klass = HEV_SOCKS5_AUTHENTICATOR_TYPE;
return 0;
}
static void
hev_socks5_authenticator_destruct (HevObject *base)
{
HevSocks5Authenticator *self = HEV_SOCKS5_AUTHENTICATOR (base);
LOG_D ("%p socks5 authenticator destruct", self);
hev_socks5_authenticator_clear (self);
HEV_OBJECT_ATOMIC_TYPE->destruct (base);
free (base);
}
HevObjectClass *
hev_socks5_authenticator_class (void)
{
static HevSocks5AuthenticatorClass klass;
HevSocks5AuthenticatorClass *kptr = &klass;
HevObjectClass *okptr = HEV_OBJECT_CLASS (kptr);
if (!okptr->name) {
memcpy (kptr, HEV_OBJECT_ATOMIC_TYPE, sizeof (HevObjectAtomicClass));
okptr->name = "HevSocks5Authenticator";
okptr->destruct = hev_socks5_authenticator_destruct;
}
return okptr;
}

View file

@ -0,0 +1,64 @@
/*
============================================================================
Name : hev-socks5-authenticator.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2023 hev
Description : Socks5 Authenticator
============================================================================
*/
#ifndef __HEV_SOCKS5_AUTHENTICATOR_H__
#define __HEV_SOCKS5_AUTHENTICATOR_H__
#include <hev-object-atomic.h>
#include "hev-rbtree.h"
#include "hev-socks5-user.h"
#ifdef __cplusplus
extern "C" {
#endif
#define HEV_SOCKS5_AUTHENTICATOR(p) ((HevSocks5Authenticator *)p)
#define HEV_SOCKS5_AUTHENTICATOR_CLASS(p) ((HevSocks5AuthenticatorClass *)p)
#define HEV_SOCKS5_AUTHENTICATOR_TYPE (hev_socks5_authenticator_class ())
typedef struct _HevSocks5Authenticator HevSocks5Authenticator;
typedef struct _HevSocks5AuthenticatorClass HevSocks5AuthenticatorClass;
typedef enum _HevSocks5AuthenticatorType HevSocks5AuthenticatorType;
struct _HevSocks5Authenticator
{
HevObjectAtomic base;
HevRBTree tree;
};
struct _HevSocks5AuthenticatorClass
{
HevObjectAtomicClass base;
};
HevObjectClass *hev_socks5_authenticator_class (void);
int hev_socks5_authenticator_construct (HevSocks5Authenticator *self);
HevSocks5Authenticator *hev_socks5_authenticator_new (void);
int hev_socks5_authenticator_add (HevSocks5Authenticator *self,
HevSocks5User *user);
int hev_socks5_authenticator_del (HevSocks5Authenticator *self,
const char *name, unsigned int name_len);
HevSocks5User *hev_socks5_authenticator_get (HevSocks5Authenticator *self,
const char *name,
unsigned int name_len);
void hev_socks5_authenticator_clear (HevSocks5Authenticator *self);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_AUTHENTICATOR_H__ */

View file

@ -0,0 +1,185 @@
/*
============================================================================
Name : hev-socks5-client-tcp.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2025 hev
Description : Socks5 Client TCP
============================================================================
*/
#include <string.h>
#include <hev-memory-allocator.h>
#include "hev-socks5-misc-priv.h"
#include "hev-socks5-logger-priv.h"
#include "hev-socks5-client-tcp.h"
HevSocks5ClientTCP *
hev_socks5_client_tcp_new_name (const char *name, int port)
{
HevSocks5ClientTCP *self;
HevSocks5Addr addr;
int res;
self = hev_malloc0 (sizeof (HevSocks5ClientTCP));
if (!self)
return NULL;
hev_socks5_addr_from_name (&addr, name, port);
res = hev_socks5_client_tcp_construct (self, &addr);
if (res < 0) {
hev_free (self);
return NULL;
}
LOG_D ("%p socks5 client tcp new name", self);
return self;
}
HevSocks5ClientTCP *
hev_socks5_client_tcp_new_ipv4 (const void *ipv4, int port)
{
HevSocks5ClientTCP *self;
HevSocks5Addr addr;
int res;
self = hev_malloc0 (sizeof (HevSocks5ClientTCP));
if (!self)
return NULL;
hev_socks5_addr_from_ipv4 (&addr, ipv4, port);
res = hev_socks5_client_tcp_construct (self, &addr);
if (res < 0) {
hev_free (self);
return NULL;
}
LOG_D ("%p socks5 client tcp new ipv4", self);
return self;
}
HevSocks5ClientTCP *
hev_socks5_client_tcp_new_ipv6 (const void *ipv6, int port)
{
HevSocks5ClientTCP *self;
HevSocks5Addr addr;
int res;
self = hev_malloc0 (sizeof (HevSocks5ClientTCP));
if (!self)
return NULL;
hev_socks5_addr_from_ipv6 (&addr, ipv6, port);
res = hev_socks5_client_tcp_construct (self, &addr);
if (res < 0) {
hev_free (self);
return NULL;
}
LOG_D ("%p socks5 client tcp new ipv6", self);
return self;
}
static HevSocks5Addr *
hev_socks5_client_tcp_get_upstream_addr (HevSocks5Client *base)
{
HevSocks5ClientTCP *self = HEV_SOCKS5_CLIENT_TCP (base);
HevSocks5Addr *addr;
addr = self->addr;
self->addr = NULL;
return addr;
}
static int
hev_socks5_client_tcp_set_upstream_addr (HevSocks5Client *base,
HevSocks5Addr *addr)
{
return 0;
}
int
hev_socks5_client_tcp_construct (HevSocks5ClientTCP *self,
const HevSocks5Addr *addr)
{
int res;
res = hev_socks5_client_construct (&self->base, HEV_SOCKS5_TYPE_TCP);
if (res < 0)
return res;
LOG_D ("%p socks5 client tcp construct", self);
HEV_OBJECT (self)->klass = HEV_SOCKS5_CLIENT_TCP_TYPE;
res = hev_socks5_addr_len (addr);
self->addr = hev_malloc (res);
if (!self->addr)
return -1;
memcpy (self->addr, addr, res);
if (LOG_ON ()) {
const char *str;
char buf[272];
str = hev_socks5_addr_into_str (self->addr, buf, sizeof (buf));
LOG_I ("%p socks5 client tcp -> %s", self, str);
}
return 0;
}
static void
hev_socks5_client_tcp_destruct (HevObject *base)
{
HevSocks5ClientTCP *self = HEV_SOCKS5_CLIENT_TCP (base);
LOG_D ("%p socks5 client tcp destruct", self);
if (self->addr)
hev_free (self->addr);
HEV_SOCKS5_CLIENT_TYPE->destruct (base);
}
static void *
hev_socks5_client_tcp_iface (HevObject *base, void *type)
{
HevSocks5ClientTCPClass *klass = HEV_OBJECT_GET_CLASS (base);
return &klass->tcp;
}
HevObjectClass *
hev_socks5_client_tcp_class (void)
{
static HevSocks5ClientTCPClass klass;
HevSocks5ClientTCPClass *kptr = &klass;
HevObjectClass *okptr = HEV_OBJECT_CLASS (kptr);
if (!okptr->name) {
HevSocks5ClientClass *ckptr;
HevSocks5TCPIface *tiptr;
memcpy (kptr, HEV_SOCKS5_CLIENT_TYPE, sizeof (HevSocks5ClientClass));
okptr->name = "HevSocks5ClientTCP";
okptr->destruct = hev_socks5_client_tcp_destruct;
okptr->iface = hev_socks5_client_tcp_iface;
ckptr = HEV_SOCKS5_CLIENT_CLASS (kptr);
ckptr->get_upstream_addr = hev_socks5_client_tcp_get_upstream_addr;
ckptr->set_upstream_addr = hev_socks5_client_tcp_set_upstream_addr;
tiptr = &kptr->tcp;
memcpy (tiptr, HEV_SOCKS5_TCP_TYPE, sizeof (HevSocks5TCPIface));
}
return okptr;
}

View file

@ -0,0 +1,56 @@
/*
============================================================================
Name : hev-socks5-client-tcp.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2025 hev
Description : Socks5 Client TCP
============================================================================
*/
#ifndef __HEV_SOCKS5_CLIENT_TCP_H__
#define __HEV_SOCKS5_CLIENT_TCP_H__
#include "hev-socks5-tcp.h"
#include "hev-socks5-proto.h"
#include "hev-socks5-client.h"
#ifdef __cplusplus
extern "C" {
#endif
#define HEV_SOCKS5_CLIENT_TCP(p) ((HevSocks5ClientTCP *)p)
#define HEV_SOCKS5_CLIENT_TCP_CLASS(p) ((HevSocks5ClientTCPClass *)p)
#define HEV_SOCKS5_CLIENT_TCP_TYPE (hev_socks5_client_tcp_class ())
typedef struct _HevSocks5ClientTCP HevSocks5ClientTCP;
typedef struct _HevSocks5ClientTCPClass HevSocks5ClientTCPClass;
struct _HevSocks5ClientTCP
{
HevSocks5Client base;
HevSocks5Addr *addr;
};
struct _HevSocks5ClientTCPClass
{
HevSocks5ClientClass base;
HevSocks5TCPIface tcp;
};
HevObjectClass *hev_socks5_client_tcp_class (void);
int hev_socks5_client_tcp_construct (HevSocks5ClientTCP *self,
const HevSocks5Addr *addr);
HevSocks5ClientTCP *hev_socks5_client_tcp_new_name (const char *name, int port);
HevSocks5ClientTCP *hev_socks5_client_tcp_new_ipv4 (const void *ipv4, int port);
HevSocks5ClientTCP *hev_socks5_client_tcp_new_ipv6 (const void *ipv6, int port);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_CLIENT_TCP_H__ */

View file

@ -0,0 +1,213 @@
/*
============================================================================
Name : hev-socks5-client-udp.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2025 hev
Description : Socks5 Client UDP
============================================================================
*/
#include <string.h>
#include <unistd.h>
#include <hev-task.h>
#include <hev-task-io.h>
#include <hev-task-io-socket.h>
#include <hev-memory-allocator.h>
#include "hev-socks5-misc-priv.h"
#include "hev-socks5-logger-priv.h"
#include "hev-socks5-client-udp.h"
#define task_io_yielder hev_socks5_task_io_yielder
HevSocks5ClientUDP *
hev_socks5_client_udp_new (HevSocks5Type type)
{
HevSocks5ClientUDP *self;
int res;
self = hev_malloc0 (sizeof (HevSocks5ClientUDP));
if (!self)
return NULL;
res = hev_socks5_client_udp_construct (self, type);
if (res < 0) {
hev_free (self);
return NULL;
}
LOG_D ("%p socks5 client udp new", self);
return self;
}
static HevSocks5Addr *
hev_socks5_client_udp_get_upstream_addr (HevSocks5Client *base)
{
HevSocks5AddrFamily family;
HevSocks5Addr *addr;
family = hev_socks5_get_addr_family (HEV_SOCKS5 (base));
switch (family) {
case HEV_SOCKS5_ADDR_FAMILY_IPV4:
addr = hev_malloc0 (7);
if (addr)
addr->atype = HEV_SOCKS5_ADDR_TYPE_IPV4;
break;
case HEV_SOCKS5_ADDR_FAMILY_IPV6:
addr = hev_malloc0 (19);
if (addr)
addr->atype = HEV_SOCKS5_ADDR_TYPE_IPV6;
break;
default:
addr = NULL;
}
return addr;
}
static int
hev_socks5_client_udp_set_upstream_addr (HevSocks5Client *base,
HevSocks5Addr *addr)
{
HevSocks5ClientUDP *self = HEV_SOCKS5_CLIENT_UDP (base);
struct sockaddr_in6 saddr;
struct sockaddr *sadp;
HevSocks5Class *klass;
int addr_family;
int res;
int fd;
if (HEV_SOCKS5 (base)->type != HEV_SOCKS5_TYPE_UDP_IN_UDP)
return 0;
memset (&saddr, 0, sizeof (saddr));
addr_family = hev_socks5_get_addr_family (HEV_SOCKS5 (self));
res = hev_socks5_addr_into_sockaddr6 (addr, &saddr, &addr_family);
if (res < 0) {
LOG_W ("%p socks5 client udp addr", self);
return -1;
}
fd = hev_socks5_socket (SOCK_DGRAM);
if (fd < 0) {
LOG_E ("%p socks5 client udp socket", self);
return -1;
}
sadp = (struct sockaddr *)&saddr;
klass = HEV_OBJECT_GET_CLASS (self);
res = klass->binder (HEV_SOCKS5 (self), fd, sadp);
if (res < 0) {
LOG_W ("%p socks5 client udp bind", self);
hev_task_del_fd (hev_task_self (), fd);
close (fd);
return -1;
}
res = hev_task_io_socket_connect (fd, sadp, sizeof (saddr), task_io_yielder,
self);
if (res < 0) {
LOG_I ("%p socks5 client udp connect", self);
hev_task_del_fd (hev_task_self (), fd);
close (fd);
return -1;
}
HEV_SOCKS5 (self)->udp_associated = 1;
self->fd = fd;
return 0;
}
static int
hev_socks5_client_udp_get_fd (HevSocks5UDP *self)
{
int fd;
switch (HEV_SOCKS5 (self)->type) {
case HEV_SOCKS5_TYPE_UDP_IN_TCP:
fd = HEV_SOCKS5 (self)->fd;
break;
case HEV_SOCKS5_TYPE_UDP_IN_UDP:
fd = HEV_SOCKS5_CLIENT_UDP (self)->fd;
break;
default:
return -1;
}
return fd;
}
int
hev_socks5_client_udp_construct (HevSocks5ClientUDP *self, HevSocks5Type type)
{
int res;
res = hev_socks5_client_construct (&self->base, type);
if (res < 0)
return res;
LOG_I ("%p socks5 client udp construct", self);
HEV_OBJECT (self)->klass = HEV_SOCKS5_CLIENT_UDP_TYPE;
self->fd = -1;
return 0;
}
static void
hev_socks5_client_udp_destruct (HevObject *base)
{
HevSocks5ClientUDP *self = HEV_SOCKS5_CLIENT_UDP (base);
LOG_D ("%p socks5 client udp destruct", self);
if (self->fd >= 0) {
hev_task_del_fd (hev_task_self (), self->fd);
close (self->fd);
}
HEV_SOCKS5_CLIENT_TYPE->destruct (base);
}
static void *
hev_socks5_client_udp_iface (HevObject *base, void *type)
{
HevSocks5ClientUDPClass *klass = HEV_OBJECT_GET_CLASS (base);
return &klass->udp;
}
HevObjectClass *
hev_socks5_client_udp_class (void)
{
static HevSocks5ClientUDPClass klass;
HevSocks5ClientUDPClass *kptr = &klass;
HevObjectClass *okptr = HEV_OBJECT_CLASS (kptr);
if (!okptr->name) {
HevSocks5ClientClass *ckptr;
HevSocks5UDPIface *uiptr;
memcpy (kptr, HEV_SOCKS5_CLIENT_TYPE, sizeof (HevSocks5ClientClass));
okptr->name = "HevSocks5ClientUDP";
okptr->destruct = hev_socks5_client_udp_destruct;
okptr->iface = hev_socks5_client_udp_iface;
ckptr = HEV_SOCKS5_CLIENT_CLASS (kptr);
ckptr->get_upstream_addr = hev_socks5_client_udp_get_upstream_addr;
ckptr->set_upstream_addr = hev_socks5_client_udp_set_upstream_addr;
uiptr = &kptr->udp;
memcpy (uiptr, HEV_SOCKS5_UDP_TYPE, sizeof (HevSocks5UDPIface));
uiptr->get_fd = hev_socks5_client_udp_get_fd;
}
return okptr;
}

View file

@ -0,0 +1,53 @@
/*
============================================================================
Name : hev-socks5-client-udp.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2023 hev
Description : Socks5 Client UDP
============================================================================
*/
#ifndef __HEV_SOCKS5_CLIENT_UDP_H__
#define __HEV_SOCKS5_CLIENT_UDP_H__
#include "hev-socks5-udp.h"
#include "hev-socks5-client.h"
#ifdef __cplusplus
extern "C" {
#endif
#define HEV_SOCKS5_CLIENT_UDP(p) ((HevSocks5ClientUDP *)p)
#define HEV_SOCKS5_CLIENT_UDP_CLASS(p) ((HevSocks5ClientUDPClass *)p)
#define HEV_SOCKS5_CLIENT_UDP_TYPE (hev_socks5_client_udp_class ())
typedef struct _HevSocks5ClientUDP HevSocks5ClientUDP;
typedef struct _HevSocks5ClientUDPClass HevSocks5ClientUDPClass;
struct _HevSocks5ClientUDP
{
HevSocks5Client base;
int fd;
};
struct _HevSocks5ClientUDPClass
{
HevSocks5ClientClass base;
HevSocks5UDPIface udp;
};
HevObjectClass *hev_socks5_client_udp_class (void);
int hev_socks5_client_udp_construct (HevSocks5ClientUDP *self,
HevSocks5Type type);
HevSocks5ClientUDP *hev_socks5_client_udp_new (HevSocks5Type type);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_CLIENT_UDP_H__ */

View file

@ -0,0 +1,475 @@
/*
============================================================================
Name : hev-socks5-client.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2025 hev
Description : Socks5 Client
============================================================================
*/
#include <string.h>
#include <unistd.h>
#include <hev-task.h>
#include <hev-task-io.h>
#include <hev-task-io-socket.h>
#include <hev-memory-allocator.h>
#include "hev-socks5-misc-priv.h"
#include "hev-socks5-logger-priv.h"
#include "hev-socks5-client.h"
#define task_io_yielder hev_socks5_task_io_yielder
static int
hev_socks5_client_write_auth_methods (HevSocks5Client *self)
{
HevSocks5Auth auth;
int res;
LOG_D ("%p socks5 client write auth methods", self);
auth.ver = HEV_SOCKS5_VERSION_5;
auth.method_len = 1;
if (!self->auth.user || !self->auth.pass)
auth.methods[0] = HEV_SOCKS5_AUTH_METHOD_NONE;
else
auth.methods[0] = HEV_SOCKS5_AUTH_METHOD_USER;
res = hev_task_io_socket_send (HEV_SOCKS5 (self)->fd, &auth, 3, MSG_WAITALL,
task_io_yielder, self);
if (res <= 0) {
LOG_I ("%p socks5 client write auth methods", self);
return -1;
}
return 0;
}
static int
hev_socks5_client_write_auth_creds (HevSocks5Client *self)
{
struct msghdr mh = { 0 };
struct iovec iov[4];
unsigned char ub[3];
int res;
LOG_D ("%p socks5 client write auth creds", self);
if (!self->auth.user || !self->auth.pass)
return 0;
ub[0] = HEV_SOCKS5_AUTH_VERSION_1;
ub[1] = strlen (self->auth.user);
ub[2] = strlen (self->auth.pass);
iov[0].iov_base = &ub[0];
iov[0].iov_len = 2;
iov[1].iov_base = (void *)self->auth.user;
iov[1].iov_len = ub[1];
iov[2].iov_base = &ub[2];
iov[2].iov_len = 1;
iov[3].iov_base = (void *)self->auth.pass;
iov[3].iov_len = ub[2];
mh.msg_iov = iov;
mh.msg_iovlen = 4;
res = hev_task_io_socket_sendmsg (HEV_SOCKS5 (self)->fd, &mh, MSG_WAITALL,
task_io_yielder, self);
if (res <= 0) {
LOG_I ("%p socks5 client write auth creds", self);
return -1;
}
return 0;
}
static int
hev_socks5_client_write_request (HevSocks5Client *self)
{
HevSocks5ClientClass *klass;
struct msghdr mh = { 0 };
struct iovec iov[2];
HevSocks5Addr *addr;
HevSocks5ReqRes req;
int addrlen;
int ret;
LOG_D ("%p socks5 client write request", self);
req.ver = HEV_SOCKS5_VERSION_5;
req.rsv = 0;
switch (HEV_SOCKS5 (self)->type) {
case HEV_SOCKS5_TYPE_TCP:
req.cmd = HEV_SOCKS5_REQ_CMD_CONNECT;
break;
case HEV_SOCKS5_TYPE_UDP_IN_TCP:
req.cmd = HEV_SOCKS5_REQ_CMD_FWD_UDP;
break;
case HEV_SOCKS5_TYPE_UDP_IN_UDP:
req.cmd = HEV_SOCKS5_REQ_CMD_UDP_ASC;
break;
default:
return -1;
}
iov[0].iov_base = &req;
iov[0].iov_len = 3;
klass = HEV_OBJECT_GET_CLASS (self);
addr = klass->get_upstream_addr (self);
switch (addr->atype) {
case HEV_SOCKS5_ADDR_TYPE_IPV4:
addrlen = 7;
break;
case HEV_SOCKS5_ADDR_TYPE_IPV6:
addrlen = 19;
break;
case HEV_SOCKS5_ADDR_TYPE_NAME:
addrlen = 4 + addr->domain.len;
break;
default:
LOG_I ("%p socks5 client req.atype %u", self, addr->atype);
return -1;
}
iov[1].iov_base = addr;
iov[1].iov_len = addrlen;
mh.msg_iov = iov;
mh.msg_iovlen = 2;
ret = hev_task_io_socket_sendmsg (HEV_SOCKS5 (self)->fd, &mh, MSG_WAITALL,
task_io_yielder, self);
if (ret <= 0) {
LOG_I ("%p socks5 client write request", self);
return -1;
}
hev_free (addr);
return 0;
}
static int
hev_socks5_client_read_auth_method (HevSocks5Client *self)
{
HevSocks5Auth auth;
int res;
LOG_D ("%p socks5 client read auth method", self);
res = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, &auth, 2, MSG_WAITALL,
task_io_yielder, self);
if (res != 2) {
LOG_I ("%p socks5 client read auth", self);
return -1;
}
if (auth.ver != HEV_SOCKS5_VERSION_5) {
LOG_I ("%p socks5 client auth.ver %u", self, auth.ver);
return -1;
}
return auth.method;
}
static int
hev_socks5_client_read_auth_creds (HevSocks5Client *self)
{
HevSocks5ReqRes res;
int ret;
LOG_D ("%p socks5 client read auth creds", self);
ret = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, &res, 2, MSG_WAITALL,
task_io_yielder, self);
if (ret != 2) {
LOG_I ("%p socks5 client read auth creds", self);
return -1;
}
if (res.ver != HEV_SOCKS5_AUTH_VERSION_1) {
LOG_I ("%p socks5 client auth.res.ver %u", self, res.ver);
return -1;
}
if (res.rep != HEV_SOCKS5_RES_REP_SUCC) {
LOG_I ("%p socks5 client auth.res.rep %u", self, res.rep);
return -1;
}
LOG_D ("%p socks5 client auth done", self);
return 0;
}
static int
hev_socks5_client_read_response (HevSocks5Client *self)
{
HevSocks5ClientClass *klass;
HevSocks5ReqRes res;
int addrlen;
int ret;
LOG_D ("%p socks5 client read response", self);
ret = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, &res, 4, MSG_WAITALL,
task_io_yielder, self);
if (ret != 4) {
LOG_I ("%p socks5 client read response", self);
return -1;
}
if (res.ver != HEV_SOCKS5_VERSION_5) {
LOG_I ("%p socks5 client res.ver %u", self, res.ver);
return -1;
}
if (res.rep != HEV_SOCKS5_RES_REP_SUCC) {
LOG_I ("%p socks5 client res.rep %u", self, res.rep);
return -1;
}
switch (res.addr.atype) {
case HEV_SOCKS5_ADDR_TYPE_IPV4:
addrlen = 6;
break;
case HEV_SOCKS5_ADDR_TYPE_IPV6:
addrlen = 18;
break;
default:
LOG_I ("%p socks5 client res.atype %u", self, res.addr.atype);
return -1;
}
ret = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, &res.addr.ipv4,
addrlen, MSG_WAITALL, task_io_yielder, self);
if (ret != addrlen) {
LOG_I ("%p socks5 client read addr", self);
return -1;
}
klass = HEV_OBJECT_GET_CLASS (self);
ret = klass->set_upstream_addr (self, &res.addr);
if (ret < 0) {
LOG_W ("%p socks5 client set upstream addr", self);
return -1;
}
return 0;
}
int
hev_socks5_client_connect (HevSocks5Client *self, const char *addr, int port)
{
HevSocks5Class *klass;
struct sockaddr_in6 saddr;
struct sockaddr *sap;
int addr_family;
int timeout;
int fd, res;
LOG_D ("%p socks5 client connect [%s]:%d", self, addr, port);
timeout = hev_socks5_get_connect_timeout ();
hev_socks5_set_timeout (HEV_SOCKS5 (self), timeout);
memset (&saddr, 0, sizeof (saddr));
addr_family = hev_socks5_get_addr_family (HEV_SOCKS5 (self));
res = hev_socks5_name_into_sockaddr6 (addr, port, &saddr, &addr_family);
if (res < 0) {
LOG_I ("%p socks5 client resolve [%s]:%d", self, addr, port);
return -1;
}
fd = hev_socks5_socket (SOCK_STREAM);
if (fd < 0) {
LOG_E ("%p socks5 client socket", self);
return -1;
}
sap = (struct sockaddr *)&saddr;
klass = HEV_OBJECT_GET_CLASS (self);
res = klass->binder (HEV_SOCKS5 (self), fd, sap);
if (res < 0) {
LOG_W ("%p socks5 client bind", self);
hev_task_del_fd (hev_task_self (), fd);
close (fd);
return -1;
}
res = hev_task_io_socket_connect (fd, sap, sizeof (saddr), task_io_yielder,
self);
if (res < 0) {
LOG_I ("%p socks5 client connect", self);
hev_task_del_fd (hev_task_self (), fd);
close (fd);
return -1;
}
HEV_SOCKS5 (self)->fd = fd;
hev_socks5_set_addr_family (HEV_SOCKS5 (self), addr_family);
LOG_D ("%p socks5 client connect server fd %d", self, fd);
return 0;
}
static int
hev_socks5_client_handshake_standard (HevSocks5Client *self)
{
int res;
LOG_D ("%p socks5 client handshake standard", self);
res = hev_socks5_client_write_auth_methods (self);
if (res < 0)
return -1;
res = hev_socks5_client_read_auth_method (self);
if (res < 0)
return -1;
if (res == HEV_SOCKS5_AUTH_METHOD_USER) {
res = hev_socks5_client_write_auth_creds (self);
if (res < 0)
return -1;
res = hev_socks5_client_read_auth_creds (self);
if (res < 0)
return -1;
} else if (res != HEV_SOCKS5_AUTH_METHOD_NONE) {
LOG_I ("%p socks5 client auth method %d", self, res);
return -1;
}
res = hev_socks5_client_write_request (self);
if (res < 0)
return -1;
res = hev_socks5_client_read_response (self);
if (res < 0)
return -1;
return 0;
}
static int
hev_socks5_client_handshake_pipeline (HevSocks5Client *self)
{
int res;
LOG_D ("%p socks5 client handshake pipeline", self);
res = hev_socks5_client_write_auth_methods (self);
if (res < 0)
return -1;
res = hev_socks5_client_write_auth_creds (self);
if (res < 0)
return -1;
res = hev_socks5_client_write_request (self);
if (res < 0)
return -1;
res = hev_socks5_client_read_auth_method (self);
if (res < 0)
return -1;
if (res == HEV_SOCKS5_AUTH_METHOD_USER) {
res = hev_socks5_client_read_auth_creds (self);
if (res < 0)
return -1;
} else if (res != HEV_SOCKS5_AUTH_METHOD_NONE) {
LOG_I ("%p socks5 client auth method %d", self, res);
return -1;
}
res = hev_socks5_client_read_response (self);
if (res < 0)
return -1;
return 0;
}
int
hev_socks5_client_handshake (HevSocks5Client *self, int pipeline)
{
int timeout;
int res;
timeout = hev_socks5_get_tcp_timeout ();
hev_socks5_set_timeout (HEV_SOCKS5 (self), timeout);
if (pipeline)
res = hev_socks5_client_handshake_pipeline (self);
else
res = hev_socks5_client_handshake_standard (self);
switch (HEV_SOCKS5 (self)->type) {
case HEV_SOCKS5_TYPE_UDP_IN_TCP:
case HEV_SOCKS5_TYPE_UDP_IN_UDP:
timeout = hev_socks5_get_udp_timeout ();
hev_socks5_set_timeout (HEV_SOCKS5 (self), timeout);
break;
default:
break;
}
return res;
}
void
hev_socks5_client_set_auth (HevSocks5Client *self, const char *user,
const char *pass)
{
LOG_D ("%p socks5 client set auth", self);
self->auth.user = user;
self->auth.pass = pass;
}
int
hev_socks5_client_construct (HevSocks5Client *self, HevSocks5Type type)
{
int res;
res = hev_socks5_construct (&self->base, type);
if (res < 0)
return res;
LOG_D ("%p socks5 client construct", self);
HEV_OBJECT (self)->klass = HEV_SOCKS5_CLIENT_TYPE;
return 0;
}
static void
hev_socks5_client_destruct (HevObject *base)
{
HevSocks5Client *self = HEV_SOCKS5_CLIENT (base);
LOG_D ("%p socks5 client destruct", self);
HEV_SOCKS5_TYPE->destruct (base);
}
HevObjectClass *
hev_socks5_client_class (void)
{
static HevSocks5ClientClass klass;
HevSocks5ClientClass *kptr = &klass;
HevObjectClass *okptr = HEV_OBJECT_CLASS (kptr);
if (!okptr->name) {
memcpy (kptr, HEV_SOCKS5_TYPE, sizeof (HevSocks5Class));
okptr->name = "HevSocks5Client";
okptr->destruct = hev_socks5_client_destruct;
}
return okptr;
}

View file

@ -0,0 +1,62 @@
/*
============================================================================
Name : hev-socks5-client.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2024 hev
Description : Socks5 Client
============================================================================
*/
#ifndef __HEV_SOCKS5_CLIENT_H__
#define __HEV_SOCKS5_CLIENT_H__
#include "hev-socks5.h"
#include "hev-socks5-proto.h"
#ifdef __cplusplus
extern "C" {
#endif
#define HEV_SOCKS5_CLIENT(p) ((HevSocks5Client *)p)
#define HEV_SOCKS5_CLIENT_CLASS(p) ((HevSocks5ClientClass *)p)
#define HEV_SOCKS5_CLIENT_TYPE (hev_socks5_client_class ())
typedef struct _HevSocks5Client HevSocks5Client;
typedef struct _HevSocks5ClientClass HevSocks5ClientClass;
struct _HevSocks5Client
{
HevSocks5 base;
struct
{
const char *user;
const char *pass;
} auth;
};
struct _HevSocks5ClientClass
{
HevSocks5Class base;
HevSocks5Addr *(*get_upstream_addr) (HevSocks5Client *self);
int (*set_upstream_addr) (HevSocks5Client *self, HevSocks5Addr *addr);
};
HevObjectClass *hev_socks5_client_class (void);
int hev_socks5_client_construct (HevSocks5Client *self, HevSocks5Type type);
int hev_socks5_client_connect (HevSocks5Client *self, const char *addr,
int port);
int hev_socks5_client_handshake (HevSocks5Client *self, int pipeline);
void hev_socks5_client_set_auth (HevSocks5Client *self, const char *user,
const char *pass);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_CLIENT_H__ */

View file

@ -0,0 +1,37 @@
/*
============================================================================
Name : hev-socks5-logger-priv.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 hev
Description : Socks5 Logger Private
============================================================================
*/
#ifndef __HEV_SOCKS5_LOGGER_PRIV_H__
#define __HEV_SOCKS5_LOGGER_PRIV_H__
#include "hev-socks5-logger.h"
#ifdef __cplusplus
extern "C" {
#endif
#define LOG_D(fmt...) hev_socks5_logger_log (HEV_SOCKS5_LOGGER_DEBUG, fmt)
#define LOG_I(fmt...) hev_socks5_logger_log (HEV_SOCKS5_LOGGER_INFO, fmt)
#define LOG_W(fmt...) hev_socks5_logger_log (HEV_SOCKS5_LOGGER_WARN, fmt)
#define LOG_E(fmt...) hev_socks5_logger_log (HEV_SOCKS5_LOGGER_ERROR, fmt)
#define LOG_ON() hev_socks5_logger_enabled (HEV_SOCKS5_LOGGER_UNSET)
#define LOG_ON_D() hev_socks5_logger_enabled (HEV_SOCKS5_LOGGER_DEBUG)
#define LOG_ON_I() hev_socks5_logger_enabled (HEV_SOCKS5_LOGGER_INFO)
#define LOG_ON_W() hev_socks5_logger_enabled (HEV_SOCKS5_LOGGER_WARN)
#define LOG_ON_E() hev_socks5_logger_enabled (HEV_SOCKS5_LOGGER_ERROR)
int hev_socks5_logger_enabled (HevSocks5LoggerLevel level);
void hev_socks5_logger_log (HevSocks5LoggerLevel level, const char *fmt, ...);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_LOGGER_PRIV_H__ */

View file

@ -0,0 +1,113 @@
/*
============================================================================
Name : hev-socks5-logger.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 hev
Description : Socks5 Logger
============================================================================
*/
#include <time.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdarg.h>
#include <string.h>
#include <unistd.h>
#include <sys/uio.h>
#include <sys/stat.h>
#include "hev-socks5-logger.h"
#include "hev-socks5-logger-priv.h"
static int fd = -1;
static HevSocks5LoggerLevel req_level;
int
hev_socks5_logger_init (HevSocks5LoggerLevel level, const char *path)
{
req_level = level;
if (0 == strcmp (path, "stdout"))
fd = dup (1);
else if (0 == strcmp (path, "stderr"))
fd = dup (2);
else
fd = open (path, O_WRONLY | O_APPEND | O_CREAT, 0640);
if (fd < 0)
return -1;
return 0;
}
void
hev_socks5_logger_fini (void)
{
close (fd);
}
int
hev_socks5_logger_enabled (HevSocks5LoggerLevel level)
{
if (level >= req_level && fd >= 0)
return 1;
return 0;
}
void
hev_socks5_logger_log (HevSocks5LoggerLevel level, const char *fmt, ...)
{
struct iovec iov[4];
const char *ts_fmt;
char msg[1024];
struct tm *ti;
char ts[32];
time_t now;
va_list ap;
int len;
if (level < req_level || fd < 0)
return;
time (&now);
ti = localtime (&now);
ts_fmt = "[%04u-%02u-%02u %02u:%02u:%02u] ";
len = snprintf (ts, sizeof (ts), ts_fmt, 1900 + ti->tm_year, 1 + ti->tm_mon,
ti->tm_mday, ti->tm_hour, ti->tm_min, ti->tm_sec);
iov[0].iov_base = ts;
iov[0].iov_len = len;
switch (level) {
case HEV_SOCKS5_LOGGER_DEBUG:
iov[1].iov_base = "[D] ";
break;
case HEV_SOCKS5_LOGGER_INFO:
iov[1].iov_base = "[I] ";
break;
case HEV_SOCKS5_LOGGER_WARN:
iov[1].iov_base = "[W] ";
break;
case HEV_SOCKS5_LOGGER_ERROR:
iov[1].iov_base = "[E] ";
break;
case HEV_SOCKS5_LOGGER_UNSET:
iov[1].iov_base = "[?] ";
break;
}
iov[1].iov_len = 4;
va_start (ap, fmt);
iov[2].iov_base = msg;
iov[2].iov_len = vsnprintf (msg, 1024, fmt, ap);
va_end (ap);
iov[3].iov_base = "\n";
iov[3].iov_len = 1;
if (writev (fd, iov, 4)) {
/* ignore return value */
}
}

View file

@ -0,0 +1,35 @@
/*
============================================================================
Name : hev-socks5-logger.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 hev
Description : Socks5 Logger
============================================================================
*/
#ifndef __HEV_SOCKS5_LOGGER_H__
#define __HEV_SOCKS5_LOGGER_H__
#ifdef __cplusplus
extern "C" {
#endif
typedef enum _HevSocks5LoggerLevel HevSocks5LoggerLevel;
enum _HevSocks5LoggerLevel
{
HEV_SOCKS5_LOGGER_DEBUG,
HEV_SOCKS5_LOGGER_INFO,
HEV_SOCKS5_LOGGER_WARN,
HEV_SOCKS5_LOGGER_ERROR,
HEV_SOCKS5_LOGGER_UNSET,
};
int hev_socks5_logger_init (HevSocks5LoggerLevel level, const char *path);
void hev_socks5_logger_fini (void);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_LOGGER_H__ */

View file

@ -0,0 +1,41 @@
/*
============================================================================
Name : hev-socks5-misc-priv.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2025 hev
Description : Socks5 Misc Private
============================================================================
*/
#ifndef __HEV_SOCKS5_MISC_PRIV_H__
#define __HEV_SOCKS5_MISC_PRIV_H__
#include <netinet/in.h>
#include <hev-task.h>
#include <hev-task-io.h>
#include "hev-socks5-misc.h"
#include "hev-socks5-proto.h"
#ifdef __cplusplus
extern "C" {
#endif
int hev_socks5_socket (int type);
const char *hev_socks5_addr_into_str (const HevSocks5Addr *addr, char *buf,
int len);
int hev_socks5_get_connect_timeout (void);
int hev_socks5_get_tcp_timeout (void);
int hev_socks5_get_udp_timeout (void);
int hev_socks5_get_task_stack_size (void);
int hev_socks5_get_udp_copy_buffer_nums (void);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_MISC_PRIV_H__ */

View file

@ -0,0 +1,385 @@
/*
============================================================================
Name : hev-socks5-misc.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2025 hev
Description : Socks5 Misc
============================================================================
*/
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <hev-task.h>
#include <hev-task-io.h>
#include <hev-task-io-socket.h>
#include <hev-task-dns.h>
#include <hev-memory-allocator.h>
#include "hev-socks5.h"
#include "hev-socks5-logger-priv.h"
#include "hev-socks5-misc.h"
#include "hev-socks5-misc-priv.h"
static int connect_timeout = 10000;
static int tcp_timeout = 300000;
static int udp_timeout = 60000;
static int task_stack_size = 8192;
static int udp_recv_buffer_size = 512 * 1024;
static int udp_copy_buffer_nums = 10;
int
hev_socks5_task_io_yielder (HevTaskYieldType type, void *data)
{
HevSocks5 *self = data;
if (type == HEV_TASK_YIELD) {
hev_task_yield (HEV_TASK_YIELD);
return 0;
}
if (self->timeout < 0) {
hev_task_yield (HEV_TASK_WAITIO);
} else {
int timeout = self->timeout;
timeout = hev_task_sleep (timeout);
if (timeout <= 0) {
LOG_I ("%p io timeout", self);
return -1;
}
}
return 0;
}
int
hev_socks5_socket (int type)
{
HevTask *task = hev_task_self ();
int fd, res, zero = 0;
fd = hev_task_io_socket_socket (AF_INET6, type, 0);
if (fd < 0)
return -1;
res = setsockopt (fd, IPPROTO_IPV6, IPV6_V6ONLY, &zero, sizeof (zero));
if (res < 0) {
close (fd);
return -1;
}
res = hev_task_add_fd (task, fd, POLLIN | POLLOUT);
if (res < 0)
hev_task_mod_fd (task, fd, POLLIN | POLLOUT);
if (type == SOCK_DGRAM) {
res = udp_recv_buffer_size;
setsockopt (fd, SOL_SOCKET, SO_RCVBUF, &res, sizeof (res));
}
return fd;
}
const char *
hev_socks5_addr_into_str (const HevSocks5Addr *addr, char *buf, int len)
{
const char *res = buf;
uint16_t port;
char sa[256];
switch (addr->atype) {
case HEV_SOCKS5_ADDR_TYPE_IPV4:
port = ntohs (addr->ipv4.port);
inet_ntop (AF_INET, addr->ipv4.addr, sa, sizeof (sa));
break;
case HEV_SOCKS5_ADDR_TYPE_IPV6:
port = ntohs (addr->ipv6.port);
inet_ntop (AF_INET6, addr->ipv6.addr, sa, sizeof (sa));
break;
case HEV_SOCKS5_ADDR_TYPE_NAME:
memcpy (sa, addr->domain.addr, addr->domain.len);
sa[addr->domain.len] = '\0';
memcpy (&port, addr->domain.addr + addr->domain.len, 2);
port = ntohs (port);
break;
default:
return NULL;
}
snprintf (buf, len, "[%s]:%u", sa, port);
return res;
}
int
hev_socks5_addr_len (const HevSocks5Addr *addr)
{
switch (addr->atype) {
case HEV_SOCKS5_ADDR_TYPE_IPV4:
return 7;
case HEV_SOCKS5_ADDR_TYPE_IPV6:
return 19;
case HEV_SOCKS5_ADDR_TYPE_NAME:
return 4 + addr->domain.len;
default:
return -1;
}
}
int
hev_socks5_addr_from_name (HevSocks5Addr *addr, const char *name, int _port)
{
uint16_t port = _port;
addr->atype = HEV_SOCKS5_ADDR_TYPE_NAME;
strncpy ((char *)addr->domain.addr, name, 256);
addr->domain.len = strlen ((char *)addr->domain.addr);
memcpy ((char *)addr->domain.addr + addr->domain.len, &port, 2);
return 4 + addr->domain.len;
}
int
hev_socks5_addr_from_ipv4 (HevSocks5Addr *addr, const void *ipv4, int port)
{
addr->atype = HEV_SOCKS5_ADDR_TYPE_IPV4;
memcpy (addr->ipv4.addr, ipv4, sizeof (addr->ipv4.addr));
addr->ipv4.port = port;
return 7;
}
int
hev_socks5_addr_from_ipv6 (HevSocks5Addr *addr, const void *ipv6, int port)
{
addr->atype = HEV_SOCKS5_ADDR_TYPE_IPV6;
memcpy (addr->ipv6.addr, ipv6, sizeof (addr->ipv6.addr));
addr->ipv6.port = port;
return 19;
}
int
hev_socks5_addr_from_sockaddr6 (HevSocks5Addr *addr, struct sockaddr_in6 *saddr)
{
if (IN6_IS_ADDR_V4MAPPED (&saddr->sin6_addr)) {
addr->atype = HEV_SOCKS5_ADDR_TYPE_IPV4;
addr->ipv4.port = saddr->sin6_port;
memcpy (addr->ipv4.addr, &saddr->sin6_addr.s6_addr[12], 4);
return 7;
}
addr->atype = HEV_SOCKS5_ADDR_TYPE_IPV6;
addr->ipv6.port = saddr->sin6_port;
memcpy (addr->ipv6.addr, &saddr->sin6_addr, 16);
return 19;
}
static void
hev_socks5_ipv4_into_sockaddr6 (const HevSocks5Addr *addr,
struct sockaddr_in6 *saddr)
{
saddr->sin6_family = AF_INET6;
saddr->sin6_port = addr->ipv4.port;
memset (&saddr->sin6_addr, 0, 10);
saddr->sin6_addr.s6_addr[10] = 0xff;
saddr->sin6_addr.s6_addr[11] = 0xff;
memcpy (&saddr->sin6_addr.s6_addr[12], addr->ipv4.addr, 4);
}
static void
hev_socks5_ipv6_into_sockaddr6 (const HevSocks5Addr *addr,
struct sockaddr_in6 *saddr)
{
saddr->sin6_family = AF_INET6;
saddr->sin6_port = addr->ipv6.port;
memcpy (&saddr->sin6_addr, addr->ipv6.addr, 16);
}
int
hev_socks5_addr_into_sockaddr6 (const HevSocks5Addr *addr,
struct sockaddr_in6 *saddr, int *family)
{
switch (addr->atype) {
case HEV_SOCKS5_ADDR_TYPE_IPV4: {
hev_socks5_ipv4_into_sockaddr6 (addr, saddr);
*family = HEV_SOCKS5_ADDR_FAMILY_IPV4;
break;
}
case HEV_SOCKS5_ADDR_TYPE_IPV6: {
hev_socks5_ipv6_into_sockaddr6 (addr, saddr);
*family = HEV_SOCKS5_ADDR_FAMILY_IPV6;
break;
}
case HEV_SOCKS5_ADDR_TYPE_NAME: {
char name[256];
uint16_t port;
memcpy (name, addr->domain.addr, addr->domain.len);
name[addr->domain.len] = '\0';
memcpy (&port, addr->domain.addr + addr->domain.len, 2);
return hev_socks5_name_into_sockaddr6 (name, ntohs (port), saddr,
family);
}
default:
return -1;
}
return 0;
}
static int
hev_socks5_name_resolve_ipv4 (const char *name, struct sockaddr_in6 *saddr)
{
int res;
memset (&saddr->sin6_addr, 0, 10);
res = inet_pton (AF_INET, name, &saddr->sin6_addr.s6_addr[12]);
if (res == 0)
return -1;
saddr->sin6_addr.s6_addr[10] = 0xff;
saddr->sin6_addr.s6_addr[11] = 0xff;
return 0;
}
static int
hev_socks5_name_resolve_ipv6 (const char *name, struct sockaddr_in6 *saddr)
{
int res;
res = inet_pton (AF_INET6, name, &saddr->sin6_addr);
if (res == 0)
return -1;
return 0;
}
static int
hev_socks5_name_resolve_name (const char *name, struct sockaddr_in6 *saddr,
int *family)
{
struct addrinfo *result = NULL;
struct addrinfo hints = { 0 };
int res = 0;
hints.ai_family = *family;
hints.ai_socktype = SOCK_STREAM;
hev_task_dns_getaddrinfo (name, NULL, &hints, &result);
if (!result)
return -1;
switch (result->ai_family) {
case AF_INET: {
struct sockaddr_in *sa = (struct sockaddr_in *)result->ai_addr;
memset (&saddr->sin6_addr, 0, 10);
saddr->sin6_addr.s6_addr[10] = 0xff;
saddr->sin6_addr.s6_addr[11] = 0xff;
memcpy (&saddr->sin6_addr.s6_addr[12], &sa->sin_addr, 4);
*family = HEV_SOCKS5_ADDR_FAMILY_IPV4;
break;
}
case AF_INET6: {
struct sockaddr_in6 *sa = (struct sockaddr_in6 *)result->ai_addr;
memcpy (&saddr->sin6_addr, &sa->sin6_addr, 16);
*family = HEV_SOCKS5_ADDR_FAMILY_IPV6;
break;
}
default:
res = -1;
}
freeaddrinfo (result);
return res;
}
int
hev_socks5_name_into_sockaddr6 (const char *name, int port,
struct sockaddr_in6 *saddr, int *family)
{
int res;
saddr->sin6_family = AF_INET6;
saddr->sin6_port = htons (port);
res = hev_socks5_name_resolve_ipv4 (name, saddr);
if (res == 0) {
*family = HEV_SOCKS5_ADDR_FAMILY_IPV4;
return 0;
}
res = hev_socks5_name_resolve_ipv6 (name, saddr);
if (res == 0) {
*family = HEV_SOCKS5_ADDR_FAMILY_IPV6;
return 0;
}
res = hev_socks5_name_resolve_name (name, saddr, family);
return res;
}
void
hev_socks5_set_connect_timeout (int timeout)
{
connect_timeout = timeout;
}
int
hev_socks5_get_connect_timeout (void)
{
return connect_timeout;
}
void
hev_socks5_set_tcp_timeout (int timeout)
{
tcp_timeout = timeout;
}
int
hev_socks5_get_tcp_timeout (void)
{
return tcp_timeout;
}
void
hev_socks5_set_udp_timeout (int timeout)
{
udp_timeout = timeout;
}
int
hev_socks5_get_udp_timeout (void)
{
return udp_timeout;
}
void
hev_socks5_set_task_stack_size (int stack_size)
{
task_stack_size = stack_size;
}
int
hev_socks5_get_task_stack_size (void)
{
return task_stack_size;
}
void
hev_socks5_set_udp_recv_buffer_size (int buffer_size)
{
udp_recv_buffer_size = buffer_size;
}
void
hev_socks5_set_udp_copy_buffer_nums (int nums)
{
udp_copy_buffer_nums = nums;
}
int
hev_socks5_get_udp_copy_buffer_nums (void)
{
return udp_copy_buffer_nums;
}

View file

@ -0,0 +1,48 @@
/*
============================================================================
Name : hev-socks5-misc.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2025 hev
Description : Socks5 Misc
============================================================================
*/
#ifndef __HEV_SOCKS5_MISC_H__
#define __HEV_SOCKS5_MISC_H__
#include <netinet/in.h>
#include <hev-task.h>
#include "hev-socks5-proto.h"
#ifdef __cplusplus
extern "C" {
#endif
int hev_socks5_task_io_yielder (HevTaskYieldType type, void *data);
void hev_socks5_set_connect_timeout (int timeout);
void hev_socks5_set_tcp_timeout (int timeout);
void hev_socks5_set_udp_timeout (int timeout);
void hev_socks5_set_task_stack_size (int stack_size);
void hev_socks5_set_udp_recv_buffer_size (int buffer_size);
void hev_socks5_set_udp_copy_buffer_nums (int nums);
int hev_socks5_addr_len (const HevSocks5Addr *addr);
int hev_socks5_addr_from_name (HevSocks5Addr *addr, const char *name, int port);
int hev_socks5_addr_from_ipv4 (HevSocks5Addr *addr, const void *ipv4, int port);
int hev_socks5_addr_from_ipv6 (HevSocks5Addr *addr, const void *ipv6, int port);
int hev_socks5_addr_from_sockaddr6 (HevSocks5Addr *addr,
struct sockaddr_in6 *saddr);
int hev_socks5_addr_into_sockaddr6 (const HevSocks5Addr *addr,
struct sockaddr_in6 *saddr, int *family);
int hev_socks5_name_into_sockaddr6 (const char *host, int port,
struct sockaddr_in6 *saddr, int *family);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_MISC_H__ */

View file

@ -0,0 +1,135 @@
/*
============================================================================
Name : hev-socks5-proto.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2023 hev
Description : Socks5 Proto
============================================================================
*/
#ifndef __HEV_SOCKS5_PROTO_H__
#define __HEV_SOCKS5_PROTO_H__
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef enum _HevSocks5Version HevSocks5Version;
typedef enum _HevSocks5AuthMethod HevSocks5AuthMethod;
typedef enum _HevSocks5AuthVersion HevSocks5AuthVersion;
typedef enum _HevSocks5ReqCmd HevSocks5ReqCmd;
typedef enum _HevSocks5ResRep HevSocks5ResRep;
typedef enum _HevSocks5AddrType HevSocks5AddrType;
typedef struct _HevSocks5Auth HevSocks5Auth;
typedef struct _HevSocks5Addr HevSocks5Addr;
typedef struct _HevSocks5ReqRes HevSocks5ReqRes;
typedef struct _HevSocks5UDPHdr HevSocks5UDPHdr;
enum _HevSocks5Version
{
HEV_SOCKS5_VERSION_5 = 5,
};
enum _HevSocks5AuthMethod
{
HEV_SOCKS5_AUTH_METHOD_NONE = 0,
HEV_SOCKS5_AUTH_METHOD_USER = 2,
HEV_SOCKS5_AUTH_METHOD_DENY = 255,
};
enum _HevSocks5AuthVersion
{
HEV_SOCKS5_AUTH_VERSION_1 = 1,
};
enum _HevSocks5ReqCmd
{
HEV_SOCKS5_REQ_CMD_CONNECT = 1,
HEV_SOCKS5_REQ_CMD_UDP_ASC = 3,
HEV_SOCKS5_REQ_CMD_FWD_UDP = 5,
};
enum _HevSocks5ResRep
{
HEV_SOCKS5_RES_REP_SUCC = 0,
HEV_SOCKS5_RES_REP_FAIL = 1,
HEV_SOCKS5_RES_REP_HOST = 4,
HEV_SOCKS5_RES_REP_IMPL = 7,
HEV_SOCKS5_RES_REP_ADDR = 8,
};
enum _HevSocks5AddrType
{
HEV_SOCKS5_ADDR_TYPE_IPV4 = 1,
HEV_SOCKS5_ADDR_TYPE_IPV6 = 4,
HEV_SOCKS5_ADDR_TYPE_NAME = 3,
};
struct _HevSocks5Auth
{
uint8_t ver;
union
{
uint8_t method;
uint8_t method_len;
};
uint8_t methods[256];
} __attribute__ ((packed));
struct _HevSocks5Addr
{
uint8_t atype;
union
{
struct
{
uint8_t addr[4];
uint16_t port;
} ipv4 __attribute__ ((packed));
struct
{
uint8_t addr[16];
uint16_t port;
} ipv6 __attribute__ ((packed));
struct
{
uint8_t len;
uint8_t addr[256 + 2];
} domain;
};
} __attribute__ ((packed));
struct _HevSocks5ReqRes
{
uint8_t ver;
union
{
uint8_t cmd;
uint8_t rep;
};
uint8_t rsv;
HevSocks5Addr addr;
} __attribute__ ((packed));
struct _HevSocks5UDPHdr
{
union
{
uint8_t rsv[3];
struct
{
uint16_t datlen;
uint8_t hdrlen;
} __attribute__ ((packed));
};
HevSocks5Addr addr;
} __attribute__ ((packed));
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_PROTO_H__ */

View file

@ -0,0 +1,684 @@
/*
============================================================================
Name : hev-socks5-server.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2025 hev
Description : Socks5 Server
============================================================================
*/
#include <string.h>
#include <unistd.h>
#include <hev-task.h>
#include <hev-task-io.h>
#include <hev-task-io-socket.h>
#include <hev-memory-allocator.h>
#include "hev-socks5-proto.h"
#include "hev-socks5-misc-priv.h"
#include "hev-socks5-logger-priv.h"
#include "hev-socks5-server.h"
#define task_io_yielder hev_socks5_task_io_yielder
HevSocks5Server *
hev_socks5_server_new (int fd)
{
HevSocks5Server *self;
int res;
self = hev_malloc0 (sizeof (HevSocks5Server));
if (!self)
return NULL;
res = hev_socks5_server_construct (self, fd);
if (res < 0) {
hev_free (self);
return NULL;
}
LOG_D ("%p socks5 server new", self);
return self;
}
void
hev_socks5_server_set_auth (HevSocks5Server *self, HevSocks5Authenticator *auth)
{
if (self->auth)
hev_object_unref (HEV_OBJECT (self->auth));
hev_object_ref (HEV_OBJECT (auth));
self->auth = auth;
}
static int
hev_socks5_server_read_auth_method (HevSocks5Server *self)
{
HevSocks5Auth auth;
HevSocks5AuthMethod method;
int res;
int i;
LOG_D ("%p socks5 server read auth method", self);
res = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, &auth, 2, MSG_WAITALL,
task_io_yielder, self);
if (res != 2) {
LOG_I ("%p socks5 server read auth method", self);
return -1;
}
if (auth.ver != HEV_SOCKS5_VERSION_5) {
LOG_I ("%p socks5 server auth.ver %u", self, auth.ver);
return -1;
}
res = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, &auth.methods,
auth.method_len, MSG_WAITALL,
task_io_yielder, self);
if (res != auth.method_len) {
LOG_I ("%p socks5 server read auth methods", self);
return -1;
}
if (self->auth)
method = HEV_SOCKS5_AUTH_METHOD_USER;
else
method = HEV_SOCKS5_AUTH_METHOD_NONE;
res = -1;
for (i = 0; i < auth.method_len; i++) {
if (auth.methods[i] == method) {
res = method;
break;
}
}
return res;
}
static int
hev_socks5_server_write_auth_method (HevSocks5Server *self, int auth_method)
{
HevSocks5Auth auth;
int res;
LOG_D ("%p socks5 server write auth method", self);
auth.ver = HEV_SOCKS5_VERSION_5;
auth.method = auth_method;
res = hev_task_io_socket_send (HEV_SOCKS5 (self)->fd, &auth, 2, MSG_WAITALL,
task_io_yielder, self);
if (res <= 0) {
LOG_I ("%p socks5 server write auth method", self);
return -1;
}
return 0;
}
static int
hev_socks5_server_read_auth_user (HevSocks5Server *self)
{
HevSocks5User *user;
uint8_t nlen, plen;
uint8_t name[257];
uint8_t pass[257];
uint8_t head[2];
int res;
LOG_D ("%p socks5 server read auth user", self);
res = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, head, 2, MSG_WAITALL,
task_io_yielder, self);
if (res != 2) {
LOG_I ("%p socks5 server read auth user.ver", self);
return -1;
}
if (head[0] != 1) {
LOG_I ("%p socks5 server auth user.ver %u", self, head[0]);
return -1;
}
nlen = head[1];
if (nlen == 0) {
LOG_I ("%p socks5 server auth user.nlen %u", self, nlen);
return -1;
}
res = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, name, nlen + 1,
MSG_WAITALL, task_io_yielder, self);
if (res != (nlen + 1)) {
LOG_I ("%p socks5 server read auth user.name", self);
return -1;
}
plen = name[nlen];
if (plen == 0) {
LOG_I ("%p socks5 server auth user.plen %u", self, plen);
return -1;
}
res = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, pass, plen,
MSG_WAITALL, task_io_yielder, self);
if (res != plen) {
LOG_I ("%p socks5 server read auth user.pass", self);
return -1;
}
user = hev_socks5_authenticator_get (self->auth, (char *)name, nlen);
if (!user) {
LOG_I ("%p socks5 server auth user: %s pass: %s", self, name, pass);
return -1;
}
res = hev_socks5_user_check (user, (char *)pass, plen);
if (res < 0) {
LOG_I ("%p socks5 server auth user: %s pass: %s", self, name, pass);
return -1;
}
hev_object_ref (HEV_OBJECT (user));
hev_object_unref (HEV_OBJECT (self->auth));
self->user = user;
return 0;
}
static int
hev_socks5_server_write_auth_user (HevSocks5Server *self, int auth_res)
{
uint8_t buf[2];
int res;
LOG_D ("%p socks5 server write auth user", self);
buf[0] = 1;
buf[1] = auth_res;
res = hev_task_io_socket_send (HEV_SOCKS5 (self)->fd, buf, 2, MSG_WAITALL,
task_io_yielder, self);
if (res <= 0) {
LOG_I ("%p socks5 server write auth user", self);
return -1;
}
return 0;
}
static int
hev_socks5_server_auth (HevSocks5Server *self)
{
int method;
int res;
method = hev_socks5_server_read_auth_method (self);
res = hev_socks5_server_write_auth_method (self, method);
if (res < 0)
return -1;
switch (method) {
case HEV_SOCKS5_AUTH_METHOD_NONE:
break;
case HEV_SOCKS5_AUTH_METHOD_USER:
res = hev_socks5_server_read_auth_user (self);
res |= hev_socks5_server_write_auth_user (self, res);
if (res < 0)
return -1;
break;
default:
return -1;
}
return 0;
}
static int
hev_socks5_server_read_request (HevSocks5Server *self, int *cmd, int *rep,
struct sockaddr_in6 *addr)
{
HevSocks5ReqRes req;
int addr_family;
int addrlen;
int res;
LOG_D ("%p socks5 server read request", self);
res = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, &req, 5, MSG_WAITALL,
task_io_yielder, self);
if (res != 5) {
LOG_I ("%p socks5 server read request", self);
return -1;
}
if (req.ver != HEV_SOCKS5_VERSION_5) {
*rep = HEV_SOCKS5_RES_REP_FAIL;
LOG_I ("%p socks5 server req.ver %u", self, req.ver);
return 0;
}
switch (req.addr.atype) {
case HEV_SOCKS5_ADDR_TYPE_IPV4:
addrlen = 5;
break;
case HEV_SOCKS5_ADDR_TYPE_IPV6:
addrlen = 17;
break;
case HEV_SOCKS5_ADDR_TYPE_NAME:
addrlen = 2 + req.addr.domain.len;
break;
default:
*rep = HEV_SOCKS5_RES_REP_ADDR;
LOG_I ("%p socks5 server req.atype %u", self, req.addr.atype);
return 0;
}
res = hev_task_io_socket_recv (HEV_SOCKS5 (self)->fd, req.addr.domain.addr,
addrlen, MSG_WAITALL, task_io_yielder, self);
if (res != addrlen) {
*rep = HEV_SOCKS5_RES_REP_ADDR;
LOG_I ("%p socks5 server read addr", self);
return 0;
}
addr_family = hev_socks5_get_addr_family (HEV_SOCKS5 (self));
res = hev_socks5_addr_into_sockaddr6 (&req.addr, addr, &addr_family);
if (res < 0) {
*rep = HEV_SOCKS5_RES_REP_ADDR;
LOG_I ("%p socks5 server resolve addr", self);
return 0;
}
hev_socks5_set_addr_family (HEV_SOCKS5 (self), addr_family);
if (LOG_ON ()) {
const char *type;
const char *str;
char buf[272];
switch (req.cmd) {
case HEV_SOCKS5_REQ_CMD_CONNECT:
type = "tcp";
break;
case HEV_SOCKS5_REQ_CMD_UDP_ASC:
case HEV_SOCKS5_REQ_CMD_FWD_UDP:
type = "udp";
break;
default:
type = "unknown";
break;
}
str = hev_socks5_addr_into_str (&req.addr, buf, sizeof (buf));
LOG_I ("%p socks5 server %s %s", self, type, str);
}
*cmd = req.cmd;
return 0;
}
static int
hev_socks5_server_write_response (HevSocks5Server *self, int rep,
struct sockaddr_in6 *addr)
{
HevSocks5ReqRes res;
int ret;
LOG_D ("%p socks5 server write response", self);
res.ver = HEV_SOCKS5_VERSION_5;
res.rep = rep;
res.rsv = 0;
ret = hev_socks5_addr_from_sockaddr6 (&res.addr, addr);
ret = hev_task_io_socket_send (HEV_SOCKS5 (self)->fd, &res, 3 + ret,
MSG_WAITALL, task_io_yielder, self);
if (ret <= 0) {
LOG_I ("%p socks5 server write response", self);
return -1;
}
return 0;
}
static int
hev_socks5_server_connect (HevSocks5Server *self, struct sockaddr_in6 *addr)
{
HevSocks5Class *klass;
int timeout;
int res;
int fd;
LOG_D ("%p socks5 server connect", self);
fd = hev_socks5_socket (SOCK_STREAM);
if (fd < 0) {
LOG_E ("%p socks5 server socket stream", self);
return -1;
}
klass = HEV_OBJECT_GET_CLASS (self);
res = klass->binder (HEV_SOCKS5 (self), fd, (struct sockaddr *)addr);
if (res < 0) {
LOG_W ("%p socks5 server bind", self);
hev_task_del_fd (hev_task_self (), fd);
close (fd);
return -1;
}
timeout = hev_socks5_get_connect_timeout ();
hev_socks5_set_timeout (HEV_SOCKS5 (self), timeout);
res = hev_task_io_socket_connect (fd, (struct sockaddr *)addr,
sizeof (*addr), task_io_yielder, self);
if (res < 0) {
LOG_I ("%p socks5 server connect", self);
hev_task_del_fd (hev_task_self (), fd);
close (fd);
return -1;
}
timeout = hev_socks5_get_tcp_timeout ();
hev_socks5_set_timeout (HEV_SOCKS5 (self), timeout);
self->fds[0] = fd;
return 0;
}
static int
hev_socks5_server_bind (HevSocks5Server *self, struct sockaddr_in6 *addr)
{
HevSocks5ServerClass *sskptr = HEV_OBJECT_GET_CLASS (self);
int one = 1;
int res;
int fd;
LOG_D ("%p socks5 server bind", self);
fd = hev_socks5_socket (SOCK_DGRAM);
if (fd < 0) {
LOG_E ("%p socks5 server socket dgram", self);
return -1;
}
self->fds[0] = fd;
if (!addr)
return 0;
fd = hev_socks5_socket (SOCK_DGRAM);
if (fd < 0) {
LOG_E ("%p socks5 server socket dgram", self);
return -1;
}
res = setsockopt (fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof (one));
if (res < 0) {
LOG_W ("%p socks5 server socket reuse", self);
hev_task_del_fd (hev_task_self (), fd);
close (fd);
return -1;
}
res = sskptr->binder (self, fd, addr);
if (res < 0) {
LOG_W ("%p socks5 server bind", self);
hev_task_del_fd (hev_task_self (), fd);
close (fd);
return -1;
}
self->fds[1] = fd;
return 0;
}
static int
hev_socks5_server_udp_bind (HevSocks5Server *self, int sock,
struct sockaddr_in6 *src)
{
socklen_t alen;
int res;
LOG_D ("%p socks5 server udp bind", self);
alen = sizeof (struct sockaddr_in6);
res = getsockname (HEV_SOCKS5 (self)->fd, (struct sockaddr *)src, &alen);
if (res < 0) {
LOG_W ("%p socks5 server tcp socket name", self);
return -1;
}
src->sin6_port = 0;
res = bind (sock, (struct sockaddr *)src, alen);
if (res < 0) {
LOG_W ("%p socks5 server socket bind", self);
return -1;
}
res = getsockname (sock, (struct sockaddr *)src, &alen);
if (res < 0) {
LOG_W ("%p socks5 server udp socket name", self);
return -1;
}
return 0;
}
static int
hev_socks5_server_handshake (HevSocks5Server *self)
{
struct sockaddr_in6 addr;
int timeout;
int cmd;
int rep;
int res;
LOG_D ("%p socks5 server handshake", self);
timeout = hev_socks5_get_tcp_timeout ();
hev_socks5_set_timeout (HEV_SOCKS5 (self), timeout);
res = hev_socks5_server_auth (self);
if (res < 0)
return -1;
rep = HEV_SOCKS5_RES_REP_SUCC;
res = hev_socks5_server_read_request (self, &cmd, &rep, &addr);
if (res < 0)
return -1;
if (rep == HEV_SOCKS5_RES_REP_SUCC) {
switch (cmd) {
case HEV_SOCKS5_REQ_CMD_CONNECT:
res = hev_socks5_server_connect (self, &addr);
if (res < 0)
rep = HEV_SOCKS5_RES_REP_HOST;
HEV_SOCKS5 (self)->type = HEV_SOCKS5_TYPE_TCP;
break;
case HEV_SOCKS5_REQ_CMD_UDP_ASC:
res = hev_socks5_server_bind (self, &addr);
if (res < 0)
rep = HEV_SOCKS5_RES_REP_FAIL;
HEV_SOCKS5 (self)->type = HEV_SOCKS5_TYPE_UDP_IN_UDP;
break;
case HEV_SOCKS5_REQ_CMD_FWD_UDP:
res = hev_socks5_server_bind (self, NULL);
if (res < 0)
rep = HEV_SOCKS5_RES_REP_FAIL;
HEV_SOCKS5 (self)->type = HEV_SOCKS5_TYPE_UDP_IN_TCP;
break;
default:
rep = HEV_SOCKS5_RES_REP_IMPL;
break;
}
}
res = hev_socks5_server_write_response (self, rep, &addr);
if ((res < 0) || (rep != HEV_SOCKS5_RES_REP_SUCC))
return -1;
return 0;
}
static int
hev_socks5_server_service (HevSocks5Server *self)
{
int timeout;
LOG_D ("%p socks5 server service", self);
switch (HEV_SOCKS5 (self)->type) {
case HEV_SOCKS5_TYPE_TCP:
hev_socks5_tcp_splice (HEV_SOCKS5_TCP (self), self->fds[0]);
break;
case HEV_SOCKS5_TYPE_UDP_IN_UDP:
case HEV_SOCKS5_TYPE_UDP_IN_TCP:
timeout = hev_socks5_get_udp_timeout ();
hev_socks5_set_timeout (HEV_SOCKS5 (self), timeout);
hev_socks5_udp_splice (HEV_SOCKS5_UDP (self), self->fds[0]);
break;
default:
return -1;
}
return 0;
}
int
hev_socks5_server_run (HevSocks5Server *self)
{
HevTask *task = hev_task_self ();
int res;
int fd;
LOG_D ("%p socks5 server run", self);
fd = HEV_SOCKS5 (self)->fd;
res = hev_task_add_fd (task, fd, POLLIN | POLLOUT);
if (res < 0)
hev_task_mod_fd (task, fd, POLLIN | POLLOUT);
res = hev_socks5_server_handshake (self);
if (res < 0)
return -1;
res = hev_socks5_server_service (self);
if (res < 0)
return -1;
return 0;
}
static int
hev_socks5_server_get_fd (HevSocks5UDP *self)
{
int fd;
switch (HEV_SOCKS5 (self)->type) {
case HEV_SOCKS5_TYPE_UDP_IN_TCP:
fd = HEV_SOCKS5 (self)->fd;
break;
case HEV_SOCKS5_TYPE_UDP_IN_UDP:
fd = HEV_SOCKS5_SERVER (self)->fds[1];
break;
default:
return -1;
}
return fd;
}
int
hev_socks5_server_construct (HevSocks5Server *self, int fd)
{
int res;
res = hev_socks5_construct (&self->base, HEV_SOCKS5_TYPE_NONE);
if (res < 0)
return res;
LOG_D ("%p socks5 server construct", self);
HEV_OBJECT (self)->klass = HEV_SOCKS5_SERVER_TYPE;
HEV_SOCKS5 (self)->fd = fd;
self->fds[0] = -1;
self->fds[1] = -1;
return 0;
}
static void
hev_socks5_server_destruct (HevObject *base)
{
HevSocks5Server *self = HEV_SOCKS5_SERVER (base);
HevTask *task = hev_task_self ();
LOG_D ("%p socks5 server destruct", self);
if (self->fds[0] >= 0) {
hev_task_del_fd (task, self->fds[0]);
close (self->fds[0]);
}
if (self->fds[1] >= 0) {
hev_task_del_fd (task, self->fds[1]);
close (self->fds[1]);
}
if (self->obj)
hev_object_unref (self->obj);
HEV_SOCKS5_TYPE->destruct (base);
}
static void *
hev_socks5_server_iface (HevObject *base, void *type)
{
HevSocks5ServerClass *klass = HEV_OBJECT_GET_CLASS (base);
if (type == HEV_SOCKS5_TCP_TYPE)
return &klass->tcp;
if (type == HEV_SOCKS5_UDP_TYPE)
return &klass->udp;
return NULL;
}
HevObjectClass *
hev_socks5_server_class (void)
{
static HevSocks5ServerClass klass;
HevSocks5ServerClass *kptr = &klass;
HevObjectClass *okptr = HEV_OBJECT_CLASS (kptr);
if (!okptr->name) {
HevSocks5TCPIface *tiptr;
HevSocks5UDPIface *uiptr;
memcpy (kptr, HEV_SOCKS5_TYPE, sizeof (HevSocks5Class));
okptr->name = "HevSocks5Server";
okptr->destruct = hev_socks5_server_destruct;
okptr->iface = hev_socks5_server_iface;
kptr->binder = hev_socks5_server_udp_bind;
tiptr = &kptr->tcp;
memcpy (tiptr, HEV_SOCKS5_TCP_TYPE, sizeof (HevSocks5TCPIface));
uiptr = &kptr->udp;
memcpy (uiptr, HEV_SOCKS5_UDP_TYPE, sizeof (HevSocks5UDPIface));
uiptr->get_fd = hev_socks5_server_get_fd;
}
return okptr;
}

View file

@ -0,0 +1,71 @@
/*
============================================================================
Name : hev-socks5-server.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2023 hev
Description : Socks5 Server
============================================================================
*/
#ifndef __HEV_SOCKS5_SERVER_H__
#define __HEV_SOCKS5_SERVER_H__
#include <hev-object.h>
#include "hev-socks5.h"
#include "hev-socks5-tcp.h"
#include "hev-socks5-udp.h"
#include "hev-socks5-user.h"
#include "hev-socks5-authenticator.h"
#ifdef __cplusplus
extern "C" {
#endif
#define HEV_SOCKS5_SERVER(p) ((HevSocks5Server *)p)
#define HEV_SOCKS5_SERVER_CLASS(p) ((HevSocks5ServerClass *)p)
#define HEV_SOCKS5_SERVER_TYPE (hev_socks5_server_class ())
typedef struct _HevSocks5Server HevSocks5Server;
typedef struct _HevSocks5ServerClass HevSocks5ServerClass;
struct _HevSocks5Server
{
HevSocks5 base;
int fds[2];
union
{
HevObject *obj;
HevSocks5User *user;
HevSocks5Authenticator *auth;
};
};
struct _HevSocks5ServerClass
{
HevSocks5Class base;
int (*binder) (HevSocks5Server *self, int sock, struct sockaddr_in6 *src);
HevSocks5TCPIface tcp;
HevSocks5UDPIface udp;
};
HevObjectClass *hev_socks5_server_class (void);
int hev_socks5_server_construct (HevSocks5Server *self, int fd);
HevSocks5Server *hev_socks5_server_new (int fd);
void hev_socks5_server_set_auth (HevSocks5Server *self,
HevSocks5Authenticator *auth);
int hev_socks5_server_run (HevSocks5Server *self);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_SERVER_H__ */

View file

@ -0,0 +1,57 @@
/*
============================================================================
Name : hev-socks5-tcp.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 hev
Description : Socks5 TCP
============================================================================
*/
#include "hev-socks5.h"
#include "hev-socks5-misc-priv.h"
#include "hev-socks5-logger-priv.h"
#include "hev-socks5-tcp.h"
#define task_io_yielder hev_socks5_task_io_yielder
static int
hev_socks5_tcp_splicer (HevSocks5TCP *self, int fd)
{
HevTask *task = hev_task_self ();
int cfd;
int res;
LOG_D ("%p socks5 tcp splicer", self);
cfd = HEV_SOCKS5 (self)->fd;
if (cfd < 0)
return -1;
res = hev_task_add_fd (task, fd, POLLIN | POLLOUT);
if (res < 0)
hev_task_mod_fd (task, fd, POLLIN | POLLOUT);
hev_task_io_splice (cfd, cfd, fd, fd, 8192, task_io_yielder, self);
return 0;
}
int
hev_socks5_tcp_splice (HevSocks5TCP *self, int fd)
{
HevSocks5TCPIface *iface;
iface = HEV_OBJECT_GET_IFACE (self, HEV_SOCKS5_TCP_TYPE);
return iface->splicer (self, fd);
}
void *
hev_socks5_tcp_iface (void)
{
static HevSocks5TCPIface type = {
.splicer = hev_socks5_tcp_splicer,
};
return &type;
}

View file

@ -0,0 +1,37 @@
/*
============================================================================
Name : hev-socks5-tcp.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 hev
Description : Socks5 TCP
============================================================================
*/
#ifndef __HEV_SOCKS5_TCP_H__
#define __HEV_SOCKS5_TCP_H__
#ifdef __cplusplus
extern "C" {
#endif
#define HEV_SOCKS5_TCP(p) ((HevSocks5TCP *)p)
#define HEV_SOCKS5_TCP_IFACE(p) ((HevSocks5TCPIface *)p)
#define HEV_SOCKS5_TCP_TYPE (hev_socks5_tcp_iface ())
typedef void HevSocks5TCP;
typedef struct _HevSocks5TCPIface HevSocks5TCPIface;
struct _HevSocks5TCPIface
{
int (*splicer) (HevSocks5TCP *self, int fd);
};
void *hev_socks5_tcp_iface (void);
int hev_socks5_tcp_splice (HevSocks5TCP *self, int fd);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_TCP_H__ */

View file

@ -0,0 +1,508 @@
/*
============================================================================
Name : hev-socks5-udp.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2025 hev
Description : Socks5 UDP
============================================================================
*/
#define _GNU_SOURCE
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <hev-task.h>
#include <hev-task-io.h>
#include <hev-task-io-socket.h>
#include <hev-memory-allocator.h>
#include "hev-socks5.h"
#include "hev-socks5-misc-priv.h"
#include "hev-socks5-logger-priv.h"
#include "hev-socks5-udp.h"
#define UDP_BUF_SIZE 1500
static int
task_io_yielder (HevTaskYieldType type, void *data)
{
HevSocks5 *self = data;
if (self->type == HEV_SOCKS5_TYPE_UDP_IN_UDP) {
ssize_t res;
char buf;
res = recv (self->fd, &buf, sizeof (buf), 0);
if ((res == 0) || ((res < 0) && (errno != EAGAIN))) {
hev_socks5_set_timeout (self, 0);
return -1;
}
}
return hev_socks5_task_io_yielder (type, data);
}
int
hev_socks5_udp_get_fd (HevSocks5UDP *self)
{
HevSocks5UDPIface *iface;
iface = HEV_OBJECT_GET_IFACE (self, HEV_SOCKS5_UDP_TYPE);
return iface->get_fd (self);
}
static int
hev_socks5_udp_sendmmsg_tcp (HevSocks5UDP *self, HevSocks5UDPMsg *msgv,
unsigned int num)
{
struct iovec iov[num * 3];
HevSocks5UDPHdr udp[num];
struct msghdr mh;
int i, res;
mh.msg_name = NULL;
mh.msg_namelen = 0;
mh.msg_control = NULL;
mh.msg_controllen = 0;
mh.msg_iov = iov;
mh.msg_iovlen = num * 3;
for (i = 0; i < num; i++) {
int addrlen;
addrlen = hev_socks5_addr_len (msgv[i].addr);
if (addrlen <= 0) {
LOG_D ("%p socks5 udp addr", self);
return -1;
}
udp[i].datlen = htons (msgv[i].len);
udp[i].hdrlen = 3 + addrlen;
iov[i * 3].iov_base = &udp[i];
iov[i * 3].iov_len = 3;
iov[i * 3 + 1].iov_base = msgv[i].addr;
iov[i * 3 + 1].iov_len = addrlen;
iov[i * 3 + 2].iov_base = msgv[i].buf;
iov[i * 3 + 2].iov_len = msgv[i].len;
}
res = hev_task_io_socket_sendmsg (hev_socks5_udp_get_fd (self), &mh,
MSG_WAITALL, task_io_yielder, self);
if (res <= 0) {
LOG_D ("%p socks5 udp write tcp", self);
return -1;
}
return num;
}
static int
hev_socks5_udp_sendmmsg_udp (HevSocks5UDP *self, HevSocks5UDPMsg *msgv,
unsigned int num)
{
struct iovec iov[num * 3];
struct mmsghdr mvec[num];
HevSocks5UDPHdr udp[num];
int i, res;
for (i = 0; i < num; i++) {
int addrlen;
addrlen = hev_socks5_addr_len (msgv[i].addr);
if (addrlen <= 0) {
LOG_D ("%p socks5 udp addr", self);
return -1;
}
udp[i].datlen = 0;
udp[i].hdrlen = 0;
iov[i * 3].iov_base = &udp[i];
iov[i * 3].iov_len = 3;
iov[i * 3 + 1].iov_base = msgv[i].addr;
iov[i * 3 + 1].iov_len = addrlen;
iov[i * 3 + 2].iov_base = msgv[i].buf;
iov[i * 3 + 2].iov_len = msgv[i].len;
mvec[i].msg_hdr.msg_name = NULL;
mvec[i].msg_hdr.msg_namelen = 0;
mvec[i].msg_hdr.msg_control = NULL;
mvec[i].msg_hdr.msg_controllen = 0;
mvec[i].msg_hdr.msg_iov = &iov[i * 3];
mvec[i].msg_hdr.msg_iovlen = 3;
}
res = hev_task_io_socket_sendmmsg (hev_socks5_udp_get_fd (self), mvec, num,
MSG_WAITALL, task_io_yielder, self);
if (res <= 0)
LOG_D ("%p socks5 udp write udp", self);
return res;
}
int
hev_socks5_udp_sendmmsg (HevSocks5UDP *self, HevSocks5UDPMsg *msgv,
unsigned int num)
{
switch (HEV_SOCKS5 (self)->type) {
case HEV_SOCKS5_TYPE_UDP_IN_TCP:
return hev_socks5_udp_sendmmsg_tcp (self, msgv, num);
case HEV_SOCKS5_TYPE_UDP_IN_UDP:
return hev_socks5_udp_sendmmsg_udp (self, msgv, num);
default:
return -1;
}
}
static int
hev_socks5_udp_recvmmsg_tcp (HevSocks5UDP *self, HevSocks5UDPMsg *msgv,
unsigned int num, int nonblock)
{
int i, fd, rlen = 0;
fd = hev_socks5_udp_get_fd (self);
if (nonblock)
nonblock = MSG_DONTWAIT;
for (i = 0; i < num; i++) {
HevSocks5UDPHdr udp;
struct iovec iov[2];
struct msghdr mh;
int addrlen;
int res;
res = hev_task_io_socket_recv (fd, &udp, 5, nonblock, task_io_yielder,
self);
if (res > 0 && res < 5)
res += hev_task_io_socket_recv (fd, (void *)&udp + res, 5 - res,
MSG_WAITALL, task_io_yielder, self);
if (res != 5) {
if (rlen > 0)
break;
if (res != -1 || errno != EAGAIN)
LOG_D ("%p socks5 udp read udp head", self);
return res;
}
if (udp.hdrlen < 5) {
LOG_D ("%p socks5 udp head len", self);
return -1;
}
addrlen = udp.hdrlen - 3;
udp.datlen = ntohs (udp.datlen);
if (udp.datlen > (msgv[i].len - addrlen)) {
LOG_D ("%p socks5 udp data len", self);
return -1;
}
mh.msg_name = NULL;
mh.msg_namelen = 0;
mh.msg_control = NULL;
mh.msg_controllen = 0;
mh.msg_iov = iov;
mh.msg_iovlen = 2;
iov[0].iov_base = msgv[i].buf + 2;
iov[0].iov_len = addrlen - 2;
iov[1].iov_base = msgv[i].buf + addrlen;
iov[1].iov_len = udp.datlen;
res = hev_task_io_socket_recvmsg (fd, &mh, MSG_WAITALL, task_io_yielder,
self);
if (res != (addrlen - 2 + udp.datlen)) {
LOG_D ("%p socks5 udp read udp data", self);
return res;
}
msgv[i].addr = msgv[i].buf;
msgv[i].buf = iov[1].iov_base;
msgv[i].len = udp.datlen;
msgv[i].addr->atype = udp.addr.atype;
msgv[i].addr->domain.len = udp.addr.domain.len;
rlen++;
}
return rlen;
}
static int
hev_socks5_udp_recvmmsg_udp (HevSocks5UDP *self, HevSocks5UDPMsg *msgv,
unsigned int num, int nonblock)
{
struct sockaddr_in6 taddr;
struct mmsghdr mvec[num];
struct iovec iov[num];
int i, fd, res;
fd = hev_socks5_udp_get_fd (self);
if (nonblock)
nonblock = MSG_DONTWAIT;
for (i = 0; i < num; i++) {
mvec[i].msg_hdr.msg_name = NULL;
mvec[i].msg_hdr.msg_namelen = 0;
mvec[i].msg_hdr.msg_control = NULL;
mvec[i].msg_hdr.msg_controllen = 0;
mvec[i].msg_hdr.msg_iov = &iov[i];
mvec[i].msg_hdr.msg_iovlen = 1;
iov[i].iov_base = msgv[i].buf;
iov[i].iov_len = msgv[i].len;
}
if (!HEV_SOCKS5 (self)->udp_associated) {
mvec[0].msg_hdr.msg_name = &taddr;
mvec[0].msg_hdr.msg_namelen = sizeof (taddr);
}
res = hev_task_io_socket_recvmmsg (fd, mvec, num, nonblock, task_io_yielder,
self);
if (res <= 0) {
if (res != -1 || errno != EAGAIN)
LOG_D ("%p socks5 udp read udp", self);
return res;
}
if (!HEV_SOCKS5 (self)->udp_associated) {
struct sockaddr *saddr = mvec[0].msg_hdr.msg_name;
socklen_t alen = mvec[0].msg_hdr.msg_namelen;
if (connect (fd, saddr, alen) < 0)
return -1;
HEV_SOCKS5 (self)->udp_associated = 1;
}
for (i = 0; i < res; i++) {
HevSocks5UDPHdr *udp = msgv[i].buf;
int addrlen = hev_socks5_addr_len (&udp->addr);
int doff;
msgv[i].len = mvec[i].msg_len;
if (msgv[i].len < 4) {
msgv[i].addr = NULL;
msgv[i].len = 0;
continue;
}
if (addrlen <= 0) {
LOG_D ("%p socks5 udp addr", self);
return -1;
}
doff = 3 + addrlen;
if (doff > msgv[i].len) {
LOG_D ("%p socks5 udp data len", self);
return -1;
}
msgv[i].addr = &udp->addr;
msgv[i].buf += doff;
msgv[i].len -= doff;
}
return res;
}
int
hev_socks5_udp_recvmmsg (HevSocks5UDP *self, HevSocks5UDPMsg *msgv,
unsigned int num, int nonblock)
{
switch (HEV_SOCKS5 (self)->type) {
case HEV_SOCKS5_TYPE_UDP_IN_TCP:
return hev_socks5_udp_recvmmsg_tcp (self, msgv, num, nonblock);
case HEV_SOCKS5_TYPE_UDP_IN_UDP:
return hev_socks5_udp_recvmmsg_udp (self, msgv, num, nonblock);
default:
return -1;
}
}
static int
hev_socks5_udp_fwd_f (HevSocks5UDP *self, int fd, void *buf, unsigned int num,
int *bind)
{
HevSocks5UDPMsg svec[num];
int i, res;
for (i = 0; i < num; i++) {
svec[i].buf = buf + UDP_BUF_SIZE * i;
svec[i].len = UDP_BUF_SIZE;
}
res = hev_socks5_udp_recvmmsg (self, svec, num, 1);
if (res > 0) {
struct sockaddr_in6 addr[res];
struct mmsghdr dvec[res];
struct iovec iov[res];
int ret;
for (i = 0; i < res; i++) {
int family;
if (!svec[i].len || !svec[i].addr) {
LOG_D ("%p socks5 udp invalid", self);
return -1;
}
family = hev_socks5_get_addr_family (HEV_SOCKS5 (self));
ret = hev_socks5_addr_into_sockaddr6 (svec[i].addr, &addr[i],
&family);
if (ret < 0) {
LOG_D ("%p socks5 udp sockaddr", self);
return -1;
}
dvec[i].msg_hdr.msg_name = (struct sockaddr *)&addr[i];
dvec[i].msg_hdr.msg_namelen = sizeof (struct sockaddr_in6);
dvec[i].msg_hdr.msg_control = NULL;
dvec[i].msg_hdr.msg_controllen = 0;
dvec[i].msg_hdr.msg_iov = &iov[i];
dvec[i].msg_hdr.msg_iovlen = 1;
iov[i].iov_base = svec[i].buf;
iov[i].iov_len = svec[i].len;
}
if (!*bind) {
HevSocks5Class *skptr = HEV_OBJECT_GET_CLASS (self);
struct sockaddr *addr = dvec[0].msg_hdr.msg_name;
ret = skptr->binder (HEV_SOCKS5 (self), fd, addr);
if (ret < 0) {
LOG_W ("%p socks5 udp bind", self);
return -1;
}
*bind = 1;
}
res = hev_task_io_socket_sendmmsg (fd, dvec, res, MSG_WAITALL,
task_io_yielder, self);
}
if (res <= 0) {
if (res == -1 && errno == EAGAIN)
return 0;
LOG_D ("%p socks5 udp fwd f recv send", self);
return -1;
}
return 1;
}
static int
hev_socks5_udp_fwd_b (HevSocks5UDP *self, int fd, struct mmsghdr *svec,
unsigned int num)
{
int i, res;
res = hev_task_io_socket_recvmmsg (fd, svec, num, MSG_DONTWAIT,
task_io_yielder, self);
if (res > 0) {
HevSocks5UDPMsg dvec[res];
char saddr[res][19];
for (i = 0; i < res; i++) {
dvec[i].buf = svec[i].msg_hdr.msg_iov->iov_base;
dvec[i].len = svec[i].msg_len;
dvec[i].addr = (HevSocks5Addr *)&saddr[i];
hev_socks5_addr_from_sockaddr6 (dvec[i].addr,
svec[i].msg_hdr.msg_name);
}
res = hev_socks5_udp_sendmmsg (self, dvec, res);
}
if (res <= 0) {
if (res == -1 && errno == EAGAIN)
return 0;
LOG_D ("%p socks5 udp fwd b recv send", self);
return -1;
}
return 1;
}
static int
hev_socks5_udp_splicer (HevSocks5UDP *self, int fd_b)
{
HevTask *task = hev_task_self ();
int res_f = 1, res_b = 1;
int bind = 0;
void *buf;
int fd_a;
int num;
LOG_D ("%p socks5 udp splicer", self);
num = hev_socks5_get_udp_copy_buffer_nums ();
buf = hev_malloc (UDP_BUF_SIZE * num * 2);
if (!buf)
return -1;
fd_a = hev_socks5_udp_get_fd (self);
if (hev_task_mod_fd (task, fd_a, POLLIN | POLLOUT) < 0)
hev_task_add_fd (task, fd_a, POLLIN | POLLOUT);
if (hev_task_add_fd (task, fd_b, POLLIN | POLLOUT) < 0)
hev_task_mod_fd (task, fd_b, POLLIN | POLLOUT);
{
struct mmsghdr vec[num];
struct sockaddr_in6 addr[num];
struct iovec iov[num];
int i;
for (i = 0; i < num; i++) {
vec[i].msg_hdr.msg_name = (struct sockaddr *)&addr[i];
vec[i].msg_hdr.msg_namelen = sizeof (struct sockaddr_in6);
vec[i].msg_hdr.msg_control = NULL;
vec[i].msg_hdr.msg_controllen = 0;
vec[i].msg_hdr.msg_iov = &iov[i];
vec[i].msg_hdr.msg_iovlen = 1;
iov[i].iov_base = buf + UDP_BUF_SIZE * (num + i);
iov[i].iov_len = UDP_BUF_SIZE;
}
for (;;) {
HevTaskYieldType type;
if (res_f >= 0)
res_f = hev_socks5_udp_fwd_f (self, fd_b, buf, num, &bind);
if (res_b >= 0)
res_b = hev_socks5_udp_fwd_b (self, fd_b, vec, num);
if (res_f > 0 || res_b > 0)
type = HEV_TASK_YIELD;
else if ((res_f & res_b) == 0)
type = HEV_TASK_WAITIO;
else
break;
if (task_io_yielder (type, self))
break;
}
}
hev_free (buf);
return 0;
}
int
hev_socks5_udp_splice (HevSocks5UDP *self, int fd)
{
HevSocks5UDPIface *iface;
iface = HEV_OBJECT_GET_IFACE (self, HEV_SOCKS5_UDP_TYPE);
return iface->splicer (self, fd);
}
void *
hev_socks5_udp_iface (void)
{
static HevSocks5UDPIface type = {
.splicer = hev_socks5_udp_splicer,
};
return &type;
}

View file

@ -0,0 +1,55 @@
/*
============================================================================
Name : hev-socks5-udp.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2025 hev
Description : Socks5 UDP
============================================================================
*/
#ifndef __HEV_SOCKS5_UDP_H__
#define __HEV_SOCKS5_UDP_H__
#include "hev-socks5-proto.h"
#ifdef __cplusplus
extern "C" {
#endif
#define HEV_SOCKS5_UDP(p) ((HevSocks5UDP *)p)
#define HEV_SOCKS5_UDP_IFACE(p) ((HevSocks5UDPIface *)p)
#define HEV_SOCKS5_UDP_TYPE (hev_socks5_udp_iface ())
typedef void HevSocks5UDP;
typedef struct _HevSocks5UDPMsg HevSocks5UDPMsg;
typedef struct _HevSocks5UDPIface HevSocks5UDPIface;
struct _HevSocks5UDPMsg
{
HevSocks5Addr *addr;
void *buf;
size_t len;
};
struct _HevSocks5UDPIface
{
int (*get_fd) (HevSocks5UDP *self);
int (*splicer) (HevSocks5UDP *self, int fd);
};
void *hev_socks5_udp_iface (void);
int hev_socks5_udp_get_fd (HevSocks5UDP *self);
int hev_socks5_udp_sendmmsg (HevSocks5UDP *self, HevSocks5UDPMsg *msgv,
unsigned int num);
int hev_socks5_udp_recvmmsg (HevSocks5UDP *self, HevSocks5UDPMsg *msgv,
unsigned int num, int nonblock);
int hev_socks5_udp_splice (HevSocks5UDP *self, int fd);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_UDP_H__ */

View file

@ -0,0 +1,120 @@
/*
============================================================================
Name : hev-socks5-user.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2023 hev
Description : Socks5 User
============================================================================
*/
#include <string.h>
#include <stdlib.h>
#include "hev-socks5-logger-priv.h"
#include "hev-socks5-user.h"
HevSocks5User *
hev_socks5_user_new (const char *name, unsigned int name_len, const char *pass,
unsigned int pass_len)
{
HevSocks5User *self;
int res;
self = calloc (1, sizeof (HevSocks5User));
if (!self)
return NULL;
res = hev_socks5_user_construct (self, name, name_len, pass, pass_len);
if (res < 0) {
free (self);
return NULL;
}
LOG_D ("%p socks5 user new", self);
return self;
}
int
hev_socks5_user_check (HevSocks5User *self, const char *pass,
unsigned int pass_len)
{
HevSocks5UserClass *klass = HEV_OBJECT_GET_CLASS (self);
return klass->checker (self, pass, pass_len);
}
static int
hev_socks5_user_checker (HevSocks5User *self, const char *pass,
unsigned int pass_len)
{
LOG_D ("%p socks5 user checker", self);
if (self->pass_len != pass_len)
return -1;
if (memcmp (self->pass, pass, pass_len) != 0)
return -1;
return 0;
}
int
hev_socks5_user_construct (HevSocks5User *self, const char *name,
unsigned int name_len, const char *pass,
unsigned int pass_len)
{
int res;
res = hev_object_atomic_construct (&self->base);
if (res < 0)
return res;
LOG_D ("%p socks5 user construct", self);
HEV_OBJECT (self)->klass = HEV_SOCKS5_USER_TYPE;
self->name = malloc (name_len);
self->name_len = name_len;
memcpy (self->name, name, name_len);
self->pass = malloc (pass_len);
self->pass_len = pass_len;
memcpy (self->pass, pass, pass_len);
return 0;
}
static void
hev_socks5_user_destruct (HevObject *base)
{
HevSocks5User *self = HEV_SOCKS5_USER (base);
LOG_D ("%p socks5 user destruct", self);
free (self->name);
free (self->pass);
HEV_OBJECT_ATOMIC_TYPE->destruct (base);
free (base);
}
HevObjectClass *
hev_socks5_user_class (void)
{
static HevSocks5UserClass klass;
HevSocks5UserClass *kptr = &klass;
HevObjectClass *okptr = HEV_OBJECT_CLASS (kptr);
if (!okptr->name) {
memcpy (kptr, HEV_OBJECT_ATOMIC_TYPE, sizeof (HevObjectAtomicClass));
okptr->name = "HevSocks5User";
okptr->destruct = hev_socks5_user_destruct;
kptr->checker = hev_socks5_user_checker;
}
return okptr;
}

View file

@ -0,0 +1,64 @@
/*
============================================================================
Name : hev-socks5-user.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2023 hev
Description : Socks5 User
============================================================================
*/
#ifndef __HEV_SOCKS5_USER_H__
#define __HEV_SOCKS5_USER_H__
#include <hev-object-atomic.h>
#include "hev-rbtree.h"
#ifdef __cplusplus
extern "C" {
#endif
#define HEV_SOCKS5_USER(p) ((HevSocks5User *)p)
#define HEV_SOCKS5_USER_CLASS(p) ((HevSocks5UserClass *)p)
#define HEV_SOCKS5_USER_TYPE (hev_socks5_user_class ())
typedef struct _HevSocks5User HevSocks5User;
typedef struct _HevSocks5UserClass HevSocks5UserClass;
struct _HevSocks5User
{
HevObjectAtomic base;
HevRBTreeNode node;
char *name;
char *pass;
unsigned int name_len;
unsigned int pass_len;
};
struct _HevSocks5UserClass
{
HevObjectAtomicClass base;
int (*checker) (HevSocks5User *self, const char *pass,
unsigned int pass_len);
};
HevObjectClass *hev_socks5_user_class (void);
int hev_socks5_user_construct (HevSocks5User *self, const char *name,
unsigned int name_len, const char *pass,
unsigned int pass_len);
HevSocks5User *hev_socks5_user_new (const char *name, unsigned int name_len,
const char *pass, unsigned int pass_len);
int hev_socks5_user_check (HevSocks5User *self, const char *pass,
unsigned int pass_len);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_USER_H__ */

View file

@ -0,0 +1,109 @@
/*
============================================================================
Name : hev-socks5.c
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2023 hev
Description : Socks5
============================================================================
*/
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <hev-task.h>
#include <hev-task-io.h>
#include <hev-task-io-socket.h>
#include <hev-task-dns.h>
#include <hev-memory-allocator.h>
#include "hev-socks5-logger-priv.h"
#include "hev-socks5.h"
int
hev_socks5_get_timeout (HevSocks5 *self)
{
return self->timeout;
}
void
hev_socks5_set_timeout (HevSocks5 *self, int timeout)
{
self->timeout = timeout;
}
HevSocks5AddrFamily
hev_socks5_get_addr_family (HevSocks5 *self)
{
return self->addr_family;
}
void
hev_socks5_set_addr_family (HevSocks5 *self, HevSocks5AddrFamily family)
{
self->addr_family = family;
}
static int
hev_socks5_bind (HevSocks5 *self, int sock, const struct sockaddr *dest)
{
return 0;
}
int
hev_socks5_construct (HevSocks5 *self, HevSocks5Type type)
{
int res;
res = hev_object_construct (&self->base);
if (res < 0)
return res;
LOG_D ("%p socks5 construct", self);
HEV_OBJECT (self)->klass = HEV_SOCKS5_TYPE;
self->fd = -1;
self->timeout = -1;
self->type = type;
self->addr_family = HEV_SOCKS5_ADDR_FAMILY_UNSPEC;
return 0;
}
static void
hev_socks5_destruct (HevObject *base)
{
HevSocks5 *self = HEV_SOCKS5 (base);
LOG_D ("%p socks5 destruct", self);
if (self->fd >= 0) {
hev_task_del_fd (hev_task_self (), self->fd);
close (self->fd);
}
HEV_OBJECT_TYPE->destruct (base);
hev_free (base);
}
HevObjectClass *
hev_socks5_class (void)
{
static HevSocks5Class klass;
HevSocks5Class *kptr = &klass;
HevObjectClass *okptr = HEV_OBJECT_CLASS (kptr);
if (!okptr->name) {
memcpy (kptr, HEV_OBJECT_TYPE, sizeof (HevObjectClass));
okptr->name = "HevSocks5";
okptr->destruct = hev_socks5_destruct;
kptr->binder = hev_socks5_bind;
}
return okptr;
}

View file

@ -0,0 +1,78 @@
/*
============================================================================
Name : hev-socks5.h
Author : Heiher <r@hev.cc>
Copyright : Copyright (c) 2021 - 2023 hev
Description : Socks5
============================================================================
*/
#ifndef __HEV_SOCKS5_H__
#define __HEV_SOCKS5_H__
#include <netinet/in.h>
#include <sys/socket.h>
#include <hev-object.h>
#ifdef __cplusplus
extern "C" {
#endif
#define HEV_SOCKS5(p) ((HevSocks5 *)p)
#define HEV_SOCKS5_CLASS(p) ((HevSocks5Class *)p)
#define HEV_SOCKS5_TYPE (hev_socks5_class ())
typedef struct _HevSocks5 HevSocks5;
typedef struct _HevSocks5Class HevSocks5Class;
typedef enum _HevSocks5Type HevSocks5Type;
typedef enum _HevSocks5AddrFamily HevSocks5AddrFamily;
enum _HevSocks5Type
{
HEV_SOCKS5_TYPE_NONE,
HEV_SOCKS5_TYPE_TCP,
HEV_SOCKS5_TYPE_UDP_IN_TCP,
HEV_SOCKS5_TYPE_UDP_IN_UDP,
};
enum _HevSocks5AddrFamily
{
HEV_SOCKS5_ADDR_FAMILY_IPV4 = AF_INET,
HEV_SOCKS5_ADDR_FAMILY_IPV6 = AF_INET6,
HEV_SOCKS5_ADDR_FAMILY_UNSPEC = AF_UNSPEC,
};
struct _HevSocks5
{
HevObject base;
int fd;
int timeout;
int udp_associated;
HevSocks5Type type;
HevSocks5AddrFamily addr_family;
};
struct _HevSocks5Class
{
HevObjectClass base;
int (*binder) (HevSocks5 *self, int sock, const struct sockaddr *dest);
};
HevObjectClass *hev_socks5_class (void);
int hev_socks5_construct (HevSocks5 *self, HevSocks5Type type);
int hev_socks5_get_timeout (HevSocks5 *self);
void hev_socks5_set_timeout (HevSocks5 *self, int timeout);
HevSocks5AddrFamily hev_socks5_get_addr_family (HevSocks5 *self);
void hev_socks5_set_addr_family (HevSocks5 *self, HevSocks5AddrFamily family);
#ifdef __cplusplus
}
#endif
#endif /* __HEV_SOCKS5_H__ */

View file

@ -0,0 +1,21 @@
/*
============================================================================
Name : hev-config-const.h
Author : hev <r@hev.cc>
Copyright : Copyright (c) 2019 - 2024 hev
Description : Config Constants
============================================================================
*/
#ifndef __HEV_CONFIG_CONST_H__
#define __HEV_CONFIG_CONST_H__
#define MAJOR_VERSION (2)
#define MINOR_VERSION (15)
#define MICRO_VERSION (0)
static const int UDP_BUF_SIZE = 1500;
static const int UDP_POOL_SIZE = 512;
static const int TASK_STACK_SIZE = 20480;
#endif /* __HEV_CONFIG_CONST_H__ */

Some files were not shown because too many files have changed in this diff Show more