DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,242 @@
|
||||
package logcrypt
|
||||
|
||||
import (
|
||||
"crypto/cipher"
|
||||
cryptorand "crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Encoder 把明文写成一个 logcrypt 容器的记录区;文件头(含被公钥封装的 DEK)由它负责写。
|
||||
//
|
||||
// 它**不含轮转、清理、队列**——那是 Writer 的职责。给两类调用方用:
|
||||
//
|
||||
// - 自己管文件、序号与保留策略的调用方(例如 records 的 activityRecorder);
|
||||
// - 需要把字节流当"行"写的调用方(StreamWriter 包在它外面)。
|
||||
//
|
||||
// 一个 Encoder 对应一个文件;换文件就新建一个。
|
||||
type Encoder struct {
|
||||
core *encoderCore
|
||||
}
|
||||
|
||||
// NewEncoder 用收件人公钥为 w 生成一把随机 DEK、写文件头,并返回可继续写记录的编码器。
|
||||
func NewEncoder(recipient *Recipient, w io.Writer, random io.Reader) (*Encoder, error) {
|
||||
core, err := newEncoderCore(recipient, w, random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Encoder{core: core}, nil
|
||||
}
|
||||
|
||||
// WriteRecord 把一段明文作为一条记录写入。
|
||||
func (e *Encoder) WriteRecord(plain []byte) error {
|
||||
if e == nil || e.core == nil {
|
||||
return errors.New("logcrypt: encoder is not initialized")
|
||||
}
|
||||
return e.core.writeRecord(plain)
|
||||
}
|
||||
|
||||
// WriteLine 写一行(自动补换行)。
|
||||
func (e *Encoder) WriteLine(line []byte) error {
|
||||
if len(line) == 0 || line[len(line)-1] != '\n' {
|
||||
line = append(append([]byte(nil), line...), '\n')
|
||||
}
|
||||
return e.WriteRecord(line)
|
||||
}
|
||||
|
||||
// Bytes 已写入容器的总字节数(含文件头)。调用方用它判断轮转。
|
||||
func (e *Encoder) Bytes() int64 {
|
||||
if e == nil || e.core == nil {
|
||||
return 0
|
||||
}
|
||||
return e.core.written
|
||||
}
|
||||
|
||||
// KeyID 本文件使用的收件人公钥标识。
|
||||
func (e *Encoder) KeyID() string {
|
||||
if e == nil || e.core == nil {
|
||||
return ""
|
||||
}
|
||||
return e.core.keyID
|
||||
}
|
||||
|
||||
// StreamWriter 把任意字节流按行切成加密记录,实现 io.Writer。
|
||||
//
|
||||
// 用于 os/exec 的 cmd.Stdout / cmd.Stderr:子进程写出来的是字节流,行边界可能被切开,
|
||||
// 这里缓冲到 '\n' 再落一条记录;超过 maxLineBytes 时也先落一条,避免内存被一行撑爆。
|
||||
type StreamWriter struct {
|
||||
mu sync.Mutex
|
||||
core *encoderCore
|
||||
buf []byte
|
||||
maxLineBytes int
|
||||
closed bool
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// NewStreamWriter 创建行切分 + 加密的 io.Writer。
|
||||
func NewStreamWriter(recipient *Recipient, w io.Writer, random io.Reader) (*StreamWriter, error) {
|
||||
core, err := newEncoderCore(recipient, w, random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &StreamWriter{core: core, maxLineBytes: 1 << 20}, nil
|
||||
}
|
||||
|
||||
// Write 实现 io.Writer。永远返回 len(p):调用方是 os/exec 的管道拷贝协程,
|
||||
// 报短写会让它把子进程的输出丢掉。
|
||||
func (s *StreamWriter) Write(p []byte) (int, error) {
|
||||
if s == nil || s.core == nil {
|
||||
return 0, errors.New("logcrypt: stream writer is not initialized")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return 0, ErrClosed
|
||||
}
|
||||
s.buf = append(s.buf, p...)
|
||||
for {
|
||||
index := indexByte(s.buf, '\n')
|
||||
if index < 0 {
|
||||
if len(s.buf) >= s.maxLineBytes {
|
||||
if err := s.core.writeRecord(s.buf); err != nil {
|
||||
return len(p), err
|
||||
}
|
||||
s.buf = s.buf[:0]
|
||||
}
|
||||
break
|
||||
}
|
||||
if err := s.core.writeRecord(s.buf[:index+1]); err != nil {
|
||||
return len(p), err
|
||||
}
|
||||
s.buf = append(s.buf[:0], s.buf[index+1:]...)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Flush 把缓冲区里最后一段没有换行的内容落一条记录。
|
||||
func (s *StreamWriter) Flush() error {
|
||||
if s == nil || s.core == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.flushLocked()
|
||||
}
|
||||
|
||||
func (s *StreamWriter) flushLocked() error {
|
||||
if len(s.buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := s.core.writeRecord(s.buf)
|
||||
s.buf = s.buf[:0]
|
||||
return err
|
||||
}
|
||||
|
||||
// Close 落盘残余内容并封闭;可重复调用。
|
||||
func (s *StreamWriter) Close() error {
|
||||
if s == nil || s.core == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return s.closeErr
|
||||
}
|
||||
s.closed = true
|
||||
s.closeErr = s.flushLocked()
|
||||
return s.closeErr
|
||||
}
|
||||
|
||||
// Bytes 已写入字节数。
|
||||
func (s *StreamWriter) Bytes() int64 {
|
||||
if s == nil || s.core == nil {
|
||||
return 0
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.core.written
|
||||
}
|
||||
|
||||
// KeyID 本流使用的收件人公钥标识。
|
||||
func (s *StreamWriter) KeyID() string {
|
||||
if s == nil || s.core == nil {
|
||||
return ""
|
||||
}
|
||||
return s.core.keyID
|
||||
}
|
||||
|
||||
// encoderCore 是 Encoder 与 StreamWriter 共用的"一个文件/一个流"的加密核心。
|
||||
type encoderCore struct {
|
||||
aead cipher.AEAD
|
||||
header []byte
|
||||
random io.Reader
|
||||
out io.Writer
|
||||
written int64
|
||||
keyID string
|
||||
}
|
||||
|
||||
func newEncoderCore(recipient *Recipient, w io.Writer, random io.Reader) (*encoderCore, error) {
|
||||
if recipient == nil || recipient.pub == nil {
|
||||
return nil, ErrRecipientMissing
|
||||
}
|
||||
if w == nil {
|
||||
return nil, errors.New("logcrypt: encoder needs a writer")
|
||||
}
|
||||
if random == nil {
|
||||
random = defaultRandom()
|
||||
}
|
||||
dek, ephemeralPublic, wrapNonce, wrappedDEK, err := recipient.wrapDEK(random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header := buildHeader(recipient, ephemeralPublic, wrapNonce, wrappedDEK)
|
||||
aead, err := newAEAD(dek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
core := &encoderCore{aead: aead, header: header, random: random, out: w, keyID: recipient.keyID}
|
||||
n, err := writeFull(w, header)
|
||||
core.written += int64(n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: write container header: %w", err)
|
||||
}
|
||||
return core, nil
|
||||
}
|
||||
|
||||
func (c *encoderCore) writeRecord(plain []byte) error {
|
||||
record, err := appendRecord(nil, c.aead, c.header, plain, c.random)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := writeFull(c.out, record)
|
||||
c.written += int64(n)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeFull(w io.Writer, payload []byte) (int, error) {
|
||||
total := 0
|
||||
for total < len(payload) {
|
||||
n, err := w.Write(payload[total:])
|
||||
total += n
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
if n == 0 {
|
||||
return total, io.ErrShortWrite
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func indexByte(data []byte, target byte) int {
|
||||
for index, value := range data {
|
||||
if value == target {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func defaultRandom() io.Reader { return cryptorand.Reader }
|
||||
@@ -0,0 +1,352 @@
|
||||
// Package logcrypt 提供"落盘即密文"的日志写入器,采用**公钥信封**方案。
|
||||
//
|
||||
// 为什么是公钥而不是对称密钥(2026-09-30 与运营确认):
|
||||
//
|
||||
// - 服务端要**分发出去**。若把对称密钥放在服务端,拿到服务端包的人就能解开全部日志。
|
||||
// - 因此服务端只持有**收件人公钥**(只能加密,无法解密);**私钥只留在运营本机**,
|
||||
// 排障时把日志拉回本机用 logtool 解密。
|
||||
//
|
||||
// 关键约定:
|
||||
//
|
||||
// - **绝不自动生成收件人密钥对**。密钥对只由运营在本机执行 `logtool keygen` 生成一次;
|
||||
// 公钥随服务端包分发,私钥永不分发。缺少公钥时各日志通道直接关闭(绝不退化成明文)。
|
||||
// - 逐条独立加密(每条记录自带随机 nonce),文件被截断只有最后一条解不开。
|
||||
// - 每个文件用一把随机 DEK(数据密钥),DEK 用公钥信封封装后写在文件头里。
|
||||
package logcrypt
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// DEKSize 是文件级数据密钥(AES-256)长度。
|
||||
DEKSize = 32
|
||||
// PublicKeySize 是 X25519 公钥长度。
|
||||
PublicKeySize = 32
|
||||
// PrivateKeySize 是 X25519 私钥长度。
|
||||
PrivateKeySize = 32
|
||||
// NonceSize 是 GCM nonce 长度。
|
||||
NonceSize = 12
|
||||
// keyIDSize 是用作密钥标识的 SHA-256 前缀字节数(hex 后 16 字符)。
|
||||
keyIDSize = 8
|
||||
// envelopeInfo 是信封密钥派生的域分隔串。
|
||||
envelopeInfo = "longheng:log-envelope:v1"
|
||||
|
||||
// PublicKeyFileName / PrivateKeyFileName 是 `logtool keygen` 的默认输出名。
|
||||
PublicKeyFileName = "log-public.key"
|
||||
PrivateKeyFileName = "log-private.key"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrKeyInvalid 表示密钥材料长度/编码不合法。
|
||||
ErrKeyInvalid = errors.New("logcrypt: key material is invalid")
|
||||
// ErrNotEncrypted 表示文件不是 logcrypt 容器。
|
||||
ErrNotEncrypted = errors.New("logcrypt: file is not a logcrypt container")
|
||||
// ErrRecipientMissing 表示没有配置收件人公钥。
|
||||
ErrRecipientMissing = errors.New("logcrypt: recipient public key is required (log output stays off rather than writing plaintext)")
|
||||
)
|
||||
|
||||
// Recipient 是收件人身份:只有公钥。服务端持有它,只能加密。
|
||||
type Recipient struct {
|
||||
keyID string
|
||||
pub *ecdh.PublicKey
|
||||
}
|
||||
|
||||
// KeyID 返回收件人公钥标识(SHA-256 前 8 字节的 hex)。
|
||||
//
|
||||
// 它写在每个文件头里:换过密钥对之后,旧文件依然能被对应的旧私钥解开,
|
||||
// 解密工具按文件头里的 keyID 选私钥,而不是假定"当前私钥一定对"。
|
||||
func (r *Recipient) KeyID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.keyID
|
||||
}
|
||||
|
||||
// PublicKeyBytes 返回原始 32 字节公钥。
|
||||
func (r *Recipient) PublicKeyBytes() []byte {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]byte(nil), r.pub.Bytes()...)
|
||||
}
|
||||
|
||||
// RecipientKeyID 由原始公钥计算 keyID。
|
||||
func RecipientKeyID(publicKey []byte) (string, error) {
|
||||
parsed, err := parsePublicKey(publicKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = parsed
|
||||
sum := sha256.Sum256(publicKey)
|
||||
return hex.EncodeToString(sum[:keyIDSize]), nil
|
||||
}
|
||||
|
||||
// GenerateKeyPair 生成一对 X25519 密钥(仅供运营本机 keygen 使用,服务端从不调用)。
|
||||
func GenerateKeyPair() (publicKey []byte, privateKey []byte, err error) {
|
||||
private, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("logcrypt: generate key pair: %w", err)
|
||||
}
|
||||
return append([]byte(nil), private.PublicKey().Bytes()...),
|
||||
append([]byte(nil), private.Bytes()...), nil
|
||||
}
|
||||
|
||||
// PublicKeyOf 由私钥推导公钥,用于确认"手上的私钥"和"服务端固化的公钥"是不是同一对。
|
||||
func PublicKeyOf(privateKey []byte) ([]byte, error) {
|
||||
raw, err := ParsePrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
private, err := ecdh.X25519().NewPrivateKey(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: private key: %w", err)
|
||||
}
|
||||
return append([]byte(nil), private.PublicKey().Bytes()...), nil
|
||||
}
|
||||
|
||||
// PrivateKeyKeyID 由私钥算出对应的 keyID(与同对公钥算出的 keyID 相同)。
|
||||
func PrivateKeyKeyID(privateKey []byte) (string, error) {
|
||||
publicKey, err := PublicKeyOf(privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return RecipientKeyID(publicKey)
|
||||
}
|
||||
|
||||
// ParseRecipientPublicKey 解析公钥:32 字节原始、44 字符 base64 或 64 字符 hex。
|
||||
func ParseRecipientPublicKey(data []byte) (*Recipient, error) {
|
||||
raw, err := decodeKeyMaterial(data, PublicKeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: public key: %w", err)
|
||||
}
|
||||
pub, err := parsePublicKey(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return &Recipient{keyID: hex.EncodeToString(sum[:keyIDSize]), pub: pub}, nil
|
||||
}
|
||||
|
||||
// LoadRecipientPublicKey 从文件读取收件人公钥。
|
||||
//
|
||||
// 文件不存在时返回 ErrRecipientMissing——调用方据此关闭日志通道,**绝不写明文**。
|
||||
func LoadRecipientPublicKey(path string) (*Recipient, error) {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return nil, ErrRecipientMissing
|
||||
}
|
||||
raw, err := os.ReadFile(trimmed) //nolint:gosec // G304:路径来自部署配置。
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrRecipientMissing, trimmed)
|
||||
}
|
||||
return nil, fmt.Errorf("logcrypt: read public key: %w", err)
|
||||
}
|
||||
recipient, err := ParseRecipientPublicKey(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: public key file %s: %w", trimmed, err)
|
||||
}
|
||||
return recipient, nil
|
||||
}
|
||||
|
||||
// ParsePrivateKey 解析私钥:32 字节原始、44 字符 base64 或 64 字符 hex。
|
||||
func ParsePrivateKey(data []byte) ([]byte, error) {
|
||||
raw, err := decodeKeyMaterial(data, PrivateKeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: private key: %w", err)
|
||||
}
|
||||
if _, err := ecdh.X25519().NewPrivateKey(raw); err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: private key: %w", err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// LoadPrivateKey 从文件读取私钥(只有运营本机需要)。
|
||||
func LoadPrivateKey(path string) ([]byte, error) {
|
||||
raw, err := os.ReadFile(path) //nolint:gosec // G304:路径来自运营本机命令行。
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: read private key: %w", err)
|
||||
}
|
||||
key, err := ParsePrivateKey(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: private key file %s: %w", path, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// EncodeKeyFile 把密钥材料编码成落盘文本(base64 + 换行)。密钥对一律以这种形式保存,
|
||||
// 便于人工备份与核对;服务端只需要公钥那一份。
|
||||
func EncodeKeyFile(material []byte) []byte {
|
||||
return append([]byte(base64.StdEncoding.EncodeToString(material)), '\n')
|
||||
}
|
||||
|
||||
func decodeKeyMaterial(data []byte, want int) ([]byte, error) {
|
||||
if len(data) == want {
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
text := strings.TrimSpace(string(data))
|
||||
if decoded, err := hex.DecodeString(text); err == nil && len(decoded) == want {
|
||||
return decoded, nil
|
||||
}
|
||||
for _, encoding := range []*base64.Encoding{
|
||||
base64.StdEncoding,
|
||||
base64.RawStdEncoding,
|
||||
base64.URLEncoding,
|
||||
base64.RawURLEncoding,
|
||||
} {
|
||||
if decoded, err := encoding.DecodeString(text); err == nil && len(decoded) == want {
|
||||
return decoded, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: want %d bytes, input is %d bytes", ErrKeyInvalid, want, len(data))
|
||||
}
|
||||
|
||||
func parsePublicKey(raw []byte) (*ecdh.PublicKey, error) {
|
||||
if len(raw) != PublicKeySize {
|
||||
return nil, fmt.Errorf("%w: public key must be %d bytes, got %d", ErrKeyInvalid, PublicKeySize, len(raw))
|
||||
}
|
||||
pub, err := ecdh.X25519().NewPublicKey(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: public key: %w", err)
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
func newAEAD(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: new cipher: %w", err)
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// deriveWrapKey 从 X25519 共享秘密派生信封密钥。
|
||||
//
|
||||
// salt 同时包含临时公钥与收件人公钥,info 做域分隔——与 `crypto/box` 的思路一致,
|
||||
// 使信封与"发给谁"绑定。
|
||||
func deriveWrapKey(shared, ephemeralPublic, recipientPublic []byte) ([]byte, error) {
|
||||
salt := make([]byte, 0, len(ephemeralPublic)+len(recipientPublic))
|
||||
salt = append(salt, ephemeralPublic...)
|
||||
salt = append(salt, recipientPublic...)
|
||||
key, err := hkdf.Key(sha256.New, shared, salt, envelopeInfo, DEKSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: derive envelope key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// wrapDEK 生成一把随机 DEK 并用收件人公钥封装。
|
||||
func (r *Recipient) wrapDEK(random io.Reader) (dek, ephemeralPublic, nonce, wrapped []byte, err error) {
|
||||
if r == nil || r.pub == nil {
|
||||
return nil, nil, nil, nil, ErrRecipientMissing
|
||||
}
|
||||
if random == nil {
|
||||
return nil, nil, nil, nil, errors.New("logcrypt: random source is nil")
|
||||
}
|
||||
dek = make([]byte, DEKSize)
|
||||
if _, err = io.ReadFull(random, dek); err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("logcrypt: data key: %w", err)
|
||||
}
|
||||
ephemeral, err := ecdh.X25519().GenerateKey(random)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("logcrypt: ephemeral key: %w", err)
|
||||
}
|
||||
shared, err := ephemeral.ECDH(r.pub)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("logcrypt: envelope ecdh: %w", err)
|
||||
}
|
||||
ephemeralPublic = append([]byte(nil), ephemeral.PublicKey().Bytes()...)
|
||||
wrapKey, err := deriveWrapKey(shared, ephemeralPublic, r.pub.Bytes())
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
aead, err := newAEAD(wrapKey)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
nonce = make([]byte, NonceSize)
|
||||
if _, err := io.ReadFull(random, nonce); err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("logcrypt: wrap nonce: %w", err)
|
||||
}
|
||||
wrapped = aead.Seal(nil, nonce, dek, r.pub.Bytes())
|
||||
return dek, ephemeralPublic, nonce, wrapped, nil
|
||||
}
|
||||
|
||||
// unwrapDEK 用私钥解开文件头里的 DEK。
|
||||
func unwrapDEK(privateKey, ephemeralPublic, nonce, wrapped []byte) ([]byte, error) {
|
||||
curve := ecdh.X25519()
|
||||
private, err := curve.NewPrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: private key: %w", err)
|
||||
}
|
||||
ephemeral, err := curve.NewPublicKey(ephemeralPublic)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: ephemeral public key: %w", err)
|
||||
}
|
||||
shared, err := private.ECDH(ephemeral)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: envelope ecdh: %w", err)
|
||||
}
|
||||
recipientPublic := private.PublicKey().Bytes()
|
||||
wrapKey, err := deriveWrapKey(shared, ephemeralPublic, recipientPublic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aead, err := newAEAD(wrapKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dek, err := aead.Open(nil, nonce, wrapped, recipientPublic)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: cannot unwrap data key (wrong private key?): %w", err)
|
||||
}
|
||||
if len(dek) != DEKSize {
|
||||
return nil, fmt.Errorf("logcrypt: unwrapped data key has length %d", len(dek))
|
||||
}
|
||||
return dek, nil
|
||||
}
|
||||
|
||||
// WriteKeyPair 把密钥对写到目录下(公钥 0644、私钥 0600)。已存在且未 force 时不覆盖。
|
||||
func WriteKeyPair(dir string, force bool) (publicPath, privatePath, keyID string, err error) {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return "", "", "", errors.New("logcrypt: key output dir is required")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", "", "", fmt.Errorf("logcrypt: create key dir: %w", err)
|
||||
}
|
||||
publicPath = filepath.Join(dir, PublicKeyFileName)
|
||||
privatePath = filepath.Join(dir, PrivateKeyFileName)
|
||||
if !force {
|
||||
for _, path := range []string{publicPath, privatePath} {
|
||||
if _, statErr := os.Stat(path); statErr == nil {
|
||||
return "", "", "", fmt.Errorf("logcrypt: %s already exists (pass --force to overwrite)", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
publicKey, privateKey, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := os.WriteFile(publicPath, EncodeKeyFile(publicKey), 0o644); err != nil {
|
||||
return "", "", "", fmt.Errorf("logcrypt: write public key: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(privatePath, EncodeKeyFile(privateKey), 0o600); err != nil {
|
||||
return "", "", "", fmt.Errorf("logcrypt: write private key: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(publicKey)
|
||||
return publicPath, privatePath, hex.EncodeToString(sum[:keyIDSize]), nil
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
package logcrypt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testIdentity 生成一对测试密钥,并返回收件人与私钥。
|
||||
func testIdentity(t *testing.T) (*Recipient, []byte) {
|
||||
t.Helper()
|
||||
publicKey, privateKey, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("generate key pair: %v", err)
|
||||
}
|
||||
recipient, err := ParseRecipientPublicKey(publicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("parse public key: %v", err)
|
||||
}
|
||||
return recipient, privateKey
|
||||
}
|
||||
|
||||
func TestKeyPairRoundTripAndEncoding(t *testing.T) {
|
||||
recipient, privateKey := testIdentity(t)
|
||||
if recipient.KeyID() == "" || len(recipient.KeyID()) != 16 {
|
||||
t.Fatalf("key id = %q", recipient.KeyID())
|
||||
}
|
||||
// 公钥接受 base64 / hex / 原始字节
|
||||
publicBytes := recipient.PublicKeyBytes()
|
||||
for name, payload := range map[string][]byte{
|
||||
"raw": publicBytes,
|
||||
"base64": EncodeKeyFile(publicBytes),
|
||||
} {
|
||||
parsed, err := ParseRecipientPublicKey(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: parse public key: %v", name, err)
|
||||
}
|
||||
if parsed.KeyID() != recipient.KeyID() {
|
||||
t.Fatalf("%s: key id mismatch", name)
|
||||
}
|
||||
}
|
||||
if _, err := ParseRecipientPublicKey([]byte("too-short")); !errors.Is(err, ErrKeyInvalid) {
|
||||
t.Fatalf("short public key error = %v, want ErrKeyInvalid", err)
|
||||
}
|
||||
if _, err := ParsePrivateKey(EncodeKeyFile(privateKey)); err != nil {
|
||||
t.Fatalf("parse private key: %v", err)
|
||||
}
|
||||
if _, err := ParsePrivateKey([]byte("nope")); !errors.Is(err, ErrKeyInvalid) {
|
||||
t.Fatalf("short private key error = %v, want ErrKeyInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 契约:**没有收件人公钥就不许写**——绝不退化成明文。
|
||||
func TestOpenWithoutRecipientIsRefused(t *testing.T) {
|
||||
if _, err := Open(Config{Dir: t.TempDir(), Prefix: "packet", Ext: ".log.enc"}); !errors.Is(err, ErrRecipientMissing) {
|
||||
t.Fatalf("open without recipient = %v, want ErrRecipientMissing", err)
|
||||
}
|
||||
if _, err := NewEncoder(nil, &bytes.Buffer{}, nil); !errors.Is(err, ErrRecipientMissing) {
|
||||
t.Fatalf("encoder without recipient = %v, want ErrRecipientMissing", err)
|
||||
}
|
||||
if _, err := NewStreamWriter(nil, &bytes.Buffer{}, nil); !errors.Is(err, ErrRecipientMissing) {
|
||||
t.Fatalf("stream writer without recipient = %v, want ErrRecipientMissing", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteKeyPairRefusesToOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
publicPath, privatePath, keyID, err := WriteKeyPair(dir, false)
|
||||
if err != nil {
|
||||
t.Fatalf("write key pair: %v", err)
|
||||
}
|
||||
if keyID == "" {
|
||||
t.Fatal("empty key id")
|
||||
}
|
||||
if _, _, _, err := WriteKeyPair(dir, false); err == nil {
|
||||
t.Fatal("second WriteKeyPair without force must refuse")
|
||||
}
|
||||
if _, _, _, err := WriteKeyPair(dir, true); err != nil {
|
||||
t.Fatalf("forced rewrite: %v", err)
|
||||
}
|
||||
for _, path := range []string{publicPath, privatePath} {
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
t.Fatalf("stat %s: %v", path, statErr)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatalf("%s is empty", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 核心契约:磁盘上不得出现明文,且用私钥解密后与写入内容逐字节一致。
|
||||
func TestWriterNeverWritesPlaintextAndRoundTrips(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
recipient, privateKey := testIdentity(t)
|
||||
writer, err := Open(Config{Dir: dir, Prefix: "packet", Ext: ".log.enc", Recipient: recipient})
|
||||
if err != nil {
|
||||
t.Fatalf("open writer: %v", err)
|
||||
}
|
||||
secret := "SEND kind=game-upper conn_id=game-000001 hex=DEADBEEFCAFE"
|
||||
writer.Write("EVENT kind=starts " + secret)
|
||||
writer.Write("EVENT kind=second index=1")
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
path := writer.Path()
|
||||
if path == "" {
|
||||
path = CurrentPath(dir)
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatal("no current file path recorded")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read container: %v", err)
|
||||
}
|
||||
for _, needle := range []string{"DEADBEEF", "conn_id", "game-upper", "EVENT kind"} {
|
||||
if bytes.Contains(raw, []byte(needle)) {
|
||||
t.Fatalf("plaintext %q found on disk in %s", needle, path)
|
||||
}
|
||||
}
|
||||
if !IsContainer(raw) {
|
||||
t.Fatal("written file is not a logcrypt container")
|
||||
}
|
||||
if keyID, err := ContainerKeyID(raw); err != nil || keyID != recipient.KeyID() {
|
||||
t.Fatalf("container key id = %q err=%v want %q", keyID, err, recipient.KeyID())
|
||||
}
|
||||
|
||||
plain, records, truncated, err := DecryptAll(privateKey, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if truncated || records != 2 {
|
||||
t.Fatalf("records=%d truncated=%t", records, truncated)
|
||||
}
|
||||
text := string(plain)
|
||||
if !strings.Contains(text, secret) || !strings.Contains(text, "EVENT kind=second index=1") {
|
||||
t.Fatalf("round-trip mismatch:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有公钥的人(= 拿到服务端包的人)解不开。
|
||||
func TestPublicKeyAloneCannotDecrypt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
recipient, _ := testIdentity(t)
|
||||
writer, err := Open(Config{Dir: dir, Prefix: "packet", Ext: ".log.enc", Recipient: recipient})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
writer.Write("EVENT kind=secret")
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(writer.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
// 用另一对密钥的私钥必须失败。
|
||||
_, otherPrivate := testIdentity(t)
|
||||
if _, _, _, err := DecryptAll(otherPrivate, raw); err == nil {
|
||||
t.Fatal("decrypt with an unrelated private key succeeded")
|
||||
}
|
||||
// 公钥本身不是私钥,也必须失败。
|
||||
if _, _, _, err := DecryptAll(recipient.PublicKeyBytes(), raw); err == nil {
|
||||
t.Fatal("decrypt using the public key as private key succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterRotatesBySizeAndKeepsEveryFileReadable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
recipient, privateKey := testIdentity(t)
|
||||
writer, err := Open(Config{
|
||||
Dir: dir, Prefix: "packet", Ext: ".log.enc", Recipient: recipient,
|
||||
MaxBytes: 4096,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
const lines = 300
|
||||
for index := 0; index < lines; index++ {
|
||||
writer.Write(strings.Repeat("x", 64) + " index=" + itoa(index))
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
files, err := OpenFiles(dir, "packet")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(files) < 2 {
|
||||
t.Fatalf("expected rotation into multiple files, got %d", len(files))
|
||||
}
|
||||
total := 0
|
||||
for _, file := range files {
|
||||
raw, readErr := os.ReadFile(file)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read %s: %v", file, readErr)
|
||||
}
|
||||
if int64(len(raw)) > 4096+8192 {
|
||||
t.Fatalf("%s exceeded the cap badly: %d bytes", file, len(raw))
|
||||
}
|
||||
plain, records, truncated, decErr := DecryptAll(privateKey, raw)
|
||||
if decErr != nil || truncated {
|
||||
t.Fatalf("decrypt %s: records=%d truncated=%t err=%v", file, records, truncated, decErr)
|
||||
}
|
||||
if records > 0 && !strings.Contains(string(plain), "index=") {
|
||||
t.Fatalf("%s decrypted without the expected record body", file)
|
||||
}
|
||||
total += records
|
||||
}
|
||||
if total != lines {
|
||||
t.Fatalf("records across files = %d, want %d", total, lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriterRotatesOnDayBoundaryAndPrunesOldDirs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
recipient, privateKey := testIdentity(t)
|
||||
day := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
|
||||
now := day
|
||||
writer, err := Open(Config{
|
||||
Dir: dir, Prefix: "packet", Ext: ".log.enc", Recipient: recipient,
|
||||
RetentionDays: 7,
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
writer.Write("EVENT kind=day-one")
|
||||
waitForDir(t, filepath.Join(dir, "20260301"), true)
|
||||
|
||||
now = day.AddDate(0, 0, 1) // 20260302,保留窗口从 20260223 起
|
||||
writer.Write("EVENT kind=day-two")
|
||||
waitForDir(t, filepath.Join(dir, "20260302"), true)
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "20260301")); statErr != nil {
|
||||
t.Fatalf("20260301 must survive while inside the retention window: %v", statErr)
|
||||
}
|
||||
|
||||
now = day.AddDate(0, 0, 9) // 20260310,截止 20260303
|
||||
writer.Write("EVENT kind=day-nine")
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
waitForDir(t, filepath.Join(dir, "20260310"), true)
|
||||
files, err := OpenFiles(dir, "packet")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, file := range files {
|
||||
if !strings.Contains(file, "20260310") {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
raw, readErr := os.ReadFile(file)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read %s: %v", file, readErr)
|
||||
}
|
||||
plain, _, truncated, decErr := DecryptAll(privateKey, raw)
|
||||
if decErr != nil || truncated || !strings.Contains(string(plain), "EVENT kind=day-nine") {
|
||||
t.Fatalf("decrypt %s: truncated=%t err=%v", file, truncated, decErr)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no container written for the current day")
|
||||
}
|
||||
waitForDir(t, filepath.Join(dir, "20260301"), false)
|
||||
waitForDir(t, filepath.Join(dir, "20260302"), false)
|
||||
}
|
||||
|
||||
func waitForDir(t *testing.T, path string, want bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
_, err := os.Stat(path)
|
||||
exists := err == nil
|
||||
if exists == want {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
if want {
|
||||
t.Fatalf("directory %s never appeared: %v", path, err)
|
||||
}
|
||||
t.Fatalf("directory %s was not pruned", path)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptAllToleratesTruncatedTail(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
recipient, privateKey := testIdentity(t)
|
||||
writer, err := Open(Config{Dir: dir, Prefix: "packet", Ext: ".log.enc", Recipient: recipient})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
writer.Write("EVENT kind=first")
|
||||
writer.Write("EVENT kind=second")
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(writer.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
cut := raw[:len(raw)-9] // 砍掉最后一条的尾巴,模拟掉电半行
|
||||
plain, records, truncated, err := DecryptAll(privateKey, cut)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt truncated: %v", err)
|
||||
}
|
||||
if !truncated || records != 1 || !strings.Contains(string(plain), "EVENT kind=first") {
|
||||
t.Fatalf("records=%d truncated=%t plain=%q", records, truncated, string(plain))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentPointerFollowsRotation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
recipient, _ := testIdentity(t)
|
||||
writer, err := Open(Config{Dir: dir, Prefix: "packet", Ext: ".log.enc", Recipient: recipient, MaxBytes: 2048})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
for index := 0; index < 100; index++ {
|
||||
writer.Write(strings.Repeat("y", 96) + " i=" + itoa(index))
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
pointer := CurrentPath(dir)
|
||||
if pointer == "" {
|
||||
t.Fatal("current.txt missing")
|
||||
}
|
||||
if _, err := os.Stat(pointer); err != nil {
|
||||
t.Fatalf("pointer target missing: %v", err)
|
||||
}
|
||||
if filepath.Base(pointer) != filepath.Base(writer.Path()) {
|
||||
t.Fatalf("pointer target %q != last written %q", pointer, writer.Path())
|
||||
}
|
||||
}
|
||||
|
||||
// gatedReader 在 release 关闭前阻塞,用来确定性地制造写队列积压。
|
||||
type gatedReader struct {
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (g *gatedReader) Read(p []byte) (int, error) {
|
||||
<-g.release
|
||||
return defaultRandom().Read(p)
|
||||
}
|
||||
|
||||
func TestDropMarkerIsEncryptedToo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
recipient, privateKey := testIdentity(t)
|
||||
gate := &gatedReader{release: make(chan struct{})}
|
||||
writer, err := Open(Config{
|
||||
Dir: dir, Prefix: "packet", Ext: ".log.enc", Recipient: recipient,
|
||||
QueueSize: 1,
|
||||
DropMarkerKind: "packet-log-backpressure-dropped",
|
||||
Random: gate,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
for index := 0; index < 200; index++ {
|
||||
writer.Write("EVENT kind=flood index=" + itoa(index))
|
||||
}
|
||||
if writer.Dropped() == 0 {
|
||||
t.Fatal("expected the bounded queue to drop under a stalled writer")
|
||||
}
|
||||
close(gate.release)
|
||||
writer.Write("EVENT kind=after-release")
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(writer.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if bytes.Contains(raw, []byte("backpressure-dropped")) {
|
||||
t.Fatal("drop marker leaked to disk in plaintext")
|
||||
}
|
||||
plain, _, _, err := DecryptAll(privateKey, raw)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(plain), "EVENT kind=packet-log-backpressure-dropped") {
|
||||
t.Fatal("drop marker missing from the decrypted stream")
|
||||
}
|
||||
}
|
||||
|
||||
// StreamWriter 用于 os/exec 的 stdout/stderr:行边界可能被切开,必须按行重组。
|
||||
func TestStreamWriterSplitsPartialLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
recipient, privateKey := testIdentity(t)
|
||||
path := filepath.Join(dir, "stream.log.enc")
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
stream, err := NewStreamWriter(recipient, file, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new stream writer: %v", err)
|
||||
}
|
||||
// 故意在一行中间切开
|
||||
if _, err := stream.Write([]byte("hello wo")); err != nil {
|
||||
t.Fatalf("write 1: %v", err)
|
||||
}
|
||||
if _, err := stream.Write([]byte("rld\nsecond line\nthird")); err != nil {
|
||||
t.Fatalf("write 2: %v", err)
|
||||
}
|
||||
if err := stream.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close file: %v", err)
|
||||
}
|
||||
plain, records, _, err := ReadFilePlaintext(privateKey, path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
// 三条记录:完整的两行 + Close 时落下的残行
|
||||
if records != 3 {
|
||||
t.Fatalf("records = %d, want 3 (plain=%q)", records, string(plain))
|
||||
}
|
||||
text := string(plain)
|
||||
// 末尾残行原样保留(不加换行):解开后的拼接必须与子进程写出的字节流一致。
|
||||
if text != "hello world\nsecond line\nthird" {
|
||||
t.Fatalf("stream text = %q", text)
|
||||
}
|
||||
for _, want := range []string{"hello world\n", "second line\n", "third"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("missing %q in %q", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalPrivateKeyMissingIsNotAnError(t *testing.T) {
|
||||
key, err := OptionalPrivateKey(filepath.Join(t.TempDir(), "absent.key"))
|
||||
if err != nil || key != nil {
|
||||
t.Fatalf("missing optional key = (%v, %v), want (nil, nil)", key, err)
|
||||
}
|
||||
key, err = OptionalPrivateKey("")
|
||||
if err != nil || key != nil {
|
||||
t.Fatalf("empty optional key = (%v, %v), want (nil, nil)", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFilePlaintextRejectsNonContainer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "plain.txt")
|
||||
if err := os.WriteFile(path, []byte("not encrypted\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_, privateKey := testIdentity(t)
|
||||
if _, _, _, err := ReadFilePlaintext(privateKey, path); !errors.Is(err, ErrNotEncrypted) {
|
||||
t.Fatalf("err = %v, want ErrNotEncrypted", err)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for value > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + value%10)
|
||||
value /= 10
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package logcrypt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReadFilePlaintext 用私钥解开一个容器文件。
|
||||
//
|
||||
// 返回 (明文, 记录数, 末尾是否被截断, error)。truncated 为 true 表示文件最后一条
|
||||
// 记录没写完(掉电/强杀),前面的记录仍然有效。
|
||||
func ReadFilePlaintext(privateKey []byte, path string) ([]byte, int, bool, error) {
|
||||
raw, err := os.ReadFile(path) //nolint:gosec // G304:路径来自调用方(运维本机或本机回读)。
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return DecryptAll(privateKey, raw)
|
||||
}
|
||||
|
||||
// OptionalPrivateKey 从可选路径加载私钥,用于"服务端本机可选持有私钥"的场景
|
||||
// (例如 GM 记录查询需要回读自己写的记录)。
|
||||
//
|
||||
// 路径为空、或文件不存在时返回 (nil, nil) —— 这不是错误:分发包里**不放私钥**,
|
||||
// 此时服务端只能写、不能读自己的日志,回读接口应当明确报"已加密"而不是报故障。
|
||||
func OptionalPrivateKey(path string) ([]byte, error) {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key, err := LoadPrivateKey(trimmed)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("logcrypt: optional private key %s: %w", trimmed, err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package logcrypt
|
||||
|
||||
import (
|
||||
"crypto/cipher"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// 文件格式(v2:公钥信封)
|
||||
//
|
||||
// header := "LHLOG2" | version(1) | keyIDLen(1) | keyID(keyIDLen) | "\n"
|
||||
// | ephemeralPub(32) | wrapNonce(12) | wrappedLen(u16 LE) | wrappedDEK(wrappedLen)
|
||||
// record := plainLen(u32 LE) | nonce(12) | AES-256-GCM_DEK(plaintext) + tag(16)
|
||||
//
|
||||
// 设计要点:
|
||||
//
|
||||
// - 每个文件一把随机 DEK(数据密钥),逐条记录用它做 AES-256-GCM;DEK 用**收件人公钥**
|
||||
// 封装后写在文件头里。服务端因此只需要公钥,**拿到服务端包也解不开任何日志**。
|
||||
// - 逐条独立密封:文件被截断时只有最后一条未写完的记录解不开,前面的全部完好;
|
||||
// 也不需要"整文件才能读"。
|
||||
// - keyID 写在文件头里:换过密钥对之后,旧文件依然能被对应的旧私钥解开。
|
||||
// - 记录用整个文件头做 AAD,使记录与"它属于哪个文件、哪把 DEK"绑定,
|
||||
// 避免记录被跨文件搬运拼接。
|
||||
const (
|
||||
fileMagic = "LHLOG2"
|
||||
fileVersion = 2
|
||||
// recordOverhead 是每条记录在明文之外多占的字节:长度前缀 + nonce + GCM tag。
|
||||
recordOverhead = 4 + NonceSize + 16
|
||||
// headerFixedSize 是文件头里除 keyID 与 wrappedDEK 之外的固定部分。
|
||||
headerFixedSize = len(fileMagic) + 1 + 1 + 1 + PublicKeySize + NonceSize + 2
|
||||
)
|
||||
|
||||
// ErrRecordTruncated 表示遇到未写完/损坏的记录;之前的记录仍然有效。
|
||||
var ErrRecordTruncated = errors.New("logcrypt: trailing record is truncated or corrupted")
|
||||
|
||||
// headerLayout 是解析出来的文件头。
|
||||
type headerLayout struct {
|
||||
// dataOffset 是记录区起始偏移。
|
||||
dataOffset int
|
||||
// keyID 是收件人公钥标识。
|
||||
keyID string
|
||||
// ephemeralPublic / wrapNonce / wrappedDEK 用于解开文件级 DEK。
|
||||
ephemeralPublic []byte
|
||||
wrapNonce []byte
|
||||
wrappedDEK []byte
|
||||
}
|
||||
|
||||
func buildHeader(recipient *Recipient, ephemeralPublic, wrapNonce, wrappedDEK []byte) []byte {
|
||||
out := make([]byte, 0, headerFixedSize+len(recipient.keyID)+len(wrappedDEK))
|
||||
out = append(out, fileMagic...)
|
||||
out = append(out, fileVersion)
|
||||
out = append(out, byte(len(recipient.keyID)))
|
||||
out = append(out, recipient.keyID...)
|
||||
out = append(out, '\n')
|
||||
out = append(out, ephemeralPublic...)
|
||||
out = append(out, wrapNonce...)
|
||||
var length [2]byte
|
||||
binary.LittleEndian.PutUint16(length[:], uint16(len(wrappedDEK)))
|
||||
out = append(out, length[:]...)
|
||||
out = append(out, wrappedDEK...)
|
||||
return out
|
||||
}
|
||||
|
||||
func parseHeader(data []byte) (headerLayout, error) {
|
||||
var layout headerLayout
|
||||
base := len(fileMagic)
|
||||
if len(data) < headerFixedSize || string(data[:base]) != fileMagic {
|
||||
return layout, ErrNotEncrypted
|
||||
}
|
||||
if data[base] != fileVersion {
|
||||
return layout, fmt.Errorf("logcrypt: unsupported container version %d", data[base])
|
||||
}
|
||||
idLen := int(data[base+1])
|
||||
offset := base + 2
|
||||
if idLen <= 0 || idLen > 64 || len(data) < offset+idLen+1 {
|
||||
return layout, ErrNotEncrypted
|
||||
}
|
||||
layout.keyID = string(data[offset : offset+idLen])
|
||||
offset += idLen
|
||||
if data[offset] != '\n' {
|
||||
return layout, ErrNotEncrypted
|
||||
}
|
||||
offset++
|
||||
if len(data) < offset+PublicKeySize+NonceSize+2 {
|
||||
return layout, ErrNotEncrypted
|
||||
}
|
||||
layout.ephemeralPublic = append([]byte(nil), data[offset:offset+PublicKeySize]...)
|
||||
offset += PublicKeySize
|
||||
layout.wrapNonce = append([]byte(nil), data[offset:offset+NonceSize]...)
|
||||
offset += NonceSize
|
||||
wrappedLen := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
|
||||
offset += 2
|
||||
if wrappedLen <= 0 || len(data) < offset+wrappedLen {
|
||||
return layout, ErrNotEncrypted
|
||||
}
|
||||
layout.wrappedDEK = append([]byte(nil), data[offset:offset+wrappedLen]...)
|
||||
offset += wrappedLen
|
||||
layout.dataOffset = offset
|
||||
return layout, nil
|
||||
}
|
||||
|
||||
// appendRecord 把一条明文记录密封后追加到 dst。
|
||||
func appendRecord(dst []byte, aead cipher.AEAD, aad []byte, plain []byte, random io.Reader) ([]byte, error) {
|
||||
if int64(len(plain)) > int64(^uint32(0)) {
|
||||
return nil, fmt.Errorf("logcrypt: record too large: %d bytes", len(plain))
|
||||
}
|
||||
if random == nil {
|
||||
return nil, errors.New("logcrypt: random source is nil")
|
||||
}
|
||||
var nonce [NonceSize]byte
|
||||
if _, err := io.ReadFull(random, nonce[:]); err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: nonce: %w", err)
|
||||
}
|
||||
var size [4]byte
|
||||
binary.LittleEndian.PutUint32(size[:], uint32(len(plain)))
|
||||
dst = append(dst, size[:]...)
|
||||
dst = append(dst, nonce[:]...)
|
||||
return aead.Seal(dst, nonce[:], plain, aad), nil
|
||||
}
|
||||
|
||||
// openDEK 用私钥解开文件头里的 DEK,并返回以该 DEK 构造的 AEAD 与记录区起点。
|
||||
func openDEK(privateKey, data []byte) (cipher.AEAD, headerLayout, error) {
|
||||
layout, err := parseHeader(data)
|
||||
if err != nil {
|
||||
return nil, layout, err
|
||||
}
|
||||
dek, err := unwrapDEK(privateKey, layout.ephemeralPublic, layout.wrapNonce, layout.wrappedDEK)
|
||||
if err != nil {
|
||||
return nil, layout, err
|
||||
}
|
||||
aead, err := newAEAD(dek)
|
||||
if err != nil {
|
||||
return nil, layout, err
|
||||
}
|
||||
return aead, layout, nil
|
||||
}
|
||||
|
||||
// DecryptAll 用私钥解开一整份容器内容。
|
||||
//
|
||||
// 返回值:
|
||||
// - plaintext:已成功解出的明文(多份记录按写入顺序拼接)
|
||||
// - records:成功解出的记录条数
|
||||
// - truncated:为 true 表示末尾有一段未写完/损坏的记录被丢弃(前面的仍然有效)
|
||||
//
|
||||
// 只有"文件头不合法"或"首条记录就解不开"这类情况才返回 error——那种情况下
|
||||
// plaintext 为空,说明私钥不对或者文件不是本格式。
|
||||
func DecryptAll(privateKey, data []byte) (plaintext []byte, records int, truncated bool, err error) {
|
||||
if len(privateKey) != PrivateKeySize {
|
||||
return nil, 0, false, fmt.Errorf("%w: private key must be %d bytes", ErrKeyInvalid, PrivateKeySize)
|
||||
}
|
||||
aead, layout, err := openDEK(privateKey, data)
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
offset := layout.dataOffset
|
||||
aad := data[:offset]
|
||||
out := make([]byte, 0, len(data))
|
||||
for offset < len(data) {
|
||||
if len(data)-offset < recordOverhead {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
plainLen := int(binary.LittleEndian.Uint32(data[offset : offset+4]))
|
||||
if plainLen < 0 {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
recordLen := recordOverhead + plainLen
|
||||
if len(data)-offset < recordLen {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
nonce := data[offset+4 : offset+4+NonceSize]
|
||||
sealed := data[offset+4+NonceSize : offset+recordLen]
|
||||
plain, openErr := aead.Open(nil, nonce, sealed, aad)
|
||||
if openErr != nil {
|
||||
if records == 0 {
|
||||
return nil, 0, false, fmt.Errorf("logcrypt: cannot decrypt: %w", openErr)
|
||||
}
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
out = append(out, plain...)
|
||||
records++
|
||||
offset += recordLen
|
||||
}
|
||||
return out, records, truncated, nil
|
||||
}
|
||||
|
||||
// ContainerKeyID 只读文件头,返回该文件使用的收件人公钥标识。用于挑选私钥。
|
||||
func ContainerKeyID(data []byte) (string, error) {
|
||||
layout, err := parseHeader(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return layout.keyID, nil
|
||||
}
|
||||
|
||||
// IsContainer 判断一份字节是否是 logcrypt 容器。回读侧用它区分
|
||||
// "已加密的记录文件"与"历史遗留的明文文件"。
|
||||
func IsContainer(data []byte) bool {
|
||||
_, err := parseHeader(data)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package logcrypt
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// StreamSink 是"把任意字节流加密后写进一个已打开的文件"的最简形态。
|
||||
//
|
||||
// 与 StreamWriter 的区别:**每次 Write 直接落一条记录,不做任何行缓冲**。
|
||||
// 这是为 os/exec 的 cmd.Stdout / cmd.Stderr 准备的:
|
||||
//
|
||||
// - 子进程退出时我们拿不到"最后一行写完了"的通知,缓冲会让尾部内容永远留在内存里;
|
||||
// - 逐次落盘没有缓冲,掉电/强杀也不丢已经写出的字节;
|
||||
// - 解密时记录是顺序拼接的,因此这样切分**不改变字节流本身**——
|
||||
// "abc" + "def\n" 两条记录解开后仍是 "abcdef\n"。
|
||||
//
|
||||
// 它不负责关闭底层文件:调用方在子进程生命周期内必须保持句柄有效。
|
||||
type StreamSink struct {
|
||||
mu sync.Mutex
|
||||
core *encoderCore
|
||||
fail error
|
||||
bytes int64
|
||||
}
|
||||
|
||||
// NewStreamSink 用收件人公钥为 w 写文件头,并返回可直接接给 cmd.Stdout 的写入器。
|
||||
func NewStreamSink(recipient *Recipient, w io.Writer, random io.Reader) (*StreamSink, error) {
|
||||
core, err := newEncoderCore(recipient, w, random)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &StreamSink{core: core}, nil
|
||||
}
|
||||
|
||||
// Write 实现 io.Writer:把本次写入的内容整体作为一条记录落盘。
|
||||
//
|
||||
// 永远返回 len(p):调用方是 os/exec 的管道拷贝协程,报短写会让它丢弃子进程输出。
|
||||
func (s *StreamSink) Write(p []byte) (int, error) {
|
||||
if s == nil || s.core == nil {
|
||||
return 0, ErrClosed
|
||||
}
|
||||
if len(p) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.fail != nil {
|
||||
return len(p), s.fail
|
||||
}
|
||||
if err := s.core.writeRecord(p); err != nil {
|
||||
// 记下第一个错误,后续写入继续返回它但不重复做 IO。
|
||||
s.fail = err
|
||||
s.bytes = s.core.written
|
||||
return len(p), err
|
||||
}
|
||||
s.bytes = s.core.written
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Bytes 已写入容器的总字节数(含文件头)。
|
||||
func (s *StreamSink) Bytes() int64 {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.bytes
|
||||
}
|
||||
|
||||
// Err 返回第一次写入失败的错误(没有失败返回 nil)。
|
||||
// 注意:写入失败不会向上抛给子进程,只在这里留痕,供调用方在停服时检查。
|
||||
func (s *StreamSink) Err() error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.fail
|
||||
}
|
||||
|
||||
// KeyID 本流使用的收件人公钥标识。
|
||||
func (s *StreamSink) KeyID() string {
|
||||
if s == nil || s.core == nil {
|
||||
return ""
|
||||
}
|
||||
return s.core.keyID
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
package logcrypt
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/cipher"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 默认参数。MaxBytes/RetentionDays 可由部署环境覆盖。
|
||||
const (
|
||||
// DefaultMaxBytes 是单个日志文件的上限(100 MB)。
|
||||
DefaultMaxBytes = 100 << 20
|
||||
// DefaultRetentionDays 是日志保留天数。
|
||||
DefaultRetentionDays = 7
|
||||
// DefaultQueueSize 是有界队列长度;队列满时丢行而不是阻塞游戏。
|
||||
DefaultQueueSize = 8192
|
||||
// DefaultBufferSize 是 bufio 缓冲。
|
||||
DefaultBufferSize = 256 * 1024
|
||||
// DefaultFlushInterval 是强制 flush 间隔(也是掉电最多丢多少日志)。
|
||||
DefaultFlushInterval = 100 * time.Millisecond
|
||||
// currentPointerName 指向"当前正在写的文件",滚动时重写。
|
||||
// 解密工具默认读它,因此外部脚本不必关心轮转命名。
|
||||
currentPointerName = "current.txt"
|
||||
)
|
||||
|
||||
// ErrClosed 表示写入器已关闭。
|
||||
var ErrClosed = errors.New("logcrypt: writer is closed")
|
||||
|
||||
// Config 是写入器配置。
|
||||
type Config struct {
|
||||
// Dir 是日志根目录;每一天在它下面建一个 <YYYYMMDD>/ 子目录。
|
||||
Dir string
|
||||
// Prefix 是文件名前缀,例如 "packet"。
|
||||
Prefix string
|
||||
// Ext 是文件扩展名,例如 ".log.enc"。
|
||||
Ext string
|
||||
// Recipient 是收件人公钥。**必填**:没有它就不写任何日志(绝不退化成明文)。
|
||||
Recipient *Recipient
|
||||
// MaxBytes 是单文件上限,<=0 时用 DefaultMaxBytes。
|
||||
MaxBytes int64
|
||||
// RetentionDays 是保留天数,<=0 时用 DefaultRetentionDays。
|
||||
RetentionDays int
|
||||
// QueueSize / BufferSize / FlushInterval <=0 时用默认值。
|
||||
QueueSize int
|
||||
BufferSize int
|
||||
FlushInterval time.Duration
|
||||
// DropMarkerKind 是队列溢出时写入的标记事件名。
|
||||
DropMarkerKind string
|
||||
// Now 可注入时钟(测试用);nil 时用 time.Now。
|
||||
Now func() time.Time
|
||||
// Random 可注入随机源(测试用);nil 时用 crypto/rand。
|
||||
Random io.Reader
|
||||
// Logger 可选,用于报告轮转、清理与写入错误。
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Writer 是"落盘即密文 + 按天分目录 + 按大小轮转 + 过期清理"的异步日志写入器。
|
||||
//
|
||||
// 并发模型与既有的 packetLogger 保持一致:
|
||||
// - Write 只做入队,永不阻塞调用方(游戏线程);队列满则丢弃并计数。
|
||||
// - 单写 goroutine 独占文件句柄,加密、缓冲、轮转、清理都在它里面完成,
|
||||
// 因此轮转不需要任何锁。
|
||||
//
|
||||
// 每个文件一把随机 DEK;**重启后一定另起新分片**(DEK 不落盘,无法续写旧文件)。
|
||||
type Writer struct {
|
||||
cfg Config
|
||||
header []byte // 当前文件的头(含被公钥封装的 DEK),同时用作记录 AAD
|
||||
|
||||
queue chan string
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
|
||||
dropped atomic.Uint64
|
||||
pruning atomic.Bool
|
||||
writeFail atomic.Uint64
|
||||
|
||||
mu sync.RWMutex
|
||||
closed bool
|
||||
closeErr error
|
||||
|
||||
// 以下字段只由 run goroutine 访问。
|
||||
file *os.File
|
||||
buf *bufio.Writer
|
||||
aead cipher.AEAD
|
||||
path string
|
||||
day string
|
||||
index int
|
||||
bytesInFile int64
|
||||
recordsInFile int
|
||||
}
|
||||
|
||||
// Open 创建写入器并启动写 goroutine。
|
||||
//
|
||||
// 收件人公钥缺失时返回 ErrRecipientMissing——调用方据此关闭该日志通道,
|
||||
// **绝不写明文**。
|
||||
func Open(cfg Config) (*Writer, error) {
|
||||
if strings.TrimSpace(cfg.Dir) == "" {
|
||||
return nil, errors.New("logcrypt: Dir is required")
|
||||
}
|
||||
if cfg.Prefix == "" {
|
||||
return nil, errors.New("logcrypt: Prefix is required")
|
||||
}
|
||||
if cfg.Recipient == nil || cfg.Recipient.pub == nil {
|
||||
return nil, ErrRecipientMissing
|
||||
}
|
||||
if cfg.MaxBytes <= 0 {
|
||||
cfg.MaxBytes = DefaultMaxBytes
|
||||
}
|
||||
if cfg.RetentionDays <= 0 {
|
||||
cfg.RetentionDays = DefaultRetentionDays
|
||||
}
|
||||
if cfg.QueueSize <= 0 {
|
||||
cfg.QueueSize = DefaultQueueSize
|
||||
}
|
||||
if cfg.BufferSize <= 0 {
|
||||
cfg.BufferSize = DefaultBufferSize
|
||||
}
|
||||
if cfg.FlushInterval <= 0 {
|
||||
cfg.FlushInterval = DefaultFlushInterval
|
||||
}
|
||||
if cfg.Now == nil {
|
||||
cfg.Now = time.Now
|
||||
}
|
||||
if cfg.Random == nil {
|
||||
cfg.Random = defaultRandom()
|
||||
}
|
||||
if cfg.DropMarkerKind == "" {
|
||||
cfg.DropMarkerKind = "log-backpressure-dropped"
|
||||
}
|
||||
if err := os.MkdirAll(cfg.Dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("logcrypt: create log dir: %w", err)
|
||||
}
|
||||
writer := &Writer{
|
||||
cfg: cfg,
|
||||
queue: make(chan string, cfg.QueueSize),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go writer.run()
|
||||
return writer, nil
|
||||
}
|
||||
|
||||
// Write 入队一行(行尾换行可选)。永不阻塞。
|
||||
func (w *Writer) Write(line string) {
|
||||
if w == nil || line == "" {
|
||||
return
|
||||
}
|
||||
if !strings.HasSuffix(line, "\n") {
|
||||
line += "\n"
|
||||
}
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
if w.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case w.queue <- line:
|
||||
default:
|
||||
// 诊断日志绝不能拖住游戏:队列满就丢,由写 goroutine 补一条计数标记。
|
||||
w.dropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Path 返回当前正在写的文件路径(滚动后变化;Close 之后仍返回最后一个)。
|
||||
func (w *Writer) Path() string {
|
||||
if w == nil {
|
||||
return ""
|
||||
}
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
return w.path
|
||||
}
|
||||
|
||||
// KeyID 当前文件使用的收件人公钥标识。
|
||||
func (w *Writer) KeyID() string {
|
||||
if w == nil || w.cfg.Recipient == nil {
|
||||
return ""
|
||||
}
|
||||
return w.cfg.Recipient.keyID
|
||||
}
|
||||
|
||||
// Dropped 返回因队列溢出被丢弃的行数。
|
||||
func (w *Writer) Dropped() uint64 {
|
||||
if w == nil {
|
||||
return 0
|
||||
}
|
||||
return w.dropped.Load()
|
||||
}
|
||||
|
||||
// Close 停止写入器:排空队列、flush、关闭文件。可重复调用。
|
||||
func (w *Writer) Close() error {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
w.mu.Lock()
|
||||
if w.closed {
|
||||
done := w.done
|
||||
w.mu.Unlock()
|
||||
<-done
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
return w.closeErr
|
||||
}
|
||||
w.closed = true
|
||||
close(w.stop)
|
||||
done := w.done
|
||||
w.mu.Unlock()
|
||||
<-done
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
return w.closeErr
|
||||
}
|
||||
|
||||
func (w *Writer) now() time.Time { return w.cfg.Now() }
|
||||
|
||||
func (w *Writer) warn(msg string, args ...any) {
|
||||
if w.cfg.Logger != nil {
|
||||
w.cfg.Logger.Warn(msg, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Writer) run() {
|
||||
defer close(w.done)
|
||||
ticker := time.NewTicker(w.cfg.FlushInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
writeDropMarker := func() {
|
||||
if count := w.dropped.Swap(0); count > 0 {
|
||||
w.appendLocked(fmt.Sprintf("EVENT kind=%s count=%d\n", w.cfg.DropMarkerKind, count))
|
||||
}
|
||||
}
|
||||
flush := func() {
|
||||
writeDropMarker()
|
||||
if w.buf != nil {
|
||||
_ = w.buf.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case line := <-w.queue:
|
||||
writeDropMarker()
|
||||
w.appendLocked(line)
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
case <-w.stop:
|
||||
for {
|
||||
select {
|
||||
case line := <-w.queue:
|
||||
writeDropMarker()
|
||||
w.appendLocked(line)
|
||||
default:
|
||||
flush()
|
||||
if err := w.closeFileLocked(); err != nil {
|
||||
w.warn("logcrypt: close failed", "error", err)
|
||||
w.mu.Lock()
|
||||
if w.closeErr == nil {
|
||||
w.closeErr = err
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// appendLocked 写一条记录;必要时先轮转。任何失败都只计数并告警,绝不向上抛,
|
||||
// 以免日志问题影响游戏。
|
||||
func (w *Writer) appendLocked(line string) {
|
||||
now := w.now()
|
||||
day := now.Format("20060102")
|
||||
if w.file == nil || w.day != day ||
|
||||
(w.recordsInFile > 0 && w.bytesInFile >= w.cfg.MaxBytes) {
|
||||
if err := w.rollLocked(now); err != nil {
|
||||
if n := w.writeFail.Add(1); n == 1 {
|
||||
w.warn("logcrypt: rotate failed", "dir", w.cfg.Dir, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
record, err := appendRecord(nil, w.aead, w.header, []byte(line), w.cfg.Random)
|
||||
if err != nil {
|
||||
if n := w.writeFail.Add(1); n == 1 {
|
||||
w.warn("logcrypt: encrypt failed", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := w.buf.Write(record); err != nil {
|
||||
if n := w.writeFail.Add(1); n == 1 {
|
||||
w.warn("logcrypt: write failed", "path", w.path, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.bytesInFile += int64(len(record))
|
||||
w.recordsInFile++
|
||||
}
|
||||
|
||||
// rollLocked 关闭当前文件并打开新文件(跨天或超过大小上限)。
|
||||
//
|
||||
// 一定新建:DEK 不落盘,续写旧文件无法解密,所以重启/滚动都另起分片。
|
||||
func (w *Writer) rollLocked(now time.Time) error {
|
||||
previous := w.path
|
||||
if err := w.closeFileLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
day := now.Format("20060102")
|
||||
path, index, err := w.nextTarget(day)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) //nolint:gosec // G304:路径来自部署配置。
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.file = file
|
||||
w.buf = bufio.NewWriterSize(file, w.cfg.BufferSize)
|
||||
w.path = path
|
||||
w.index = index
|
||||
w.day = day
|
||||
w.bytesInFile = 0
|
||||
w.recordsInFile = 0
|
||||
w.aead = nil
|
||||
w.header = nil
|
||||
|
||||
dek, ephemeralPublic, wrapNonce, wrappedDEK, err := w.cfg.Recipient.wrapDEK(w.cfg.Random)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header := buildHeader(w.cfg.Recipient, ephemeralPublic, wrapNonce, wrappedDEK)
|
||||
aead, err := newAEAD(dek)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.header = header
|
||||
w.aead = aead
|
||||
if _, err := w.buf.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
w.bytesInFile += int64(len(header))
|
||||
|
||||
w.writePointerLocked()
|
||||
if previous != "" && previous != path {
|
||||
w.schedulePruneLocked(now)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextTarget 返回当天应新建的分片路径:取磁盘上最大序号 +1。
|
||||
func (w *Writer) nextTarget(day string) (string, int, error) {
|
||||
dir := filepath.Join(w.cfg.Dir, day)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", 0, fmt.Errorf("logcrypt: create day dir: %w", err)
|
||||
}
|
||||
pattern := filepath.Join(dir, fmt.Sprintf("%s-%s-*%s", w.cfg.Prefix, day, w.cfg.Ext))
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("logcrypt: glob day dir: %w", err)
|
||||
}
|
||||
maxIndex := 0
|
||||
for _, match := range matches {
|
||||
base := strings.TrimSuffix(filepath.Base(match), w.cfg.Ext)
|
||||
parts := strings.Split(base, "-")
|
||||
index, convErr := strconv.Atoi(parts[len(parts)-1])
|
||||
if convErr != nil || index <= 0 {
|
||||
continue
|
||||
}
|
||||
if index > maxIndex {
|
||||
maxIndex = index
|
||||
}
|
||||
}
|
||||
index := maxIndex + 1
|
||||
name := fmt.Sprintf("%s-%s-%03d%s", w.cfg.Prefix, day, index, w.cfg.Ext)
|
||||
return filepath.Join(dir, name), index, nil
|
||||
}
|
||||
|
||||
func (w *Writer) closeFileLocked() error {
|
||||
if w.buf != nil {
|
||||
if err := w.buf.Flush(); err != nil {
|
||||
w.warn("logcrypt: flush failed", "path", w.path, "error", err)
|
||||
}
|
||||
w.buf = nil
|
||||
}
|
||||
if w.file != nil {
|
||||
err := w.file.Close()
|
||||
w.file = nil
|
||||
// 刻意保留 w.path / w.header:Close 之后 Path() 仍应返回"最后写入的文件",
|
||||
// 调用方与测试都依赖它来定位产物。rollLocked 会在赋值前先取 previous。
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writePointerLocked 重写 current.txt,供解密工具/脚本定位"当前文件"。
|
||||
func (w *Writer) writePointerLocked() {
|
||||
relative, err := filepath.Rel(w.cfg.Dir, w.path)
|
||||
if err != nil {
|
||||
relative = w.path
|
||||
}
|
||||
pointer := filepath.Join(w.cfg.Dir, currentPointerName)
|
||||
_ = os.WriteFile(pointer, []byte(filepath.ToSlash(relative)+"\n"), 0o640)
|
||||
}
|
||||
|
||||
// schedulePruneLocked 异步清理过期目录。只删 <YYYYMMDD> 形状且严格早于截止日的目录,
|
||||
// 绝不触碰当天目录;同一时刻只允许一次清理在跑。
|
||||
func (w *Writer) schedulePruneLocked(now time.Time) {
|
||||
if w.cfg.RetentionDays <= 0 || !w.pruning.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
today := now.Format("20060102")
|
||||
cutoff := now.AddDate(0, 0, -w.cfg.RetentionDays).Format("20060102")
|
||||
dir := w.cfg.Dir
|
||||
logger := w.cfg.Logger
|
||||
go func() {
|
||||
defer w.pruning.Store(false)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if len(name) != 8 || !isDigits(name) || name == today || name >= cutoff {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(dir, name)
|
||||
if removeErr := os.RemoveAll(full); removeErr != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("logcrypt: prune failed", "dir", full, "error", removeErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("logcrypt: pruned expired log dir", "dir", full, "retention_days", w.cfg.RetentionDays)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func isDigits(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
// CurrentPath 读取 <dir>/current.txt 指向的当前文件绝对路径。
|
||||
// 找不到时返回空字符串;供解密工具与外部脚本使用。
|
||||
func CurrentPath(dir string) string {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, currentPointerName))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
relative := strings.TrimSpace(string(raw))
|
||||
if relative == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(relative) {
|
||||
return relative
|
||||
}
|
||||
return filepath.Join(dir, filepath.FromSlash(relative))
|
||||
}
|
||||
|
||||
// OpenFiles 列出 dir 下所有容器文件(按天目录排序)。
|
||||
// prefix 为空时匹配全部。
|
||||
func OpenFiles(dir string, prefix string) ([]string, error) {
|
||||
var out []string
|
||||
days, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pattern := "*"
|
||||
if prefix != "" {
|
||||
pattern = prefix + "-*"
|
||||
}
|
||||
for _, day := range days {
|
||||
if !day.IsDir() || len(day.Name()) != 8 || !isDigits(day.Name()) {
|
||||
continue
|
||||
}
|
||||
matches, globErr := filepath.Glob(filepath.Join(dir, day.Name(), pattern))
|
||||
if globErr != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, matches...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in new issue
Block a user