This commit is contained in:
2026-07-25 13:56:32 +08:00
51 changed files with 2656 additions and 40 deletions

4
.gitignore vendored
View File

@@ -50,3 +50,7 @@ resources/Pure_Component/
*.so
*.dylib
cmd/api/__debug_bin*
# 戎行 OpenVPN 机密(仅保留 secrets/openvpn/.gitkeep
secrets/openvpn/*
!secrets/openvpn/.gitkeep

View File

@@ -860,6 +860,8 @@ rongxing:
app_id: "hykj"
private_key: "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw=="
timeout: 10s
# 仅戎行请求走 VPN SOCKS本机已连 OpenVPN GUI 时可留空。生产 Dockersocks5://rongxing-vpn:1080
proxy: ""
logging:
enabled: true
log_dir: "logs/external_services"

View File

@@ -279,6 +279,8 @@ rongxing:
app_id: "hykj"
private_key: "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw=="
timeout: 10s
# 开发机用 OpenVPN GUI 直连时可留空;若走本地 Docker VPN 可填 socks5://rongxing-vpn:1080
proxy: ""
logging:
enabled: true
log_dir: "logs/external_services"

View File

@@ -245,6 +245,8 @@ rongxing:
app_id: "hykj"
private_key: "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw=="
timeout: 10s
# 生产:戎行请求经 Docker 内 rongxing-vpn 的 SOCKS5 进企业内网
proxy: "socks5://rongxing-vpn:1080"
logging:
enabled: true
log_dir: "logs/external_services"

View File

@@ -0,0 +1,35 @@
# 戎行专用OpenVPN 客户端 + microsocks SOCKS5走系统路由/tun0
FROM alpine:3.19 AS socks-builder
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache build-base
WORKDIR /src
COPY third_party/microsocks/ ./
RUN make && strip microsocks
FROM alpine:3.19
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache \
openvpn \
bash \
iproute2 \
curl
COPY --from=socks-builder /src/microsocks /usr/local/bin/microsocks
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh /usr/local/bin/microsocks
ENV VPN_DIR=/vpn \
SOCKS_HOST=0.0.0.0 \
SOCKS_PORT=1080 \
RONGXING_HOST=192.168.3.43
VOLUME ["/vpn"]
EXPOSE 1080
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=5 \
CMD ip link show tun0 >/dev/null 2>&1 || exit 1
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,14 @@
# Rongxing OpenVPN部署附件
完整方案见:[docs/戎行OpenVPN接入方案.md](../../docs/戎行OpenVPN接入方案.md)
| 文件 | 用途 |
|------|------|
| `Dockerfile` | OpenVPN + 自编译 microsocks |
| `entrypoint.sh` | 启动 VPN仅路由戎行 IP并监听 SOCKS5 `:1080` |
| `third_party/microsocks/` | SOCKS5 源码(避免 Alpine 无包 / Dante 绑错网卡) |
| `tiany0721.ovpn.example` | ovpn 模板 |
| `docker-compose.rongxing-vpn.snippet.yml` | 独立编排参考prod 已内置) |
| `upload-checklist.txt` | 上传清单 |
**真实证书放到服务器 secrets 目录或 `./secrets/openvpn/`,不要提交 Git。**

View File

@@ -0,0 +1,31 @@
# 已并入 docker-compose.prod.yml 的 rongxing-vpn 服务。
# 保留本文件便于 1Panel 单独编排时参考。
#
# 1Panel 机密目录示例:
# RONGXING_VPN_CONFIG=/opt/1panel/apps/hyapi-secrets/openvpn
#
# 独立栈时需 external 网络(名称以 docker network ls 为准):
# networks:
# hyapi-network:
# external: true
# name: hyapi-server_hyapi-network
services:
rongxing-vpn:
build:
context: ./deployments/openvpn
dockerfile: Dockerfile
image: hyapi-rongxing-vpn:local
container_name: hyapi-rongxing-vpn
environment:
TZ: Asia/Shanghai
RONGXING_HOST: "192.168.3.43"
volumes:
- ${RONGXING_VPN_CONFIG:-./secrets/openvpn}:/vpn:ro
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
networks:
- hyapi-network
restart: unless-stopped

View File

@@ -0,0 +1,85 @@
#!/bin/bash
# OpenVPN 连通后拉起 microsocks出口跟随系统路由含 tun0避免 Dante 绑错网卡。
set -euo pipefail
VPN_DIR="${VPN_DIR:-/vpn}"
SOCKS_HOST="${SOCKS_HOST:-0.0.0.0}"
SOCKS_PORT="${SOCKS_PORT:-1080}"
RONGXING_HOST="${RONGXING_HOST:-192.168.3.43}"
cd "$VPN_DIR"
CONF=""
for f in *.conf *.ovpn; do
if [ -f "$f" ]; then
CONF="$f"
break
fi
done
if [ -z "$CONF" ]; then
echo "[rongxing-vpn] 未在 ${VPN_DIR} 找到 .ovpn/.conf请挂载证书目录"
exit 1
fi
echo "[rongxing-vpn] 使用配置: ${CONF}"
echo "[rongxing-vpn] 仅路由 ${RONGXING_HOST}/32SOCKS5 ${SOCKS_HOST}:${SOCKS_PORT}"
openvpn \
--config "$CONF" \
--cd "$VPN_DIR" \
--route-nopull \
--route "$RONGXING_HOST" 255.255.255.255 \
--daemon \
--writepid /var/run/openvpn.pid \
--log /var/log/openvpn.log \
--verb 3
for i in $(seq 1 90); do
if ip link show tun0 >/dev/null 2>&1; then
echo "[rongxing-vpn] tun0 已就绪 (${i}s)"
break
fi
if [ "$i" -eq 90 ]; then
echo "[rongxing-vpn] 等待 tun0 超时OpenVPN 日志:"
cat /var/log/openvpn.log || true
exit 1
fi
sleep 1
done
echo "[rongxing-vpn] 路由表:"
ip route || true
# 直连自检(不阻断)
if curl -sS -m 5 -o /tmp/rx_probe.out -w "[rongxing-vpn] 直连探测 HTTP %{http_code}\n" "http://${RONGXING_HOST}:7007/" ; then
head -c 200 /tmp/rx_probe.out 2>/dev/null || true
echo
else
echo "[rongxing-vpn] 直连探测失败: http://${RONGXING_HOST}:7007/"
fi
microsocks -i "$SOCKS_HOST" -p "$SOCKS_PORT" &
SOCKS_PID=$!
sleep 1
if ! kill -0 "$SOCKS_PID" 2>/dev/null; then
echo "[rongxing-vpn] microsocks 启动失败"
exit 1
fi
echo "[rongxing-vpn] SOCKS5 已监听 :${SOCKS_PORT} (microsocks pid=${SOCKS_PID})"
cleanup() {
kill "$SOCKS_PID" 2>/dev/null || true
if [ -f /var/run/openvpn.pid ]; then
kill "$(cat /var/run/openvpn.pid)" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
while kill -0 "$(cat /var/run/openvpn.pid)" 2>/dev/null && kill -0 "$SOCKS_PID" 2>/dev/null; do
sleep 5
done
echo "[rongxing-vpn] OpenVPN 或 SOCKS 已退出"
cat /var/log/openvpn.log || true
exit 1

View File

@@ -0,0 +1,6 @@
# 可选:复制为 .env 供 docker compose 读取
# 1Panel 推荐:
RONGXING_VPN_CONFIG=/opt/1panel/apps/hyapi-secrets/openvpn
# 本地默认(不设则用 compose 内 ./secrets/openvpn
# RONGXING_VPN_CONFIG=./secrets/openvpn

View File

@@ -0,0 +1,3 @@
*.o
*.out

View File

@@ -0,0 +1,24 @@
microSocks is licensed under the following standard MIT license:
----------------------------------------------------------------------
Copyright © 2017 rofl0r.
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,35 @@
# if you want to change/override some variables, do so in a file called
# config.mak, which is gets included automatically if it exists.
prefix = /usr/local
bindir = $(prefix)/bin
PROG = microsocks
SRCS = sockssrv.c server.c sblist.c sblist_delete.c
OBJS = $(SRCS:.c=.o)
LIBS = -lpthread
CFLAGS += -Wall -std=c99
INSTALL = ./install.sh
-include config.mak
all: $(PROG)
install: $(PROG)
$(INSTALL) -D -m 755 $(PROG) $(DESTDIR)$(bindir)/$(PROG)
clean:
rm -f $(PROG)
rm -f $(OBJS)
%.o: %.c
$(CC) $(CPPFLAGS) $(CFLAGS) $(INC) $(PIC) -c -o $@ $<
$(PROG): $(OBJS)
$(CC) $(LDFLAGS) $(OBJS) $(LIBS) -o $@
.PHONY: all clean install

View File

@@ -0,0 +1,65 @@
MicroSocks - multithreaded, small, efficient SOCKS5 server.
===========================================================
a SOCKS5 service that you can run on your remote boxes to tunnel connections
through them, if for some reason SSH doesn't cut it for you.
It's very lightweight, and very light on resources too:
for every client, a thread with a stack size of 8KB is spawned.
the main process basically doesn't consume any resources at all.
the only limits are the amount of file descriptors and the RAM.
It's also designed to be robust: it handles resource exhaustion
gracefully by simply denying new connections, instead of calling abort()
as most other programs do these days.
another plus is ease-of-use: no config file necessary, everything can be
done from the command line and doesn't even need any parameters for quick
setup.
History
-------
This is the successor of "rocksocks5", and it was written with
different goals in mind:
- prefer usage of standard libc functions over homegrown ones
- no artificial limits
- do not aim for minimal binary size, but for minimal source code size,
and maximal readability, reusability, and extensibility.
as a result of that, ipv4, dns, and ipv6 is supported out of the box
and can use the same code, while rocksocks5 has several compile time
defines to bring down the size of the resulting binary to extreme values
like 10 KB static linked when only ipv4 support is enabled.
still, if optimized for size, *this* program when static linked against musl
libc is not even 50 KB. that's easily usable even on the cheapest routers.
command line options
--------------------
microsocks -1 -i listenip -p port -u user -P password -b bindaddr
all arguments are optional.
by default listenip is 0.0.0.0 and port 1080.
option -1 activates auth_once mode: once a specific ip address
authed successfully with user/pass, it is added to a whitelist
and may use the proxy without auth.
this is handy for programs like firefox that don't support
user/pass auth. for it to work you'd basically make one connection
with another program that supports it, and then you can use firefox too.
for example, authenticate once using curl:
curl --socks5 user:password@listenip:port anyurl
Supported SOCKS5 Features
-------------------------
- authentication: none, password, one-time
- IPv4, IPv6, DNS
- TCP (no UDP at this time)

View File

@@ -0,0 +1,28 @@
#!/bin/sh
if [ -z "$VER" ] ; then
echo set VER!
exit
fi
me=`pwd`
proj=microsocks
projver=${proj}-${VER}
tempdir=/tmp/${proj}-0000
rm -rf "$tempdir"
mkdir -p "$tempdir"
cd $tempdir
GITDIR=https://github.com/rofl0r/$proj
GITDIR=$me
git clone "$GITDIR" $projver
rm -rf $projver/.git
rm -rf $projver/docs
rm -f $projver/.gitignore
rm -f $projver/create-dist.sh
tar cf $proj.tar $projver/
xz -z -9 -e $proj.tar
mv $proj.tar.xz $me/$projver.tar.xz
rm -rf "$tempdir"

View File

@@ -0,0 +1,67 @@
#!/bin/sh
#
# Written by Rich Felker, originally as part of musl libc.
# Multi-licensed under MIT, 0BSD, and CC0.
#
# This is an actually-safe install command which installs the new
# file atomically in the new location, rather than overwriting
# existing files.
#
usage() {
printf "usage: %s [-D] [-l] [-m mode] src dest\n" "$0" 1>&2
exit 1
}
mkdirp=
symlink=
mode=755
while getopts Dlm: name ; do
case "$name" in
D) mkdirp=yes ;;
l) symlink=yes ;;
m) mode=$OPTARG ;;
?) usage ;;
esac
done
shift $(($OPTIND - 1))
test "$#" -eq 2 || usage
src=$1
dst=$2
tmp="$dst.tmp.$$"
case "$dst" in
*/) printf "%s: %s ends in /\n", "$0" "$dst" 1>&2 ; exit 1 ;;
esac
set -C
set -e
if test "$mkdirp" ; then
umask 022
case "$2" in
*/*) mkdir -p "${dst%/*}" ;;
esac
fi
trap 'rm -f "$tmp"' EXIT INT QUIT TERM HUP
umask 077
if test "$symlink" ; then
ln -s "$1" "$tmp"
else
cat < "$1" > "$tmp"
chmod "$mode" "$tmp"
fi
mv -f "$tmp" "$2"
test -d "$2" && {
rm -f "$2/$tmp"
printf "%s: %s is a directory\n" "$0" "$dst" 1>&2
exit 1
}
exit 0

View File

@@ -0,0 +1,73 @@
#undef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 200809L
#include "sblist.h"
#include <limits.h>
#include <stdlib.h>
#include <string.h>
#define MY_PAGE_SIZE 4096
sblist* sblist_new(size_t itemsize, size_t blockitems) {
sblist* ret = (sblist*) malloc(sizeof(sblist));
sblist_init(ret, itemsize, blockitems);
return ret;
}
static void sblist_clear(sblist* l) {
l->items = NULL;
l->capa = 0;
l->count = 0;
}
void sblist_init(sblist* l, size_t itemsize, size_t blockitems) {
if(l) {
l->blockitems = blockitems ? blockitems : MY_PAGE_SIZE / itemsize;
l->itemsize = itemsize;
sblist_clear(l);
}
}
void sblist_free_items(sblist* l) {
if(l) {
if(l->items) free(l->items);
sblist_clear(l);
}
}
void sblist_free(sblist* l) {
if(l) {
sblist_free_items(l);
free(l);
}
}
char* sblist_item_from_index(sblist* l, size_t idx) {
return l->items + (idx * l->itemsize);
}
void* sblist_get(sblist* l, size_t item) {
if(item < l->count) return (void*) sblist_item_from_index(l, item);
return NULL;
}
int sblist_set(sblist* l, void* item, size_t pos) {
if(pos >= l->count) return 0;
memcpy(sblist_item_from_index(l, pos), item, l->itemsize);
return 1;
}
int sblist_grow_if_needed(sblist* l) {
char* temp;
if(l->count == l->capa) {
temp = realloc(l->items, (l->capa + l->blockitems) * l->itemsize);
if(!temp) return 0;
l->capa += l->blockitems;
l->items = temp;
}
return 1;
}
int sblist_add(sblist* l, void* item) {
if(!sblist_grow_if_needed(l)) return 0;
l->count++;
return sblist_set(l, item, l->count - 1);
}

View File

@@ -0,0 +1,92 @@
#ifndef SBLIST_H
#define SBLIST_H
/* this file is part of libulz, as of commit 8ab361a27743aaf025323ee43b8b8876dc054fdd
modified for direct inclusion in microsocks. */
#ifdef __cplusplus
extern "C" {
#endif
#include <stddef.h>
/*
* simple buffer list.
*
* this thing here is basically a generic dynamic array
* will realloc after every blockitems inserts
* can store items of any size.
*
* so think of it as a by-value list, as opposed to a typical by-ref list.
* you typically use it by having some struct on the stack, and pass a pointer
* to sblist_add, which will copy the contents into its internal memory.
*
*/
typedef struct {
size_t itemsize;
size_t blockitems;
size_t count;
size_t capa;
char* items;
} sblist;
#define sblist_getsize(X) ((X)->count)
#define sblist_get_count(X) ((X)->count)
#define sblist_empty(X) ((X)->count == 0)
// for dynamic style
sblist* sblist_new(size_t itemsize, size_t blockitems);
void sblist_free(sblist* l);
//for static style
void sblist_init(sblist* l, size_t itemsize, size_t blockitems);
void sblist_free_items(sblist* l);
// accessors
void* sblist_get(sblist* l, size_t item);
// returns 1 on success, 0 on OOM
int sblist_add(sblist* l, void* item);
int sblist_set(sblist* l, void* item, size_t pos);
void sblist_delete(sblist* l, size_t item);
char* sblist_item_from_index(sblist* l, size_t idx);
int sblist_grow_if_needed(sblist* l);
int sblist_insert(sblist* l, void* item, size_t pos);
/* same as sblist_add, but returns list index of new item, or -1 */
size_t sblist_addi(sblist* l, void* item);
void sblist_sort(sblist *l, int (*compar)(const void *, const void *));
/* insert element into presorted list, returns listindex of new entry or -1*/
size_t sblist_insert_sorted(sblist* l, void* o, int (*compar)(const void *, const void *));
#ifndef __COUNTER__
#define __COUNTER__ __LINE__
#endif
#define __sblist_concat_impl( x, y ) x##y
#define __sblist_macro_concat( x, y ) __sblist_concat_impl( x, y )
#define __sblist_iterator_name __sblist_macro_concat(sblist_iterator, __COUNTER__)
// use with custom iterator variable
#define sblist_iter_counter(LIST, ITER, PTR) \
for(size_t ITER = 0; (PTR = sblist_get(LIST, ITER)), ITER < sblist_getsize(LIST); ITER++)
// use with custom iterator variable, which is predeclared
#define sblist_iter_counter2(LIST, ITER, PTR) \
for(ITER = 0; (PTR = sblist_get(LIST, ITER)), ITER < sblist_getsize(LIST); ITER++)
// use with custom iterator variable, which is predeclared and signed
// useful for a loop which can delete items from the list, and then decrease the iterator var.
#define sblist_iter_counter2s(LIST, ITER, PTR) \
for(ITER = 0; (PTR = sblist_get(LIST, ITER)), ITER < (ssize_t) sblist_getsize(LIST); ITER++)
// uses "magic" iterator variable
#define sblist_iter(LIST, PTR) sblist_iter_counter(LIST, __sblist_iterator_name, PTR)
#ifdef __cplusplus
}
#endif
#pragma RcB2 DEP "sblist.c" "sblist_delete.c"
#endif

View File

@@ -0,0 +1,9 @@
#include "sblist.h"
#include <string.h>
void sblist_delete(sblist* l, size_t item) {
if (l->count && item < l->count) {
memmove(sblist_item_from_index(l, item), sblist_item_from_index(l, item + 1), (sblist_getsize(l) - (item + 1)) * l->itemsize);
l->count--;
}
}

View File

@@ -0,0 +1,64 @@
#include "server.h"
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int resolve(const char *host, unsigned short port, struct addrinfo** addr) {
struct addrinfo hints = {
.ai_family = AF_UNSPEC,
.ai_socktype = SOCK_STREAM,
.ai_flags = AI_PASSIVE,
};
char port_buf[8];
snprintf(port_buf, sizeof port_buf, "%u", port);
return getaddrinfo(host, port_buf, &hints, addr);
}
int resolve_sa(const char *host, unsigned short port, union sockaddr_union *res) {
struct addrinfo *ainfo = 0;
int ret;
SOCKADDR_UNION_AF(res) = AF_UNSPEC;
if((ret = resolve(host, port, &ainfo))) return ret;
memcpy(res, ainfo->ai_addr, ainfo->ai_addrlen);
freeaddrinfo(ainfo);
return 0;
}
int bindtoip(int fd, union sockaddr_union *bindaddr) {
socklen_t sz = SOCKADDR_UNION_LENGTH(bindaddr);
if(sz)
return bind(fd, (struct sockaddr*) bindaddr, sz);
return 0;
}
int server_waitclient(struct server *server, struct client* client) {
socklen_t clen = sizeof client->addr;
return ((client->fd = accept(server->fd, (void*)&client->addr, &clen)) == -1)*-1;
}
int server_setup(struct server *server, const char* listenip, unsigned short port) {
struct addrinfo *ainfo = 0;
if(resolve(listenip, port, &ainfo)) return -1;
struct addrinfo* p;
int listenfd = -1;
for(p = ainfo; p; p = p->ai_next) {
if((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
continue;
int yes = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int));
if(bind(listenfd, p->ai_addr, p->ai_addrlen) < 0) {
close(listenfd);
listenfd = -1;
continue;
}
break;
}
freeaddrinfo(ainfo);
if(listenfd < 0) return -2;
if(listen(listenfd, SOMAXCONN) < 0) {
close(listenfd);
return -3;
}
server->fd = listenfd;
return 0;
}

View File

@@ -0,0 +1,49 @@
#ifndef SERVER_H
#define SERVER_H
#undef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 200809L
#include <sys/socket.h>
#include <netdb.h>
#include <netinet/in.h>
#pragma RcB2 DEP "server.c"
union sockaddr_union {
struct sockaddr_in v4;
struct sockaddr_in6 v6;
};
#define SOCKADDR_UNION_AF(PTR) (PTR)->v4.sin_family
#define SOCKADDR_UNION_LENGTH(PTR) ( \
( SOCKADDR_UNION_AF(PTR) == AF_INET ) ? sizeof((PTR)->v4) : ( \
( SOCKADDR_UNION_AF(PTR) == AF_INET6 ) ? sizeof((PTR)->v6) : 0 ) )
#define SOCKADDR_UNION_ADDRESS(PTR) ( \
( SOCKADDR_UNION_AF(PTR) == AF_INET ) ? (void*) &(PTR)->v4.sin_addr : ( \
( SOCKADDR_UNION_AF(PTR) == AF_INET6 ) ? (void*) &(PTR)->v6.sin6_addr : (void*) 0 ) )
#define SOCKADDR_UNION_PORT(PTR) ( \
( SOCKADDR_UNION_AF(PTR) == AF_INET ) ? (PTR)->v4.sin_port : ( \
( SOCKADDR_UNION_AF(PTR) == AF_INET6 ) ? (PTR)->v6.sin6_port : 0 ) )
struct client {
union sockaddr_union addr;
int fd;
};
struct server {
int fd;
};
int resolve(const char *host, unsigned short port, struct addrinfo** addr);
int resolve_sa(const char *host, unsigned short port, union sockaddr_union *res);
int bindtoip(int fd, union sockaddr_union *bindaddr);
int server_waitclient(struct server *server, struct client* client);
int server_setup(struct server *server, const char* listenip, unsigned short port);
#endif

View File

@@ -0,0 +1,482 @@
/*
MicroSocks - multithreaded, small, efficient SOCKS5 server.
Copyright (C) 2017 rofl0r.
This is the successor of "rocksocks5", and it was written with
different goals in mind:
- prefer usage of standard libc functions over homegrown ones
- no artificial limits
- do not aim for minimal binary size, but for minimal source code size,
and maximal readability, reusability, and extensibility.
as a result of that, ipv4, dns, and ipv6 is supported out of the box
and can use the same code, while rocksocks5 has several compile time
defines to bring down the size of the resulting binary to extreme values
like 10 KB static linked when only ipv4 support is enabled.
still, if optimized for size, *this* program when static linked against musl
libc is not even 50 KB. that's easily usable even on the cheapest routers.
*/
#define _GNU_SOURCE
#include <unistd.h>
#define _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <pthread.h>
#include <signal.h>
#include <poll.h>
#include <arpa/inet.h>
#include <errno.h>
#include <limits.h>
#include "server.h"
#include "sblist.h"
/* timeout in microseconds on resource exhaustion to prevent excessive
cpu usage. */
#ifndef FAILURE_TIMEOUT
#define FAILURE_TIMEOUT 64
#endif
#ifndef MAX
#define MAX(x, y) ((x) > (y) ? (x) : (y))
#endif
#ifdef PTHREAD_STACK_MIN
#define THREAD_STACK_SIZE MAX(8*1024, PTHREAD_STACK_MIN)
#else
#define THREAD_STACK_SIZE 64*1024
#endif
#if defined(__APPLE__)
#undef THREAD_STACK_SIZE
#define THREAD_STACK_SIZE 64*1024
#elif defined(__GLIBC__) || defined(__FreeBSD__)
#undef THREAD_STACK_SIZE
#define THREAD_STACK_SIZE 32*1024
#endif
static const char* auth_user;
static const char* auth_pass;
static sblist* auth_ips;
static pthread_rwlock_t auth_ips_lock = PTHREAD_RWLOCK_INITIALIZER;
static const struct server* server;
static union sockaddr_union bind_addr = {.v4.sin_family = AF_UNSPEC};
enum socksstate {
SS_1_CONNECTED,
SS_2_NEED_AUTH, /* skipped if NO_AUTH method supported */
SS_3_AUTHED,
};
enum authmethod {
AM_NO_AUTH = 0,
AM_GSSAPI = 1,
AM_USERNAME = 2,
AM_INVALID = 0xFF
};
enum errorcode {
EC_SUCCESS = 0,
EC_GENERAL_FAILURE = 1,
EC_NOT_ALLOWED = 2,
EC_NET_UNREACHABLE = 3,
EC_HOST_UNREACHABLE = 4,
EC_CONN_REFUSED = 5,
EC_TTL_EXPIRED = 6,
EC_COMMAND_NOT_SUPPORTED = 7,
EC_ADDRESSTYPE_NOT_SUPPORTED = 8,
};
struct thread {
pthread_t pt;
struct client client;
enum socksstate state;
volatile int done;
};
#ifndef CONFIG_LOG
#define CONFIG_LOG 1
#endif
#if CONFIG_LOG
/* we log to stderr because it's not using line buffering, i.e. malloc which would need
locking when called from different threads. for the same reason we use dprintf,
which writes directly to an fd. */
#define dolog(...) dprintf(2, __VA_ARGS__)
#else
static void dolog(const char* fmt, ...) { }
#endif
static struct addrinfo* addr_choose(struct addrinfo* list, union sockaddr_union* bind_addr) {
int af = SOCKADDR_UNION_AF(bind_addr);
if(af == AF_UNSPEC) return list;
struct addrinfo* p;
for(p=list; p; p=p->ai_next)
if(p->ai_family == af) return p;
return list;
}
static int connect_socks_target(unsigned char *buf, size_t n, struct client *client) {
if(n < 5) return -EC_GENERAL_FAILURE;
if(buf[0] != 5) return -EC_GENERAL_FAILURE;
if(buf[1] != 1) return -EC_COMMAND_NOT_SUPPORTED; /* we support only CONNECT method */
if(buf[2] != 0) return -EC_GENERAL_FAILURE; /* malformed packet */
int af = AF_INET;
size_t minlen = 4 + 4 + 2, l;
char namebuf[256];
struct addrinfo* remote;
switch(buf[3]) {
case 4: /* ipv6 */
af = AF_INET6;
minlen = 4 + 2 + 16;
/* fall through */
case 1: /* ipv4 */
if(n < minlen) return -EC_GENERAL_FAILURE;
if(namebuf != inet_ntop(af, buf+4, namebuf, sizeof namebuf))
return -EC_GENERAL_FAILURE; /* malformed or too long addr */
break;
case 3: /* dns name */
l = buf[4];
minlen = 4 + 2 + l + 1;
if(n < 4 + 2 + l + 1) return -EC_GENERAL_FAILURE;
memcpy(namebuf, buf+4+1, l);
namebuf[l] = 0;
break;
default:
return -EC_ADDRESSTYPE_NOT_SUPPORTED;
}
unsigned short port;
port = (buf[minlen-2] << 8) | buf[minlen-1];
/* there's no suitable errorcode in rfc1928 for dns lookup failure */
if(resolve(namebuf, port, &remote)) return -EC_GENERAL_FAILURE;
struct addrinfo* raddr = addr_choose(remote, &bind_addr);
int fd = socket(raddr->ai_family, SOCK_STREAM, 0);
if(fd == -1) {
eval_errno:
if(fd != -1) close(fd);
freeaddrinfo(remote);
switch(errno) {
case ETIMEDOUT:
return -EC_TTL_EXPIRED;
case EPROTOTYPE:
case EPROTONOSUPPORT:
case EAFNOSUPPORT:
return -EC_ADDRESSTYPE_NOT_SUPPORTED;
case ECONNREFUSED:
return -EC_CONN_REFUSED;
case ENETDOWN:
case ENETUNREACH:
return -EC_NET_UNREACHABLE;
case EHOSTUNREACH:
return -EC_HOST_UNREACHABLE;
case EBADF:
default:
perror("socket/connect");
return -EC_GENERAL_FAILURE;
}
}
if(SOCKADDR_UNION_AF(&bind_addr) == raddr->ai_family &&
bindtoip(fd, &bind_addr) == -1)
goto eval_errno;
if(connect(fd, raddr->ai_addr, raddr->ai_addrlen) == -1)
goto eval_errno;
freeaddrinfo(remote);
if(CONFIG_LOG) {
char clientname[256];
af = SOCKADDR_UNION_AF(&client->addr);
void *ipdata = SOCKADDR_UNION_ADDRESS(&client->addr);
inet_ntop(af, ipdata, clientname, sizeof clientname);
dolog("client[%d] %s: connected to %s:%d\n", client->fd, clientname, namebuf, port);
}
return fd;
}
static int is_authed(union sockaddr_union *client, union sockaddr_union *authedip) {
int af = SOCKADDR_UNION_AF(authedip);
if(af == SOCKADDR_UNION_AF(client)) {
size_t cmpbytes = af == AF_INET ? 4 : 16;
void *cmp1 = SOCKADDR_UNION_ADDRESS(client);
void *cmp2 = SOCKADDR_UNION_ADDRESS(authedip);
if(!memcmp(cmp1, cmp2, cmpbytes)) return 1;
}
return 0;
}
static int is_in_authed_list(union sockaddr_union *caddr) {
size_t i;
for(i=0;i<sblist_getsize(auth_ips);i++)
if(is_authed(caddr, sblist_get(auth_ips, i)))
return 1;
return 0;
}
static void add_auth_ip(union sockaddr_union *caddr) {
sblist_add(auth_ips, caddr);
}
static enum authmethod check_auth_method(unsigned char *buf, size_t n, struct client*client) {
if(buf[0] != 5) return AM_INVALID;
size_t idx = 1;
if(idx >= n ) return AM_INVALID;
int n_methods = buf[idx];
idx++;
while(idx < n && n_methods > 0) {
if(buf[idx] == AM_NO_AUTH) {
if(!auth_user) return AM_NO_AUTH;
else if(auth_ips) {
int authed = 0;
if(pthread_rwlock_rdlock(&auth_ips_lock) == 0) {
authed = is_in_authed_list(&client->addr);
pthread_rwlock_unlock(&auth_ips_lock);
}
if(authed) return AM_NO_AUTH;
}
} else if(buf[idx] == AM_USERNAME) {
if(auth_user) return AM_USERNAME;
}
idx++;
n_methods--;
}
return AM_INVALID;
}
static void send_auth_response(int fd, int version, enum authmethod meth) {
unsigned char buf[2];
buf[0] = version;
buf[1] = meth;
write(fd, buf, 2);
}
static void send_error(int fd, enum errorcode ec) {
/* position 4 contains ATYP, the address type, which is the same as used in the connect
request. we're lazy and return always IPV4 address type in errors. */
char buf[10] = { 5, ec, 0, 1 /*AT_IPV4*/, 0,0,0,0, 0,0 };
write(fd, buf, 10);
}
static void copyloop(int fd1, int fd2) {
struct pollfd fds[2] = {
[0] = {.fd = fd1, .events = POLLIN},
[1] = {.fd = fd2, .events = POLLIN},
};
while(1) {
/* inactive connections are reaped after 15 min to free resources.
usually programs send keep-alive packets so this should only happen
when a connection is really unused. */
switch(poll(fds, 2, 60*15*1000)) {
case 0:
return;
case -1:
if(errno == EINTR || errno == EAGAIN) continue;
else perror("poll");
return;
}
int infd = (fds[0].revents & POLLIN) ? fd1 : fd2;
int outfd = infd == fd2 ? fd1 : fd2;
char buf[1024];
ssize_t sent = 0, n = read(infd, buf, sizeof buf);
if(n <= 0) return;
while(sent < n) {
ssize_t m = write(outfd, buf+sent, n-sent);
if(m < 0) return;
sent += m;
}
}
}
static enum errorcode check_credentials(unsigned char* buf, size_t n) {
if(n < 5) return EC_GENERAL_FAILURE;
if(buf[0] != 1) return EC_GENERAL_FAILURE;
unsigned ulen, plen;
ulen=buf[1];
if(n < 2 + ulen + 2) return EC_GENERAL_FAILURE;
plen=buf[2+ulen];
if(n < 2 + ulen + 1 + plen) return EC_GENERAL_FAILURE;
char user[256], pass[256];
memcpy(user, buf+2, ulen);
memcpy(pass, buf+2+ulen+1, plen);
user[ulen] = 0;
pass[plen] = 0;
if(!strcmp(user, auth_user) && !strcmp(pass, auth_pass)) return EC_SUCCESS;
return EC_NOT_ALLOWED;
}
static void* clientthread(void *data) {
struct thread *t = data;
t->state = SS_1_CONNECTED;
unsigned char buf[1024];
ssize_t n;
int ret;
int remotefd = -1;
enum authmethod am;
while((n = recv(t->client.fd, buf, sizeof buf, 0)) > 0) {
switch(t->state) {
case SS_1_CONNECTED:
am = check_auth_method(buf, n, &t->client);
if(am == AM_NO_AUTH) t->state = SS_3_AUTHED;
else if (am == AM_USERNAME) t->state = SS_2_NEED_AUTH;
send_auth_response(t->client.fd, 5, am);
if(am == AM_INVALID) goto breakloop;
break;
case SS_2_NEED_AUTH:
ret = check_credentials(buf, n);
send_auth_response(t->client.fd, 1, ret);
if(ret != EC_SUCCESS)
goto breakloop;
t->state = SS_3_AUTHED;
if(auth_ips && !pthread_rwlock_wrlock(&auth_ips_lock)) {
if(!is_in_authed_list(&t->client.addr))
add_auth_ip(&t->client.addr);
pthread_rwlock_unlock(&auth_ips_lock);
}
break;
case SS_3_AUTHED:
ret = connect_socks_target(buf, n, &t->client);
if(ret < 0) {
send_error(t->client.fd, ret*-1);
goto breakloop;
}
remotefd = ret;
send_error(t->client.fd, EC_SUCCESS);
copyloop(t->client.fd, remotefd);
goto breakloop;
}
}
breakloop:
if(remotefd != -1)
close(remotefd);
close(t->client.fd);
t->done = 1;
return 0;
}
static void collect(sblist *threads) {
size_t i;
for(i=0;i<sblist_getsize(threads);) {
struct thread* thread = *((struct thread**)sblist_get(threads, i));
if(thread->done) {
pthread_join(thread->pt, 0);
sblist_delete(threads, i);
free(thread);
} else
i++;
}
}
static int usage(void) {
dprintf(2,
"MicroSocks SOCKS5 Server\n"
"------------------------\n"
"usage: microsocks -1 -i listenip -p port -u user -P password -b bindaddr\n"
"all arguments are optional.\n"
"by default listenip is 0.0.0.0 and port 1080.\n\n"
"option -b specifies which ip outgoing connections are bound to\n"
"option -1 activates auth_once mode: once a specific ip address\n"
"authed successfully with user/pass, it is added to a whitelist\n"
"and may use the proxy without auth.\n"
"this is handy for programs like firefox that don't support\n"
"user/pass auth. for it to work you'd basically make one connection\n"
"with another program that supports it, and then you can use firefox too.\n"
);
return 1;
}
/* prevent username and password from showing up in top. */
static void zero_arg(char *s) {
size_t i, l = strlen(s);
for(i=0;i<l;i++) s[i] = 0;
}
int main(int argc, char** argv) {
int ch;
const char *listenip = "0.0.0.0";
unsigned port = 1080;
while((ch = getopt(argc, argv, ":1b:i:p:u:P:")) != -1) {
switch(ch) {
case '1':
auth_ips = sblist_new(sizeof(union sockaddr_union), 8);
break;
case 'b':
resolve_sa(optarg, 0, &bind_addr);
break;
case 'u':
auth_user = strdup(optarg);
zero_arg(optarg);
break;
case 'P':
auth_pass = strdup(optarg);
zero_arg(optarg);
break;
case 'i':
listenip = optarg;
break;
case 'p':
port = atoi(optarg);
break;
case ':':
dprintf(2, "error: option -%c requires an operand\n", optopt);
/* fall through */
case '?':
return usage();
}
}
if((auth_user && !auth_pass) || (!auth_user && auth_pass)) {
dprintf(2, "error: user and pass must be used together\n");
return 1;
}
if(auth_ips && !auth_pass) {
dprintf(2, "error: auth-once option must be used together with user/pass\n");
return 1;
}
signal(SIGPIPE, SIG_IGN);
struct server s;
sblist *threads = sblist_new(sizeof (struct thread*), 8);
if(server_setup(&s, listenip, port)) {
perror("server_setup");
return 1;
}
server = &s;
while(1) {
collect(threads);
struct client c;
struct thread *curr = malloc(sizeof (struct thread));
if(!curr) goto oom;
curr->done = 0;
if(server_waitclient(&s, &c)) {
dolog("failed to accept connection\n");
free(curr);
usleep(FAILURE_TIMEOUT);
continue;
}
curr->client = c;
if(!sblist_add(threads, &curr)) {
close(curr->client.fd);
free(curr);
oom:
dolog("rejecting connection due to OOM\n");
usleep(FAILURE_TIMEOUT); /* prevent 100% CPU usage in OOM situation */
continue;
}
pthread_attr_t *a = 0, attr;
if(pthread_attr_init(&attr) == 0) {
a = &attr;
pthread_attr_setstacksize(a, THREAD_STACK_SIZE);
}
if(pthread_create(&curr->pt, a, clientthread, curr) != 0)
dolog("pthread_create failed. OOM?\n");
if(a) pthread_attr_destroy(&attr);
}
}

View File

@@ -0,0 +1,23 @@
# 复制为服务器上的 tiany0721.ovpn并与 ca.crt / 证书 / 密钥同目录放置。
# 勿将真实证书提交到 Git。
client
dev tun
proto tcp
remote 111.31.14.226 32199
resolv-retry infinite
nobind
persist-key
persist-tun
ca ca.crt
cert tiany0721.crt
key tiany0721.key
remote-cert-tls server
tls-auth ta.key 1
cipher AES-256-CBC
compress lz4-v2
verb 3
# 仅路由戎行主机,不接管默认网关(其它流量仍走容器原出口)
route-nopull
route 192.168.3.43 255.255.255.255

View File

@@ -0,0 +1,17 @@
# 从本机 C:\Users\a1726\Desktop\11\OpenVPN\config 上传到服务器:
# /opt/1panel/apps/hyapi-secrets/openvpn/
#
# 需要:
# [ ] tiany0721.ovpn (上传后按 example 加上 route-nopull + route
# [ ] ca.crt
# [ ] tiany0721.crt
# [ ] tiany0721.key
# [ ] ta.key
#
# 不需要:
# [x] bin/、doc/、Uninstall.exe、openvpn-gui.exe 等 Windows 安装文件
#
# 权限:
# chmod 700 /opt/1panel/apps/hyapi-secrets /opt/1panel/apps/hyapi-secrets/openvpn
# chmod 600 *.key
# chmod 644 *.crt *.ovpn

View File

@@ -38,6 +38,45 @@ services:
ports:
- "25010:5432"
# 戎行 OpenVPN 客户端 + SOCKS5仅 192.168.3.43 走隧道)
# 证书目录:默认 ./secrets/openvpn1Panel 建议:
# export RONGXING_VPN_CONFIG=/opt/1panel/apps/hyapi-secrets/openvpn
rongxing-vpn:
build:
context: ./deployments/openvpn
dockerfile: Dockerfile
image: hyapi-rongxing-vpn:local
container_name: hyapi-rongxing-vpn
environment:
TZ: Asia/Shanghai
RONGXING_HOST: "192.168.3.43"
SOCKS_HOST: "0.0.0.0"
SOCKS_PORT: "1080"
volumes:
- ${RONGXING_VPN_CONFIG:-./secrets/openvpn}:/vpn:ro
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
networks:
- hyapi-network
# 禁止把 1080 暴露到宿主机公网
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "ip link show tun0 >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
deploy:
resources:
limits:
memory: 256M
cpus: "0.3"
reservations:
memory: 64M
cpus: "0.1"
# Redis 缓存 (生产环境)
redis:
image: redis:8.0.2
@@ -101,6 +140,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
rongxing-vpn:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
@@ -134,6 +175,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
rongxing-vpn:
condition: service_started
healthcheck:
test: ["CMD", "ps", "aux", "|", "grep", "worker"]
interval: 30s

View File

@@ -0,0 +1,262 @@
# 戎行数据源 OpenVPN 接入方案1Panel
> 目标:**仅** `rongxing``http://192.168.3.43:7007`)走企业 VPNPostgres / Redis / 其它外部 API 仍走原出口。
> 方案:**OpenVPN 容器 + SOCKS5 代理 + 戎行 HTTP 客户端单独走代理**。
---
## 1. 架构
```
hyapi-network现有 bridge
┌─────────────────────────────────────────────────────────────┐
│ hyapi-app / hyapi-worker │
│ ├─ DB / Redis / 其它 API ──► 默认出口(不经 VPN
│ └─ 戎行 Client ──► socks5://rongxing-vpn:1080 │
│ │ │
│ rongxing-vpnopenvpn + microsocks│ │
│ TUN ──► OpenVPN Server 111.31.14.226:32199 │
│ ──► 企业内网 192.168.3.43:7007 │
└─────────────────────────────────────────────────────────────┘
```
要点:
- 业务容器网络身份不变(端口、健康检查、服务发现保持现状)。
- VPN 只负责进企业网;对外暴露的是 Docker 内网 SOCKS5不映射到公网。
- OpenVPN 使用 `route-nopull`,只加 `192.168.3.43`,避免抢走默认路由。
---
## 2. VPN 材料要不要上传?
**要上传,但只上传「客户端配置与证书」,不要上传整个 Windows OpenVPN 安装目录。**
| 本地路径(你机器) | 是否上传 | 说明 |
|--------------------|----------|------|
| `OpenVPN\config\tiany0721.ovpn` | ✅ | 连接配置(服务器上会再加路由控制指令) |
| `OpenVPN\config\ca.crt` | ✅ | CA |
| `OpenVPN\config\tiany0721.crt` | ✅ | 客户端证书 |
| `OpenVPN\config\tiany0721.key` | ✅ | 客户端私钥(敏感) |
| `OpenVPN\config\ta.key` | ✅ | tls-auth |
| `OpenVPN\bin\``doc\``Uninstall.exe` 等 | ❌ | Windows 安装包,容器不用 |
服务器上使用 **Linux 版 OpenVPN 镜像**,复用上述 5 个文件即可。
---
## 3. 1Panel 推荐目录(最优落点)
假设 1Panel 数据盘常用根为 `/opt/1panel`(若你改过安装目录,把前缀换成实际路径)。
**推荐:密钥与业务代码分离。**
```text
/opt/1panel/apps/hyapi/ # 业务 Compose / 代码(可随发布更新)
├── docker-compose.prod.yml
├── config.yaml / configs/...
├── ...
└── (不要把 .key 放进 git 仓库目录)
/opt/1panel/apps/hyapi-secrets/openvpn/ # ★ VPN 机密目录(推荐)
├── tiany0721.ovpn # 服务器侧改过的版本(见下文)
├── ca.crt
├── tiany0721.crt
├── tiany0721.key # 权限 600
└── ta.key # 权限 600
```
备选(同样可以):
```text
/data/hyapi/secrets/openvpn/ # 若 1Panel 数据盘挂在 /data
```
**不要放:**
- 网站静态目录 `/opt/1panel/www/...`(易被误暴露)
- 仓库内 `hyapi-server/config/` 或会随 CI 同步的路径(防泄漏)
**权限建议SSH 上执行):**
```bash
sudo mkdir -p /opt/1panel/apps/hyapi-secrets/openvpn
sudo chmod 700 /opt/1panel/apps/hyapi-secrets
sudo chmod 700 /opt/1panel/apps/hyapi-secrets/openvpn
# 上传文件后:
sudo chmod 600 /opt/1panel/apps/hyapi-secrets/openvpn/*.key
sudo chmod 644 /opt/1panel/apps/hyapi-secrets/openvpn/*.{crt,ovpn}
```
---
## 4. 服务器侧 OpenVPN 配置(准备好再挂载)
把本地 `tiany0721.ovpn` 拷到服务器后,**追加**以下行(保留原有 `remote` / 证书引用):
```conf
# --- 仅路由戎行内网,不接管默认网关 ---
route-nopull
route 192.168.3.43 255.255.255.255
```
完整示例见仓库:`deployments/openvpn/tiany0721.ovpn.example`
证书相对路径保持与 `.ovpn` 同目录即可(`ca ca.crt` 等),挂载整个目录到容器 `/vpn`
---
## 5. Compose 片段(接入现有 prod
参考文件:`deployments/openvpn/docker-compose.rongxing-vpn.snippet.yml`
核心服务示意:
```yaml
rongxing-vpn:
image: ghcr.io/linuxserver/openvpn-as:latest # 不推荐 AS见下方推荐镜像
```
**推荐用轻量客户端镜像**(文档配套 snippet 已按此写):
- 镜像:`dperson/openvpn-client` **或** 自建 `alpine + openvpn + microsocks`
- 能力:`cap_add: [NET_ADMIN]``devices: [/dev/net/tun]`
- 挂载:`/opt/1panel/apps/hyapi-secrets/openvpn:/vpn:ro`
- 网络:加入现有 `hyapi-network`
- **不要**把 `1080` 映射到宿主机公网端口
业务侧:
- `hyapi-app` /(若异步也会调戎行)`hyapi-worker` 保持原 `networks: [hyapi-network]`
- 配置增加代理地址:`socks5://rongxing-vpn:1080`
---
## 6. 应用配置(仅戎行)
`rongxing` 段增加代理字段(实现代码时再接线;配置先预留):
```yaml
rongxing:
url: "http://192.168.3.43:7007"
account: "..."
password: "..."
app_id: "..."
private_key: "..."
timeout: 10s
# 仅戎行走 VPN 旁路代理;留空则直连(开发机已连 VPN 时可留空)
proxy: "socks5://rongxing-vpn:1080"
logging:
# ...
```
开发机若已用 GUI 连上 VPN`proxy` 留空即可,无需起 Docker VPN。
---
## 7. 代码改动清单(后续开发)
当前 `RongxingService` 使用裸 `http.Client`,需:
1. `RongxingConfig` / `serviceConfig` 增加 `Proxy string`
2. `NewRongxingServiceWithConfig` 传入 `cfg.Rongxing.Proxy`
3. 创建 `http.Client` 时:若 `Proxy` 非空,用 `http.ProxyURL` 或 SOCKS5 dialer`golang.org/x/net/proxy`
4. **只改戎行包**,其它外部服务不动
验证点:未配 `proxy` 时行为与现在一致。
---
## 8. 1Panel 上操作清单
### 8.1 一次性准备
1. SSH 登录服务器,创建 `/opt/1panel/apps/hyapi-secrets/openvpn/`
2. 用 SFTP / `scp` 上传 5 个文件(见第 2 节)
3. 编辑服务器上的 `tiany0721.ovpn`,加上 `route-nopull` + `route 192.168.3.43 ...`
4. 设好目录/文件权限(第 3 节)
5. 确认内核允许 TUN多数 1Panel/Linux 默认有 `/dev/net/tun`;若容器报错再装 `tun` 模块
### 8.2 接入 Compose
任选其一:
- **A. 1Panel「编排」**:打开现有 hyapi 的 Compose粘贴 `rongxing-vpn` 服务片段,挂载 secrets 目录,重新部署
- **B. 独立编排**:单独建一个 `rongxing-vpn` 栈,但必须加入与 hyapi **同一个** Docker network`hyapi-network` 的外部网络名以 `docker network ls` 为准)
查现有网络名:
```bash
docker network ls | grep hyapi
docker inspect hyapi-app-prod --format '{{json .NetworkSettings.Networks}}'
```
若网络是 compose 项目前缀(如 `hyapi-server_hyapi-network`),独立栈里要:
```yaml
networks:
hyapi-network:
external: true
name: hyapi-server_hyapi-network # 以实际名为准
```
### 8.3 发布后验证
```bash
# 1) VPN 容器是否拿到 tun / 路由
docker exec -it <rongxing-vpn容器名> ip route
# 应能看到 192.168.3.43 经 tun0
# 2) 在 VPN 容器内测内网(镜像若有 curl
docker exec -it <rongxing-vpn容器名> wget -qO- --timeout=5 http://192.168.3.43:7007/ || true
# 3) 从业务容器经 SOCKS5 测(需业务镜像有 curl或临时 alpine
docker run --rm --network <hyapi网络名> curlimages/curl:8.5.0 \
-x socks5h://rongxing-vpn:1080 -m 10 -v http://192.168.3.43:7007/
# 4) 确认「不走代理」时业务仍能访问公网/库(对照现有 health
curl -f http://127.0.0.1:25000/health
```
业务侧再打一笔戎行真实查询,看 `logs/external_services` 下戎行日志是否成功。
---
## 9. 安全与运维
- `.key` / `ta.key` / 账号密码:**不进 Git**secrets 目录不挂到可下载的 Web 根。
- SOCKS5 **仅**监听容器网络,不 `-p 1080:1080` 到宿主机。
- VPN 证书到期前找对方续期,替换 secrets 目录文件后 `docker compose restart rongxing-vpn`
- 断线策略:容器 `restart: unless-stopped`OpenVPN 侧已有 `resolv-retry infinite` / `persist-tun`
- 监控:可对 `rongxing-vpn` 做 1Panel 容器存活告警;业务侧戎行错误率告警。
---
## 10. 本地文件 → 服务器对照表
| 本地 | 服务器推荐路径 |
|------|----------------|
| `...\OpenVPN\config\tiany0721.ovpn` | `/opt/1panel/apps/hyapi-secrets/openvpn/tiany0721.ovpn`(改过路由) |
| `...\OpenVPN\config\ca.crt` | 同目录 |
| `...\OpenVPN\config\tiany0721.crt` | 同目录 |
| `...\OpenVPN\config\tiany0721.key` | 同目录600 |
| `...\OpenVPN\config\ta.key` | 同目录600 |
| Windows `bin/` 整包 | **不上传** |
---
## 11. 实施状态与顺序
**代码与 Compose 已落地**(见仓库当前变更):
- `rongxing` 支持 `proxy`SOCKS5/HTTP
- `docker-compose.prod.yml` 已加入 `rongxing-vpn` 服务
- `deployments/openvpn/` 自建镜像OpenVPN + microsocks
- 生产配置 `configs/env.production.yaml``proxy: "socks5://rongxing-vpn:1080"`
**1Panel 服务器仍需人工完成:**
1. 创建 `/opt/1panel/apps/hyapi-secrets/openvpn/`,上传 5 个证书/配置文件(可用本机已备好的 `secrets/openvpn/` 内容)
2. 设置环境变量:`RONGXING_VPN_CONFIG=/opt/1panel/apps/hyapi-secrets/openvpn`
3. 拉取/构建并重新部署 Compose确保服务器能访问 Docker Hub / 镜像源以下载 `alpine`
4. 验证:`docker exec hyapi-rongxing-vpn ip route``192.168.3.43`;业务侧打一笔戎行查询

View File

@@ -846,6 +846,7 @@ type RongxingConfig struct {
AppID string `mapstructure:"app_id"` // 应用 ID
PrivateKey string `mapstructure:"private_key"` // RSA 私钥PEM 或 PKCS#8 Base64
Timeout time.Duration `mapstructure:"timeout"`
Proxy string `mapstructure:"proxy"` // 可选,仅戎行请求走此代理,如 socks5://rongxing-vpn:1080
Logging RongxingLoggingConfig `mapstructure:"logging"`
}

View File

@@ -233,6 +233,13 @@ type JRZQH6M3Req struct {
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
// JRZQ5J5CReq 无间司南-纯黑版C10 金融黑名单
type JRZQ5J5CReq struct {
Name string `json:"name" validate:"required,min=1,validName"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
// JRZQW3L8Req 信用司南
type JRZQW3L8Req struct {
Name string `json:"name" validate:"required,min=1,validName"`
@@ -1350,6 +1357,10 @@ type FLXGC4CTReq struct {
AuthPDFBase64 string `json:"auth_pdf_base64" validate:"required,validAuthPDFBase64"`
}
type FLXLLD77Req struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
}
type QYGLLUCMReq struct {
EntName string `json:"ent_name" validate:"required,min=1,validEnterpriseName"`
AuthPDFBase64 string `json:"auth_pdf_base64" validate:"required,validAuthPDFBase64"`
@@ -1360,3 +1371,37 @@ type JRZQ0OO1Req struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
}
type JRZQT2C1Req struct {
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
}
type JRZQ2PV2Req struct {
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
}
type QCXGV20OReq struct {
VinCode string `json:"vin_code" validate:"required"`
}
type QCXGVP00Req struct {
VinCode string `json:"vin_code" validate:"required"`
}
type QCXGVJ70Req struct {
PlateNo string `json:"plate_no" validate:"required"`
}
type QCXG2Y8XReq struct {
PlateNo string `json:"plate_no" validate:"omitempty"`
IDCard string `json:"id_card" validate:"omitempty,validIDCard"`
VinCode string `json:"vin_code" validate:"omitempty"`
}
type QCXG74YTReq struct {
PlateNo string `json:"plate_no" validate:"omitempty"`
IDCard string `json:"id_card" validate:"omitempty,validIDCard"`
VinCode string `json:"vin_code" validate:"omitempty"`
}

View File

@@ -176,6 +176,7 @@ func registerAllProcessors(combService *comb.CombService) {
"FLXGJI17": flxg.ProcessFLXGJI17Request, //董监高司法综合信息核验
"FLXGMMG7": flxg.ProcessFLXGMMG7Request, //个人诉讼定制版
"FLXGC4CT": flxg.ProcessFLXGC4CTRequest, //个人涉诉案件查询汇博
"FLXLLD77": flxg.ProcessFLXLLD77Request, //劳动仲裁查询天远
// JRZQ系列处理器
"JRZQOICN": jrzq.ProcessJRZQOICNRequest, // 银行卡四要素
"JRZQMDQ1": jrzq.ProcessJRZQMDQ1Request, // 银行卡OCR数卖
@@ -209,6 +210,12 @@ func registerAllProcessors(combService *comb.CombService) {
"JRZQW3L8": jrzq.ProcessJRZQW3L8Request, //信用司南
"JRZQP8D2": jrzq.ProcessJRZQP8D2Request, //全景雷达BH
"JRZQ0OO1": jrzq.ProcessJRZQ0OO1Request, //戎行贷后信息
"JRZQT2C1": jrzq.ProcessJRZQT2C1Request, //探针C
"JRZQT2A1": jrzq.ProcessJRZQT2A1Request, //探针A
"JRZQS74D": jrzq.ProcessJRZQS74DRequest, //申请借贷
"JRZQ2F4W": jrzq.ProcessJRZQ2F4WRequest, //支付行为
"JRZQ2PV2": jrzq.ProcessJRZQ2PV2Request, //租赁通用版v2
"JRZQ5J5C": jrzq.ProcessJRZQ5J5CRequest, //无间司南-纯黑版C10 金融黑名单
// QYGL系列处理器
"QYGL7HBN": qygl.ProcessQYGL7HBNRequest, //企业全景报告(聚合 QYGLUY3S/QYGLJ0Q1/QYGL5S1I
@@ -223,6 +230,11 @@ func registerAllProcessors(combService *comb.CombService) {
"QCXGCP77": qcxg.ProcessQCXGCP77Request, //全国车辆配置查验(车辆详情)
"QCXGX2X6": qcxg.ProcessQCXGX2X6Request, //行驶证信息核验V2
"QCXG1S2L": qcxg.ProcessQCXG1S2LRequest, //汽车车辆五项
"QCXGV20O": qcxg.ProcessQCXGV20ORequest, //车VIN查询估值
"QCXGVP00": qcxg.ProcessQCXGVP00Request, //车VIN查车牌号
"QCXGVJ70": qcxg.ProcessQCXGVJ70Request, //车牌号查vin
"QCXG2Y8X": qcxg.ProcessQCXG2Y8XRequest, //商业险有效性
"QCXG74YT": qcxg.ProcessQCXG74YTRequest, //交强险有效性
// YYSY系列处理器
"YYSY0YYV": yysy.ProcessYYSY0YYVRequest, //运营商二要素查询

View File

@@ -320,7 +320,18 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string
"IVYZX7J9": &dto.IVYZX7J9Req{}, //学籍核验
"QCXG6U5G": &dto.QCXG6U5GReq{}, //车辆核验
"JRZQ0OO1": &dto.JRZQ0OO1Req{}, //戎行贷后信息 Info360
"JRZQT2C1": &dto.JRZQT2C1Req{}, //探针C
"JRZQT2A1": &dto.JRZQT2C1Req{}, //探针A
"JRZQS74D": &dto.JRZQT2C1Req{}, //申请借贷
"JRZQ2F4W": &dto.JRZQT2C1Req{}, //支付行为
"JRZQ2PV2": &dto.JRZQ2PV2Req{}, //租赁通用版v2
"FLXLLD77": &dto.FLXLLD77Req{}, //劳动仲裁查询天远
"QCXGV20O": &dto.QCXGV20OReq{}, //车VIN查询估值
"QCXGVP00": &dto.QCXGVP00Req{}, //车VIN查车牌号
"QCXGVJ70": &dto.QCXGVJ70Req{}, //车牌号查vin
"QCXG2Y8X": &dto.QCXG2Y8XReq{}, //商业险有效性
"QCXG74YT": &dto.QCXG74YTReq{}, //交强险有效性
"JRZQ5J5C": &dto.JRZQ5J5CReq{}, //无间司南-纯黑版C10 金融黑名单
}
// 优先返回已配置的DTO

View File

@@ -0,0 +1,33 @@
package flxg
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
)
// ProcessFLXLLD77Request FLXLLD77 API处理方法 - 劳动仲裁查询天远
func ProcessFLXLLD77Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.FLXLLD77Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
reqdata := map[string]interface{}{
"name": paramsDto.Name,
"id_card": paramsDto.IDCard,
}
respBytes, err := deps.TianyuanapiService.CallAPI(ctx, "IVYZ0S0D", reqdata)
if err != nil {
return nil, errors.Join(processors.ErrDatasource, err)
}
return respBytes, nil
}

View File

@@ -42,5 +42,9 @@ func ProcessJRZQ0OO1Request(ctx context.Context, params []byte, deps *processors
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
transformed, err := transformJRZQ0OO1Response(respBytes)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return transformed, nil
}

View File

@@ -0,0 +1,70 @@
package jrzq
import (
"encoding/json"
"fmt"
"strings"
)
// JRZQ0OO1 字段命名约定:{窗口}_{机构类型}_{事件}_{指标}
// 窗口: m1/m3/m6/m12
// 机构类型: 省略=全部贷款bank=银行cfc=消金sloan=小贷
// 事件: total/severe_overdue/overdue/repay_fail/repay_ok/repay_remind/
//
// disburse_fail/disburse_ok/audit_fail/audit_ok/apply/register/verify_code/marketing/other
//
// 指标: cnt=次数org=机构数
var (
jrzq0OO1Periods = []string{"m1", "m3", "m6", "m12"}
jrzq0OO1InstTypes = []string{"", "bank", "cfc", "sloan"}
jrzq0OO1Events = []string{
"total", "severe_overdue", "overdue", "repay_fail", "repay_ok",
"repay_remind", "disburse_fail", "disburse_ok", "audit_fail", "audit_ok",
"apply", "register", "verify_code", "marketing", "other",
}
jrzq0OO1Metrics = []string{"cnt", "org"}
// jrzq0OO1FieldMap B001-B480 -> 语义化扁平字段名
jrzq0OO1FieldMap = buildJRZQ0OO1FieldMap()
)
func buildJRZQ0OO1FieldMap() map[string]string {
m := make(map[string]string, 480)
n := 1
for _, period := range jrzq0OO1Periods {
for _, inst := range jrzq0OO1InstTypes {
for _, event := range jrzq0OO1Events {
for _, metric := range jrzq0OO1Metrics {
key := fmt.Sprintf("B%03d", n)
parts := []string{period}
if inst != "" {
parts = append(parts, inst)
}
parts = append(parts, event, metric)
m[key] = strings.Join(parts, "_")
n++
}
}
}
}
return m
}
// transformJRZQ0OO1Response 将 Info360 的 Bxxx 字段重命名为语义化扁平字段。
// 未在映射表中的键原样保留。
func transformJRZQ0OO1Response(respBytes []byte) ([]byte, error) {
var raw map[string]interface{}
if err := json.Unmarshal(respBytes, &raw); err != nil {
return nil, err
}
out := make(map[string]interface{}, len(raw))
for k, v := range raw {
if renamed, ok := jrzq0OO1FieldMap[k]; ok {
out[renamed] = v
continue
}
out[k] = v
}
return json.Marshal(out)
}

View File

@@ -0,0 +1,156 @@
package jrzq
import (
"encoding/json"
"testing"
)
func TestBuildJRZQ0OO1FieldMap_CountAndBounds(t *testing.T) {
if len(jrzq0OO1FieldMap) != 480 {
t.Fatalf("expected 480 mappings, got %d", len(jrzq0OO1FieldMap))
}
if got, want := jrzq0OO1FieldMap["B001"], "m1_total_cnt"; got != want {
t.Fatalf("B001: got %q want %q", got, want)
}
if got, want := jrzq0OO1FieldMap["B480"], "m12_sloan_other_org"; got != want {
t.Fatalf("B480: got %q want %q", got, want)
}
}
func TestJRZQ0OO1FieldMap_DocSpotChecks(t *testing.T) {
cases := map[string]string{
"B001": "m1_total_cnt", // 最近1个月贷款类总次数
"B009": "m1_repay_ok_cnt", // 最近1个月还款成功次数
"B031": "m1_bank_total_cnt", // 最近1个月银行类贷款总次数
"B129": "m3_repay_ok_cnt", // 最近3个月还款成功次数
"B130": "m3_repay_ok_org", // 最近3个月还款成功机构数
"B133": "m3_disburse_fail_cnt", // 最近3个月放款失败次数
"B134": "m3_disburse_fail_org", // 最近3个月放款失败机构数
"B157": "m3_bank_repay_fail_cnt", // 最近3个月银行类还款失败次数
"B158": "m3_bank_repay_fail_org", // 最近3个月银行类还款失败机构数
"B219": "m3_sloan_repay_ok_cnt", // 最近3个月小贷类还款成功次数
"B220": "m3_sloan_repay_ok_org", // 最近3个月小贷类还款成功机构数
"B223": "m3_sloan_disburse_fail_cnt", // 最近3个月小贷类放款失败次数
"B224": "m3_sloan_disburse_fail_org", // 最近3个月小贷类放款失败机构数
"B247": "m6_repay_fail_cnt", // 最近6个月还款失败次数
"B248": "m6_repay_fail_org", // 最近6个月还款失败机构数
"B257": "m6_audit_fail_cnt", // 最近6个月审核失败次数
"B258": "m6_audit_fail_org", // 最近6个月审核失败机构数
"B261": "m6_apply_cnt", // 最近6个月贷款申请次数
"B262": "m6_apply_org", // 最近6个月贷款申请机构数
"B285": "m6_bank_disburse_ok_cnt", // 最近6个月银行类放款成功次数
"B286": "m6_bank_disburse_ok_org", // 最近6个月银行类放款成功机构数
"B347": "m6_sloan_audit_fail_cnt", // 最近6个月小贷类审核失败次数
"B348": "m6_sloan_audit_fail_org", // 最近6个月小贷类审核失败机构数
"B351": "m6_sloan_apply_cnt", // 最近6个月小贷类贷款申请次数
"B352": "m6_sloan_apply_org", // 最近6个月小贷类贷款申请机构数
"B375": "m12_disburse_ok_cnt", // 最近12个月放款成功次数
"B376": "m12_disburse_ok_org", // 最近12个月放款成功机构数
"B061": "m1_cfc_total_cnt", // 最近1个月持牌消金类贷款总次数
"B301": "m6_cfc_total_cnt", // 最近6个月消金类贷款总次数
}
for src, want := range cases {
if got := jrzq0OO1FieldMap[src]; got != want {
t.Errorf("%s: got %q want %q", src, got, want)
}
}
}
func TestTransformJRZQ0OO1Response_Sample(t *testing.T) {
in := []byte(`{
"B129": 6,
"B130": 3,
"B133": 4,
"B134": 2,
"B157": 2,
"B158": 1,
"B219": 6,
"B220": 3,
"B223": 4,
"B224": 2,
"B247": 2,
"B248": 1,
"B257": 6,
"B258": 3,
"B261": 4,
"B262": 2,
"B285": 2,
"B286": 1,
"B347": 6,
"B348": 3,
"B351": 4,
"B352": 2,
"B375": 2,
"B376": 1
}`)
outBytes, err := transformJRZQ0OO1Response(in)
if err != nil {
t.Fatal(err)
}
var out map[string]interface{}
if err := json.Unmarshal(outBytes, &out); err != nil {
t.Fatal(err)
}
want := map[string]float64{
"m3_repay_ok_cnt": 6,
"m3_repay_ok_org": 3,
"m3_disburse_fail_cnt": 4,
"m3_disburse_fail_org": 2,
"m3_bank_repay_fail_cnt": 2,
"m3_bank_repay_fail_org": 1,
"m3_sloan_repay_ok_cnt": 6,
"m3_sloan_repay_ok_org": 3,
"m3_sloan_disburse_fail_cnt": 4,
"m3_sloan_disburse_fail_org": 2,
"m6_repay_fail_cnt": 2,
"m6_repay_fail_org": 1,
"m6_audit_fail_cnt": 6,
"m6_audit_fail_org": 3,
"m6_apply_cnt": 4,
"m6_apply_org": 2,
"m6_bank_disburse_ok_cnt": 2,
"m6_bank_disburse_ok_org": 1,
"m6_sloan_audit_fail_cnt": 6,
"m6_sloan_audit_fail_org": 3,
"m6_sloan_apply_cnt": 4,
"m6_sloan_apply_org": 2,
"m12_disburse_ok_cnt": 2,
"m12_disburse_ok_org": 1,
}
if len(out) != len(want) {
t.Fatalf("field count: got %d want %d, out=%v", len(out), len(want), out)
}
for k, w := range want {
got, ok := out[k]
if !ok {
t.Errorf("missing key %s", k)
continue
}
num, ok := got.(float64)
if !ok || num != w {
t.Errorf("%s: got %#v want %v", k, got, w)
}
}
}
func TestTransformJRZQ0OO1Response_KeepUnknown(t *testing.T) {
in := []byte(`{"B001":1,"extra":"x"}`)
outBytes, err := transformJRZQ0OO1Response(in)
if err != nil {
t.Fatal(err)
}
var out map[string]interface{}
if err := json.Unmarshal(outBytes, &out); err != nil {
t.Fatal(err)
}
if _, ok := out["m1_total_cnt"]; !ok {
t.Fatal("expected m1_total_cnt")
}
if out["extra"] != "x" {
t.Fatalf("expected extra kept, got %#v", out["extra"])
}
}

View File

@@ -0,0 +1,50 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessJRZQ2F4WRequest 支付行为(上游 jy000017body encryptType=1 MD5 + timestamp
func ProcessJRZQ2F4WRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQT2C1Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"name": paramsDto.Name,
"idNo": paramsDto.IDCard,
"mobile": paramsDto.MobileNo,
}
apiKey := "jy000039"
apiPath := "/api/v1/payment/index"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.DefaultCallOptions())
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,49 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessJRZQ2PV2Request 租赁通用版v2
func ProcessJRZQ2PV2Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQ2PV2Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"idNo": paramsDto.IDCard,
"mobile": paramsDto.MobileNo,
}
apiKey := "jy000049"
apiPath := "/api/v1/generlea/agreev2"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.DefaultCallOptions())
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,50 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessJRZQ5J5CRequest 无间司南-纯黑A版上游 jy000052
func ProcessJRZQ5J5CRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQ5J5CReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"name": paramsDto.Name,
"idCard": paramsDto.IDCard,
"mobile": paramsDto.MobileNo,
}
apiKey := "jy000055"
apiPath := "/api/v1/blacklist/c10/verify"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.TopEncryptionOptions(1))
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,50 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessJRZQS74DRequest 申请借贷(上游 jy000017body encryptType=1 MD5 + timestamp
func ProcessJRZQS74DRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQT2C1Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"name": paramsDto.Name,
"idNo": paramsDto.IDCard,
"mobile": paramsDto.MobileNo,
}
apiKey := "jy000048"
apiPath := "/api/v1/pd/loan/behavior"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.DefaultCallOptions())
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,50 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessJRZQT2A1Request 探针A上游 jy000017body encryptType=1 MD5 + timestamp
func ProcessJRZQT2A1Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQT2C1Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"name": jiyi.MD5Encrypt(paramsDto.Name),
"idCard": jiyi.MD5Encrypt(paramsDto.IDCard),
"mobile": jiyi.MD5Encrypt(paramsDto.MobileNo),
}
apiKey := "jy000017"
apiPath := "/api/v1/probe/a/verify/encrypt"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.CaveInvestOptions(1))
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,50 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessJRZQT2C1Request 探针C上游 jy000008顶层 encryptionType=2 MD5
func ProcessJRZQT2C1Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQT2C1Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"name": jiyi.MD5Encrypt(paramsDto.Name),
"idCard": jiyi.MD5Encrypt(paramsDto.IDCard),
"mobile": jiyi.MD5Encrypt(paramsDto.MobileNo),
}
apiKey := "jy000008"
apiPath := "/api/v1/probe/c/verify"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.TopEncryptionOptions(2))
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,57 @@
package qcxg
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
"github.com/google/uuid"
)
// ProcessQCXG2Y8XRequest 商业险有效性
func ProcessQCXG2Y8XRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QCXG2Y8XReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if paramsDto.PlateNo == "" && paramsDto.IDCard == "" && paramsDto.VinCode == "" {
return nil, errors.Join(processors.ErrInvalidParam, errors.New("plate_no、id_card、vin_code 至少需要传其中一个"))
}
body := map[string]string{
"licenseNo": paramsDto.PlateNo,
"idNo": paramsDto.IDCard,
"vin": paramsDto.VinCode,
"nonce": uuid.New().String(),
}
apiKey := "jy000019"
apiPath := "/api/v1/commercial/validity"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.DefaultCallOptions())
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,57 @@
package qcxg
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
"github.com/google/uuid"
)
// ProcessQCXG74YTRequest 交强险有效性
func ProcessQCXG74YTRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QCXG74YTReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if paramsDto.PlateNo == "" && paramsDto.IDCard == "" && paramsDto.VinCode == "" {
return nil, errors.Join(processors.ErrInvalidParam, errors.New("plate_no、id_card、vin_code 至少需要传其中一个"))
}
body := map[string]string{
"licenseNo": paramsDto.PlateNo,
"idNo": paramsDto.IDCard,
"vin": paramsDto.VinCode,
"nonce": uuid.New().String(),
}
apiKey := "jy000020"
apiPath := "/api/v1/compulsory/validity"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.DefaultCallOptions())
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,48 @@
package qcxg
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessQCXGV20ORequest 车VIN查询估值
func ProcessQCXGV20ORequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QCXGV20OReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"vin": paramsDto.VinCode,
}
apiKey := "jy000025"
apiPath := "/api/v1/vehicle/valuation"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.DefaultCallOptions())
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,48 @@
package qcxg
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessQCXGVJ70Request 车牌号查vin
func ProcessQCXGVJ70Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QCXGVJ70Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"plateNo": paramsDto.PlateNo,
}
apiKey := "jy000018"
apiPath := "/api/v1/car/vin"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.DefaultCallOptions())
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,48 @@
package qcxg
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessQCXGVP00Request 车VIN查车牌号
func ProcessQCXGVP00Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QCXGVP00Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"vin": paramsDto.VinCode,
}
apiKey := "jy000028"
apiPath := "/api/v1/plateNumber/vin"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.DefaultCallOptions())
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -19,8 +19,14 @@ const defaultRequestTimeout = 4 * time.Second
// queryBillingAPIKeys 查询计费接口:未查得/空结果仍按成功返回空数据,由平台侧计费
var queryBillingAPIKeys = map[string]struct{}{
"jy000008": {}, // 探针C
"jy000017": {}, // 探针A
"jy000019": {}, // 商业险有效性
"jy000020": {}, // 交强险有效性
"jy000022": {}, // 洞侦1.0
"jy000028": {}, // 车VIN查车牌号
"jy000042": {}, // 借贷意向验证3.0
"jy000048": {}, // 申请借贷
"jy000052": {}, // 无间司南-纯黑A版
}

View File

@@ -5,14 +5,21 @@ import (
)
// generateCurlCommand 生成可直接复现的 curl 命令,便于联调排查。
func generateCurlCommand(method, url string, headers map[string]string, body string) string {
// proxyURL 非空时附加 -x便于确认线上是否应走 SOCKS。
func generateCurlCommand(method, requestURL string, headers map[string]string, body, proxyURL string) string {
var cmd strings.Builder
cmd.WriteString("curl -X ")
cmd.WriteString(method)
cmd.WriteString(" '")
cmd.WriteString(url)
cmd.WriteString(requestURL)
cmd.WriteString("'")
if p := strings.TrimSpace(proxyURL); p != "" {
cmd.WriteString(" \\\n -x '")
cmd.WriteString(escapeSingleQuotes(p))
cmd.WriteString("'")
}
for key, value := range headers {
cmd.WriteString(" \\\n -H '")
cmd.WriteString(key)

View File

@@ -0,0 +1,47 @@
package rongxing
import (
"net/http"
"testing"
"time"
)
func TestNewHTTPClient_Direct(t *testing.T) {
client, err := newHTTPClient(5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client.Timeout != 5*time.Second {
t.Fatalf("timeout = %v", client.Timeout)
}
if client.Transport != nil && client.Transport != http.DefaultTransport {
// 直连允许 Transport 为 nil使用 DefaultTransport
}
}
func TestNewHTTPClient_Socks5(t *testing.T) {
client, err := newHTTPClient(3*time.Second, "socks5://rongxing-vpn:1080")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client.Transport == nil {
t.Fatal("expected custom transport for socks5")
}
}
func TestNewHTTPClient_HTTPProxy(t *testing.T) {
client, err := newHTTPClient(3*time.Second, "http://127.0.0.1:8888")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client.Transport == nil {
t.Fatal("expected custom transport for http proxy")
}
}
func TestNewHTTPClient_UnsupportedScheme(t *testing.T) {
_, err := newHTTPClient(time.Second, "ftp://x")
if err == nil {
t.Fatal("expected error for unsupported scheme")
}
}

View File

@@ -46,5 +46,6 @@ func NewRongxingServiceWithConfig(cfg *config.Config) (*RongxingService, error)
AppID: cfg.Rongxing.AppID,
PrivateKey: cfg.Rongxing.PrivateKey,
Timeout: timeout,
}, logger), nil
Proxy: cfg.Rongxing.Proxy,
}, logger)
}

View File

@@ -7,7 +7,10 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
@@ -15,6 +18,7 @@ import (
"hyapi-server/internal/shared/external_logger"
"go.uber.org/zap"
"golang.org/x/net/proxy"
)
const (
@@ -32,15 +36,17 @@ type serviceConfig struct {
AppID string
PrivateKey string
Timeout time.Duration
Proxy string
}
// RongxingService 戎行数据源服务
type RongxingService struct {
config serviceConfig
logger *external_logger.ExternalServiceLogger
client *http.Client
tokenMu sync.RWMutex
cachedToken string
// loginMu 避免并发登录打爆对方;不缓存 token每次业务调用重新登录。
loginMu sync.Mutex
}
// apiResponse 戎行统一响应。code 可能是数字或字符串;扣费以 consumeFlag 为准。
@@ -65,12 +71,52 @@ func (r apiResponse) code() string {
}
// NewRongxingService 创建戎行服务实例
func NewRongxingService(cfg serviceConfig, logger *external_logger.ExternalServiceLogger) *RongxingService {
func NewRongxingService(cfg serviceConfig, logger *external_logger.ExternalServiceLogger) (*RongxingService, error) {
if cfg.Timeout <= 0 {
cfg.Timeout = defaultRequestTimeout
}
cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
return &RongxingService{config: cfg, logger: logger}
client, err := newHTTPClient(cfg.Timeout, cfg.Proxy)
if err != nil {
return nil, err
}
return &RongxingService{config: cfg, logger: logger, client: client}, nil
}
// newHTTPClient 构建 HTTP 客户端proxyURL 支持 socks5/socks5h/http/https空则直连。
func newHTTPClient(timeout time.Duration, proxyURL string) (*http.Client, error) {
client := &http.Client{Timeout: timeout}
proxyURL = strings.TrimSpace(proxyURL)
if proxyURL == "" {
return client, nil
}
u, err := url.Parse(proxyURL)
if err != nil {
return nil, fmt.Errorf("解析 proxy 失败: %w", err)
}
switch strings.ToLower(u.Scheme) {
case "socks5", "socks5h":
dialer, err := proxy.FromURL(u, proxy.Direct)
if err != nil {
return nil, fmt.Errorf("创建 SOCKS 代理失败: %w", err)
}
transport := &http.Transport{}
if cd, ok := dialer.(proxy.ContextDialer); ok {
transport.DialContext = cd.DialContext
} else {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
}
}
client.Transport = transport
case "http", "https":
client.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
default:
return nil, fmt.Errorf("不支持的 proxy scheme: %s支持 socks5/socks5h/http/https", u.Scheme)
}
return client, nil
}
// GetConfig 获取运行时配置
@@ -80,7 +126,7 @@ func (s *RongxingService) GetConfig() serviceConfig {
// CallAPI 通用业务接口调用。
// apiPath 为相对路径(如 /third/loan/info360reqData 为已组装好的请求体。
// Token 获取与 Header 注入由服务内部处理401/403 时自动刷新 Token 并重试一次。
// 每次调用重新登录取 Token不本地缓存若业务码/HTTP 仍为 401/403再登录重试一次。
func (s *RongxingService) CallAPI(ctx context.Context, apiPath string, reqData map[string]interface{}) ([]byte, error) {
apiKey := strings.Trim(apiPath, "/")
@@ -118,7 +164,7 @@ func (s *RongxingService) CallAPI(ctx context.Context, apiPath string, reqData m
"Content-Type": "application/json",
headerDmsToken: token,
}
curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr)
curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr, s.config.Proxy)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes))
if err != nil {
@@ -139,7 +185,6 @@ func (s *RongxingService) CallAPI(ctx context.Context, apiPath string, reqData m
respStr := string(respBody)
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
s.clearToken()
if attempt == 0 {
continue
}
@@ -162,6 +207,11 @@ func (s *RongxingService) CallAPI(ctx context.Context, apiPath string, reqData m
}
code := resp.code()
// 对方常以 HTTP 200 + body.code=401 表示 token 无效(非 HTTP 401
if isTokenInvalidCode(code) && attempt == 0 {
continue
}
payload := extractBusinessPayload(resp.Data)
// 扣费只看 consumeFlag1 扣费按成功返回0 不扣费
@@ -180,32 +230,20 @@ func (s *RongxingService) CallAPI(ctx context.Context, apiPath string, reqData m
return nil, errors.Join(ErrDatasource, errors.New("请求失败"))
}
// getToken 每次重新登录,不缓存 token。
func (s *RongxingService) getToken(ctx context.Context, transactionID string) (string, error) {
s.tokenMu.RLock()
token := s.cachedToken
s.tokenMu.RUnlock()
if token != "" {
return token, nil
}
s.tokenMu.Lock()
defer s.tokenMu.Unlock()
if s.cachedToken != "" {
return s.cachedToken, nil
}
token, err := s.login(ctx, transactionID)
if err != nil {
return "", err
}
s.cachedToken = token
return token, nil
s.loginMu.Lock()
defer s.loginMu.Unlock()
return s.login(ctx, transactionID)
}
func (s *RongxingService) clearToken() {
s.tokenMu.Lock()
s.cachedToken = ""
s.tokenMu.Unlock()
func isTokenInvalidCode(code string) bool {
switch strings.TrimSpace(code) {
case "401", "403":
return true
default:
return false
}
}
func (s *RongxingService) login(ctx context.Context, transactionID string) (string, error) {
@@ -253,7 +291,7 @@ func (s *RongxingService) login(ctx context.Context, transactionID string) (stri
bodyStr := string(bodyBytes)
headers := map[string]string{"Content-Type": "application/json"}
curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr)
curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr, s.config.Proxy)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes))
if err != nil {
@@ -309,10 +347,9 @@ func (s *RongxingService) login(ctx context.Context, transactionID string) (stri
}
func (s *RongxingService) doHTTP(req *http.Request) ([]byte, int, error) {
client := &http.Client{Timeout: s.config.Timeout}
resp, err := client.Do(req)
resp, err := s.client.Do(req)
if err != nil {
return nil, 0, err
return nil, 0, s.wrapTransportError(req.URL.String(), err)
}
defer resp.Body.Close()
@@ -323,6 +360,49 @@ func (s *RongxingService) doHTTP(req *http.Request) ([]byte, int, error) {
return body, resp.StatusCode, nil
}
// wrapTransportError 把超时/拒连/代理失败等包装成可读诊断,便于区分「联不通」原因。
func (s *RongxingService) wrapTransportError(targetURL string, err error) error {
diag := classifyTransportError(err)
proxyMode := strings.TrimSpace(s.config.Proxy)
if proxyMode == "" {
proxyMode = "(直连,未配置 proxy)"
}
return fmt.Errorf(
"戎行 HTTP 失败 target=%s proxy=%s diagnosis=%s cause=%w",
targetURL, proxyMode, diag, err,
)
}
func classifyTransportError(err error) string {
if err == nil {
return "unknown"
}
msg := err.Error()
switch {
case os.IsTimeout(err) || errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(msg, "Client.Timeout") || strings.Contains(msg, "deadline exceeded"):
return "请求超时(未在 timeout 内收到响应头;可能:目标 192.168.3.43:7007 不可达、VPN/SOCKS 未转发、或服务无响应)"
case strings.Contains(msg, "connection refused"):
return "连接被拒绝(端口未监听或代理/目标拒绝)"
case strings.Contains(msg, "no such host") || strings.Contains(msg, "lookup"):
return "DNS/主机名解析失败(检查 rongxing-vpn 服务名或目标域名)"
case strings.Contains(msg, "network is unreachable") || strings.Contains(msg, "no route to host"):
return "网络不可达(无路由;直连内网 IP 时常见于未走 VPN/代理)"
case strings.Contains(msg, "i/o timeout") || strings.Contains(msg, "TLS handshake timeout"):
return "传输层超时(链路通但握手/读写超时)"
case strings.Contains(msg, "proxy") || strings.Contains(msg, "socks"):
return "代理链路异常(检查 socks5://rongxing-vpn:1080 与 VPN 容器)"
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return "网络超时"
}
// 完整原始错误在同一条日志的 error/cause 字段中,不在别的文件
return "其他网络错误(见本条日志 error 全文)"
}
func (s *RongxingService) validateConfig() error {
if s.config.BaseURL == "" {
return errors.New("戎行 url 未配置")
@@ -385,12 +465,35 @@ func (s *RongxingService) logErrorWithCurl(transactionID, apiKey string, err err
if s.logger == nil {
return
}
proxyMode := strings.TrimSpace(s.config.Proxy)
if proxyMode == "" {
proxyMode = "(直连,未配置 proxy)"
}
s.logger.LogErrorWithFields("rongxing API错误",
zap.String("transaction_id", transactionID),
zap.String("api_code", apiKey),
zap.String("base_url", s.config.BaseURL),
zap.String("proxy", proxyMode),
zap.String("diagnosis", extractDiagnosis(err)),
zap.Error(err),
zap.Any("params", payload),
zap.String("curl", curlCmd),
zap.String("response_body", respBody),
)
}
func extractDiagnosis(err error) string {
if err == nil {
return ""
}
const marker = "diagnosis="
msg := err.Error()
if i := strings.Index(msg, marker); i >= 0 {
rest := msg[i+len(marker):]
if j := strings.Index(rest, " cause="); j >= 0 {
return rest[:j]
}
return rest
}
return classifyTransportError(err)
}

View File

@@ -0,0 +1,43 @@
package rongxing
import (
"context"
"errors"
"strings"
"testing"
"time"
)
func TestClassifyTransportError_Timeout(t *testing.T) {
err := errors.New(`Post "http://192.168.3.43:7007/auth/login": context deadline exceeded (Client.Timeout exceeded while awaiting headers)`)
got := classifyTransportError(err)
if !strings.Contains(got, "请求超时") {
t.Fatalf("got %q", got)
}
}
func TestClassifyTransportError_Deadline(t *testing.T) {
got := classifyTransportError(context.DeadlineExceeded)
if !strings.Contains(got, "请求超时") {
t.Fatalf("got %q", got)
}
}
func TestWrapTransportError_IncludesProxy(t *testing.T) {
svc, err := NewRongxingService(serviceConfig{
BaseURL: "http://192.168.3.43:7007",
Timeout: time.Second,
Proxy: "socks5://rongxing-vpn:1080",
}, nil)
if err != nil {
t.Fatal(err)
}
wrapped := svc.wrapTransportError("http://192.168.3.43:7007/auth/login", context.DeadlineExceeded)
msg := wrapped.Error()
if !strings.Contains(msg, "proxy=socks5://rongxing-vpn:1080") {
t.Fatalf("missing proxy in error: %s", msg)
}
if !strings.Contains(msg, "diagnosis=") {
t.Fatalf("missing diagnosis in error: %s", msg)
}
}

3
secrets/openvpn/.gitkeep Normal file
View File

@@ -0,0 +1,3 @@
# 本地/仓库内占位:真实证书请放到此目录(勿提交 *.key / *.crt / *.ovpn
# 1Panel 推荐改用绝对路径:/opt/1panel/apps/hyapi-secrets/openvpn
# Compose 通过环境变量 RONGXING_VPN_CONFIG 覆盖挂载源目录。