DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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 }
+352
View File
@@ -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
}
+206
View File
@@ -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
}
+502
View File
@@ -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
}