简介:一套采用Java编写的TACACS+协议客户端与服务端实现,面向网络设备访问控制、AAA认证开发及运维人员,用于解决远程登录场景下的身份验证、授权与记账需求。压缩包共36个文件,以23个Java源码文件为主,辅以XML配置、Gradle构建脚本、依赖JAR及说明文档,整体仅107KB,轻量而便于直接阅读核心逻辑。已有84人学习下载,适合正在研究TACACS+协议或需要搭建Java版认证服务的开发者。源码完整覆盖客户端登录请求发送、服务端并发连接处理、认证授权策略解析、日志记录与用户数据库接口等关键模块;配套开发文档、安装部署指南、示例配置与单元测试用例,可帮助理解协议命令与响应格式,并在此基础上扩展对接企业现有身份认证基础设施。项目采用Gradle构建,目录结构清晰,具备一定的可扩展性设计,方便按需定制协议行为。
1. TACACS+ 方案先看价值:认证、授权、记账为什么拆成三个动作?
第一次听到“tacacsjava客户端以及服务端”这个名字,多半是网络设备准入或者堡垒机改造的项目。TACACS+(Terminal Access Controller Access Control System Plus)跑在 TCP 49 端口,和 RADIUS 最大的不同在于:它把认证、授权、记账拆成三个独立动作。RADIUS 只能回答“你能不能连”,TACACS+ 还能回答“你连上来之后能敲哪些命令”,并且把每一次操作都记成审计日志。这个差异决定了它在交换机、路由器、防火墙的运维审计场景里几乎不可替代。
如果你已经具备 Java 基础,又要给一批思科或华为设备接 AAA 认证,自己动手写一套 TACACS+ 客户端和服务端是完全可行的。协议核心只有两个难点:12 字节的定长头部,以及基于 MD5 的异或加密。下面我从报文格式讲起,接着给出 Java 客户端的最小实现,再落到服务端解析和排错,最后说几条生产环境验证技巧。
读到这里你可能会问:现在都流行用现成的开源 AAA 服务,为什么要用 Java 自己写?常见场景是内网设备数量不多、不想额外部署一套 C 服务,或者需要把认证结果直接对接到公司已有的 Java 账号体系。自己实现一套,反而比套一个重型的开源框架更可控。
2. 报文与加密:TACACS+ 协议里绕不开的 12 字节头、会话号和 MD5 流
2.1 为什么 TACACS+ 要单独起一个 TCP 服务:与 RADIUS 的三点本质差别
很多团队选型时第一反应是 RADIUS,因为它资料多、部署简单。但 TACACS+ 和 RADIUS 有三点本质差别,决定了它更适合做运维审计:
第一,传输层不同。RADIUS 走 UDP,丢包靠应用层重传;TACACS+ 走 TCP 49 端口,连接管理、顺序、重传都交给 TCP 协议栈。对一个“设备先问认证、再逐条授权命令”的场景来说,TCP 的可靠性价值很明显,网络抖动时不会出现认证消息丢失导致用户卡在登录界面。
第二,加密范围不同。RADIUS 只加密密码字段,用户名、授权信息都是明文;TACACS+ 对包体整体加密,只有头部 12 字节是明文。你在交换机上开 debug 抓包,看不到用户密码,也看不到授权了哪条命令。
第三,动作拆分的粒度不同。RADIUS 一个 Access-Request 同时承担认证和授权;TACACS+ 则是三个独立包类型:Authentication(认证)、Authorization(授权)、Accounting(记账)。设备侧流程通常是先认证身份,再单独发授权请求确认“这条命令允不允许执行”,执行完再发记账包记录结果。这个拆分让命令级授权成为可能。
一句话总结选型理由:如果你的诉求只是“员工能连上 WiFi”,RADIUS 足够;如果诉求是“DBA 登录数据库堡垒机之后,只能执行 select,不能执行 drop”,TACACS+ 更对路。
2.2 TACACS+ 包结构:头部字段、类型与动作流程
所有 TACACS+ 报文都从一个 12 字节的头部开始,各字段固定长度、固定顺序:
| 字段 | 长度 | 说明 |
|---|---|---|
| version | 1 字节 | 主版本号 + 次版本号,常见值 0xC0(老设备)或 0xC1(RFC 8907) |
| type | 1 字节 | 1=Authentication,2=Authorization,3=Accounting |
| seq_no | 1 字节 | 会话内序号,请求为奇数,响应为偶数 |
| flags | 1 字节 | 0x01=单连接模式,0x00=每次认证一条独立 TCP 连接 |
| session_id | 4 字节 | 客户端生成的随机数,用于计算加密密钥 |
| length | 4 字节 | 包体长度,不含头部 |
头部之后是包体。认证阶段的包体内容因动作而异,但大方向是:客户端发 START(seq=1),服务端回 REPLY(seq=2)。如果 REPLY 的状态是 CONTINUE(0x03),客户端要再发一个 CONTINUE 包(seq=3),服务端再回 REPLY(seq=4)。一个完整的 PAP 登录流程就是 START → REPLY(CONTINUE) → CONTINUE → REPLY(ACCEPT/REJECT)。
这里的 seq_no 是新手最容易忽略的字段。它从 1 开始,每发一个包加 1,响应包的 seq_no 必须在请求包基础上加 1。两边各自维护序号,服务端校验时会对比“我期望的序号”和“实际收到的序号”,不一致直接解密失败或拒包。
2.3 加密不是 AES 而是“异或”:会话密钥怎么算出来的
TACACS+ 的包体加密方案写出来会让你觉得复古:用 MD5 生成一段伪随机流,把明文字节和伪随机流逐字节异或,得到密文。计算伪随机流的第一步是:
pad = MD5(session_id 的 4 字节大端表示 + 共享密钥 secret + seq_no 的 1 字节)
如果包体长度超过 16 字节(一个 MD5 摘要的长度),就把上一步算出的 16 字节 hash 追加到输入末尾,再算一次 MD5,得到后续 16 字节的伪随机流,循环直到覆盖整个包体。
这个设计有两个直接后果。第一,共享密钥(secret)必须两端完全一致,包括尾随空格,否则算出来的 pad 不同,解密出来全是乱码。第二,session_id 必须是每次连接随机生成的新值,不能复用;如果客户端第二次登录还沿用上次的 session_id,攻击者用已知明文就能推算出密钥流。服务端不需要把 session_id 当密码使用,只需要在解密时把包头里的 session_id 取出来参与 MD5 计算即可。
注意一点:早期设备的 version 是 0xC0,加密后包体长度等于明文体长度;RFC 8907 后来把版本收敛为 0xC1,但 0xC1 在加密时会在密文尾部追加一个版本字节。两端如果版本不一致,服务端按 0xC1 去解析 0xC0 的包,会多读一个字节,导致解密后长度对不上。我一般建议客户端和服务端都用同一个版本常量,示例代码里用 0xC0 兼容更多存量设备。
3. Java 客户端落地:从建连到收到 ACCEPT 的最小代码
3.1 客户端要不要用现成库?选型理由先想清楚
写 Java 客户端之前,先回答一个问题:自己封装还是引第三方依赖?我接手这类项目的第一反应是去搜开源库,但实际评估后发现,很多 Java 库对 CONTINUE 流程支持不完整,要么只支持一次性把用户名密码塞进 START 包,要么对 seq_no 的维护有 bug。而认证这一个动作,自己封装核心逻辑只需要四十多行代码,比维护一个不熟悉的依赖更可控。
另一个考虑是协议版本。开源库通常默认按某个版本写死,万一你们内网设备用的是老版本 TACACS+,就得改库源码。自己封装时把 version、加密方式都做成常量,改起来只动一个字段。
当然,如果你的项目还涉及完整的授权策略下发、多种认证方式、批量设备管理,再引入成熟库或直接对接开源 AAA 服务更划算。自己封装适合的是“认证逻辑简单、账号源在公司内部、设备数量几十台”的场景。
3.2 认证动作的完整代码:握手、构造包、验响应三件事
下面这段代码是客户端最核心的部分,包含构造头部、加密包体、处理 CONTINUE 三项功能。代码按 0xC0 版本实现:
import java.io.*; import java.net.*; import java.security.MessageDigest; public class TacacsAuthClient { private static final byte VERSION_0xC0 = (byte) 0xC0; private static final byte TYPE_AUTHEN = 0x01; private static final byte FLAG_SINGLE_CONNECT = 0x01; private static final byte ACTION_LOGIN = 0x01; private static final byte PRIV_LVL_USER = 0x01; private static final byte AUTHEN_TYPE_ASCII = 0x01; private static final byte AUTHEN_SERVICE_LOGIN = 0x01; private static final byte STATUS_ACCEPT = 0x01; private static final byte STATUS_REJECT = 0x02; private static final byte STATUS_CONTINUE = 0x03; private final String host; private final int port; private final String secret; private final int timeoutMs; public TacacsAuthClient(String host, int port, String secret, int timeoutMs) { this.host = host; this.port = port; this.secret = secret; this.timeoutMs = timeoutMs; } public byte login(String username, String password) throws Exception { try (Socket socket = new Socket()) { socket.connect(new InetSocketAddress(host, port), timeoutMs); socket.setSoTimeout(timeoutMs); DataInputStream in = new DataInputStream(socket.getInputStream()); DataOutputStream out = new DataOutputStream(socket.getOutputStream()); int sessionId = new java.util.Random().nextInt(); byte seq = 1; // 构造 AUTHEN START 包体 ByteArrayOutputStream bodyBuf = new ByteArrayOutputStream(); bodyBuf.write(ACTION_LOGIN); bodyBuf.write(PRIV_LVL_USER); bodyBuf.write(AUTHEN_TYPE_ASCII); bodyBuf.write(AUTHEN_SERVICE_LOGIN); bodyBuf.write(username.getBytes("UTF-8").length); bodyBuf.write("cli".getBytes("UTF-8").length); // port 长度 bodyBuf.write("127.0.0.1".getBytes("UTF-8").length); // rem_addr 长度 bodyBuf.write(0); // data 长度 bodyBuf.write(username.getBytes("UTF-8")); bodyBuf.write("cli".getBytes("UTF-8")); bodyBuf.write("127.0.0.1".getBytes("UTF-8")); sendPacket(out, sessionId, seq++, TYPE_AUTHEN, bodyBuf.toByteArray()); TacacsPacket reply = readPacket(in); // 如果服务端要求继续,则发送 CONTINUE 包携带密码 if (reply.status == STATUS_CONTINUE) { ByteArrayOutputStream contBuf = new ByteArrayOutputStream(); contBuf.write(0); // user_msg 长度 contBuf.write(password.getBytes("UTF-8").length); // data 长度 contBuf.write(password.getBytes("UTF-8")); sendPacket(out, sessionId, seq++, TYPE_AUTHEN, contBuf.toByteArray()); reply = readPacket(in); } return reply.status; } } private void sendPacket(DataOutputStream out, int sessionId, byte seq, byte type, byte[] plainBody) throws Exception { byte[] encrypted = encryptBody(sessionId, seq, plainBody); out.writeByte(VERSION_0xC0); out.writeByte(type); out.writeByte(seq); out.writeByte(FLAG_SINGLE_CONNECT); out.writeInt(sessionId); out.writeInt(encrypted.length); out.write(encrypted); out.flush(); } private TacacsPacket readPacket(DataInputStream in) throws Exception { byte[] header = new byte[12]; in.readFully(header); int sessionId = ((header[4] & 0xFF) << 24) | ((header[5] & 0xFF) << 16) | ((header[6] & 0xFF) << 8) | (header[7] & 0xFF); int length = ((header[8] & 0xFF) << 24) | ((header[9] & 0xFF) << 16) | ((header[10] & 0xFF) << 8) | (header[11] & 0xFF); byte[] encrypted = new byte[length]; in.readFully(encrypted); byte[] plain = decryptBody(sessionId, header[2], encrypted); return new TacacsPacket(plain[0]); } private byte[] encryptBody(int sessionId, byte seq, byte[] plain) throws Exception { byte[] key = generatePad(sessionId, seq, plain.length); byte[] out = new byte[plain.length]; for (int i = 0; i < plain.length; i++) { out[i] = (byte) (plain[i] ^ key[i]); } return out; } private byte[] decryptBody(int sessionId, byte seq, byte[] encrypted) throws Exception { byte[] key = generatePad(sessionId, seq, encrypted.length); byte[] out = new byte[encrypted.length]; for (int i = 0; i < encrypted.length; i++) { out[i] = (byte) (encrypted[i] ^ key[i]); } return out; } private byte[] generatePad(int sessionId, byte seq, int len) throws Exception { ByteArrayOutputStream seed = new ByteArrayOutputStream(); DataOutputStream ds = new DataOutputStream(seed); ds.writeInt(sessionId); ds.write(secret.getBytes("UTF-8")); ds.writeByte(seq); MessageDigest md5 = MessageDigest.getInstance("MD5"); byte[] prev = md5.digest(seed.toByteArray()); byte[] pad = new byte[len]; System.arraycopy(prev, 0, pad, 0, Math.min(16, len)); int offset = 16; while (offset < len) { ByteArrayOutputStream nextSeed = new ByteArrayOutputStream(); nextSeed.write(seed.toByteArray()); nextSeed.write(prev); prev = md5.digest(nextSeed.toByteArray()); System.arraycopy(prev, 0, pad, offset, Math.min(16, len - offset)); offset += 16; } return pad; } static class TacacsPacket { byte status; TacacsPacket(byte status) { this.status = status; } } }这段代码有三个要点需要说明。
第一,sendPacket和readPacket之间的 seq 维护。START 包 seq=1,CONTINUE 包 seq=3,服务端返回的 REPLY 分别是 2 和 4。代码里用前置seq++,保证每次发包序号都递增。如果你在两个包之间忘记递增,服务端解密时算出的 pad 和客户端不一致,立刻翻车。
第二,generatePad方法严格实现了会话密钥生成:session_id 按 4 字节大端写入、secret 原始字节、seq 单字节追加,第一段 16 字节是第一次 MD5 结果,后续每 16 字节把上一次 hash 追加进输入。这样无论认证包体多长都能覆盖。
第三,readPacket里用header[2]取出的是响应包的 seq_no,客户端解密时不需要校验它,但实际项目里你应该加上校验:响应 seq 必须等于请求 seq+1。否则可能存在重放攻击或中间人篡改。
3.3 必调的四个参数:shared secret、timeout、端口和重试策略
刚才的客户端代码里有四个参数,生产环境必须认真调:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| shared secret | 至少 24 字节随机串 | 用openssl rand -hex 24生成,不要手打键盘 |
| socket timeout | 3 秒 | 必须小于设备侧 TACACS+ 超时(通常 5 秒),否则服务端还没回包客户端先报超时 |
| 端口 | 49 | 有些设备允许改端口,但客户端与服务端必须一致 |
| 重试策略 | 最多 2 次,每次新 session_id | 认证失败不重试,网络超时才重试 |
特别提醒 secret 的坑:很多设备面板上粘贴 secret 时会自动加一个换行符,Java 端如果用配置文件读取,String.trim()会把换行去掉,导致两端 secret 不一致。我的习惯是配置文件里用 Base64 编码存储 secret,读取时解码成字节数组,彻底避开换行和编码问题。
4. 服务端实现:用最简单的 Java 线程池在 49 端口把包解出来
4.1 服务端架构:TCP 接入层、认证逻辑、记账输出的最小分层
服务端不一定要上 Spring Boot 或 Netty。如果是内部认证服务,一个ServerSocket加固定线程池就足够清晰。我一般把代码分成三层:
第一层是网络接入,负责 accept TCP 连接、读取字节流、解析出一个个完整的 TACACS+ 包;第二层是协议处理,根据 type 字段分发到认证、授权、记账三个处理入口;第三层是业务逻辑,比如校验密码、加载用户权限、写审计日志。
这样做的好处是排错时能快速定位问题在哪个环节。如果网络层读包出错,看 readFully 相关日志;如果协议层解密乱码,看 secret 和 seq;如果业务层密码不对,看用户存储。
4.2 数据包解析代码:处理粘包、半包和定长头部
TCP 是字节流,没有消息边界。一个连接里可能同时到达多个 TACACS+ 包,也可能一个包分几次才到齐。最常见的错误是直接用in.read()读一次就当完整包处理。正确做法是先读满 12 字节头部,从中取出 length,再按 length 读满包体。
byte[] readPacket(DataInputStream in) throws IOException { byte[] header = new byte[12]; in.readFully(header); // 读不满 12 字节会阻塞等待 int sessionId = readIntBE(header, 4); int length = readIntBE(header, 8); if (length < 0 || length > 65535) { throw new IOException("invalid tacacs+ length: " + length); } byte[] body = new byte[length]; in.readFully(body); // 自动处理半包 return merge(header, body); } private int readIntBE(byte[] buf, int offset) { return ((buf[offset] & 0xFF) << 24) | ((buf[offset + 1] & 0xFF) << 16) | ((buf[offset + 2] & 0xFF) << 8) | (buf[offset + 3] & 0xFF); }这段代码的关键是readFully。它内部会循环读取,直到读满指定字节数,天然解决了半包粘包问题。你不需要自己维护一个累积缓冲区。需要注意 length 上限校验,防止恶意客户端发一个超大长度值把服务端内存打爆。
服务端的主循环放在线程池里:
ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2); ServerSocket serverSocket = new ServerSocket(49); while (!stopped) { Socket client = serverSocket.accept(); client.setSoTimeout(10000); pool.execute(() -> handleClient(client)); }线程池大小建议至少是 CPU 核数的两倍。因为 TACACS+ 包体处理里涉及 MD5 计算和可能的用户存储查询,属于 IO 混合型任务,线程太少会阻塞在等待用户存储响应上。
4.3 响应与记账:AUTHEN 成功之后还要回什么
服务端解析完认证包后,要根据校验结果构造 REPLY 包。REPLY 的包体结构是:status(1 字节)+ server_msg_len(2 字节)+ data_len(2 字节)+ server_msg + data。
下面这段代码演示如何构造一个 ACCEPT 响应:
void sendAuthReply(DataOutputStream out, int sessionId, byte reqSeq, byte status) throws Exception { byte[] body = new byte[5]; // 没有 server_msg 和 data body[0] = status; body[1] = 0; // server_msg 长度高位 body[2] = 0; // server_msg 长度低位 body[3] = 0; // data 长度高位 body[4] = 0; // data 长度低位 byte seq = (byte) (reqSeq + 1); // 响应 seq 必须加 1 byte[] encrypted = encryptBody(sessionId, seq, body); out.writeByte(0xC0); // version out.writeByte(0x01); // type: AUTHEN out.writeByte(seq); out.writeByte(0x00); // flags:普通响应 out.writeInt(sessionId); out.writeInt(encrypted.length); out.write(encrypted); out.flush(); }这里的 seq 是新手的重灾区。有人图省事直接把请求的 seq 原样写回,结果服务端发出去后自己解密都解不对。牢记:响应 seq = 请求 seq + 1。
认证成功后,生产环境必须同时写记账日志。一条最小记录要包含:用户名、来源 IP、时间戳、认证结果。这里有个建议:Accounting 的 start 消息和认证成功消息不是同一个包,但很多设备在认证成功后不会再发独立的 start 包,这时要以认证成功事件为准落审计日志,不要等记账包。
5. 避坑与排查:seq_no、secret、粘包这三个地方最容易翻车
5.1 现象:服务端解出来的包体全是乱码
原因最常见的是两端 secret 不一致,或者是 seq_no 算错。TACACS+ 加密是异或,密钥流只要差一个字节,解密结果就完全不同。我之前排查过一个案例,配置文件的 secret 末尾有个不可见的\r,肉眼完全看不出来。
解决:服务端和客户端各打一条 hexdump 日志,先对比 session_id 和 length 是否一致,再对比 secret 的字节数组。如果 secret 长度一致、内容差异只在尾部,百分之百是换行符问题。改用 Base64 存储 secret 可以从根上避免。
5.2 现象:客户端明明按流程发包,服务端却回 ERROR
这是典型的版本不匹配。客户端按 0xC1 版本加密,密文尾部多写了一个版本字节;服务端按 0xC0 版本解密,把多出来的字节当成明文的一部分,解析 status 字段时取到错误的值。
解决:两端把 version 常量写死成同一个值。我建议用 0xC0 兼容存量设备,并且在握手时不要自作聪明去协商版本,TACACS+ 协议没有版本协商机制,只能静态统一。
5.3 现象:并发一高就丢包或者超时
原因往往是服务端只有一个 accept 线程,处理完一个客户端才接下一个;或者线程池开得太小,某个线程阻塞在用户存储查询上,拖垮整体吞吐。
解决:按前面代码用固定线程池,并把每个连接的setSoTimeout设成 10 秒。一旦某个客户端连接不活跃,自动关闭释放线程。还需要注意ServerSocket的 backlog 参数,默认 50 可能不够,建议设成 128。
5.4 现象:认证能过,但设备侧授权始终失败
如果你在认证成功后又单独实现了 Authorization 流程,要检查是否把认证和授权混在一个处理函数里。TACACS+ 的授权包 type=2,包体结构和认证包完全不同。常见错误是按照认证包的解析方式读授权包,导致 priv_lvl 或命令参数全部错位。
解决:在协议分发层按 type 严格分支。认证只做身份校验,授权单独解析命令和参数,记账单独落库。三层各管各的,排查时也容易定位。
5.5 现象:客户端连上后立刻被服务端 RST
原因通常是端口没监听对,或者服务端 accept 后立即抛异常关闭连接。先用系统命令确认端口状态:
ss -lntp | grep 49 telnet 127.0.0.1 49如果端口通但连接还是被重置,去看服务端日志里 readPacket 的异常。最常见的是客户端没设置连接超时,服务端 10 秒没读到完整包头,主动断开。
6. 从 demo 到生产:性能验证、记账审计和一条能救命的最小客户端
6.1 用最小客户端验证服务端真的没算错
服务端上线前别急着接真实设备。我用前面那段客户端代码写一个命令行入口,先跑三次:正确密码、错误密码、超时密码。每次用tcpdump -i any port 49 -XX抓包,对照服务端日志里的 session_id 和 length 是否一致。这一步能排除八成以上的协议握手问题。
| 验证项 | 预期结果 |
|---|---|
| 正确密码 | 服务端日志出现 ACCEPT,客户端收到 status=1 |
| 错误密码 | 服务端日志出现 REJECT,客户端收到 status=2 |
| 抓包对比 | 客户端发出的 seq=1,服务端返回 seq=2 |
6.2 记账审计里的“最后一道防线”
认证通过后马上写一条审计日志,这是我在生产环境里的底线。日志不要只记用户名和时间,要把来源 IP 也记进去。交换机登录时通常通过 rem_addr 字段携带源地址,解析时要取这一段。建议日志格式用 JSON,字段固定:timestamp、username、source_ip、auth_result。这样后续接审计平台时不需要改格式。
6.3 查问题时我习惯先拉的一条 debug 日志
我排查 TACACS+ 问题时,第一步永远是把包头和 session_id 打出来,第二步才看解密后的明文。注意明文里可能包含密码,绝对不要打全量明文。我一般在服务端打印解密后包体的第一个字节(status),配合包头里的 session_id 和 length 就足够定位九成问题。
自己写第一版服务端时,我曾在 seq_no 上卡了两天:响应包把请求 seq 原样返回,服务端自己解密都失败。后面把所有 debug 日志统一加上 session_id 和期望序号,一对比就发现是 +1 的问题。这个教训让我后来写任何协议解析代码,第一件事就是画清请求与响应的序号流转图。希望帮到你。
本文还有配套的精品资源,点击获取