Files source (source_type=file), declared in Settings > Log sources > Log files (/data/files.json, 50 at most): name (= app), path as mounted in the container (file or glob, rotated copies .1/.gz left out, 200 files per glob), format, optional host, switch. - formats: plain text (level detected, leading timestamp read and removed), JSON lines (msg/time/level keys, numeric pino levels, other keys as fields, nested ones dotted), logfmt, access log CLF (nginx, Apache, Traefik CLF with router/backend/duration), Traefik access log in JSON (summary "GET host/path 200 · 12.5 ms · router · client"), regular expression with named groups time/level/msg/host/app/status; 5xx = err, 4xx = warning; fields named like LogStream's own renamed field_<name> - indented lines (text) and non-matching lines (regex) added to the previous entry, which waits 2 s for them - inode + offset per file in /data/files-state.json, rotation (end of .1 read first), copytruncate, file renamed within the glob; last FILES_BACKFILL (1h) of dated lines read when turned on, new files read from their start - status per file (files followed, entries read, not found / permission) UI: rows with switch, name, path, format, host (regex field when needed), status line; green edge, tint and FILE badge in the list, "Files" in the source filter, HTTP fields first in the details, FR/EN. docker-compose, .env.example and README (EN/FR): mounts, formats, FILES_BACKFILL. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
590 lines
15 KiB
Go
590 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Formats of the "Files" source (Settings > Sources), one per declared file.
|
|
const (
|
|
fmtText = "text" // one line per entry, level and leading timestamp detected
|
|
fmtJSON = "json" // JSON lines: msg/time/level recognized, other keys as fields
|
|
fmtLogfmt = "logfmt" // key=value pairs (Traefik "common" logs, Go programs…)
|
|
fmtRegex = "regex" // regular expression with named groups
|
|
fmtCLF = "clf" // access logs in Common/Combined Log Format (nginx, Apache, Traefik)
|
|
fmtTraefik = "traefik" // Traefik access log in JSON
|
|
)
|
|
|
|
var fileFormats = map[string]bool{fmtText: true, fmtJSON: true, fmtLogfmt: true, fmtRegex: true, fmtCLF: true, fmtTraefik: true}
|
|
|
|
// fileRec is one parsed line (or several, for continuation lines).
|
|
type fileRec struct {
|
|
t time.Time
|
|
dated bool // t comes from the line
|
|
sev int
|
|
msg string
|
|
host string
|
|
app string
|
|
fields map[string]string
|
|
}
|
|
|
|
const (
|
|
maxFileFields = 64
|
|
maxFieldValue = 4096
|
|
maxFileMsg = 64 << 10 // message with its continuation lines
|
|
maxContinuation = 500 // continuation lines added to one entry
|
|
)
|
|
|
|
// fileParser turns a line of a file into an entry, for one format.
|
|
type fileParser struct {
|
|
format string
|
|
re *regexp.Regexp // fmtRegex
|
|
}
|
|
|
|
func newFileParser(format, expr string) (*fileParser, error) {
|
|
if !fileFormats[format] {
|
|
return nil, fmt.Errorf("unknown format %q", format)
|
|
}
|
|
p := &fileParser{format: format}
|
|
if format == fmtRegex {
|
|
if strings.TrimSpace(expr) == "" {
|
|
return nil, fmt.Errorf("the regex format needs a regular expression")
|
|
}
|
|
re, err := regexp.Compile(expr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid regular expression: %v", err)
|
|
}
|
|
p.re = re
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// multiline tells whether lines that do not start an entry are added to the
|
|
// previous one (stack traces…). Structured formats are one entry per line.
|
|
func (p *fileParser) multiline() bool {
|
|
return p.format == fmtText || p.format == fmtRegex
|
|
}
|
|
|
|
// continuation reports a line that belongs to the previous entry: indented,
|
|
// or not matching the regular expression.
|
|
func (p *fileParser) continuation(line string) bool {
|
|
if line != "" && (line[0] == ' ' || line[0] == '\t') {
|
|
return true
|
|
}
|
|
return p.format == fmtRegex && !p.re.MatchString(line)
|
|
}
|
|
|
|
// parse decodes one line; it never fails (a line in another format is kept as text).
|
|
func (p *fileParser) parse(line string, now time.Time) *fileRec {
|
|
line = cleanLine(line)
|
|
switch p.format {
|
|
case fmtJSON, fmtTraefik:
|
|
if obj, ok := decodeJSONLine(line); ok {
|
|
if p.format == fmtTraefik {
|
|
if r := traefikRec(obj, line, now); r != nil {
|
|
return r
|
|
}
|
|
}
|
|
return jsonRec(obj, line, now)
|
|
}
|
|
case fmtLogfmt:
|
|
if kv := parseLogfmt(line); len(kv) > 0 {
|
|
return logfmtRec(kv, line, now)
|
|
}
|
|
case fmtRegex:
|
|
if m := p.re.FindStringSubmatch(line); m != nil {
|
|
return p.regexRec(m, line, now)
|
|
}
|
|
case fmtCLF:
|
|
if r := clfRec(line, now); r != nil {
|
|
return r
|
|
}
|
|
}
|
|
return textRec(line, now)
|
|
}
|
|
|
|
func cleanLine(s string) string {
|
|
s = strings.TrimRight(s, "\r")
|
|
s = ansiRe.ReplaceAllString(s, "")
|
|
if !utf8.ValidString(s) {
|
|
s = strings.ToValidUTF8(s, "�")
|
|
}
|
|
return s
|
|
}
|
|
|
|
// textRec: the line itself, without its leading timestamp when there is one.
|
|
func textRec(line string, now time.Time) *fileRec {
|
|
r := &fileRec{t: now, sev: 6, msg: line}
|
|
if m := leadTimeRe.FindStringSubmatchIndex(line); m != nil {
|
|
if t, ok := parseLogTime(line[m[2]:m[3]], now); ok {
|
|
r.t, r.dated = t, true
|
|
if rest := line[m[1]:]; strings.TrimSpace(rest) != "" {
|
|
r.msg = rest
|
|
}
|
|
}
|
|
}
|
|
if n, ok := detectLevel(r.msg); ok {
|
|
r.sev = n
|
|
}
|
|
return r
|
|
}
|
|
|
|
/* ---------- Timestamps ---------- */
|
|
|
|
// leadTimeRe: a timestamp at the start of a line, possibly in brackets
|
|
// (ISO 8601 / RFC 3339, 2006/01/02 15:04:05, CLF, syslog "Jan _2 15:04:05").
|
|
var leadTimeRe = regexp.MustCompile(`^\[?(\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?: ?(?:Z|[+-]\d{2}:?\d{2}))?|\d{2}/[A-Z][a-z]{2}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}|[A-Z][a-z]{2} +\d{1,2} \d{2}:\d{2}:\d{2})\]?[ \t]*(?:[-|:][ \t]+)?`)
|
|
|
|
var timeLayouts = []string{
|
|
time.RFC3339Nano,
|
|
"2006-01-02T15:04:05Z0700",
|
|
"2006-01-02T15:04:05",
|
|
"02/Jan/2006:15:04:05 -0700",
|
|
"Jan _2 15:04:05",
|
|
time.RFC1123Z,
|
|
time.RFC1123,
|
|
time.UnixDate,
|
|
time.ANSIC,
|
|
}
|
|
|
|
// parseLogTime reads the usual log timestamps; times without a zone are local.
|
|
func parseLogTime(s string, now time.Time) (time.Time, bool) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return time.Time{}, false
|
|
}
|
|
if s[0] >= '0' && s[0] <= '9' && !strings.ContainsAny(s, ":/-") {
|
|
if whole, _, _ := strings.Cut(s, "."); len(whole) < 9 {
|
|
return time.Time{}, false // not a Unix time of this century
|
|
}
|
|
return epochTime(s)
|
|
}
|
|
// 2006/01/02 15:04:05,123 +02:00 -> 2006-01-02T15:04:05.123+02:00
|
|
if len(s) >= 19 && s[4] == '/' && s[7] == '/' {
|
|
s = s[:4] + "-" + s[5:7] + "-" + s[8:]
|
|
}
|
|
if len(s) >= 19 && s[4] == '-' && s[10] == ' ' {
|
|
s = s[:10] + "T" + s[11:]
|
|
}
|
|
if len(s) > 19 && s[4] == '-' {
|
|
s = s[:19] + strings.Replace(strings.Replace(s[19:], ",", ".", 1), " ", "", 1)
|
|
}
|
|
s = strings.Join(strings.Fields(s), " ") // "Jan 5" -> "Jan 5"
|
|
for _, layout := range timeLayouts {
|
|
if layout == "Jan _2 15:04:05" {
|
|
layout = "Jan 2 15:04:05"
|
|
}
|
|
t, err := time.ParseInLocation(layout, s, time.Local)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if t.Year() == 0 { // syslog timestamps have no year
|
|
t = t.AddDate(now.Year(), 0, 0)
|
|
if t.After(now.Add(24 * time.Hour)) {
|
|
t = t.AddDate(-1, 0, 0)
|
|
}
|
|
}
|
|
return t, true
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// epochTime reads a Unix time in seconds, milliseconds, microseconds or
|
|
// nanoseconds (guessed from its size), with an optional fraction.
|
|
func epochTime(s string) (time.Time, bool) {
|
|
f, err := strconv.ParseFloat(s, 64)
|
|
if err != nil || f <= 0 {
|
|
return time.Time{}, false
|
|
}
|
|
switch {
|
|
case f < 1e11:
|
|
return time.Unix(0, int64(f*1e9)), true
|
|
case f < 1e14:
|
|
return time.UnixMilli(int64(f)), true
|
|
case f < 1e17:
|
|
return time.UnixMicro(int64(f)), true
|
|
}
|
|
return time.Unix(0, int64(f)), true
|
|
}
|
|
|
|
/* ---------- Fields ---------- */
|
|
|
|
// reservedFields are stored by LogStream itself: fields of the logs with the
|
|
// same name are renamed field_<name>.
|
|
var reservedFields = map[string]bool{
|
|
"_time": true, "_msg": true, "_stream": true, "_stream_id": true, "received": true, "msg_time": true,
|
|
"host": true, "host_name": true, "host_ip": true, "app": true, "facility": true, "severity": true,
|
|
"sevnum": true, "source": true, "proto": true, "procid": true, "sd": true, "source_type": true, "log_file": true,
|
|
}
|
|
|
|
func fieldName(k string) string {
|
|
k = strings.Map(func(r rune) rune {
|
|
if r == '_' || r == '.' || r == '-' || r >= '0' && r <= '9' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' {
|
|
return r
|
|
}
|
|
return '_'
|
|
}, k)
|
|
if k == "" || reservedFields[k] || k[0] == '_' {
|
|
k = "field_" + strings.TrimLeft(k, "_")
|
|
}
|
|
return k
|
|
}
|
|
|
|
func (r *fileRec) set(k, v string) {
|
|
if v == "" {
|
|
return
|
|
}
|
|
if r.fields == nil {
|
|
r.fields = map[string]string{}
|
|
}
|
|
k = fieldName(k)
|
|
if _, ok := r.fields[k]; !ok && len(r.fields) >= maxFileFields {
|
|
return
|
|
}
|
|
if len(v) > maxFieldValue {
|
|
v = strings.ToValidUTF8(v[:maxFieldValue], "") + "…"
|
|
}
|
|
r.fields[k] = v
|
|
}
|
|
|
|
/* ---------- JSON lines ---------- */
|
|
|
|
func decodeJSONLine(line string) (map[string]any, bool) {
|
|
s := strings.TrimSpace(line)
|
|
if len(s) < 2 || s[0] != '{' {
|
|
return nil, false
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(s))
|
|
dec.UseNumber()
|
|
var obj map[string]any
|
|
if dec.Decode(&obj) != nil {
|
|
return nil, false
|
|
}
|
|
return obj, true
|
|
}
|
|
|
|
var (
|
|
jsonMsgKeys = []string{"msg", "message", "@message", "log", "text"}
|
|
jsonTimeKeys = []string{"time", "ts", "timestamp", "@timestamp", "@t", "t", "datetime", "date"}
|
|
jsonLevelKeys = []string{"level", "lvl", "severity", "log.level", "@level", "@l", "loglevel", "levelname"}
|
|
)
|
|
|
|
// takeString removes the first of keys found in obj and returns it as text.
|
|
func takeString(obj map[string]any, keys []string) (string, bool) {
|
|
for _, k := range keys {
|
|
if v, ok := obj[k]; ok {
|
|
if s := scalarString(v); s != "" {
|
|
delete(obj, k)
|
|
return s, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func scalarString(v any) string {
|
|
switch v := v.(type) {
|
|
case string:
|
|
return v
|
|
case json.Number:
|
|
return v.String()
|
|
case bool:
|
|
return strconv.FormatBool(v)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func jsonRec(obj map[string]any, line string, now time.Time) *fileRec {
|
|
r := &fileRec{t: now, sev: 6, msg: line}
|
|
for _, k := range jsonTimeKeys {
|
|
if t, ok := parseLogTime(scalarString(obj[k]), now); ok {
|
|
r.t, r.dated = t, true
|
|
delete(obj, k)
|
|
break
|
|
}
|
|
}
|
|
lvl, hasLevel := takeString(obj, jsonLevelKeys)
|
|
if hasLevel {
|
|
if n, ok := levelNum(lvl); ok {
|
|
r.sev = n
|
|
} else if n, err := strconv.Atoi(lvl); err == nil {
|
|
r.sev = numericLevel(n)
|
|
} else {
|
|
r.set("level", lvl)
|
|
}
|
|
}
|
|
if s, ok := takeString(obj, jsonMsgKeys); ok {
|
|
r.msg = strings.TrimRight(s, "\n")
|
|
}
|
|
flattenJSON("", obj, r)
|
|
if !hasLevel {
|
|
if n, ok := detectLevel(r.msg); ok {
|
|
r.sev = n
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// numericLevel maps numeric levels: syslog (0-7) or pino/bunyan (10-60).
|
|
func numericLevel(n int) int {
|
|
switch {
|
|
case n >= 0 && n <= 7:
|
|
return n
|
|
case n >= 60:
|
|
return 2
|
|
case n >= 50:
|
|
return 3
|
|
case n >= 40:
|
|
return 4
|
|
case n >= 30:
|
|
return 6
|
|
}
|
|
return 7
|
|
}
|
|
|
|
// flattenJSON stores the remaining keys as fields; nested objects give
|
|
// dotted names, arrays stay in JSON.
|
|
func flattenJSON(prefix string, obj map[string]any, r *fileRec) {
|
|
for k, v := range obj {
|
|
name := prefix + k
|
|
switch v := v.(type) {
|
|
case map[string]any:
|
|
flattenJSON(name+".", v, r)
|
|
case []any:
|
|
b, _ := json.Marshal(v)
|
|
r.set(name, string(b))
|
|
case nil:
|
|
default:
|
|
r.set(name, scalarString(v))
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------- logfmt ---------- */
|
|
|
|
// parseLogfmt splits `k=v k2="v 2"` (with \" escapes); words without a value
|
|
// are skipped. Nil when the line has no pair.
|
|
func parseLogfmt(s string) [][2]string {
|
|
var out [][2]string
|
|
for i := 0; i < len(s); {
|
|
for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
|
|
i++
|
|
}
|
|
start := i
|
|
for i < len(s) && s[i] != '=' && s[i] != ' ' && s[i] != '\t' {
|
|
i++
|
|
}
|
|
if i >= len(s) || s[i] != '=' || i == start {
|
|
for i < len(s) && s[i] != ' ' && s[i] != '\t' {
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
k := s[start:i]
|
|
i++
|
|
var v string
|
|
if i < len(s) && s[i] == '"' {
|
|
var b strings.Builder
|
|
i++
|
|
for i < len(s) && s[i] != '"' {
|
|
if s[i] == '\\' && i+1 < len(s) {
|
|
i++
|
|
switch s[i] {
|
|
case 'n':
|
|
b.WriteByte('\n')
|
|
case 't':
|
|
b.WriteByte('\t')
|
|
default:
|
|
b.WriteByte(s[i])
|
|
}
|
|
} else {
|
|
b.WriteByte(s[i])
|
|
}
|
|
i++
|
|
}
|
|
i++ // closing quote
|
|
v = b.String()
|
|
} else {
|
|
end := i
|
|
for end < len(s) && s[end] != ' ' && s[end] != '\t' {
|
|
end++
|
|
}
|
|
v, i = s[i:end], end
|
|
}
|
|
out = append(out, [2]string{k, v})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func logfmtRec(kv [][2]string, line string, now time.Time) *fileRec {
|
|
obj := make(map[string]any, len(kv))
|
|
for _, p := range kv {
|
|
obj[p[0]] = p[1]
|
|
}
|
|
return jsonRec(obj, line, now)
|
|
}
|
|
|
|
/* ---------- Regular expression ---------- */
|
|
|
|
func (p *fileParser) regexRec(m []string, line string, now time.Time) *fileRec {
|
|
r := &fileRec{t: now, sev: 6, msg: line}
|
|
hasLevel := false
|
|
for i, name := range p.re.SubexpNames() {
|
|
if i == 0 || name == "" || m[i] == "" {
|
|
continue
|
|
}
|
|
v := m[i]
|
|
switch strings.ToLower(name) {
|
|
case "time", "timestamp", "ts":
|
|
if t, ok := parseLogTime(v, now); ok {
|
|
r.t, r.dated = t, true
|
|
}
|
|
case "level", "severity", "lvl":
|
|
if n, ok := levelNum(v); ok {
|
|
r.sev, hasLevel = n, true
|
|
}
|
|
case "msg", "message":
|
|
r.msg = v
|
|
case "host":
|
|
r.host = v
|
|
case "app":
|
|
r.app = v
|
|
case "status":
|
|
r.set(name, v)
|
|
if n, ok := statusLevel(v); ok && !hasLevel {
|
|
r.sev, hasLevel = n, true
|
|
}
|
|
default:
|
|
r.set(name, v)
|
|
}
|
|
}
|
|
if !hasLevel {
|
|
if n, ok := detectLevel(r.msg); ok {
|
|
r.sev = n
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
/* ---------- Access logs ---------- */
|
|
|
|
// statusLevel: 5xx are errors, 4xx warnings, the rest info.
|
|
func statusLevel(s string) (int, bool) {
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n < 100 || n > 999 {
|
|
return 0, false
|
|
}
|
|
switch {
|
|
case n >= 500:
|
|
return 3, true
|
|
case n >= 400:
|
|
return 4, true
|
|
}
|
|
return 6, true
|
|
}
|
|
|
|
// clfRe: Common Log Format, the combined extension (referer, user agent) and
|
|
// the Traefik one (request count, router, server URL, duration).
|
|
var clfRe = regexp.MustCompile(`^(\S+) (\S+) (\S+) \[([^\]]+)\] "((?:[^"\\]|\\.)*)" (\d{3}) (\d+|-)(?: "((?:[^"\\]|\\.)*)" "((?:[^"\\]|\\.)*)")?(?: (\d+) "([^"]*)" "([^"]*)" (\d+)ms)?`)
|
|
|
|
func clfRec(line string, now time.Time) *fileRec {
|
|
m := clfRe.FindStringSubmatch(line)
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
r := &fileRec{t: now, sev: 6, msg: line}
|
|
if t, ok := parseLogTime(m[4], now); ok {
|
|
r.t, r.dated = t, true
|
|
}
|
|
dash := func(s string) string {
|
|
if s == "-" {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
r.set("client_ip", m[1])
|
|
r.set("http_user", dash(m[3]))
|
|
if req := strings.Fields(m[5]); len(req) == 3 {
|
|
r.set("http_method", req[0])
|
|
r.set("http_path", req[1])
|
|
r.set("http_proto", req[2])
|
|
} else {
|
|
r.set("http_request", m[5])
|
|
}
|
|
r.set("http_status", m[6])
|
|
r.set("http_size", dash(m[7]))
|
|
r.set("http_referer", dash(m[8]))
|
|
r.set("user_agent", dash(m[9]))
|
|
r.set("router", dash(m[11]))
|
|
r.set("backend", dash(m[12]))
|
|
r.set("duration_ms", m[13])
|
|
r.sev, _ = statusLevel(m[6])
|
|
return r
|
|
}
|
|
|
|
// traefikRec reads a Traefik access log line in JSON; nil for the other
|
|
// lines (Traefik's own logs), read as generic JSON.
|
|
func traefikRec(obj map[string]any, line string, now time.Time) *fileRec {
|
|
status := scalarString(obj["DownstreamStatus"])
|
|
if status == "" {
|
|
status = scalarString(obj["OriginStatus"])
|
|
}
|
|
method := scalarString(obj["RequestMethod"])
|
|
if status == "" && method == "" {
|
|
return nil
|
|
}
|
|
r := &fileRec{t: now, sev: 6}
|
|
if t, ok := parseLogTime(firstNonEmpty(scalarString(obj["StartUTC"]), scalarString(obj["time"])), now); ok {
|
|
r.t, r.dated = t, true
|
|
}
|
|
if n, ok := statusLevel(status); ok {
|
|
r.sev = n
|
|
}
|
|
rename := map[string]string{
|
|
"ClientHost": "client_ip", "ClientUsername": "http_user", "RequestMethod": "http_method",
|
|
"RequestHost": "http_host", "RequestPath": "http_path", "RequestProtocol": "http_proto",
|
|
"DownstreamStatus": "http_status", "DownstreamContentSize": "http_size", "OriginStatus": "origin_status",
|
|
"RouterName": "router", "ServiceName": "service", "ServiceURL": "backend", "entryPointName": "entrypoint",
|
|
"request_User-Agent": "user_agent", "request_Referer": "http_referer",
|
|
}
|
|
dur := ""
|
|
if d, err := strconv.ParseFloat(scalarString(obj["Duration"]), 64); err == nil {
|
|
dur = strconv.FormatFloat(d/1e6, 'f', -1, 64)
|
|
r.set("duration_ms", dur)
|
|
}
|
|
for _, k := range []string{"Duration", "StartUTC", "StartLocal", "time", "level", "msg"} {
|
|
delete(obj, k)
|
|
}
|
|
for k, v := range obj {
|
|
if to, ok := rename[k]; ok {
|
|
r.set(to, scalarString(v))
|
|
delete(obj, k)
|
|
}
|
|
}
|
|
flattenJSON("", obj, r)
|
|
// "GET example.com/path 404 · 12.5 ms · router@docker"
|
|
var b strings.Builder
|
|
b.WriteString(firstNonEmpty(method, "-"))
|
|
b.WriteByte(' ')
|
|
b.WriteString(r.fields["http_host"])
|
|
b.WriteString(r.fields["http_path"])
|
|
b.WriteByte(' ')
|
|
b.WriteString(firstNonEmpty(status, "-"))
|
|
if dur != "" {
|
|
b.WriteString(" · " + dur + " ms")
|
|
}
|
|
if rt := r.fields["router"]; rt != "" {
|
|
b.WriteString(" · " + rt)
|
|
}
|
|
if ip := r.fields["client_ip"]; ip != "" {
|
|
b.WriteString(" · " + ip)
|
|
}
|
|
r.msg = b.String()
|
|
return r
|
|
}
|