Skip to content

Commit

Permalink
feat(backends): sing-box (#16)
Browse files Browse the repository at this point in the history
* feat(backends): sing-box

* refactor(sing-box): rename some env variables

* ci(sing-box): add sing-box to the docker image, include the path in compose
  • Loading branch information
khodedawsh authored Nov 25, 2024
1 parent d7f657f commit 3a43957
Show file tree
Hide file tree
Showing 17 changed files with 990 additions and 5 deletions.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
#HYSTERIA_CONFIG_PATH=/etc/hysteria/config.yaml


#SING_BOX_ENABLED=False
#SING_BOX_EXECUTABLE_PATH=/usr/bin/sing-box
#SING_BOX_CONFIG_PATH=/etc/sing-box/config.json
#SING_BOX_RESTART_ON_FAILURE=False
#SING_BOX_RESTART_ON_FAILURE_INTERVAL=0


#SSL_KEY_FILE=./server.key
#SSL_CERT_FILE=./server.cert
#SSL_CLIENT_CERT_FILE=./client.cert
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
FROM tobyxdd/hysteria:v2 AS hysteria-image
FROM jklolixxs/sing-box:latest AS sing-box-image

FROM python:3.12-alpine

ENV PYTHONUNBUFFERED=1

COPY --from=hysteria-image /usr/local/bin/hysteria /usr/local/bin/hysteria
COPY --from=sing-box-image /usr/local/bin/sing-box /usr/local/bin/sing-box

WORKDIR /app

Expand Down
2 changes: 2 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ services:
XRAY_EXECUTABLE_PATH: "/usr/local/bin/xray"
XRAY_ASSETS_PATH: "/usr/local/lib/xray"
XRAY_CONFIG_PATH: "/var/lib/marznode/xray_config.json"
SING_BOX_EXECUTABLE_PATH: "/usr/local/bin/sing-box"
HYSTERIA_EXECUTABLE_PATH: "/usr/local/bin/hysteria"
SSL_CLIENT_CERT_FILE: "/var/lib/marznode/client.pem"
SSL_KEY_FILE: "./server.key"
SSL_CERT_FILE: "./server.cert"
Expand Down
4 changes: 0 additions & 4 deletions marznode/backends/abstract_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,3 @@ def list_inbounds(self):
@abstractmethod
def get_config(self):
raise NotImplementedError

@abstractmethod
def save_config(self, config: str):
raise NotImplementedError
121 changes: 121 additions & 0 deletions marznode/backends/singbox/_accounts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Implements accounts for different protocols in sing-box"""

# pylint: disable=E0611,C0115
from abc import ABC
from enum import Enum
from typing import Optional

from pydantic import (
BaseModel,
field_validator,
ValidationInfo,
ValidationError,
computed_field,
Field,
)

from marznode.utils.key_gen import generate_password, generate_uuid


class SingBoxAccount(BaseModel, ABC):
identifier: str
seed: str

@field_validator("uuid", "password", check_fields=False)
@classmethod
def generate_creds(cls, v: str, info: ValidationInfo):
if v:
return v
if "seed" in info.data:
seed = info.data["seed"]
if info.field_name == "uuid":
return str(generate_uuid(seed))
elif info.field_name == "password":
return generate_password(seed)
raise ValidationError("Both password/id and seed are empty")

def to_dict(self):
return self.model_dump(exclude={"seed", "identifier"})

def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.email}>"


class NamedAccount(SingBoxAccount):
@computed_field
@property
def name(self) -> str:
return self.identifier


class UserNamedAccount(SingBoxAccount):
@computed_field
@property
def username(self) -> str:
return self.identifier


class VMessAccount(NamedAccount, SingBoxAccount):
uuid: Optional[str] = Field(None, validate_default=True)


class XTLSFlows(str, Enum):
NONE = ""
VISION = "xtls-rprx-vision"


class VLESSAccount(NamedAccount, SingBoxAccount):
uuid: Optional[str] = Field(None, validate_default=True)
flow: XTLSFlows = XTLSFlows.NONE


class TrojanAccount(NamedAccount, SingBoxAccount):
password: Optional[str] = Field(None, validate_default=True)


class ShadowsocksAccount(NamedAccount, SingBoxAccount):
password: Optional[str] = Field(None, validate_default=True)


class TUICAccount(NamedAccount, SingBoxAccount):
uuid: Optional[str] = Field(None, validate_default=True)
password: Optional[str] = Field(None, validate_default=True)


class Hysteria2Account(NamedAccount, SingBoxAccount):
password: Optional[str] = Field(None, validate_default=True)


class NaiveAccount(UserNamedAccount, SingBoxAccount):
password: Optional[str] = Field(None, validate_default=True)


class ShadowTLSAccount(NamedAccount, SingBoxAccount):
password: Optional[str] = Field(None, validate_default=True)


class SocksAccount(UserNamedAccount, SingBoxAccount):
password: Optional[str] = Field(None, validate_default=True)


class HTTPAccount(UserNamedAccount, SingBoxAccount):
password: Optional[str] = Field(None, validate_default=True)


class MixedAccount(UserNamedAccount, SingBoxAccount):
password: Optional[str] = Field(None, validate_default=True)


accounts_map = {
"shadowsocks": ShadowsocksAccount,
"trojan": TrojanAccount,
"vmess": VMessAccount,
"vless": VLESSAccount,
"shadowtls": ShadowTLSAccount,
"tuic": TUICAccount,
"hysteria2": Hysteria2Account,
"naive": NaiveAccount,
"socks": SocksAccount,
"mixed": MixedAccount,
"http": HTTPAccount,
}
141 changes: 141 additions & 0 deletions marznode/backends/singbox/_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import json

import commentjson

from marznode.backends.singbox._accounts import accounts_map
from marznode.backends.xray._utils import get_x25519
from marznode.config import XRAY_EXECUTABLE_PATH
from marznode.models import User, Inbound
from marznode.storage import BaseStorage


class SingBoxConfig(dict):
def __init__(
self,
config: str,
api_host: str = "127.0.0.1",
api_port: int = 8080,
):
try:
# considering string as json
config = commentjson.loads(config)
except (json.JSONDecodeError, ValueError):
# considering string as file path
with open(config) as file:
config = commentjson.loads(file.read())

self.api_host = api_host
self.api_port = api_port

super().__init__(config)

self.inbounds = []
self.inbounds_by_tag = {}
self._resolve_inbounds()

self._apply_api()

def _apply_api(self):
if not self.get("experimental"):
self["experimental"] = {}
self["experimental"]["v2ray_api"] = {
"listen": self.api_host + ":" + str(self.api_port),
"stats": {"enabled": True, "users": []},
}

def _resolve_inbounds(self):
for inbound in self.get("inbounds", []):
if inbound.get("type") not in {
"shadowsocks",
"vmess",
"trojan",
"vless",
"hysteria2",
"tuic",
} or not inbound.get("tag"):
continue

settings = {
"tag": inbound["tag"],
"protocol": inbound["type"],
"port": inbound.get("listen_port"),
"network": "tcp",
"tls": "none",
"sni": [],
"host": [],
"path": None,
"header_type": None,
"flow": None,
}

if "tls" in inbound and inbound["tls"].get("enabled") == True:
settings["tls"] = "tls"
if sni := inbound["tls"].get("server_name"):
settings["sni"].append(sni)
if inbound["tls"].get("reality", {}).get("enabled"):
settings["tls"] = "reality"
pvk = inbound["tls"]["reality"].get("private_key")

x25519 = get_x25519(XRAY_EXECUTABLE_PATH, pvk)
settings["pbk"] = x25519["public_key"]

settings["sid"] = inbound["tls"]["reality"].get("short_id", [""])[0]

if "transport" in inbound:
settings["network"] = inbound["transport"].get("type")
if settings["network"] == "ws":
settings["path"] = inbound["transport"].get("path")
elif settings["network"] == "http":
settings["path"] = inbound["transport"].get("path")
settings["network"] = "tcp"
settings["header_type"] = "http"
settings["host"] = inbound["transport"].get("host", [])
elif settings["network"] == "grpc":
settings["path"] = inbound["transport"].get("service_name")
elif settings["network"] == "httpupgrade":
settings["path"] = inbound["transport"].get("path")

self.inbounds.append(settings)
self.inbounds_by_tag[inbound["tag"]] = settings

def append_user(self, user: User, inbound: Inbound):
identifier = str(user.id) + "." + user.username
account = accounts_map[inbound.protocol](identifier=identifier, seed=user.key)
for i in self.get("inbounds", []):
if i.get("tag") == inbound.tag:
if not i.get("users"):
i["users"] = []
i["users"].append(account.to_dict())
if (
identifier
not in self["experimental"]["v2ray_api"]["stats"]["users"]
):
self["experimental"]["v2ray_api"]["stats"]["users"].append(
identifier
)
break

def pop_user(self, user: User, inbound: Inbound):
identifier = str(user.id) + "." + user.username
for i in self.get("inbounds", []):
if i.get("tag") != inbound.tag or not i.get("users"):
continue
i["users"] = [
user
for user in i["users"]
if user.get("name") != identifier and user.get("username") != identifier
]
break

def register_inbounds(self, storage: BaseStorage):
for inbound in self.list_inbounds():
storage.register_inbound(inbound)

def list_inbounds(self) -> list[Inbound]:
return [
Inbound(tag=i["tag"], protocol=i["protocol"], config=i)
for i in self.inbounds_by_tag.values()
]

def to_json(self, **json_kwargs):
return json.dumps(self, **json_kwargs)
Loading

0 comments on commit 3a43957

Please sign in to comment.