mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-24 10:22:35 -04:00
Compare commits
1 Commits
add-packet
...
github-iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b8e40f78d |
26
.github/issue-resolution/prompts/issue-resolution-system.txt
vendored
Normal file
26
.github/issue-resolution/prompts/issue-resolution-system.txt
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
You are a GitHub issue resolution classifier.
|
||||
|
||||
Your job is to decide whether an open GitHub issue is:
|
||||
- AUTO_CLOSE
|
||||
- MANUAL_REVIEW
|
||||
- KEEP_OPEN
|
||||
|
||||
Rules:
|
||||
1. AUTO_CLOSE is only allowed if there is objective, hard evidence:
|
||||
- a merged linked PR that clearly resolves the issue, or
|
||||
- an explicit maintainer/member/owner/collaborator comment saying the issue is fixed, resolved, duplicate, or superseded
|
||||
2. If there is any contradictory later evidence, do NOT AUTO_CLOSE.
|
||||
3. If evidence is promising but not airtight, choose MANUAL_REVIEW.
|
||||
4. If the issue still appears active or unresolved, choose KEEP_OPEN.
|
||||
5. Do not invent evidence.
|
||||
6. Output valid JSON only.
|
||||
|
||||
Maintainer-authoritative roles:
|
||||
- MEMBER
|
||||
- OWNER
|
||||
- COLLABORATOR
|
||||
|
||||
Important:
|
||||
- Later comments outweigh earlier ones.
|
||||
- A non-maintainer saying "fixed for me" is not enough for AUTO_CLOSE.
|
||||
- If uncertain, prefer MANUAL_REVIEW or KEEP_OPEN.
|
||||
78
.github/issue-resolution/schemas/issue-resolution-output.json
vendored
Normal file
78
.github/issue-resolution/schemas/issue-resolution-output.json
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"decision",
|
||||
"reason_code",
|
||||
"confidence",
|
||||
"hard_signals",
|
||||
"contradictions",
|
||||
"summary",
|
||||
"close_comment",
|
||||
"manual_review_note"
|
||||
],
|
||||
"properties": {
|
||||
"decision": {
|
||||
"type": "string",
|
||||
"enum": ["AUTO_CLOSE", "MANUAL_REVIEW", "KEEP_OPEN"]
|
||||
},
|
||||
"reason_code": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"resolved_by_merged_pr",
|
||||
"maintainer_confirmed_resolved",
|
||||
"duplicate_confirmed",
|
||||
"superseded_confirmed",
|
||||
"likely_fixed_but_unconfirmed",
|
||||
"still_open",
|
||||
"unclear"
|
||||
]
|
||||
},
|
||||
"confidence": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"hard_signals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["type", "url"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"merged_pr",
|
||||
"maintainer_comment",
|
||||
"duplicate_reference",
|
||||
"superseded_reference"
|
||||
]
|
||||
},
|
||||
"url": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"contradictions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["type", "url"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"reporter_still_broken",
|
||||
"later_unresolved_comment",
|
||||
"ambiguous_pr_link",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"url": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": { "type": "string" },
|
||||
"close_comment": { "type": "string" },
|
||||
"manual_review_note": { "type": "string" }
|
||||
}
|
||||
}
|
||||
152
.github/issue-resolution/scripts/apply-decisions.mjs
vendored
Normal file
152
.github/issue-resolution/scripts/apply-decisions.mjs
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const decisions = JSON.parse(await fs.readFile("decisions.json", "utf8"));
|
||||
const dryRun = String(process.env.DRY_RUN).toLowerCase() === "true";
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${process.env.GH_TOKEN}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
|
||||
async function rest(url, method = "GET", body) {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${url}: ${await res.text()}`);
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function graphql(query, variables) {
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ query, variables })
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
|
||||
const json = await res.json();
|
||||
if (json.errors) throw new Error(JSON.stringify(json.errors));
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function addLabel(owner, repo, issueNumber, labels) {
|
||||
return rest(
|
||||
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/labels`,
|
||||
"POST",
|
||||
{ labels }
|
||||
);
|
||||
}
|
||||
|
||||
async function addComment(owner, repo, issueNumber, body) {
|
||||
return rest(
|
||||
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
|
||||
"POST",
|
||||
{ body }
|
||||
);
|
||||
}
|
||||
|
||||
async function closeIssue(owner, repo, issueNumber) {
|
||||
return rest(
|
||||
`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`,
|
||||
"PATCH",
|
||||
{ state: "closed", state_reason: "completed" }
|
||||
);
|
||||
}
|
||||
|
||||
async function getIssueNodeId(owner, repo, issueNumber) {
|
||||
const issue = await rest(`https://api.github.com/repos/${owner}/${repo}/issues/${issueNumber}`);
|
||||
return issue.node_id;
|
||||
}
|
||||
|
||||
async function addToProject(issueNodeId) {
|
||||
const mutation = `
|
||||
mutation($projectId: ID!, $contentId: ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
|
||||
item { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await graphql(mutation, {
|
||||
projectId: process.env.PROJECT_ID,
|
||||
contentId: issueNodeId
|
||||
});
|
||||
|
||||
return data.addProjectV2ItemById.item.id;
|
||||
}
|
||||
|
||||
async function setTextField(itemId, fieldId, value) {
|
||||
const mutation = `
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: String!) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId,
|
||||
itemId: $itemId,
|
||||
fieldId: $fieldId,
|
||||
value: { text: $value }
|
||||
}) {
|
||||
projectV2Item { id }
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
return graphql(mutation, {
|
||||
projectId: process.env.PROJECT_ID,
|
||||
itemId,
|
||||
fieldId,
|
||||
value
|
||||
});
|
||||
}
|
||||
|
||||
for (const d of decisions) {
|
||||
const [owner, repo] = d.repository.split("/");
|
||||
|
||||
if (d.final_decision === "AUTO_CLOSE") {
|
||||
if (dryRun) continue;
|
||||
|
||||
await addLabel(owner, repo, d.issue_number, ["auto-closed-resolved"]);
|
||||
await addComment(
|
||||
owner,
|
||||
repo,
|
||||
d.issue_number,
|
||||
d.model.close_comment ||
|
||||
"This appears resolved based on linked evidence, so we’re closing it automatically. Reply if this still reproduces and we’ll reopen."
|
||||
);
|
||||
await closeIssue(owner, repo, d.issue_number);
|
||||
}
|
||||
|
||||
if (d.final_decision === "MANUAL_REVIEW") {
|
||||
await addLabel(owner, repo, d.issue_number, ["resolution-candidate"]);
|
||||
|
||||
const issueNodeId = await getIssueNodeId(owner, repo, d.issue_number);
|
||||
const itemId = await addToProject(issueNodeId);
|
||||
|
||||
if (process.env.PROJECT_CONFIDENCE_FIELD_ID) {
|
||||
await setTextField(itemId, process.env.PROJECT_CONFIDENCE_FIELD_ID, String(d.model.confidence));
|
||||
}
|
||||
if (process.env.PROJECT_REASON_FIELD_ID) {
|
||||
await setTextField(itemId, process.env.PROJECT_REASON_FIELD_ID, d.model.reason_code);
|
||||
}
|
||||
if (process.env.PROJECT_EVIDENCE_FIELD_ID) {
|
||||
await setTextField(itemId, process.env.PROJECT_EVIDENCE_FIELD_ID, d.issue_url);
|
||||
}
|
||||
if (process.env.PROJECT_LINKED_PR_FIELD_ID) {
|
||||
const linked = (d.model.hard_signals || []).map(x => x.url).join(", ");
|
||||
if (linked) {
|
||||
await setTextField(itemId, process.env.PROJECT_LINKED_PR_FIELD_ID, linked);
|
||||
}
|
||||
}
|
||||
if (process.env.PROJECT_REPO_FIELD_ID) {
|
||||
await setTextField(itemId, process.env.PROJECT_REPO_FIELD_ID, d.repository);
|
||||
}
|
||||
|
||||
await addComment(
|
||||
owner,
|
||||
repo,
|
||||
d.issue_number,
|
||||
d.model.manual_review_note ||
|
||||
"This issue looks like a possible resolution candidate, but not with enough certainty for automatic closure. Added to the review queue."
|
||||
);
|
||||
}
|
||||
}
|
||||
125
.github/issue-resolution/scripts/classify-candidates.mjs
vendored
Normal file
125
.github/issue-resolution/scripts/classify-candidates.mjs
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const candidates = JSON.parse(await fs.readFile("candidates.json", "utf8"));
|
||||
|
||||
function isMaintainerRole(role) {
|
||||
return ["MEMBER", "OWNER", "COLLABORATOR"].includes(role || "");
|
||||
}
|
||||
|
||||
function preScore(candidate) {
|
||||
let score = 0;
|
||||
const hardSignals = [];
|
||||
const contradictions = [];
|
||||
|
||||
for (const t of candidate.timeline) {
|
||||
const sourceIssue = t.source?.issue;
|
||||
|
||||
if (t.event === "cross-referenced" && sourceIssue?.pull_request?.html_url) {
|
||||
hardSignals.push({
|
||||
type: "merged_pr",
|
||||
url: sourceIssue.html_url
|
||||
});
|
||||
score += 40; // provisional until PR merged state is verified
|
||||
}
|
||||
|
||||
if (["referenced", "connected"].includes(t.event)) {
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of candidate.comments) {
|
||||
const body = c.body.toLowerCase();
|
||||
|
||||
if (
|
||||
isMaintainerRole(c.author_association) &&
|
||||
/\b(fixed|resolved|duplicate|superseded|closing)\b/.test(body)
|
||||
) {
|
||||
score += 25;
|
||||
hardSignals.push({
|
||||
type: "maintainer_comment",
|
||||
url: c.html_url
|
||||
});
|
||||
}
|
||||
|
||||
if (/\b(still broken|still happening|not fixed|reproducible)\b/.test(body)) {
|
||||
score -= 50;
|
||||
contradictions.push({
|
||||
type: "later_unresolved_comment",
|
||||
url: c.html_url
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { score, hardSignals, contradictions };
|
||||
}
|
||||
|
||||
async function callGitHubModel(issuePacket) {
|
||||
// Replace this stub with the GitHub Models inference call used by your org.
|
||||
// The workflow already has models: read permission.
|
||||
return {
|
||||
decision: "MANUAL_REVIEW",
|
||||
reason_code: "likely_fixed_but_unconfirmed",
|
||||
confidence: 0.74,
|
||||
hard_signals: [],
|
||||
contradictions: [],
|
||||
summary: "Potential resolution candidate; evidence is not strong enough to close automatically.",
|
||||
close_comment: "This appears resolved, so we’re closing it automatically. Reply if this is still reproducible.",
|
||||
manual_review_note: "Potential resolution candidate. Please review evidence before closing."
|
||||
};
|
||||
}
|
||||
|
||||
function enforcePolicy(modelOut, pre) {
|
||||
const approvedReasons = new Set([
|
||||
"resolved_by_merged_pr",
|
||||
"maintainer_confirmed_resolved",
|
||||
"duplicate_confirmed",
|
||||
"superseded_confirmed"
|
||||
]);
|
||||
|
||||
const hasHardSignal =
|
||||
(modelOut.hard_signals || []).some(s =>
|
||||
["merged_pr", "maintainer_comment", "duplicate_reference", "superseded_reference"].includes(s.type)
|
||||
) || pre.hardSignals.length > 0;
|
||||
|
||||
const hasContradiction =
|
||||
(modelOut.contradictions || []).length > 0 || pre.contradictions.length > 0;
|
||||
|
||||
if (
|
||||
modelOut.decision === "AUTO_CLOSE" &&
|
||||
modelOut.confidence >= 0.97 &&
|
||||
approvedReasons.has(modelOut.reason_code) &&
|
||||
hasHardSignal &&
|
||||
!hasContradiction
|
||||
) {
|
||||
return "AUTO_CLOSE";
|
||||
}
|
||||
|
||||
if (
|
||||
modelOut.decision === "MANUAL_REVIEW" ||
|
||||
modelOut.confidence >= 0.60 ||
|
||||
pre.score >= 25
|
||||
) {
|
||||
return "MANUAL_REVIEW";
|
||||
}
|
||||
|
||||
return "KEEP_OPEN";
|
||||
}
|
||||
|
||||
const decisions = [];
|
||||
for (const candidate of candidates) {
|
||||
const pre = preScore(candidate);
|
||||
const modelOut = await callGitHubModel(candidate);
|
||||
const finalDecision = enforcePolicy(modelOut, pre);
|
||||
|
||||
decisions.push({
|
||||
repository: candidate.repository,
|
||||
issue_number: candidate.issue.number,
|
||||
issue_url: candidate.issue.html_url,
|
||||
title: candidate.issue.title,
|
||||
pre_score: pre.score,
|
||||
final_decision: finalDecision,
|
||||
model: modelOut
|
||||
});
|
||||
}
|
||||
|
||||
await fs.writeFile("decisions.json", JSON.stringify(decisions, null, 2));
|
||||
50
.github/workflows/issue-resolution-triage.yml
vendored
Normal file
50
.github/workflows/issue-resolution-triage.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: issue-resolution-triage
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "If true, do not close issues"
|
||||
required: false
|
||||
default: "true"
|
||||
max_issues:
|
||||
description: "How many issues to process"
|
||||
required: false
|
||||
default: "100"
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
models: read
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ inputs.dry_run || 'true' }}
|
||||
MAX_ISSUES: ${{ inputs.max_issues || '100' }}
|
||||
REPO: ${{ github.repository }}
|
||||
PROJECT_ID: ${{ vars.ISSUE_REVIEW_PROJECT_ID }}
|
||||
PROJECT_STATUS_FIELD_ID: ${{ vars.PROJECT_STATUS_FIELD_ID }}
|
||||
PROJECT_CONFIDENCE_FIELD_ID: ${{ vars.PROJECT_CONFIDENCE_FIELD_ID }}
|
||||
PROJECT_REASON_FIELD_ID: ${{ vars.PROJECT_REASON_FIELD_ID }}
|
||||
PROJECT_EVIDENCE_FIELD_ID: ${{ vars.PROJECT_EVIDENCE_FIELD_ID }}
|
||||
PROJECT_LINKED_PR_FIELD_ID: ${{ vars.PROJECT_LINKED_PR_FIELD_ID }}
|
||||
PROJECT_REPO_FIELD_ID: ${{ vars.PROJECT_REPO_FIELD_ID }}
|
||||
PROJECT_STATUS_OPTION_NEEDS_REVIEW_ID: ${{ vars.PROJECT_STATUS_OPTION_NEEDS_REVIEW_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- run: npm ci
|
||||
- run: node scripts/fetch-candidates.mjs
|
||||
- run: node scripts/classify-candidates.mjs
|
||||
- run: node scripts/apply-decisions.mjs
|
||||
@@ -17,7 +17,6 @@ ENV \
|
||||
NETBIRD_BIN="/usr/local/bin/netbird" \
|
||||
NB_LOG_FILE="console,/var/log/netbird/client.log" \
|
||||
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
|
||||
NB_ENABLE_CAPTURE="false" \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||
|
||||
@@ -23,7 +23,6 @@ ENV \
|
||||
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
|
||||
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
|
||||
NB_DISABLE_DNS="true" \
|
||||
NB_ENABLE_CAPTURE="false" \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util/capture"
|
||||
)
|
||||
|
||||
var captureCmd = &cobra.Command{
|
||||
Use: "capture",
|
||||
Short: "Capture packets on the WireGuard interface",
|
||||
Long: `Captures decrypted packets flowing through the WireGuard interface.
|
||||
|
||||
Default output is human-readable text. Use --pcap or --output for pcap binary.
|
||||
Requires --enable-capture to be set at service install or reconfigure time.
|
||||
|
||||
Examples:
|
||||
netbird debug capture
|
||||
netbird debug capture host 100.64.0.1 and port 443
|
||||
netbird debug capture tcp
|
||||
netbird debug capture icmp
|
||||
netbird debug capture src host 10.0.0.1 and dst port 80
|
||||
netbird debug capture -o capture.pcap
|
||||
netbird debug capture --pcap | tshark -r -
|
||||
netbird debug capture --pcap | tcpdump -r - -n`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: runCapture,
|
||||
}
|
||||
|
||||
func init() {
|
||||
debugCmd.AddCommand(captureCmd)
|
||||
|
||||
captureCmd.Flags().Bool("pcap", false, "Force pcap binary output (default when --output is set)")
|
||||
captureCmd.Flags().BoolP("verbose", "v", false, "Show seq/ack, TTL, window, total length")
|
||||
captureCmd.Flags().Bool("ascii", false, "Print payload as ASCII after each packet (useful for HTTP)")
|
||||
captureCmd.Flags().Uint32("snap-len", 0, "Max bytes per packet (0 = full)")
|
||||
captureCmd.Flags().DurationP("duration", "d", 0, "Capture duration (0 = until interrupted)")
|
||||
captureCmd.Flags().StringP("output", "o", "", "Write pcap to file instead of stdout")
|
||||
}
|
||||
|
||||
func runCapture(cmd *cobra.Command, args []string) error {
|
||||
conn, err := getClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
cmd.PrintErrf(errCloseConnection, err)
|
||||
}
|
||||
}()
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
|
||||
req, err := buildCaptureRequest(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
stream, err := client.StartCapture(ctx, req)
|
||||
if err != nil {
|
||||
return handleCaptureError(err)
|
||||
}
|
||||
|
||||
// First Recv is the empty acceptance message from the server. If the
|
||||
// device is unavailable (kernel WG, not connected, capture disabled),
|
||||
// the server returns an error instead.
|
||||
if _, err := stream.Recv(); err != nil {
|
||||
return handleCaptureError(err)
|
||||
}
|
||||
|
||||
out, cleanup, err := captureOutput(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if req.TextOutput {
|
||||
cmd.PrintErrf("Capturing packets... Press Ctrl+C to stop.\n")
|
||||
} else {
|
||||
cmd.PrintErrf("Capturing packets (pcap)... Press Ctrl+C to stop.\n")
|
||||
}
|
||||
|
||||
streamErr := streamCapture(ctx, cmd, stream, out)
|
||||
cleanupErr := cleanup()
|
||||
if streamErr != nil {
|
||||
return streamErr
|
||||
}
|
||||
return cleanupErr
|
||||
}
|
||||
|
||||
func buildCaptureRequest(cmd *cobra.Command, args []string) (*proto.StartCaptureRequest, error) {
|
||||
req := &proto.StartCaptureRequest{}
|
||||
|
||||
if len(args) > 0 {
|
||||
expr := strings.Join(args, " ")
|
||||
if _, err := capture.ParseFilter(expr); err != nil {
|
||||
return nil, fmt.Errorf("invalid filter: %w", err)
|
||||
}
|
||||
req.FilterExpr = expr
|
||||
}
|
||||
|
||||
if snap, _ := cmd.Flags().GetUint32("snap-len"); snap > 0 {
|
||||
req.SnapLen = snap
|
||||
}
|
||||
if d, _ := cmd.Flags().GetDuration("duration"); d != 0 {
|
||||
if d < 0 {
|
||||
return nil, fmt.Errorf("duration must not be negative")
|
||||
}
|
||||
req.Duration = durationpb.New(d)
|
||||
}
|
||||
req.Verbose, _ = cmd.Flags().GetBool("verbose")
|
||||
req.Ascii, _ = cmd.Flags().GetBool("ascii")
|
||||
|
||||
outPath, _ := cmd.Flags().GetString("output")
|
||||
forcePcap, _ := cmd.Flags().GetBool("pcap")
|
||||
req.TextOutput = !forcePcap && outPath == ""
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func streamCapture(ctx context.Context, cmd *cobra.Command, stream proto.DaemonService_StartCaptureClient, out io.Writer) error {
|
||||
for {
|
||||
pkt, err := stream.Recv()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
cmd.PrintErrf("\nCapture stopped.\n")
|
||||
return nil //nolint:nilerr // user interrupted
|
||||
}
|
||||
if err == io.EOF {
|
||||
cmd.PrintErrf("\nCapture finished.\n")
|
||||
return nil
|
||||
}
|
||||
return handleCaptureError(err)
|
||||
}
|
||||
if _, err := out.Write(pkt.GetData()); err != nil {
|
||||
return fmt.Errorf("write output: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captureOutput returns the writer for capture data and a cleanup function
|
||||
// that finalizes the file. Errors from the cleanup must be propagated.
|
||||
func captureOutput(cmd *cobra.Command) (io.Writer, func() error, error) {
|
||||
outPath, _ := cmd.Flags().GetString("output")
|
||||
if outPath == "" {
|
||||
return os.Stdout, func() error { return nil }, nil
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp(filepath.Dir(outPath), filepath.Base(outPath)+".*.tmp")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create output file: %w", err)
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
return f, func() error {
|
||||
var merr *multierror.Error
|
||||
if err := f.Close(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("close output file: %w", err))
|
||||
}
|
||||
fi, statErr := os.Stat(tmpPath)
|
||||
if statErr != nil || fi.Size() == 0 {
|
||||
if rmErr := os.Remove(tmpPath); rmErr != nil && !os.IsNotExist(rmErr) {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove empty output file: %w", rmErr))
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
if err := os.Rename(tmpPath, outPath); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("rename output file: %w", err))
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
cmd.PrintErrf("Wrote %s\n", outPath)
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func handleCaptureError(err error) error {
|
||||
if s, ok := status.FromError(err); ok {
|
||||
return fmt.Errorf("%s", s.Message())
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
@@ -240,50 +239,11 @@ func runForDuration(cmd *cobra.Command, args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
captureStarted := false
|
||||
if wantCapture, _ := cmd.Flags().GetBool("capture"); wantCapture {
|
||||
captureTimeout := duration + 30*time.Second
|
||||
const maxBundleCapture = 10 * time.Minute
|
||||
if captureTimeout > maxBundleCapture {
|
||||
captureTimeout = maxBundleCapture
|
||||
}
|
||||
_, err := client.StartBundleCapture(cmd.Context(), &proto.StartBundleCaptureRequest{
|
||||
Timeout: durationpb.New(captureTimeout),
|
||||
})
|
||||
if err != nil {
|
||||
cmd.PrintErrf("Failed to start packet capture: %v\n", status.Convert(err).Message())
|
||||
} else {
|
||||
captureStarted = true
|
||||
cmd.Println("Packet capture started.")
|
||||
// Safety: always stop on exit, even if the normal stop below runs too.
|
||||
defer func() {
|
||||
if captureStarted {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := client.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
|
||||
cmd.PrintErrf("Failed to stop packet capture: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
if waitErr := waitForDurationOrCancel(cmd.Context(), duration, cmd); waitErr != nil {
|
||||
return waitErr
|
||||
}
|
||||
cmd.Println("\nDuration completed")
|
||||
|
||||
if captureStarted {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := client.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
|
||||
cmd.PrintErrf("Failed to stop packet capture: %v\n", err)
|
||||
} else {
|
||||
captureStarted = false
|
||||
cmd.Println("Packet capture stopped.")
|
||||
}
|
||||
}
|
||||
|
||||
if cpuProfilingStarted {
|
||||
if _, err := client.StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil {
|
||||
cmd.PrintErrf("Failed to stop CPU profiling: %v\n", err)
|
||||
@@ -456,5 +416,4 @@ func init() {
|
||||
forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
|
||||
forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
|
||||
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
|
||||
forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle")
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ var (
|
||||
mtu uint16
|
||||
profilesDisabled bool
|
||||
updateSettingsDisabled bool
|
||||
captureEnabled bool
|
||||
networksDisabled bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
|
||||
@@ -44,7 +44,6 @@ func init() {
|
||||
serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd, resetParamsCmd)
|
||||
serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles")
|
||||
serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings")
|
||||
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
|
||||
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
|
||||
|
||||
@@ -61,7 +61,7 @@ func (p *program) Start(svc service.Service) error {
|
||||
}
|
||||
}
|
||||
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, networksDisabled)
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
log.Fatalf("failed to start daemon: %v", err)
|
||||
}
|
||||
|
||||
@@ -59,10 +59,6 @@ func buildServiceArguments() []string {
|
||||
args = append(args, "--disable-update-settings")
|
||||
}
|
||||
|
||||
if captureEnabled {
|
||||
args = append(args, "--enable-capture")
|
||||
}
|
||||
|
||||
if networksDisabled {
|
||||
args = append(args, "--disable-networks")
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ type serviceParams struct {
|
||||
LogFiles []string `json:"log_files,omitempty"`
|
||||
DisableProfiles bool `json:"disable_profiles,omitempty"`
|
||||
DisableUpdateSettings bool `json:"disable_update_settings,omitempty"`
|
||||
EnableCapture bool `json:"enable_capture,omitempty"`
|
||||
DisableNetworks bool `json:"disable_networks,omitempty"`
|
||||
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
|
||||
}
|
||||
@@ -80,7 +79,6 @@ func currentServiceParams() *serviceParams {
|
||||
LogFiles: logFiles,
|
||||
DisableProfiles: profilesDisabled,
|
||||
DisableUpdateSettings: updateSettingsDisabled,
|
||||
EnableCapture: captureEnabled,
|
||||
DisableNetworks: networksDisabled,
|
||||
}
|
||||
|
||||
@@ -146,10 +144,6 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
updateSettingsDisabled = params.DisableUpdateSettings
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("enable-capture") {
|
||||
captureEnabled = params.EnableCapture
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("disable-networks") {
|
||||
networksDisabled = params.DisableNetworks
|
||||
}
|
||||
|
||||
@@ -535,7 +535,6 @@ func fieldToGlobalVar(field string) string {
|
||||
"LogFiles": "logFiles",
|
||||
"DisableProfiles": "profilesDisabled",
|
||||
"DisableUpdateSettings": "updateSettingsDisabled",
|
||||
"EnableCapture": "captureEnabled",
|
||||
"DisableNetworks": "networksDisabled",
|
||||
"ServiceEnvVars": "serviceEnvVars",
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func startClientDaemon(
|
||||
s := grpc.NewServer()
|
||||
|
||||
server := client.New(ctx,
|
||||
"", "", false, false, false, false)
|
||||
"", "", false, false, false)
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package embed
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/util/capture"
|
||||
)
|
||||
|
||||
// CaptureOptions configures a packet capture session.
|
||||
type CaptureOptions struct {
|
||||
// Output receives pcap-formatted data. Nil disables pcap output.
|
||||
Output io.Writer
|
||||
// TextOutput receives human-readable packet summaries. Nil disables text output.
|
||||
TextOutput io.Writer
|
||||
// Filter is a BPF-like filter expression (e.g. "host 10.0.0.1 and tcp port 443").
|
||||
// Empty captures all packets.
|
||||
Filter string
|
||||
// Verbose adds seq/ack, TTL, window, and total length to text output.
|
||||
Verbose bool
|
||||
// ASCII dumps transport payload as printable ASCII after each packet line.
|
||||
ASCII bool
|
||||
}
|
||||
|
||||
// CaptureStats reports capture session counters.
|
||||
type CaptureStats struct {
|
||||
Packets int64
|
||||
Bytes int64
|
||||
Dropped int64
|
||||
}
|
||||
|
||||
// CaptureSession represents an active packet capture. Call Stop to end the
|
||||
// capture and flush buffered packets.
|
||||
type CaptureSession struct {
|
||||
sess *capture.Session
|
||||
engine *internal.Engine
|
||||
}
|
||||
|
||||
// Stop ends the capture, flushes remaining packets, and detaches from the device.
|
||||
// Safe to call multiple times.
|
||||
func (cs *CaptureSession) Stop() {
|
||||
if cs.engine != nil {
|
||||
_ = cs.engine.SetCapture(nil)
|
||||
cs.engine = nil
|
||||
}
|
||||
if cs.sess != nil {
|
||||
cs.sess.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Stats returns current capture counters.
|
||||
func (cs *CaptureSession) Stats() CaptureStats {
|
||||
s := cs.sess.Stats()
|
||||
return CaptureStats{
|
||||
Packets: s.Packets,
|
||||
Bytes: s.Bytes,
|
||||
Dropped: s.Dropped,
|
||||
}
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the capture's writer goroutine
|
||||
// has fully exited and all buffered packets have been flushed.
|
||||
func (cs *CaptureSession) Done() <-chan struct{} {
|
||||
return cs.sess.Done()
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/util/capture"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -66,7 +65,7 @@ type Options struct {
|
||||
PrivateKey string
|
||||
// ManagementURL overrides the default management server URL
|
||||
ManagementURL string
|
||||
// PreSharedKey is the pre-shared key for the tunnel interface
|
||||
// PreSharedKey is the pre-shared key for the WireGuard interface
|
||||
PreSharedKey string
|
||||
// LogOutput is the output destination for logs (defaults to os.Stderr if nil)
|
||||
LogOutput io.Writer
|
||||
@@ -82,9 +81,9 @@ type Options struct {
|
||||
DisableClientRoutes bool
|
||||
// BlockInbound blocks all inbound connections from peers
|
||||
BlockInbound bool
|
||||
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
||||
// WireguardPort is the port for the WireGuard interface. Use 0 for a random port.
|
||||
WireguardPort *int
|
||||
// MTU is the MTU for the tunnel interface.
|
||||
// MTU is the MTU for the WireGuard interface.
|
||||
// Valid values are in the range 576..8192 bytes.
|
||||
// If non-nil, this value overrides any value stored in the config file.
|
||||
// If nil, the existing config MTU (if non-zero) is preserved; otherwise it defaults to 1280.
|
||||
@@ -470,52 +469,6 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
|
||||
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
||||
}
|
||||
|
||||
// StartCapture begins capturing packets on this client's tunnel device.
|
||||
// Only one capture can be active at a time; starting a new one stops the previous.
|
||||
// Call StopCapture (or CaptureSession.Stop) to end it.
|
||||
func (c *Client) StartCapture(opts CaptureOptions) (*CaptureSession, error) {
|
||||
engine, err := c.getEngine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var matcher capture.Matcher
|
||||
if opts.Filter != "" {
|
||||
m, err := capture.ParseFilter(opts.Filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse filter: %w", err)
|
||||
}
|
||||
matcher = m
|
||||
}
|
||||
|
||||
sess, err := capture.NewSession(capture.Options{
|
||||
Output: opts.Output,
|
||||
TextOutput: opts.TextOutput,
|
||||
Matcher: matcher,
|
||||
Verbose: opts.Verbose,
|
||||
ASCII: opts.ASCII,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create capture session: %w", err)
|
||||
}
|
||||
|
||||
if err := engine.SetCapture(sess); err != nil {
|
||||
sess.Stop()
|
||||
return nil, fmt.Errorf("set capture: %w", err)
|
||||
}
|
||||
|
||||
return &CaptureSession{sess: sess, engine: engine}, nil
|
||||
}
|
||||
|
||||
// StopCapture stops the active capture session if one is running.
|
||||
func (c *Client) StopCapture() error {
|
||||
engine, err := c.getEngine()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return engine.SetCapture(nil)
|
||||
}
|
||||
|
||||
// getEngine safely retrieves the engine from the client with proper locking.
|
||||
// Returns ErrClientNotStarted if the client is not started.
|
||||
// Returns ErrEngineNotStarted if the engine is not available.
|
||||
|
||||
@@ -115,13 +115,12 @@ type Manager struct {
|
||||
|
||||
localipmanager *localIPManager
|
||||
|
||||
udpTracker *conntrack.UDPTracker
|
||||
icmpTracker *conntrack.ICMPTracker
|
||||
tcpTracker *conntrack.TCPTracker
|
||||
forwarder atomic.Pointer[forwarder.Forwarder]
|
||||
pendingCapture atomic.Pointer[forwarder.PacketCapture]
|
||||
logger *nblog.Logger
|
||||
flowLogger nftypes.FlowLogger
|
||||
udpTracker *conntrack.UDPTracker
|
||||
icmpTracker *conntrack.ICMPTracker
|
||||
tcpTracker *conntrack.TCPTracker
|
||||
forwarder atomic.Pointer[forwarder.Forwarder]
|
||||
logger *nblog.Logger
|
||||
flowLogger nftypes.FlowLogger
|
||||
|
||||
blockRule firewall.Rule
|
||||
|
||||
@@ -352,19 +351,6 @@ func (m *Manager) determineRouting() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPacketCapture sets or clears packet capture on the forwarder endpoint.
|
||||
// This captures outbound response packets that bypass the FilteredDevice in netstack mode.
|
||||
func (m *Manager) SetPacketCapture(pc forwarder.PacketCapture) {
|
||||
if pc == nil {
|
||||
m.pendingCapture.Store(nil)
|
||||
} else {
|
||||
m.pendingCapture.Store(&pc)
|
||||
}
|
||||
if fwder := m.forwarder.Load(); fwder != nil {
|
||||
fwder.SetCapture(pc)
|
||||
}
|
||||
}
|
||||
|
||||
// initForwarder initializes the forwarder, it disables routing on errors
|
||||
func (m *Manager) initForwarder() error {
|
||||
if m.forwarder.Load() != nil {
|
||||
@@ -386,11 +372,6 @@ func (m *Manager) initForwarder() error {
|
||||
|
||||
m.forwarder.Store(forwarder)
|
||||
|
||||
// Re-load after store: a concurrent SetPacketCapture may have seen forwarder as nil and only updated pendingCapture.
|
||||
if pc := m.pendingCapture.Load(); pc != nil {
|
||||
forwarder.SetCapture(*pc)
|
||||
}
|
||||
|
||||
log.Debug("forwarder initialized")
|
||||
|
||||
return nil
|
||||
@@ -633,7 +614,6 @@ func (m *Manager) resetState() {
|
||||
}
|
||||
|
||||
if fwder := m.forwarder.Load(); fwder != nil {
|
||||
fwder.SetCapture(nil)
|
||||
fwder.Stop()
|
||||
}
|
||||
|
||||
|
||||
@@ -12,19 +12,12 @@ import (
|
||||
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||
)
|
||||
|
||||
// PacketCapture captures raw packets for debugging. Implementations must be
|
||||
// safe for concurrent use and must not block.
|
||||
type PacketCapture interface {
|
||||
Offer(data []byte, outbound bool)
|
||||
}
|
||||
|
||||
// endpoint implements stack.LinkEndpoint and handles integration with the wireguard device
|
||||
type endpoint struct {
|
||||
logger *nblog.Logger
|
||||
dispatcher stack.NetworkDispatcher
|
||||
device *wgdevice.Device
|
||||
mtu atomic.Uint32
|
||||
capture atomic.Pointer[PacketCapture]
|
||||
}
|
||||
|
||||
func (e *endpoint) Attach(dispatcher stack.NetworkDispatcher) {
|
||||
@@ -61,17 +54,13 @@ func (e *endpoint) WritePackets(pkts stack.PacketBufferList) (int, tcpip.Error)
|
||||
continue
|
||||
}
|
||||
|
||||
pktBytes := data.AsSlice()
|
||||
|
||||
// Send the packet through WireGuard
|
||||
address := netHeader.DestinationAddress()
|
||||
if err := e.device.CreateOutboundPacket(pktBytes, address.AsSlice()); err != nil {
|
||||
err := e.device.CreateOutboundPacket(data.AsSlice(), address.AsSlice())
|
||||
if err != nil {
|
||||
e.logger.Error1("CreateOutboundPacket: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if pc := e.capture.Load(); pc != nil {
|
||||
(*pc).Offer(pktBytes, true)
|
||||
}
|
||||
written++
|
||||
}
|
||||
|
||||
|
||||
@@ -139,16 +139,6 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// SetCapture sets or clears the packet capture on the forwarder endpoint.
|
||||
// This captures outbound packets that bypass the FilteredDevice (netstack forwarding).
|
||||
func (f *Forwarder) SetCapture(pc PacketCapture) {
|
||||
if pc == nil {
|
||||
f.endpoint.capture.Store(nil)
|
||||
return
|
||||
}
|
||||
f.endpoint.capture.Store(&pc)
|
||||
}
|
||||
|
||||
func (f *Forwarder) InjectIncomingPacket(payload []byte) error {
|
||||
if len(payload) < header.IPv4MinimumSize {
|
||||
return fmt.Errorf("packet too small: %d bytes", len(payload))
|
||||
|
||||
@@ -270,9 +270,5 @@ func (f *Forwarder) injectICMPReply(id stack.TransportEndpointID, icmpPayload []
|
||||
return 0
|
||||
}
|
||||
|
||||
if pc := f.endpoint.capture.Load(); pc != nil {
|
||||
(*pc).Offer(fullPacket, true)
|
||||
}
|
||||
|
||||
return len(fullPacket)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package device
|
||||
import (
|
||||
"net/netip"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
)
|
||||
@@ -29,20 +28,11 @@ type PacketFilter interface {
|
||||
SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool)
|
||||
}
|
||||
|
||||
// PacketCapture captures raw packets for debugging. Implementations must be
|
||||
// safe for concurrent use and must not block.
|
||||
type PacketCapture interface {
|
||||
// Offer submits a packet for capture. outbound is true for packets
|
||||
// leaving the host (Read path), false for packets arriving (Write path).
|
||||
Offer(data []byte, outbound bool)
|
||||
}
|
||||
|
||||
// FilteredDevice to override Read or Write of packets
|
||||
type FilteredDevice struct {
|
||||
tun.Device
|
||||
|
||||
filter PacketFilter
|
||||
capture atomic.Pointer[PacketCapture]
|
||||
mutex sync.RWMutex
|
||||
closeOnce sync.Once
|
||||
}
|
||||
@@ -73,25 +63,20 @@ func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, er
|
||||
if n, err = d.Device.Read(bufs, sizes, offset); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
d.mutex.RLock()
|
||||
filter := d.filter
|
||||
d.mutex.RUnlock()
|
||||
|
||||
if filter != nil {
|
||||
for i := 0; i < n; i++ {
|
||||
if filter.FilterOutbound(bufs[i][offset:offset+sizes[i]], sizes[i]) {
|
||||
bufs = append(bufs[:i], bufs[i+1:]...)
|
||||
sizes = append(sizes[:i], sizes[i+1:]...)
|
||||
n--
|
||||
i--
|
||||
}
|
||||
}
|
||||
if filter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if pc := d.capture.Load(); pc != nil {
|
||||
for i := 0; i < n; i++ {
|
||||
(*pc).Offer(bufs[i][offset:offset+sizes[i]], true)
|
||||
for i := 0; i < n; i++ {
|
||||
if filter.FilterOutbound(bufs[i][offset:offset+sizes[i]], sizes[i]) {
|
||||
bufs = append(bufs[:i], bufs[i+1:]...)
|
||||
sizes = append(sizes[:i], sizes[i+1:]...)
|
||||
n--
|
||||
i--
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,13 +85,6 @@ func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, er
|
||||
|
||||
// Write wraps write method with filtering feature
|
||||
func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
|
||||
// Capture before filtering so dropped packets are still visible in captures.
|
||||
if pc := d.capture.Load(); pc != nil {
|
||||
for _, buf := range bufs {
|
||||
(*pc).Offer(buf[offset:], false)
|
||||
}
|
||||
}
|
||||
|
||||
d.mutex.RLock()
|
||||
filter := d.filter
|
||||
d.mutex.RUnlock()
|
||||
@@ -118,10 +96,9 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
|
||||
filteredBufs := make([][]byte, 0, len(bufs))
|
||||
dropped := 0
|
||||
for _, buf := range bufs {
|
||||
if filter.FilterInbound(buf[offset:], len(buf)) {
|
||||
dropped++
|
||||
} else {
|
||||
if !filter.FilterInbound(buf[offset:], len(buf)) {
|
||||
filteredBufs = append(filteredBufs, buf)
|
||||
dropped++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,14 +113,3 @@ func (d *FilteredDevice) SetFilter(filter PacketFilter) {
|
||||
d.filter = filter
|
||||
d.mutex.Unlock()
|
||||
}
|
||||
|
||||
// SetCapture sets or clears the packet capture sink. Pass nil to disable.
|
||||
// Uses atomic store so the hot path (Read/Write) is a single pointer load
|
||||
// with no locking overhead when capture is off.
|
||||
func (d *FilteredDevice) SetCapture(pc PacketCapture) {
|
||||
if pc == nil {
|
||||
d.capture.Store(nil)
|
||||
return
|
||||
}
|
||||
d.capture.Store(&pc)
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ func TestDeviceWrapperRead(t *testing.T) {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
if n != 1 {
|
||||
if n != 0 {
|
||||
t.Errorf("expected n=1, got %d", n)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ allocs.prof: Allocations profiling information.
|
||||
threadcreate.prof: Thread creation profiling information.
|
||||
cpu.prof: CPU profiling information.
|
||||
stack_trace.txt: Complete stack traces of all goroutines at the time of bundle creation.
|
||||
capture.pcap: Packet capture in pcap format. Only present when capture was running during bundle collection. Omitted from anonymized bundles because it contains raw decrypted packet data.
|
||||
|
||||
|
||||
Anonymization Process
|
||||
@@ -235,7 +234,6 @@ type BundleGenerator struct {
|
||||
logPath string
|
||||
tempDir string
|
||||
cpuProfile []byte
|
||||
capturePath string
|
||||
refreshStatus func() // Optional callback to refresh status before bundle generation
|
||||
clientMetrics MetricsExporter
|
||||
|
||||
@@ -259,8 +257,7 @@ type GeneratorDependencies struct {
|
||||
LogPath string
|
||||
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
|
||||
CPUProfile []byte
|
||||
CapturePath string
|
||||
RefreshStatus func()
|
||||
RefreshStatus func() // Optional callback to refresh status before bundle generation
|
||||
ClientMetrics MetricsExporter
|
||||
}
|
||||
|
||||
@@ -280,7 +277,6 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
logPath: deps.LogPath,
|
||||
tempDir: deps.TempDir,
|
||||
cpuProfile: deps.CPUProfile,
|
||||
capturePath: deps.CapturePath,
|
||||
refreshStatus: deps.RefreshStatus,
|
||||
clientMetrics: deps.ClientMetrics,
|
||||
|
||||
@@ -350,10 +346,6 @@ func (g *BundleGenerator) createArchive() error {
|
||||
log.Errorf("failed to add CPU profile to debug bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := g.addCaptureFile(); err != nil {
|
||||
log.Errorf("failed to add capture file to debug bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := g.addStackTrace(); err != nil {
|
||||
log.Errorf("failed to add stack trace to debug bundle: %v", err)
|
||||
}
|
||||
@@ -677,29 +669,6 @@ func (g *BundleGenerator) addCPUProfile() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addCaptureFile() error {
|
||||
if g.capturePath == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if g.anonymize {
|
||||
log.Info("skipping capture file in anonymized bundle (contains raw packet data)")
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(g.capturePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open capture file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := g.addFileToZip(f, "capture.pcap"); err != nil {
|
||||
return fmt.Errorf("add capture file to zip: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addStackTrace() error {
|
||||
buf := make([]byte, 5242880) // 5 MB buffer
|
||||
n := runtime.Stack(buf, true)
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/firewall"
|
||||
"github.com/netbirdio/netbird/client/firewall/firewalld"
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
nbnetstack "github.com/netbirdio/netbird/client/iface/netstack"
|
||||
@@ -69,7 +68,6 @@ import (
|
||||
signal "github.com/netbirdio/netbird/shared/signal/client"
|
||||
sProto "github.com/netbirdio/netbird/shared/signal/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
"github.com/netbirdio/netbird/util/capture"
|
||||
)
|
||||
|
||||
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
|
||||
@@ -220,8 +218,6 @@ type Engine struct {
|
||||
portForwardManager *portforward.Manager
|
||||
srWatcher *guard.SRWatcher
|
||||
|
||||
afpacketCapture *capture.AFPacketCapture
|
||||
|
||||
// Sync response persistence (protected by syncRespMux)
|
||||
syncRespMux sync.RWMutex
|
||||
persistSyncResponse bool
|
||||
@@ -1702,11 +1698,6 @@ func (e *Engine) parseNATExternalIPMappings() []string {
|
||||
}
|
||||
|
||||
func (e *Engine) close() {
|
||||
if e.afpacketCapture != nil {
|
||||
e.afpacketCapture.Stop()
|
||||
e.afpacketCapture = nil
|
||||
}
|
||||
|
||||
log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
|
||||
|
||||
if e.wgInterface != nil {
|
||||
@@ -2172,62 +2163,6 @@ func (e *Engine) Address() (netip.Addr, error) {
|
||||
return e.wgInterface.Address().IP, nil
|
||||
}
|
||||
|
||||
// SetCapture sets or clears packet capture on the WireGuard device.
|
||||
// On userspace WireGuard, it taps the FilteredDevice directly.
|
||||
// On kernel WireGuard (Linux), it falls back to AF_PACKET raw socket capture.
|
||||
// Pass nil to disable capture.
|
||||
func (e *Engine) SetCapture(pc device.PacketCapture) error {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
|
||||
intf := e.wgInterface
|
||||
if intf == nil {
|
||||
return errors.New("wireguard interface not initialized")
|
||||
}
|
||||
|
||||
if e.afpacketCapture != nil {
|
||||
e.afpacketCapture.Stop()
|
||||
e.afpacketCapture = nil
|
||||
}
|
||||
|
||||
dev := intf.GetDevice()
|
||||
if dev != nil {
|
||||
dev.SetCapture(pc)
|
||||
e.setForwarderCapture(pc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Kernel mode: no FilteredDevice. Use AF_PACKET on Linux.
|
||||
if pc == nil {
|
||||
return nil
|
||||
}
|
||||
sess, ok := pc.(*capture.Session)
|
||||
if !ok {
|
||||
return errors.New("filtered device not available and AF_PACKET requires *capture.Session")
|
||||
}
|
||||
|
||||
afc := capture.NewAFPacketCapture(intf.Name(), sess)
|
||||
if err := afc.Start(); err != nil {
|
||||
return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err)
|
||||
}
|
||||
e.afpacketCapture = afc
|
||||
return nil
|
||||
}
|
||||
|
||||
// setForwarderCapture propagates capture to the USP filter's forwarder endpoint.
|
||||
// This captures outbound response packets that bypass the FilteredDevice in netstack mode.
|
||||
func (e *Engine) setForwarderCapture(pc device.PacketCapture) {
|
||||
if e.firewall == nil {
|
||||
return
|
||||
}
|
||||
type forwarderCapturer interface {
|
||||
SetPacketCapture(pc forwarder.PacketCapture)
|
||||
}
|
||||
if fc, ok := e.firewall.(forwarderCapturer); ok {
|
||||
fc.SetPacketCapture(pc)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewallManager.ForwardRule, error) {
|
||||
if e.firewall == nil {
|
||||
log.Warn("firewall is disabled, not updating forwarding rules")
|
||||
|
||||
@@ -5977,288 +5977,6 @@ func (x *ExposeServiceReady) GetPortAutoAssigned() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
type StartCaptureRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TextOutput bool `protobuf:"varint,1,opt,name=text_output,json=textOutput,proto3" json:"text_output,omitempty"`
|
||||
SnapLen uint32 `protobuf:"varint,2,opt,name=snap_len,json=snapLen,proto3" json:"snap_len,omitempty"`
|
||||
Duration *durationpb.Duration `protobuf:"bytes,3,opt,name=duration,proto3" json:"duration,omitempty"`
|
||||
FilterExpr string `protobuf:"bytes,4,opt,name=filter_expr,json=filterExpr,proto3" json:"filter_expr,omitempty"`
|
||||
Verbose bool `protobuf:"varint,5,opt,name=verbose,proto3" json:"verbose,omitempty"`
|
||||
Ascii bool `protobuf:"varint,6,opt,name=ascii,proto3" json:"ascii,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *StartCaptureRequest) Reset() {
|
||||
*x = StartCaptureRequest{}
|
||||
mi := &file_daemon_proto_msgTypes[90]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *StartCaptureRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StartCaptureRequest) ProtoMessage() {}
|
||||
|
||||
func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_daemon_proto_msgTypes[90]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead.
|
||||
func (*StartCaptureRequest) Descriptor() ([]byte, []int) {
|
||||
return file_daemon_proto_rawDescGZIP(), []int{90}
|
||||
}
|
||||
|
||||
func (x *StartCaptureRequest) GetTextOutput() bool {
|
||||
if x != nil {
|
||||
return x.TextOutput
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *StartCaptureRequest) GetSnapLen() uint32 {
|
||||
if x != nil {
|
||||
return x.SnapLen
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *StartCaptureRequest) GetDuration() *durationpb.Duration {
|
||||
if x != nil {
|
||||
return x.Duration
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *StartCaptureRequest) GetFilterExpr() string {
|
||||
if x != nil {
|
||||
return x.FilterExpr
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *StartCaptureRequest) GetVerbose() bool {
|
||||
if x != nil {
|
||||
return x.Verbose
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *StartCaptureRequest) GetAscii() bool {
|
||||
if x != nil {
|
||||
return x.Ascii
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type CapturePacket struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *CapturePacket) Reset() {
|
||||
*x = CapturePacket{}
|
||||
mi := &file_daemon_proto_msgTypes[91]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *CapturePacket) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*CapturePacket) ProtoMessage() {}
|
||||
|
||||
func (x *CapturePacket) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_daemon_proto_msgTypes[91]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead.
|
||||
func (*CapturePacket) Descriptor() ([]byte, []int) {
|
||||
return file_daemon_proto_rawDescGZIP(), []int{91}
|
||||
}
|
||||
|
||||
func (x *CapturePacket) GetData() []byte {
|
||||
if x != nil {
|
||||
return x.Data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type StartBundleCaptureRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// timeout auto-stops the capture after this duration.
|
||||
// Clamped to a server-side maximum (10 minutes). Zero or unset defaults to the maximum.
|
||||
Timeout *durationpb.Duration `protobuf:"bytes,1,opt,name=timeout,proto3" json:"timeout,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *StartBundleCaptureRequest) Reset() {
|
||||
*x = StartBundleCaptureRequest{}
|
||||
mi := &file_daemon_proto_msgTypes[92]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *StartBundleCaptureRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StartBundleCaptureRequest) ProtoMessage() {}
|
||||
|
||||
func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_daemon_proto_msgTypes[92]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead.
|
||||
func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) {
|
||||
return file_daemon_proto_rawDescGZIP(), []int{92}
|
||||
}
|
||||
|
||||
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
|
||||
if x != nil {
|
||||
return x.Timeout
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type StartBundleCaptureResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *StartBundleCaptureResponse) Reset() {
|
||||
*x = StartBundleCaptureResponse{}
|
||||
mi := &file_daemon_proto_msgTypes[93]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *StartBundleCaptureResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StartBundleCaptureResponse) ProtoMessage() {}
|
||||
|
||||
func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_daemon_proto_msgTypes[93]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead.
|
||||
func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) {
|
||||
return file_daemon_proto_rawDescGZIP(), []int{93}
|
||||
}
|
||||
|
||||
type StopBundleCaptureRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *StopBundleCaptureRequest) Reset() {
|
||||
*x = StopBundleCaptureRequest{}
|
||||
mi := &file_daemon_proto_msgTypes[94]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *StopBundleCaptureRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StopBundleCaptureRequest) ProtoMessage() {}
|
||||
|
||||
func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_daemon_proto_msgTypes[94]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead.
|
||||
func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) {
|
||||
return file_daemon_proto_rawDescGZIP(), []int{94}
|
||||
}
|
||||
|
||||
type StopBundleCaptureResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *StopBundleCaptureResponse) Reset() {
|
||||
*x = StopBundleCaptureResponse{}
|
||||
mi := &file_daemon_proto_msgTypes[95]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *StopBundleCaptureResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*StopBundleCaptureResponse) ProtoMessage() {}
|
||||
|
||||
func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_daemon_proto_msgTypes[95]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead.
|
||||
func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
|
||||
return file_daemon_proto_rawDescGZIP(), []int{95}
|
||||
}
|
||||
|
||||
type PortInfo_Range struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"`
|
||||
@@ -6269,7 +5987,7 @@ type PortInfo_Range struct {
|
||||
|
||||
func (x *PortInfo_Range) Reset() {
|
||||
*x = PortInfo_Range{}
|
||||
mi := &file_daemon_proto_msgTypes[97]
|
||||
mi := &file_daemon_proto_msgTypes[91]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -6281,7 +5999,7 @@ func (x *PortInfo_Range) String() string {
|
||||
func (*PortInfo_Range) ProtoMessage() {}
|
||||
|
||||
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_daemon_proto_msgTypes[97]
|
||||
mi := &file_daemon_proto_msgTypes[91]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -6830,23 +6548,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\vservice_url\x18\x02 \x01(\tR\n" +
|
||||
"serviceUrl\x12\x16\n" +
|
||||
"\x06domain\x18\x03 \x01(\tR\x06domain\x12,\n" +
|
||||
"\x12port_auto_assigned\x18\x04 \x01(\bR\x10portAutoAssigned\"\xd9\x01\n" +
|
||||
"\x13StartCaptureRequest\x12\x1f\n" +
|
||||
"\vtext_output\x18\x01 \x01(\bR\n" +
|
||||
"textOutput\x12\x19\n" +
|
||||
"\bsnap_len\x18\x02 \x01(\rR\asnapLen\x125\n" +
|
||||
"\bduration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\bduration\x12\x1f\n" +
|
||||
"\vfilter_expr\x18\x04 \x01(\tR\n" +
|
||||
"filterExpr\x12\x18\n" +
|
||||
"\averbose\x18\x05 \x01(\bR\averbose\x12\x14\n" +
|
||||
"\x05ascii\x18\x06 \x01(\bR\x05ascii\"#\n" +
|
||||
"\rCapturePacket\x12\x12\n" +
|
||||
"\x04data\x18\x01 \x01(\fR\x04data\"P\n" +
|
||||
"\x19StartBundleCaptureRequest\x123\n" +
|
||||
"\atimeout\x18\x01 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\x1c\n" +
|
||||
"\x1aStartBundleCaptureResponse\"\x1a\n" +
|
||||
"\x18StopBundleCaptureRequest\"\x1b\n" +
|
||||
"\x19StopBundleCaptureResponse*b\n" +
|
||||
"\x12port_auto_assigned\x18\x04 \x01(\bR\x10portAutoAssigned*b\n" +
|
||||
"\bLogLevel\x12\v\n" +
|
||||
"\aUNKNOWN\x10\x00\x12\t\n" +
|
||||
"\x05PANIC\x10\x01\x12\t\n" +
|
||||
@@ -6864,7 +6566,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"EXPOSE_UDP\x10\x03\x12\x0e\n" +
|
||||
"\n" +
|
||||
"EXPOSE_TLS\x10\x042\xff\x17\n" +
|
||||
"EXPOSE_TLS\x10\x042\xfc\x15\n" +
|
||||
"\rDaemonService\x126\n" +
|
||||
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
|
||||
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
|
||||
@@ -6885,10 +6587,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"CleanState\x12\x19.daemon.CleanStateRequest\x1a\x1a.daemon.CleanStateResponse\"\x00\x12H\n" +
|
||||
"\vDeleteState\x12\x1a.daemon.DeleteStateRequest\x1a\x1b.daemon.DeleteStateResponse\"\x00\x12u\n" +
|
||||
"\x1aSetSyncResponsePersistence\x12).daemon.SetSyncResponsePersistenceRequest\x1a*.daemon.SetSyncResponsePersistenceResponse\"\x00\x12H\n" +
|
||||
"\vTracePacket\x12\x1a.daemon.TracePacketRequest\x1a\x1b.daemon.TracePacketResponse\"\x00\x12F\n" +
|
||||
"\fStartCapture\x12\x1b.daemon.StartCaptureRequest\x1a\x15.daemon.CapturePacket\"\x000\x01\x12]\n" +
|
||||
"\x12StartBundleCapture\x12!.daemon.StartBundleCaptureRequest\x1a\".daemon.StartBundleCaptureResponse\"\x00\x12Z\n" +
|
||||
"\x11StopBundleCapture\x12 .daemon.StopBundleCaptureRequest\x1a!.daemon.StopBundleCaptureResponse\"\x00\x12D\n" +
|
||||
"\vTracePacket\x12\x1a.daemon.TracePacketRequest\x1a\x1b.daemon.TracePacketResponse\"\x00\x12D\n" +
|
||||
"\x0fSubscribeEvents\x12\x18.daemon.SubscribeRequest\x1a\x13.daemon.SystemEvent\"\x000\x01\x12B\n" +
|
||||
"\tGetEvents\x12\x18.daemon.GetEventsRequest\x1a\x19.daemon.GetEventsResponse\"\x00\x12N\n" +
|
||||
"\rSwitchProfile\x12\x1c.daemon.SwitchProfileRequest\x1a\x1d.daemon.SwitchProfileResponse\"\x00\x12B\n" +
|
||||
@@ -6923,7 +6622,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
|
||||
}
|
||||
|
||||
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
|
||||
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 99)
|
||||
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 93)
|
||||
var file_daemon_proto_goTypes = []any{
|
||||
(LogLevel)(0), // 0: daemon.LogLevel
|
||||
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
|
||||
@@ -7020,142 +6719,128 @@ var file_daemon_proto_goTypes = []any{
|
||||
(*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest
|
||||
(*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent
|
||||
(*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady
|
||||
(*StartCaptureRequest)(nil), // 95: daemon.StartCaptureRequest
|
||||
(*CapturePacket)(nil), // 96: daemon.CapturePacket
|
||||
(*StartBundleCaptureRequest)(nil), // 97: daemon.StartBundleCaptureRequest
|
||||
(*StartBundleCaptureResponse)(nil), // 98: daemon.StartBundleCaptureResponse
|
||||
(*StopBundleCaptureRequest)(nil), // 99: daemon.StopBundleCaptureRequest
|
||||
(*StopBundleCaptureResponse)(nil), // 100: daemon.StopBundleCaptureResponse
|
||||
nil, // 101: daemon.Network.ResolvedIPsEntry
|
||||
(*PortInfo_Range)(nil), // 102: daemon.PortInfo.Range
|
||||
nil, // 103: daemon.SystemEvent.MetadataEntry
|
||||
(*durationpb.Duration)(nil), // 104: google.protobuf.Duration
|
||||
(*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp
|
||||
nil, // 95: daemon.Network.ResolvedIPsEntry
|
||||
(*PortInfo_Range)(nil), // 96: daemon.PortInfo.Range
|
||||
nil, // 97: daemon.SystemEvent.MetadataEntry
|
||||
(*durationpb.Duration)(nil), // 98: google.protobuf.Duration
|
||||
(*timestamppb.Timestamp)(nil), // 99: google.protobuf.Timestamp
|
||||
}
|
||||
var file_daemon_proto_depIdxs = []int32{
|
||||
2, // 0: daemon.OSLifecycleRequest.type:type_name -> daemon.OSLifecycleRequest.CycleType
|
||||
104, // 1: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
|
||||
28, // 2: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
|
||||
105, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
|
||||
105, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
|
||||
104, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
|
||||
26, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
|
||||
23, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
|
||||
22, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
|
||||
21, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
|
||||
20, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState
|
||||
24, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState
|
||||
25, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
|
||||
58, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent
|
||||
27, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
|
||||
34, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
|
||||
101, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
|
||||
102, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
|
||||
35, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
|
||||
35, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
|
||||
36, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
|
||||
0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel
|
||||
0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel
|
||||
44, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State
|
||||
53, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
|
||||
55, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
|
||||
3, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
|
||||
4, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
|
||||
105, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
103, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
|
||||
58, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
|
||||
104, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
|
||||
71, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
|
||||
1, // 33: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
|
||||
94, // 34: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
|
||||
104, // 35: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
|
||||
104, // 36: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
|
||||
33, // 37: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
|
||||
8, // 38: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
|
||||
10, // 39: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
|
||||
12, // 40: daemon.DaemonService.Up:input_type -> daemon.UpRequest
|
||||
14, // 41: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
|
||||
16, // 42: daemon.DaemonService.Down:input_type -> daemon.DownRequest
|
||||
18, // 43: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
|
||||
29, // 44: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
|
||||
31, // 45: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
|
||||
31, // 46: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
|
||||
5, // 47: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
|
||||
38, // 48: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
|
||||
40, // 49: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
|
||||
42, // 50: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
|
||||
45, // 51: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
|
||||
47, // 52: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
|
||||
49, // 53: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
|
||||
51, // 54: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
|
||||
54, // 55: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
|
||||
95, // 56: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
|
||||
97, // 57: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
|
||||
99, // 58: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
|
||||
57, // 59: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
|
||||
59, // 60: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
|
||||
61, // 61: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
|
||||
63, // 62: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
|
||||
65, // 63: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
|
||||
67, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
|
||||
69, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
|
||||
72, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
|
||||
74, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
|
||||
76, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
|
||||
78, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
|
||||
80, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
|
||||
82, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
|
||||
84, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
|
||||
86, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
|
||||
88, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
|
||||
6, // 75: daemon.DaemonService.NotifyOSLifecycle:input_type -> daemon.OSLifecycleRequest
|
||||
90, // 76: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
|
||||
92, // 77: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
|
||||
9, // 78: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
|
||||
11, // 79: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
|
||||
13, // 80: daemon.DaemonService.Up:output_type -> daemon.UpResponse
|
||||
15, // 81: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
|
||||
17, // 82: daemon.DaemonService.Down:output_type -> daemon.DownResponse
|
||||
19, // 83: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
|
||||
30, // 84: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
|
||||
32, // 85: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
|
||||
32, // 86: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
|
||||
37, // 87: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
|
||||
39, // 88: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
|
||||
41, // 89: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
|
||||
43, // 90: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
|
||||
46, // 91: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
|
||||
48, // 92: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
|
||||
50, // 93: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
|
||||
52, // 94: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
|
||||
56, // 95: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
|
||||
96, // 96: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
|
||||
98, // 97: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
|
||||
100, // 98: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
|
||||
58, // 99: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
|
||||
60, // 100: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
|
||||
62, // 101: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
|
||||
64, // 102: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
|
||||
66, // 103: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
|
||||
68, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
|
||||
70, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
|
||||
73, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
|
||||
75, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
|
||||
77, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
|
||||
79, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
|
||||
81, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
|
||||
83, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
|
||||
85, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
|
||||
87, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
|
||||
89, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
|
||||
7, // 115: daemon.DaemonService.NotifyOSLifecycle:output_type -> daemon.OSLifecycleResponse
|
||||
91, // 116: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
|
||||
93, // 117: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
|
||||
78, // [78:118] is the sub-list for method output_type
|
||||
38, // [38:78] is the sub-list for method input_type
|
||||
38, // [38:38] is the sub-list for extension type_name
|
||||
38, // [38:38] is the sub-list for extension extendee
|
||||
0, // [0:38] is the sub-list for field type_name
|
||||
2, // 0: daemon.OSLifecycleRequest.type:type_name -> daemon.OSLifecycleRequest.CycleType
|
||||
98, // 1: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
|
||||
28, // 2: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
|
||||
99, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
|
||||
99, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
|
||||
98, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
|
||||
26, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
|
||||
23, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
|
||||
22, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
|
||||
21, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
|
||||
20, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState
|
||||
24, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState
|
||||
25, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
|
||||
58, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent
|
||||
27, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
|
||||
34, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
|
||||
95, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
|
||||
96, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
|
||||
35, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
|
||||
35, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
|
||||
36, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
|
||||
0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel
|
||||
0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel
|
||||
44, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State
|
||||
53, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
|
||||
55, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
|
||||
3, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
|
||||
4, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
|
||||
99, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
|
||||
97, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
|
||||
58, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
|
||||
98, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
|
||||
71, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
|
||||
1, // 33: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
|
||||
94, // 34: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
|
||||
33, // 35: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
|
||||
8, // 36: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
|
||||
10, // 37: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
|
||||
12, // 38: daemon.DaemonService.Up:input_type -> daemon.UpRequest
|
||||
14, // 39: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
|
||||
16, // 40: daemon.DaemonService.Down:input_type -> daemon.DownRequest
|
||||
18, // 41: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
|
||||
29, // 42: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
|
||||
31, // 43: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
|
||||
31, // 44: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
|
||||
5, // 45: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
|
||||
38, // 46: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
|
||||
40, // 47: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
|
||||
42, // 48: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
|
||||
45, // 49: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
|
||||
47, // 50: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
|
||||
49, // 51: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
|
||||
51, // 52: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
|
||||
54, // 53: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
|
||||
57, // 54: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
|
||||
59, // 55: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
|
||||
61, // 56: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
|
||||
63, // 57: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
|
||||
65, // 58: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
|
||||
67, // 59: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
|
||||
69, // 60: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
|
||||
72, // 61: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
|
||||
74, // 62: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
|
||||
76, // 63: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
|
||||
78, // 64: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
|
||||
80, // 65: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
|
||||
82, // 66: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
|
||||
84, // 67: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
|
||||
86, // 68: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
|
||||
88, // 69: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
|
||||
6, // 70: daemon.DaemonService.NotifyOSLifecycle:input_type -> daemon.OSLifecycleRequest
|
||||
90, // 71: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
|
||||
92, // 72: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
|
||||
9, // 73: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
|
||||
11, // 74: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
|
||||
13, // 75: daemon.DaemonService.Up:output_type -> daemon.UpResponse
|
||||
15, // 76: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
|
||||
17, // 77: daemon.DaemonService.Down:output_type -> daemon.DownResponse
|
||||
19, // 78: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
|
||||
30, // 79: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
|
||||
32, // 80: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
|
||||
32, // 81: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
|
||||
37, // 82: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
|
||||
39, // 83: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
|
||||
41, // 84: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
|
||||
43, // 85: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
|
||||
46, // 86: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
|
||||
48, // 87: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
|
||||
50, // 88: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
|
||||
52, // 89: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
|
||||
56, // 90: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
|
||||
58, // 91: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
|
||||
60, // 92: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
|
||||
62, // 93: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
|
||||
64, // 94: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
|
||||
66, // 95: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
|
||||
68, // 96: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
|
||||
70, // 97: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
|
||||
73, // 98: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
|
||||
75, // 99: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
|
||||
77, // 100: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
|
||||
79, // 101: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
|
||||
81, // 102: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
|
||||
83, // 103: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
|
||||
85, // 104: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
|
||||
87, // 105: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
|
||||
89, // 106: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
|
||||
7, // 107: daemon.DaemonService.NotifyOSLifecycle:output_type -> daemon.OSLifecycleResponse
|
||||
91, // 108: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
|
||||
93, // 109: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
|
||||
73, // [73:110] is the sub-list for method output_type
|
||||
36, // [36:73] is the sub-list for method input_type
|
||||
36, // [36:36] is the sub-list for extension type_name
|
||||
36, // [36:36] is the sub-list for extension extendee
|
||||
0, // [0:36] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_daemon_proto_init() }
|
||||
@@ -7185,7 +6870,7 @@ func file_daemon_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
|
||||
NumEnums: 5,
|
||||
NumMessages: 99,
|
||||
NumMessages: 93,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -64,17 +64,6 @@ service DaemonService {
|
||||
|
||||
rpc TracePacket(TracePacketRequest) returns (TracePacketResponse) {}
|
||||
|
||||
// StartCapture begins streaming packet capture on the WireGuard interface.
|
||||
// Requires --enable-capture set at service install/reconfigure time.
|
||||
rpc StartCapture(StartCaptureRequest) returns (stream CapturePacket) {}
|
||||
|
||||
// StartBundleCapture begins capturing packets to a server-side temp file
|
||||
// for inclusion in the next debug bundle. Auto-stops after the given timeout.
|
||||
rpc StartBundleCapture(StartBundleCaptureRequest) returns (StartBundleCaptureResponse) {}
|
||||
|
||||
// StopBundleCapture stops the running bundle capture. Idempotent.
|
||||
rpc StopBundleCapture(StopBundleCaptureRequest) returns (StopBundleCaptureResponse) {}
|
||||
|
||||
rpc SubscribeEvents(SubscribeRequest) returns (stream SystemEvent) {}
|
||||
|
||||
rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {}
|
||||
@@ -859,26 +848,3 @@ message ExposeServiceReady {
|
||||
string domain = 3;
|
||||
bool port_auto_assigned = 4;
|
||||
}
|
||||
|
||||
message StartCaptureRequest {
|
||||
bool text_output = 1;
|
||||
uint32 snap_len = 2;
|
||||
google.protobuf.Duration duration = 3;
|
||||
string filter_expr = 4;
|
||||
bool verbose = 5;
|
||||
bool ascii = 6;
|
||||
}
|
||||
|
||||
message CapturePacket {
|
||||
bytes data = 1;
|
||||
}
|
||||
|
||||
message StartBundleCaptureRequest {
|
||||
// timeout auto-stops the capture after this duration.
|
||||
// Clamped to a server-side maximum (10 minutes). Zero or unset defaults to the maximum.
|
||||
google.protobuf.Duration timeout = 1;
|
||||
}
|
||||
|
||||
message StartBundleCaptureResponse {}
|
||||
message StopBundleCaptureRequest {}
|
||||
message StopBundleCaptureResponse {}
|
||||
|
||||
@@ -53,14 +53,6 @@ type DaemonServiceClient interface {
|
||||
// SetSyncResponsePersistence enables or disables sync response persistence
|
||||
SetSyncResponsePersistence(ctx context.Context, in *SetSyncResponsePersistenceRequest, opts ...grpc.CallOption) (*SetSyncResponsePersistenceResponse, error)
|
||||
TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error)
|
||||
// StartCapture begins streaming packet capture on the WireGuard interface.
|
||||
// Requires --enable-capture set at service install/reconfigure time.
|
||||
StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (DaemonService_StartCaptureClient, error)
|
||||
// StartBundleCapture begins capturing packets to a server-side temp file
|
||||
// for inclusion in the next debug bundle. Auto-stops after the given timeout.
|
||||
StartBundleCapture(ctx context.Context, in *StartBundleCaptureRequest, opts ...grpc.CallOption) (*StartBundleCaptureResponse, error)
|
||||
// StopBundleCapture stops the running bundle capture. Idempotent.
|
||||
StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error)
|
||||
SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error)
|
||||
GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error)
|
||||
SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error)
|
||||
@@ -261,58 +253,8 @@ func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (DaemonService_StartCaptureClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], "/daemon.DaemonService/StartCapture", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &daemonServiceStartCaptureClient{stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := x.ClientStream.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type DaemonService_StartCaptureClient interface {
|
||||
Recv() (*CapturePacket, error)
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
type daemonServiceStartCaptureClient struct {
|
||||
grpc.ClientStream
|
||||
}
|
||||
|
||||
func (x *daemonServiceStartCaptureClient) Recv() (*CapturePacket, error) {
|
||||
m := new(CapturePacket)
|
||||
if err := x.ClientStream.RecvMsg(m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) StartBundleCapture(ctx context.Context, in *StartBundleCaptureRequest, opts ...grpc.CallOption) (*StartBundleCaptureResponse, error) {
|
||||
out := new(StartBundleCaptureResponse)
|
||||
err := c.cc.Invoke(ctx, "/daemon.DaemonService/StartBundleCapture", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error) {
|
||||
out := new(StopBundleCaptureResponse)
|
||||
err := c.cc.Invoke(ctx, "/daemon.DaemonService/StopBundleCapture", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], "/daemon.DaemonService/SubscribeEvents", opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], "/daemon.DaemonService/SubscribeEvents", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -497,7 +439,7 @@ func (c *daemonServiceClient) GetInstallerResult(ctx context.Context, in *Instal
|
||||
}
|
||||
|
||||
func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (DaemonService_ExposeServiceClient, error) {
|
||||
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], "/daemon.DaemonService/ExposeService", opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], "/daemon.DaemonService/ExposeService", opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -567,14 +509,6 @@ type DaemonServiceServer interface {
|
||||
// SetSyncResponsePersistence enables or disables sync response persistence
|
||||
SetSyncResponsePersistence(context.Context, *SetSyncResponsePersistenceRequest) (*SetSyncResponsePersistenceResponse, error)
|
||||
TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error)
|
||||
// StartCapture begins streaming packet capture on the WireGuard interface.
|
||||
// Requires --enable-capture set at service install/reconfigure time.
|
||||
StartCapture(*StartCaptureRequest, DaemonService_StartCaptureServer) error
|
||||
// StartBundleCapture begins capturing packets to a server-side temp file
|
||||
// for inclusion in the next debug bundle. Auto-stops after the given timeout.
|
||||
StartBundleCapture(context.Context, *StartBundleCaptureRequest) (*StartBundleCaptureResponse, error)
|
||||
// StopBundleCapture stops the running bundle capture. Idempotent.
|
||||
StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error)
|
||||
SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error
|
||||
GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error)
|
||||
SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error)
|
||||
@@ -664,15 +598,6 @@ func (UnimplementedDaemonServiceServer) SetSyncResponsePersistence(context.Conte
|
||||
func (UnimplementedDaemonServiceServer) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method TracePacket not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) StartCapture(*StartCaptureRequest, DaemonService_StartCaptureServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method StartCapture not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) StartBundleCapture(context.Context, *StartBundleCaptureRequest) (*StartBundleCaptureResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method StartBundleCapture not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method StopBundleCapture not implemented")
|
||||
}
|
||||
func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error {
|
||||
return status.Errorf(codes.Unimplemented, "method SubscribeEvents not implemented")
|
||||
}
|
||||
@@ -1067,63 +992,6 @@ func _DaemonService_TracePacket_Handler(srv interface{}, ctx context.Context, de
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_StartCapture_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(StartCaptureRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(DaemonServiceServer).StartCapture(m, &daemonServiceStartCaptureServer{stream})
|
||||
}
|
||||
|
||||
type DaemonService_StartCaptureServer interface {
|
||||
Send(*CapturePacket) error
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
type daemonServiceStartCaptureServer struct {
|
||||
grpc.ServerStream
|
||||
}
|
||||
|
||||
func (x *daemonServiceStartCaptureServer) Send(m *CapturePacket) error {
|
||||
return x.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func _DaemonService_StartBundleCapture_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(StartBundleCaptureRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).StartBundleCapture(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/daemon.DaemonService/StartBundleCapture",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).StartBundleCapture(ctx, req.(*StartBundleCaptureRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_StopBundleCapture_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(StopBundleCaptureRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(DaemonServiceServer).StopBundleCapture(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/daemon.DaemonService/StopBundleCapture",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(DaemonServiceServer).StopBundleCapture(ctx, req.(*StopBundleCaptureRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _DaemonService_SubscribeEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(SubscribeRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
@@ -1551,14 +1419,6 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "TracePacket",
|
||||
Handler: _DaemonService_TracePacket_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "StartBundleCapture",
|
||||
Handler: _DaemonService_StartBundleCapture_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "StopBundleCapture",
|
||||
Handler: _DaemonService_StopBundleCapture_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetEvents",
|
||||
Handler: _DaemonService_GetEvents_Handler,
|
||||
@@ -1629,11 +1489,6 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "StartCapture",
|
||||
Handler: _DaemonService_StartCapture_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "SubscribeEvents",
|
||||
Handler: _DaemonService_SubscribeEvents_Handler,
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util/capture"
|
||||
)
|
||||
|
||||
const maxBundleCaptureDuration = 10 * time.Minute
|
||||
|
||||
// bundleCapture holds the state of an in-progress capture destined for the
|
||||
// debug bundle. The lifecycle is:
|
||||
//
|
||||
// StartBundleCapture → capture running, writing to temp file
|
||||
// StopBundleCapture → capture stopped, temp file available
|
||||
// DebugBundle → temp file included in zip, then cleaned up
|
||||
type bundleCapture struct {
|
||||
mu sync.Mutex
|
||||
sess *capture.Session
|
||||
file *os.File
|
||||
engine *internal.Engine
|
||||
cancel context.CancelFunc
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// stop halts the capture session and closes the pcap writer. Idempotent.
|
||||
func (bc *bundleCapture) stop() {
|
||||
bc.mu.Lock()
|
||||
defer bc.mu.Unlock()
|
||||
|
||||
if bc.stopped {
|
||||
return
|
||||
}
|
||||
bc.stopped = true
|
||||
|
||||
if bc.cancel != nil {
|
||||
bc.cancel()
|
||||
}
|
||||
if bc.sess != nil {
|
||||
bc.sess.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// path returns the temp file path, or "" if no file exists.
|
||||
func (bc *bundleCapture) path() string {
|
||||
if bc.file == nil {
|
||||
return ""
|
||||
}
|
||||
return bc.file.Name()
|
||||
}
|
||||
|
||||
// cleanup removes the temp file.
|
||||
func (bc *bundleCapture) cleanup() {
|
||||
if bc.file == nil {
|
||||
return
|
||||
}
|
||||
name := bc.file.Name()
|
||||
if err := bc.file.Close(); err != nil {
|
||||
log.Debugf("close bundle capture file: %v", err)
|
||||
}
|
||||
if err := os.Remove(name); err != nil && !os.IsNotExist(err) {
|
||||
log.Debugf("remove bundle capture file: %v", err)
|
||||
}
|
||||
bc.file = nil
|
||||
}
|
||||
|
||||
// StartCapture streams a pcap or text packet capture over gRPC.
|
||||
// Gated by the --enable-capture service flag.
|
||||
func (s *Server) StartCapture(req *proto.StartCaptureRequest, stream proto.DaemonService_StartCaptureServer) error {
|
||||
if !s.captureEnabled {
|
||||
return status.Error(codes.PermissionDenied,
|
||||
"packet capture is disabled; reinstall or reconfigure the service with --enable-capture")
|
||||
}
|
||||
|
||||
if d := req.GetDuration(); d != nil && d.AsDuration() < 0 {
|
||||
return status.Error(codes.InvalidArgument, "duration must not be negative")
|
||||
}
|
||||
|
||||
matcher, err := parseCaptureFilter(req)
|
||||
if err != nil {
|
||||
return status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
opts := capture.Options{
|
||||
Matcher: matcher,
|
||||
SnapLen: req.GetSnapLen(),
|
||||
Verbose: req.GetVerbose(),
|
||||
ASCII: req.GetAscii(),
|
||||
}
|
||||
if req.GetTextOutput() {
|
||||
opts.TextOutput = pw
|
||||
} else {
|
||||
opts.Output = pw
|
||||
}
|
||||
|
||||
sess, err := capture.NewSession(opts)
|
||||
if err != nil {
|
||||
pw.Close()
|
||||
return status.Errorf(codes.Internal, "create capture session: %v", err)
|
||||
}
|
||||
|
||||
engine, err := s.claimCapture(sess)
|
||||
if err != nil {
|
||||
sess.Stop()
|
||||
pw.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
if err := engine.SetCapture(sess); err != nil {
|
||||
s.releaseCapture(sess)
|
||||
sess.Stop()
|
||||
pw.Close()
|
||||
return status.Errorf(codes.Internal, "set capture: %v", err)
|
||||
}
|
||||
|
||||
// Send an empty initial message to signal that the capture was accepted.
|
||||
// The client waits for this before printing the banner, so it must arrive
|
||||
// before any packet data.
|
||||
if err := stream.Send(&proto.CapturePacket{}); err != nil {
|
||||
s.clearCaptureIfOwner(sess, engine)
|
||||
sess.Stop()
|
||||
pw.Close()
|
||||
return status.Errorf(codes.Internal, "send initial message: %v", err)
|
||||
}
|
||||
|
||||
ctx := stream.Context()
|
||||
if d := req.GetDuration(); d != nil {
|
||||
if dur := d.AsDuration(); dur > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, dur)
|
||||
defer cancel()
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.clearCaptureIfOwner(sess, engine)
|
||||
sess.Stop()
|
||||
pw.Close()
|
||||
}()
|
||||
defer pr.Close()
|
||||
|
||||
log.Infof("packet capture started (text=%v, expr=%q)", req.GetTextOutput(), req.GetFilterExpr())
|
||||
defer func() {
|
||||
stats := sess.Stats()
|
||||
log.Infof("packet capture stopped: %d packets, %d bytes, %d dropped",
|
||||
stats.Packets, stats.Bytes, stats.Dropped)
|
||||
}()
|
||||
|
||||
return streamToGRPC(pr, stream)
|
||||
}
|
||||
|
||||
func streamToGRPC(r io.Reader, stream proto.DaemonService_StartCaptureServer) error {
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, readErr := r.Read(buf)
|
||||
if n > 0 {
|
||||
if err := stream.Send(&proto.CapturePacket{Data: buf[:n]}); err != nil {
|
||||
log.Debugf("capture stream send: %v", err)
|
||||
return nil //nolint:nilerr // client disconnected
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil //nolint:nilerr // pipe closed, capture stopped normally
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartBundleCapture begins capturing packets to a server-side temp file for
|
||||
// inclusion in the next debug bundle. Not gated by --enable-capture since the
|
||||
// output stays on the server (same trust level as CPU profiling).
|
||||
//
|
||||
// A timeout auto-stops the capture as a safety net if StopBundleCapture is
|
||||
// never called (e.g. CLI crash).
|
||||
func (s *Server) StartBundleCapture(_ context.Context, req *proto.StartBundleCaptureRequest) (*proto.StartBundleCaptureResponse, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.stopBundleCaptureLocked()
|
||||
s.cleanupBundleCapture()
|
||||
|
||||
if s.activeCapture != nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
|
||||
}
|
||||
|
||||
engine, err := s.getCaptureEngineLocked()
|
||||
if err != nil {
|
||||
// Not fatal: kernel mode or not connected. Log and return success
|
||||
// so the debug bundle still generates without capture data.
|
||||
log.Warnf("packet capture unavailable, skipping: %v", err)
|
||||
return &proto.StartBundleCaptureResponse{}, nil
|
||||
}
|
||||
|
||||
timeout := req.GetTimeout().AsDuration()
|
||||
if timeout <= 0 || timeout > maxBundleCaptureDuration {
|
||||
timeout = maxBundleCaptureDuration
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp("", "netbird.capture.*.pcap")
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "create temp file: %v", err)
|
||||
}
|
||||
|
||||
sess, err := capture.NewSession(capture.Options{Output: f})
|
||||
if err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return nil, status.Errorf(codes.Internal, "create capture session: %v", err)
|
||||
}
|
||||
|
||||
if err := engine.SetCapture(sess); err != nil {
|
||||
sess.Stop()
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
log.Warnf("packet capture unavailable (no filtered device), skipping: %v", err)
|
||||
return &proto.StartBundleCaptureResponse{}, nil
|
||||
}
|
||||
s.activeCapture = sess
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
bc := &bundleCapture{
|
||||
sess: sess,
|
||||
file: f,
|
||||
engine: engine,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
s.bundleCapture = bc
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.mutex.Lock()
|
||||
if s.bundleCapture == bc {
|
||||
s.stopBundleCaptureLocked()
|
||||
} else {
|
||||
bc.stop()
|
||||
}
|
||||
s.mutex.Unlock()
|
||||
log.Infof("bundle capture auto-stopped after timeout")
|
||||
}()
|
||||
log.Infof("bundle capture started (timeout=%s, file=%s)", timeout, f.Name())
|
||||
|
||||
return &proto.StartBundleCaptureResponse{}, nil
|
||||
}
|
||||
|
||||
// StopBundleCapture stops the running bundle capture. Idempotent.
|
||||
func (s *Server) StopBundleCapture(_ context.Context, _ *proto.StopBundleCaptureRequest) (*proto.StopBundleCaptureResponse, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
s.stopBundleCaptureLocked()
|
||||
return &proto.StopBundleCaptureResponse{}, nil
|
||||
}
|
||||
|
||||
// stopBundleCaptureLocked stops the bundle capture if running. Must hold s.mutex.
|
||||
func (s *Server) stopBundleCaptureLocked() {
|
||||
if s.bundleCapture == nil {
|
||||
return
|
||||
}
|
||||
bc := s.bundleCapture
|
||||
if bc.engine != nil && s.activeCapture == bc.sess {
|
||||
if err := bc.engine.SetCapture(nil); err != nil {
|
||||
log.Debugf("clear bundle capture: %v", err)
|
||||
}
|
||||
s.activeCapture = nil
|
||||
}
|
||||
bc.stop()
|
||||
|
||||
stats := bc.sess.Stats()
|
||||
log.Infof("bundle capture stopped: %d packets, %d bytes, %d dropped",
|
||||
stats.Packets, stats.Bytes, stats.Dropped)
|
||||
}
|
||||
|
||||
// bundleCapturePath returns the temp file path if a capture has been taken,
|
||||
// stops any running capture, and returns "". Called from DebugBundle.
|
||||
// Must hold s.mutex.
|
||||
func (s *Server) bundleCapturePath() string {
|
||||
if s.bundleCapture == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
s.bundleCapture.stop()
|
||||
return s.bundleCapture.path()
|
||||
}
|
||||
|
||||
// cleanupBundleCapture removes the temp file and clears state. Must hold s.mutex.
|
||||
func (s *Server) cleanupBundleCapture() {
|
||||
if s.bundleCapture == nil {
|
||||
return
|
||||
}
|
||||
s.bundleCapture.cleanup()
|
||||
s.bundleCapture = nil
|
||||
}
|
||||
|
||||
// claimCapture reserves the engine's capture slot for sess. Returns
|
||||
// FailedPrecondition if another capture is already active.
|
||||
func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.activeCapture != nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
|
||||
}
|
||||
engine, err := s.getCaptureEngineLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.activeCapture = sess
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// releaseCapture clears the active-capture owner if it still matches sess.
|
||||
func (s *Server) releaseCapture(sess *capture.Session) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
if s.activeCapture == sess {
|
||||
s.activeCapture = nil
|
||||
}
|
||||
}
|
||||
|
||||
// clearCaptureIfOwner clears engine's capture slot only if sess still owns it.
|
||||
func (s *Server) clearCaptureIfOwner(sess *capture.Session, engine *internal.Engine) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
if s.activeCapture != sess {
|
||||
return
|
||||
}
|
||||
if err := engine.SetCapture(nil); err != nil {
|
||||
log.Debugf("clear capture: %v", err)
|
||||
}
|
||||
s.activeCapture = nil
|
||||
}
|
||||
|
||||
func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) {
|
||||
if s.connectClient == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "client not connected")
|
||||
}
|
||||
engine := s.connectClient.Engine()
|
||||
if engine == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "engine not initialized")
|
||||
}
|
||||
return engine, nil
|
||||
}
|
||||
|
||||
// parseCaptureFilter returns a Matcher from the request.
|
||||
// Returns nil (match all) when no filter expression is set.
|
||||
func parseCaptureFilter(req *proto.StartCaptureRequest) (capture.Matcher, error) {
|
||||
expr := req.GetFilterExpr()
|
||||
if expr == "" {
|
||||
return nil, nil //nolint:nilnil // nil Matcher means "match all"
|
||||
}
|
||||
return capture.ParseFilter(expr)
|
||||
}
|
||||
@@ -43,9 +43,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
||||
}()
|
||||
}
|
||||
|
||||
capturePath := s.bundleCapturePath()
|
||||
defer s.cleanupBundleCapture()
|
||||
|
||||
// Prepare refresh callback for health probes
|
||||
var refreshStatus func()
|
||||
if s.connectClient != nil {
|
||||
engine := s.connectClient.Engine()
|
||||
@@ -64,7 +62,6 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
||||
SyncResponse: syncResponse,
|
||||
LogPath: s.logFile,
|
||||
CPUProfile: cpuProfileData,
|
||||
CapturePath: capturePath,
|
||||
RefreshStatus: refreshStatus,
|
||||
ClientMetrics: clientMetrics,
|
||||
},
|
||||
|
||||
@@ -33,7 +33,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/updater"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util/capture"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
@@ -90,11 +89,7 @@ type Server struct {
|
||||
profileManager *profilemanager.ServiceManager
|
||||
profilesDisabled bool
|
||||
updateSettingsDisabled bool
|
||||
captureEnabled bool
|
||||
bundleCapture *bundleCapture
|
||||
// activeCapture is the session currently installed on the engine; guarded by s.mutex.
|
||||
activeCapture *capture.Session
|
||||
networksDisabled bool
|
||||
networksDisabled bool
|
||||
|
||||
sleepHandler *sleephandler.SleepHandler
|
||||
|
||||
@@ -111,7 +106,7 @@ type oauthAuthFlow struct {
|
||||
}
|
||||
|
||||
// New server instance constructor.
|
||||
func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool, captureEnabled bool, networksDisabled bool) *Server {
|
||||
func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool, networksDisabled bool) *Server {
|
||||
s := &Server{
|
||||
rootCtx: ctx,
|
||||
logFile: logFile,
|
||||
@@ -120,7 +115,6 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
||||
profileManager: profilemanager.NewServiceManager(configFile),
|
||||
profilesDisabled: profilesDisabled,
|
||||
updateSettingsDisabled: updateSettingsDisabled,
|
||||
captureEnabled: captureEnabled,
|
||||
networksDisabled: networksDisabled,
|
||||
jwtCache: newJWTCache(),
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestConnectWithRetryRuns(t *testing.T) {
|
||||
t.Fatalf("failed to set active profile state: %v", err)
|
||||
}
|
||||
|
||||
s := New(ctx, "debug", "", false, false, false, false)
|
||||
s := New(ctx, "debug", "", false, false, false)
|
||||
|
||||
s.config = config
|
||||
|
||||
@@ -165,7 +165,7 @@ func TestServer_Up(t *testing.T) {
|
||||
t.Fatalf("failed to set active profile state: %v", err)
|
||||
}
|
||||
|
||||
s := New(ctx, "console", "", false, false, false, false)
|
||||
s := New(ctx, "console", "", false, false, false)
|
||||
err = s.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -235,7 +235,7 @@ func TestServer_SubcribeEvents(t *testing.T) {
|
||||
t.Fatalf("failed to set active profile state: %v", err)
|
||||
}
|
||||
|
||||
s := New(ctx, "console", "", false, false, false, false)
|
||||
s := New(ctx, "console", "", false, false, false)
|
||||
|
||||
err = s.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
s := New(ctx, "console", "", false, false, false, false)
|
||||
s := New(ctx, "console", "", false, false, false)
|
||||
|
||||
rosenpassEnabled := true
|
||||
rosenpassPermissive := true
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"fyne.io/fyne/v2/widget"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/skratchdot/open-golang/open"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
@@ -39,7 +38,6 @@ type debugCollectionParams struct {
|
||||
upload bool
|
||||
uploadURL string
|
||||
enablePersistence bool
|
||||
capture bool
|
||||
}
|
||||
|
||||
// UI components for progress tracking
|
||||
@@ -53,58 +51,25 @@ type progressUI struct {
|
||||
func (s *serviceClient) showDebugUI() {
|
||||
w := s.app.NewWindow("NetBird Debug")
|
||||
w.SetOnClosed(s.cancel)
|
||||
|
||||
w.Resize(fyne.NewSize(600, 500))
|
||||
w.SetFixedSize(true)
|
||||
|
||||
anonymizeCheck := widget.NewCheck("Anonymize sensitive information (public IPs, domains, ...)", nil)
|
||||
systemInfoCheck := widget.NewCheck("Include system information (routes, interfaces, ...)", nil)
|
||||
systemInfoCheck.SetChecked(true)
|
||||
captureCheck := widget.NewCheck("Include packet capture", nil)
|
||||
uploadCheck := widget.NewCheck("Upload bundle automatically after creation", nil)
|
||||
uploadCheck.SetChecked(true)
|
||||
|
||||
uploadURLContainer, uploadURL := s.buildUploadSection(uploadCheck)
|
||||
|
||||
debugModeContainer, runForDurationCheck, durationInput, noteLabel := s.buildDurationSection()
|
||||
|
||||
statusLabel := widget.NewLabel("")
|
||||
statusLabel.Hide()
|
||||
progressBar := widget.NewProgressBar()
|
||||
progressBar.Hide()
|
||||
createButton := widget.NewButton("Create Debug Bundle", nil)
|
||||
|
||||
uiControls := []fyne.Disableable{
|
||||
anonymizeCheck, systemInfoCheck, captureCheck,
|
||||
uploadCheck, uploadURL, runForDurationCheck, durationInput, createButton,
|
||||
}
|
||||
|
||||
createButton.OnTapped = s.getCreateHandler(
|
||||
statusLabel, progressBar, uploadCheck, uploadURL,
|
||||
anonymizeCheck, systemInfoCheck, captureCheck,
|
||||
runForDurationCheck, durationInput, uiControls, w,
|
||||
)
|
||||
|
||||
content := container.NewVBox(
|
||||
widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"),
|
||||
widget.NewLabel(""),
|
||||
anonymizeCheck, systemInfoCheck, captureCheck,
|
||||
uploadCheck, uploadURLContainer,
|
||||
widget.NewLabel(""),
|
||||
debugModeContainer, noteLabel,
|
||||
widget.NewLabel(""),
|
||||
statusLabel, progressBar, createButton,
|
||||
)
|
||||
|
||||
w.SetContent(container.NewPadded(content))
|
||||
w.Show()
|
||||
}
|
||||
|
||||
func (s *serviceClient) buildUploadSection(uploadCheck *widget.Check) (*fyne.Container, *widget.Entry) {
|
||||
uploadURLLabel := widget.NewLabel("Debug upload URL:")
|
||||
uploadURL := widget.NewEntry()
|
||||
uploadURL.SetText(uptypes.DefaultBundleURL)
|
||||
uploadURL.SetPlaceHolder("Enter upload URL")
|
||||
|
||||
uploadURLContainer := container.NewVBox(widget.NewLabel("Debug upload URL:"), uploadURL)
|
||||
uploadURLContainer := container.NewVBox(
|
||||
uploadURLLabel,
|
||||
uploadURL,
|
||||
)
|
||||
|
||||
uploadCheck.OnChanged = func(checked bool) {
|
||||
if checked {
|
||||
@@ -113,14 +78,13 @@ func (s *serviceClient) buildUploadSection(uploadCheck *widget.Check) (*fyne.Con
|
||||
uploadURLContainer.Hide()
|
||||
}
|
||||
}
|
||||
return uploadURLContainer, uploadURL
|
||||
}
|
||||
|
||||
func (s *serviceClient) buildDurationSection() (*fyne.Container, *widget.Check, *widget.Entry, *widget.Label) {
|
||||
debugModeContainer := container.NewHBox()
|
||||
runForDurationCheck := widget.NewCheck("Run with trace logs before creating bundle", nil)
|
||||
runForDurationCheck.SetChecked(true)
|
||||
|
||||
forLabel := widget.NewLabel("for")
|
||||
|
||||
durationInput := widget.NewEntry()
|
||||
durationInput.SetText("1")
|
||||
minutesLabel := widget.NewLabel("minute")
|
||||
@@ -144,8 +108,63 @@ func (s *serviceClient) buildDurationSection() (*fyne.Container, *widget.Check,
|
||||
}
|
||||
}
|
||||
|
||||
modeContainer := container.NewHBox(runForDurationCheck, forLabel, durationInput, minutesLabel)
|
||||
return modeContainer, runForDurationCheck, durationInput, noteLabel
|
||||
debugModeContainer.Add(runForDurationCheck)
|
||||
debugModeContainer.Add(forLabel)
|
||||
debugModeContainer.Add(durationInput)
|
||||
debugModeContainer.Add(minutesLabel)
|
||||
|
||||
statusLabel := widget.NewLabel("")
|
||||
statusLabel.Hide()
|
||||
|
||||
progressBar := widget.NewProgressBar()
|
||||
progressBar.Hide()
|
||||
|
||||
createButton := widget.NewButton("Create Debug Bundle", nil)
|
||||
|
||||
// UI controls that should be disabled during debug collection
|
||||
uiControls := []fyne.Disableable{
|
||||
anonymizeCheck,
|
||||
systemInfoCheck,
|
||||
uploadCheck,
|
||||
uploadURL,
|
||||
runForDurationCheck,
|
||||
durationInput,
|
||||
createButton,
|
||||
}
|
||||
|
||||
createButton.OnTapped = s.getCreateHandler(
|
||||
statusLabel,
|
||||
progressBar,
|
||||
uploadCheck,
|
||||
uploadURL,
|
||||
anonymizeCheck,
|
||||
systemInfoCheck,
|
||||
runForDurationCheck,
|
||||
durationInput,
|
||||
uiControls,
|
||||
w,
|
||||
)
|
||||
|
||||
content := container.NewVBox(
|
||||
widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"),
|
||||
widget.NewLabel(""),
|
||||
anonymizeCheck,
|
||||
systemInfoCheck,
|
||||
uploadCheck,
|
||||
uploadURLContainer,
|
||||
widget.NewLabel(""),
|
||||
debugModeContainer,
|
||||
noteLabel,
|
||||
widget.NewLabel(""),
|
||||
statusLabel,
|
||||
progressBar,
|
||||
createButton,
|
||||
)
|
||||
|
||||
paddedContent := container.NewPadded(content)
|
||||
w.SetContent(paddedContent)
|
||||
|
||||
w.Show()
|
||||
}
|
||||
|
||||
func validateMinute(s string, minutesLabel *widget.Label) error {
|
||||
@@ -181,7 +200,6 @@ func (s *serviceClient) getCreateHandler(
|
||||
uploadURL *widget.Entry,
|
||||
anonymizeCheck *widget.Check,
|
||||
systemInfoCheck *widget.Check,
|
||||
captureCheck *widget.Check,
|
||||
runForDurationCheck *widget.Check,
|
||||
duration *widget.Entry,
|
||||
uiControls []fyne.Disableable,
|
||||
@@ -204,7 +222,6 @@ func (s *serviceClient) getCreateHandler(
|
||||
params := &debugCollectionParams{
|
||||
anonymize: anonymizeCheck.Checked,
|
||||
systemInfo: systemInfoCheck.Checked,
|
||||
capture: captureCheck.Checked,
|
||||
upload: uploadCheck.Checked,
|
||||
uploadURL: url,
|
||||
enablePersistence: true,
|
||||
@@ -236,7 +253,10 @@ func (s *serviceClient) getCreateHandler(
|
||||
|
||||
statusLabel.SetText("Creating debug bundle...")
|
||||
go s.handleDebugCreation(
|
||||
params,
|
||||
anonymizeCheck.Checked,
|
||||
systemInfoCheck.Checked,
|
||||
uploadCheck.Checked,
|
||||
url,
|
||||
statusLabel,
|
||||
uiControls,
|
||||
w,
|
||||
@@ -351,7 +371,7 @@ func startProgressTracker(ctx context.Context, wg *sync.WaitGroup, duration time
|
||||
func (s *serviceClient) configureServiceForDebug(
|
||||
conn proto.DaemonServiceClient,
|
||||
state *debugInitialState,
|
||||
params *debugCollectionParams,
|
||||
enablePersistence bool,
|
||||
) {
|
||||
if state.wasDown {
|
||||
if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil {
|
||||
@@ -377,7 +397,7 @@ func (s *serviceClient) configureServiceForDebug(
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
if params.enablePersistence {
|
||||
if enablePersistence {
|
||||
if _, err := conn.SetSyncResponsePersistence(s.ctx, &proto.SetSyncResponsePersistenceRequest{
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
@@ -397,26 +417,6 @@ func (s *serviceClient) configureServiceForDebug(
|
||||
if _, err := conn.StartCPUProfile(s.ctx, &proto.StartCPUProfileRequest{}); err != nil {
|
||||
log.Warnf("failed to start CPU profiling: %v", err)
|
||||
}
|
||||
|
||||
s.startBundleCaptureIfEnabled(conn, params)
|
||||
}
|
||||
|
||||
func (s *serviceClient) startBundleCaptureIfEnabled(conn proto.DaemonServiceClient, params *debugCollectionParams) {
|
||||
if !params.capture {
|
||||
return
|
||||
}
|
||||
|
||||
const maxCapture = 10 * time.Minute
|
||||
timeout := params.duration + 30*time.Second
|
||||
if timeout > maxCapture {
|
||||
timeout = maxCapture
|
||||
log.Warnf("packet capture clamped to %s (server maximum)", maxCapture)
|
||||
}
|
||||
if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{
|
||||
Timeout: durationpb.New(timeout),
|
||||
}); err != nil {
|
||||
log.Warnf("failed to start bundle capture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serviceClient) collectDebugData(
|
||||
@@ -430,7 +430,7 @@ func (s *serviceClient) collectDebugData(
|
||||
var wg sync.WaitGroup
|
||||
startProgressTracker(ctx, &wg, params.duration, progress)
|
||||
|
||||
s.configureServiceForDebug(conn, state, params)
|
||||
s.configureServiceForDebug(conn, state, params.enablePersistence)
|
||||
|
||||
wg.Wait()
|
||||
progress.progressBar.Hide()
|
||||
@@ -440,14 +440,6 @@ func (s *serviceClient) collectDebugData(
|
||||
log.Warnf("failed to stop CPU profiling: %v", err)
|
||||
}
|
||||
|
||||
if params.capture {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
|
||||
log.Warnf("failed to stop bundle capture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -528,37 +520,18 @@ func handleError(progress *progressUI, errMsg string) {
|
||||
}
|
||||
|
||||
func (s *serviceClient) handleDebugCreation(
|
||||
params *debugCollectionParams,
|
||||
anonymize bool,
|
||||
systemInfo bool,
|
||||
upload bool,
|
||||
uploadURL string,
|
||||
statusLabel *widget.Label,
|
||||
uiControls []fyne.Disableable,
|
||||
w fyne.Window,
|
||||
) {
|
||||
conn, err := s.getSrvClient(failFastTimeout)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get client for debug: %v", err)
|
||||
statusLabel.SetText(fmt.Sprintf("Error: %v", err))
|
||||
enableUIControls(uiControls)
|
||||
return
|
||||
}
|
||||
log.Infof("Creating debug bundle (Anonymized: %v, System Info: %v, Upload Attempt: %v)...",
|
||||
anonymize, systemInfo, upload)
|
||||
|
||||
if params.capture {
|
||||
if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{
|
||||
Timeout: durationpb.New(30 * time.Second),
|
||||
}); err != nil {
|
||||
log.Warnf("failed to start bundle capture: %v", err)
|
||||
} else {
|
||||
defer func() {
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
|
||||
log.Warnf("failed to stop bundle capture: %v", err)
|
||||
}
|
||||
}()
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.createDebugBundle(params.anonymize, params.systemInfo, params.uploadURL)
|
||||
resp, err := s.createDebugBundle(anonymize, systemInfo, uploadURL)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create debug bundle: %v", err)
|
||||
statusLabel.SetText(fmt.Sprintf("Error creating bundle: %v", err))
|
||||
@@ -570,7 +543,7 @@ func (s *serviceClient) handleDebugCreation(
|
||||
uploadFailureReason := resp.GetUploadFailureReason()
|
||||
uploadedKey := resp.GetUploadedKey()
|
||||
|
||||
if params.upload {
|
||||
if upload {
|
||||
if uploadFailureReason != "" {
|
||||
showUploadFailedDialog(w, localPath, uploadFailureReason)
|
||||
} else {
|
||||
|
||||
@@ -5,7 +5,6 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"syscall/js"
|
||||
"time"
|
||||
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
netbird "github.com/netbirdio/netbird/client/embed"
|
||||
sshdetection "github.com/netbirdio/netbird/client/ssh/detection"
|
||||
nbstatus "github.com/netbirdio/netbird/client/status"
|
||||
wasmcapture "github.com/netbirdio/netbird/client/wasm/internal/capture"
|
||||
"github.com/netbirdio/netbird/client/wasm/internal/http"
|
||||
"github.com/netbirdio/netbird/client/wasm/internal/rdp"
|
||||
"github.com/netbirdio/netbird/client/wasm/internal/ssh"
|
||||
@@ -461,95 +459,6 @@ func createSetLogLevelMethod(client *netbird.Client) js.Func {
|
||||
})
|
||||
}
|
||||
|
||||
// createStartCaptureMethod creates the programmable packet capture method.
|
||||
// Returns a JS interface with onpacket callback and stop() method.
|
||||
//
|
||||
// Usage from JavaScript:
|
||||
//
|
||||
// const cap = await client.startCapture({ filter: "tcp port 443", verbose: true })
|
||||
// cap.onpacket = (line) => console.log(line)
|
||||
// const stats = cap.stop()
|
||||
func createStartCaptureMethod(client *netbird.Client) js.Func {
|
||||
return js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
var opts js.Value
|
||||
if len(args) > 0 {
|
||||
opts = args[0]
|
||||
}
|
||||
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
iface, err := wasmcapture.Start(client, opts)
|
||||
if err != nil {
|
||||
reject.Invoke(js.ValueOf(fmt.Sprintf("start capture: %v", err)))
|
||||
return
|
||||
}
|
||||
resolve.Invoke(iface)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// captureMethods returns capture() and stopCapture() that share state for
|
||||
// the console-log shortcut. capture() logs packets to the browser console
|
||||
// and stopCapture() ends it, like Ctrl+C on the CLI.
|
||||
//
|
||||
// Usage from browser devtools console:
|
||||
//
|
||||
// await client.capture() // capture all packets
|
||||
// await client.capture("tcp") // capture with filter
|
||||
// await client.capture({filter: "host 10.0.0.1", verbose: true})
|
||||
// client.stopCapture() // stop and print stats
|
||||
func captureMethods(client *netbird.Client) (startFn, stopFn js.Func) {
|
||||
var mu sync.Mutex
|
||||
var active *wasmcapture.Handle
|
||||
|
||||
startFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
|
||||
var opts js.Value
|
||||
if len(args) > 0 {
|
||||
opts = args[0]
|
||||
}
|
||||
|
||||
return createPromise(func(resolve, reject js.Value) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if active != nil {
|
||||
active.Stop()
|
||||
active = nil
|
||||
}
|
||||
|
||||
h, err := wasmcapture.StartConsole(client, opts)
|
||||
if err != nil {
|
||||
reject.Invoke(js.ValueOf(fmt.Sprintf("start capture: %v", err)))
|
||||
return
|
||||
}
|
||||
active = h
|
||||
|
||||
console := js.Global().Get("console")
|
||||
console.Call("log", "[capture] started, call client.stopCapture() to stop")
|
||||
resolve.Invoke(js.Undefined())
|
||||
})
|
||||
})
|
||||
|
||||
stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if active == nil {
|
||||
js.Global().Get("console").Call("log", "[capture] no active capture")
|
||||
return js.Undefined()
|
||||
}
|
||||
|
||||
stats := active.Stop()
|
||||
active = nil
|
||||
|
||||
console := js.Global().Get("console")
|
||||
console.Call("log", fmt.Sprintf("[capture] stopped: %d packets, %d bytes, %d dropped",
|
||||
stats.Packets, stats.Bytes, stats.Dropped))
|
||||
return js.Undefined()
|
||||
})
|
||||
|
||||
return startFn, stopFn
|
||||
}
|
||||
|
||||
// createPromise is a helper to create JavaScript promises
|
||||
func createPromise(handler func(resolve, reject js.Value)) js.Value {
|
||||
return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
|
||||
@@ -612,11 +521,6 @@ func createClientObject(client *netbird.Client) js.Value {
|
||||
obj["statusDetail"] = createStatusDetailMethod(client)
|
||||
obj["getSyncResponse"] = createGetSyncResponseMethod(client)
|
||||
obj["setLogLevel"] = createSetLogLevelMethod(client)
|
||||
obj["startCapture"] = createStartCaptureMethod(client)
|
||||
|
||||
capStart, capStop := captureMethods(client)
|
||||
obj["capture"] = capStart
|
||||
obj["stopCapture"] = capStop
|
||||
|
||||
return js.ValueOf(obj)
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
//go:build js
|
||||
|
||||
// Package capture bridges the util/capture package to JavaScript via syscall/js.
|
||||
package capture
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall/js"
|
||||
|
||||
netbird "github.com/netbirdio/netbird/client/embed"
|
||||
)
|
||||
|
||||
// Handle holds a running capture session so it can be stopped later.
|
||||
type Handle struct {
|
||||
cs *netbird.CaptureSession
|
||||
stopFn js.Func
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// Stop ends the capture and returns stats.
|
||||
func (h *Handle) Stop() netbird.CaptureStats {
|
||||
if h.stopped {
|
||||
return h.cs.Stats()
|
||||
}
|
||||
h.stopped = true
|
||||
h.stopFn.Release()
|
||||
|
||||
h.cs.Stop()
|
||||
return h.cs.Stats()
|
||||
}
|
||||
|
||||
func statsToJS(s netbird.CaptureStats) js.Value {
|
||||
obj := js.Global().Get("Object").Call("create", js.Null())
|
||||
obj.Set("packets", js.ValueOf(s.Packets))
|
||||
obj.Set("bytes", js.ValueOf(s.Bytes))
|
||||
obj.Set("dropped", js.ValueOf(s.Dropped))
|
||||
return obj
|
||||
}
|
||||
|
||||
// parseOpts extracts filter/verbose/ascii from a JS options value.
|
||||
func parseOpts(jsOpts js.Value) (filter string, verbose, ascii bool) {
|
||||
if jsOpts.IsNull() || jsOpts.IsUndefined() {
|
||||
return
|
||||
}
|
||||
if jsOpts.Type() == js.TypeString {
|
||||
filter = jsOpts.String()
|
||||
return
|
||||
}
|
||||
if jsOpts.Type() != js.TypeObject {
|
||||
return
|
||||
}
|
||||
if f := jsOpts.Get("filter"); !f.IsUndefined() && !f.IsNull() {
|
||||
filter = f.String()
|
||||
}
|
||||
if v := jsOpts.Get("verbose"); !v.IsUndefined() {
|
||||
verbose = v.Truthy()
|
||||
}
|
||||
if a := jsOpts.Get("ascii"); !a.IsUndefined() {
|
||||
ascii = a.Truthy()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Start creates a capture session and returns a JS interface for streaming text
|
||||
// output. The returned object exposes:
|
||||
//
|
||||
// onpacket(callback) - set callback(string) for each text line
|
||||
// stop() - stop capture and return stats { packets, bytes, dropped }
|
||||
//
|
||||
// Options: { filter: string, verbose: bool, ascii: bool } or just a filter string.
|
||||
func Start(client *netbird.Client, jsOpts js.Value) (js.Value, error) {
|
||||
filter, verbose, ascii := parseOpts(jsOpts)
|
||||
|
||||
cb := &jsCallbackWriter{}
|
||||
|
||||
cs, err := client.StartCapture(netbird.CaptureOptions{
|
||||
TextOutput: cb,
|
||||
Filter: filter,
|
||||
Verbose: verbose,
|
||||
ASCII: ascii,
|
||||
})
|
||||
if err != nil {
|
||||
return js.Undefined(), err
|
||||
}
|
||||
|
||||
handle := &Handle{cs: cs}
|
||||
|
||||
iface := js.Global().Get("Object").Call("create", js.Null())
|
||||
handle.stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
return statsToJS(handle.Stop())
|
||||
})
|
||||
iface.Set("stop", handle.stopFn)
|
||||
iface.Set("onpacket", js.Undefined())
|
||||
cb.setInterface(iface)
|
||||
|
||||
return iface, nil
|
||||
}
|
||||
|
||||
// StartConsole starts a capture that logs every packet line to console.log.
|
||||
// Returns a Handle so the caller can stop it later.
|
||||
func StartConsole(client *netbird.Client, jsOpts js.Value) (*Handle, error) {
|
||||
filter, verbose, ascii := parseOpts(jsOpts)
|
||||
|
||||
cb := &jsCallbackWriter{}
|
||||
|
||||
cs, err := client.StartCapture(netbird.CaptureOptions{
|
||||
TextOutput: cb,
|
||||
Filter: filter,
|
||||
Verbose: verbose,
|
||||
ASCII: ascii,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
handle := &Handle{cs: cs}
|
||||
handle.stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any {
|
||||
return statsToJS(handle.Stop())
|
||||
})
|
||||
|
||||
iface := js.Global().Get("Object").Call("create", js.Null())
|
||||
console := js.Global().Get("console")
|
||||
iface.Set("onpacket", console.Get("log").Call("bind", console, js.ValueOf("[capture]")))
|
||||
cb.setInterface(iface)
|
||||
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
// jsCallbackWriter is an io.Writer that buffers text until a newline, then
|
||||
// invokes the JS onpacket callback with each complete line.
|
||||
type jsCallbackWriter struct {
|
||||
mu sync.Mutex
|
||||
iface js.Value
|
||||
buf strings.Builder
|
||||
}
|
||||
|
||||
func (w *jsCallbackWriter) setInterface(iface js.Value) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.iface = iface
|
||||
}
|
||||
|
||||
func (w *jsCallbackWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
w.buf.Write(p)
|
||||
|
||||
var lines []string
|
||||
for {
|
||||
str := w.buf.String()
|
||||
idx := strings.IndexByte(str, '\n')
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
lines = append(lines, str[:idx])
|
||||
w.buf.Reset()
|
||||
if idx+1 < len(str) {
|
||||
w.buf.WriteString(str[idx+1:])
|
||||
}
|
||||
}
|
||||
|
||||
iface := w.iface
|
||||
w.mu.Unlock()
|
||||
|
||||
if iface.IsUndefined() {
|
||||
return len(p), nil
|
||||
}
|
||||
cb := iface.Get("onpacket")
|
||||
if cb.IsUndefined() || cb.IsNull() {
|
||||
return len(p), nil
|
||||
}
|
||||
for _, line := range lines {
|
||||
cb.Invoke(js.ValueOf(line))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
nbContext "github.com/netbirdio/netbird/management/server/context"
|
||||
nbhttp "github.com/netbirdio/netbird/management/server/http"
|
||||
"github.com/netbirdio/netbird/management/server/http/middleware"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
@@ -110,7 +109,7 @@ func (s *BaseServer) EventStore() activity.Store {
|
||||
|
||||
func (s *BaseServer) APIHandler() http.Handler {
|
||||
return Create(s, func() http.Handler {
|
||||
httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies, s.RateLimiter())
|
||||
httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ServiceManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer(), s.Config.ReverseProxy.TrustedHTTPProxies)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create API handler: %v", err)
|
||||
}
|
||||
@@ -118,15 +117,6 @@ func (s *BaseServer) APIHandler() http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter {
|
||||
return Create(s, func() *middleware.APIRateLimiter {
|
||||
cfg, enabled := middleware.RateLimiterConfigFromEnv()
|
||||
limiter := middleware.NewAPIRateLimiter(cfg)
|
||||
limiter.SetEnabled(enabled)
|
||||
return limiter
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) GRPCServer() *grpc.Server {
|
||||
return Create(s, func() *grpc.Server {
|
||||
trustedPeers := s.Config.ReverseProxy.TrustedPeers
|
||||
|
||||
@@ -2311,29 +2311,6 @@ func TestAccount_GetExpiredPeers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExpiredPeers_SkipsAlreadyExpired(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
testStore, cleanUp, err := store.NewTestStoreFromSQL(ctx, "testdata/store_with_expired_peers.sql", t.TempDir())
|
||||
t.Cleanup(cleanUp)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
// Verify the already-expired peer is excluded at the store level
|
||||
peers, err := testStore.GetAccountPeersWithExpiration(ctx, store.LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, peer := range peers {
|
||||
assert.NotEqual(t, "cg05lnblo1hkg2j514p0", peer.ID, "already expired peer should be excluded by the store query")
|
||||
assert.False(t, peer.Status.LoginExpired, "returned peers should not already be marked as login expired")
|
||||
}
|
||||
|
||||
// Only the non-expired peer with expiration enabled should be returned
|
||||
require.Len(t, peers, 1)
|
||||
assert.Equal(t, "notexpired01", peers[0].ID)
|
||||
}
|
||||
|
||||
func TestAccount_GetInactivePeers(t *testing.T) {
|
||||
type test struct {
|
||||
name string
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/cors"
|
||||
@@ -63,11 +66,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
apiPrefix = "/api"
|
||||
apiPrefix = "/api"
|
||||
rateLimitingEnabledKey = "NB_API_RATE_LIMITING_ENABLED"
|
||||
rateLimitingBurstKey = "NB_API_RATE_LIMITING_BURST"
|
||||
rateLimitingRPMKey = "NB_API_RATE_LIMITING_RPM"
|
||||
)
|
||||
|
||||
// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints.
|
||||
func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix, rateLimiter *middleware.APIRateLimiter) (http.Handler, error) {
|
||||
func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, serviceManager service.Manager, reverseProxyDomainManager *manager.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, trustedHTTPProxies []netip.Prefix) (http.Handler, error) {
|
||||
|
||||
// Register bypass paths for unauthenticated endpoints
|
||||
if err := bypass.AddBypassPath("/api/instance"); err != nil {
|
||||
@@ -88,10 +94,34 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
|
||||
return nil, fmt.Errorf("failed to add bypass path: %w", err)
|
||||
}
|
||||
|
||||
if rateLimiter == nil {
|
||||
log.Warn("NewAPIHandler: nil rate limiter, rate limiting disabled")
|
||||
rateLimiter = middleware.NewAPIRateLimiter(nil)
|
||||
rateLimiter.SetEnabled(false)
|
||||
var rateLimitingConfig *middleware.RateLimiterConfig
|
||||
if os.Getenv(rateLimitingEnabledKey) == "true" {
|
||||
rpm := 6
|
||||
if v := os.Getenv(rateLimitingRPMKey); v != "" {
|
||||
value, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
log.Warnf("parsing %s env var: %v, using default %d", rateLimitingRPMKey, err, rpm)
|
||||
} else {
|
||||
rpm = value
|
||||
}
|
||||
}
|
||||
|
||||
burst := 500
|
||||
if v := os.Getenv(rateLimitingBurstKey); v != "" {
|
||||
value, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
log.Warnf("parsing %s env var: %v, using default %d", rateLimitingBurstKey, err, burst)
|
||||
} else {
|
||||
burst = value
|
||||
}
|
||||
}
|
||||
|
||||
rateLimitingConfig = &middleware.RateLimiterConfig{
|
||||
RequestsPerMinute: float64(rpm),
|
||||
Burst: burst,
|
||||
CleanupInterval: 6 * time.Hour,
|
||||
LimiterTTL: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
authMiddleware := middleware.NewAuthMiddleware(
|
||||
@@ -99,7 +129,7 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
|
||||
accountManager.GetAccountIDFromUserAuth,
|
||||
accountManager.SyncUserJWTGroups,
|
||||
accountManager.GetUserFromUserAuth,
|
||||
rateLimiter,
|
||||
rateLimitingConfig,
|
||||
appMetrics.GetMeter(),
|
||||
)
|
||||
|
||||
|
||||
@@ -43,9 +43,14 @@ func NewAuthMiddleware(
|
||||
ensureAccount EnsureAccountFunc,
|
||||
syncUserJWTGroups SyncUserJWTGroupsFunc,
|
||||
getUserFromUserAuth GetUserFromUserAuthFunc,
|
||||
rateLimiter *APIRateLimiter,
|
||||
rateLimiterConfig *RateLimiterConfig,
|
||||
meter metric.Meter,
|
||||
) *AuthMiddleware {
|
||||
var rateLimiter *APIRateLimiter
|
||||
if rateLimiterConfig != nil {
|
||||
rateLimiter = NewAPIRateLimiter(rateLimiterConfig)
|
||||
}
|
||||
|
||||
var patUsageTracker *PATUsageTracker
|
||||
if meter != nil {
|
||||
var err error
|
||||
@@ -176,8 +181,10 @@ func (m *AuthMiddleware) checkPATFromRequest(r *http.Request, authHeaderParts []
|
||||
m.patUsageTracker.IncrementUsage(token)
|
||||
}
|
||||
|
||||
if !isTerraformRequest(r) && !m.rateLimiter.Allow(token) {
|
||||
return status.Errorf(status.TooManyRequests, "too many requests")
|
||||
if m.rateLimiter != nil && !isTerraformRequest(r) {
|
||||
if !m.rateLimiter.Allow(token) {
|
||||
return status.Errorf(status.TooManyRequests, "too many requests")
|
||||
}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
@@ -196,8 +196,6 @@ func TestAuthMiddleware_Handler(t *testing.T) {
|
||||
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
||||
}
|
||||
|
||||
disabledLimiter := NewAPIRateLimiter(nil)
|
||||
disabledLimiter.SetEnabled(false)
|
||||
authMiddleware := NewAuthMiddleware(
|
||||
mockAuth,
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
||||
@@ -209,7 +207,7 @@ func TestAuthMiddleware_Handler(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
disabledLimiter,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -268,7 +266,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
rateLimitConfig,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -320,7 +318,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
rateLimitConfig,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -363,7 +361,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
rateLimitConfig,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -407,7 +405,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
rateLimitConfig,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -471,7 +469,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
rateLimitConfig,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -530,7 +528,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
rateLimitConfig,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -585,7 +583,7 @@ func TestAuthMiddleware_RateLimiting(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
NewAPIRateLimiter(rateLimitConfig),
|
||||
rateLimitConfig,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -672,8 +670,6 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) {
|
||||
GetPATInfoFunc: mockGetAccountInfoFromPAT,
|
||||
}
|
||||
|
||||
disabledLimiter := NewAPIRateLimiter(nil)
|
||||
disabledLimiter.SetEnabled(false)
|
||||
authMiddleware := NewAuthMiddleware(
|
||||
mockAuth,
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (string, string, error) {
|
||||
@@ -685,7 +681,7 @@ func TestAuthMiddleware_Handler_Child(t *testing.T) {
|
||||
func(ctx context.Context, userAuth nbauth.UserAuth) (*types.User, error) {
|
||||
return &types.User{}, nil
|
||||
},
|
||||
disabledLimiter,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,27 +4,14 @@ import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
const (
|
||||
RateLimitingEnabledEnv = "NB_API_RATE_LIMITING_ENABLED"
|
||||
RateLimitingBurstEnv = "NB_API_RATE_LIMITING_BURST"
|
||||
RateLimitingRPMEnv = "NB_API_RATE_LIMITING_RPM"
|
||||
|
||||
defaultAPIRPM = 6
|
||||
defaultAPIBurst = 500
|
||||
)
|
||||
|
||||
// RateLimiterConfig holds configuration for the API rate limiter
|
||||
type RateLimiterConfig struct {
|
||||
// RequestsPerMinute defines the rate at which tokens are replenished
|
||||
@@ -47,43 +34,6 @@ func DefaultRateLimiterConfig() *RateLimiterConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func RateLimiterConfigFromEnv() (cfg *RateLimiterConfig, enabled bool) {
|
||||
rpm := defaultAPIRPM
|
||||
if v := os.Getenv(RateLimitingRPMEnv); v != "" {
|
||||
value, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
log.Warnf("parsing %s env var: %v, using default %d", RateLimitingRPMEnv, err, rpm)
|
||||
} else {
|
||||
rpm = value
|
||||
}
|
||||
}
|
||||
if rpm <= 0 {
|
||||
log.Warnf("%s=%d is non-positive, using default %d", RateLimitingRPMEnv, rpm, defaultAPIRPM)
|
||||
rpm = defaultAPIRPM
|
||||
}
|
||||
|
||||
burst := defaultAPIBurst
|
||||
if v := os.Getenv(RateLimitingBurstEnv); v != "" {
|
||||
value, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
log.Warnf("parsing %s env var: %v, using default %d", RateLimitingBurstEnv, err, burst)
|
||||
} else {
|
||||
burst = value
|
||||
}
|
||||
}
|
||||
if burst <= 0 {
|
||||
log.Warnf("%s=%d is non-positive, using default %d", RateLimitingBurstEnv, burst, defaultAPIBurst)
|
||||
burst = defaultAPIBurst
|
||||
}
|
||||
|
||||
return &RateLimiterConfig{
|
||||
RequestsPerMinute: float64(rpm),
|
||||
Burst: burst,
|
||||
CleanupInterval: 6 * time.Hour,
|
||||
LimiterTTL: 24 * time.Hour,
|
||||
}, os.Getenv(RateLimitingEnabledEnv) == "true"
|
||||
}
|
||||
|
||||
// limiterEntry holds a rate limiter and its last access time
|
||||
type limiterEntry struct {
|
||||
limiter *rate.Limiter
|
||||
@@ -96,7 +46,6 @@ type APIRateLimiter struct {
|
||||
limiters map[string]*limiterEntry
|
||||
mu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
enabled atomic.Bool
|
||||
}
|
||||
|
||||
// NewAPIRateLimiter creates a new API rate limiter with the given configuration
|
||||
@@ -110,53 +59,14 @@ func NewAPIRateLimiter(config *RateLimiterConfig) *APIRateLimiter {
|
||||
limiters: make(map[string]*limiterEntry),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
rl.enabled.Store(true)
|
||||
|
||||
go rl.cleanupLoop()
|
||||
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *APIRateLimiter) SetEnabled(enabled bool) {
|
||||
rl.enabled.Store(enabled)
|
||||
}
|
||||
|
||||
func (rl *APIRateLimiter) Enabled() bool {
|
||||
return rl.enabled.Load()
|
||||
}
|
||||
|
||||
func (rl *APIRateLimiter) UpdateConfig(config *RateLimiterConfig) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
if config.RequestsPerMinute <= 0 || config.Burst <= 0 {
|
||||
log.Warnf("UpdateConfig: ignoring invalid rpm=%v burst=%d", config.RequestsPerMinute, config.Burst)
|
||||
return
|
||||
}
|
||||
|
||||
newRPS := rate.Limit(config.RequestsPerMinute / 60.0)
|
||||
newBurst := config.Burst
|
||||
|
||||
rl.mu.Lock()
|
||||
rl.config.RequestsPerMinute = config.RequestsPerMinute
|
||||
rl.config.Burst = newBurst
|
||||
snapshot := make([]*rate.Limiter, 0, len(rl.limiters))
|
||||
for _, entry := range rl.limiters {
|
||||
snapshot = append(snapshot, entry.limiter)
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
|
||||
for _, l := range snapshot {
|
||||
l.SetLimit(newRPS)
|
||||
l.SetBurst(newBurst)
|
||||
}
|
||||
}
|
||||
|
||||
// Allow checks if a request for the given key (token) is allowed
|
||||
func (rl *APIRateLimiter) Allow(key string) bool {
|
||||
if !rl.enabled.Load() {
|
||||
return true
|
||||
}
|
||||
limiter := rl.getLimiter(key)
|
||||
return limiter.Allow()
|
||||
}
|
||||
@@ -164,9 +74,6 @@ func (rl *APIRateLimiter) Allow(key string) bool {
|
||||
// Wait blocks until the rate limiter allows another request for the given key
|
||||
// Returns an error if the context is canceled
|
||||
func (rl *APIRateLimiter) Wait(ctx context.Context, key string) error {
|
||||
if !rl.enabled.Load() {
|
||||
return nil
|
||||
}
|
||||
limiter := rl.getLimiter(key)
|
||||
return limiter.Wait(ctx)
|
||||
}
|
||||
@@ -246,10 +153,6 @@ func (rl *APIRateLimiter) Reset(key string) {
|
||||
// Returns 429 Too Many Requests if the rate limit is exceeded.
|
||||
func (rl *APIRateLimiter) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !rl.enabled.Load() {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
clientIP := getClientIP(r)
|
||||
if !rl.Allow(clientIP) {
|
||||
util.WriteErrorResponse("rate limit exceeded, please try again later", http.StatusTooManyRequests, w)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -158,172 +156,3 @@ func TestAPIRateLimiter_Reset(t *testing.T) {
|
||||
// Should be allowed again
|
||||
assert.True(t, rl.Allow("test-key"))
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_SetEnabled(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
assert.True(t, rl.Allow("key"))
|
||||
assert.False(t, rl.Allow("key"), "burst exhausted while enabled")
|
||||
|
||||
rl.SetEnabled(false)
|
||||
assert.False(t, rl.Enabled())
|
||||
for i := 0; i < 5; i++ {
|
||||
assert.True(t, rl.Allow("key"), "disabled limiter must always allow")
|
||||
}
|
||||
|
||||
rl.SetEnabled(true)
|
||||
assert.True(t, rl.Enabled())
|
||||
assert.False(t, rl.Allow("key"), "re-enabled limiter retains prior bucket state")
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_UpdateConfig(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 2,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
assert.True(t, rl.Allow("k1"))
|
||||
assert.True(t, rl.Allow("k1"))
|
||||
assert.False(t, rl.Allow("k1"), "burst=2 exhausted")
|
||||
|
||||
rl.UpdateConfig(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 10,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
|
||||
// New burst applies to existing keys in place; bucket refills up to new burst over time,
|
||||
// but importantly newly-added keys use the updated config immediately.
|
||||
assert.True(t, rl.Allow("k2"))
|
||||
for i := 0; i < 9; i++ {
|
||||
assert.True(t, rl.Allow("k2"))
|
||||
}
|
||||
assert.False(t, rl.Allow("k2"), "new burst=10 exhausted")
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_UpdateConfig_NilIgnored(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
rl.UpdateConfig(nil) // must not panic or zero the config
|
||||
|
||||
assert.True(t, rl.Allow("k"))
|
||||
assert.False(t, rl.Allow("k"))
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_UpdateConfig_NonPositiveIgnored(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 60,
|
||||
Burst: 1,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
assert.True(t, rl.Allow("k"))
|
||||
assert.False(t, rl.Allow("k"))
|
||||
|
||||
rl.UpdateConfig(&RateLimiterConfig{RequestsPerMinute: 0, Burst: 0, CleanupInterval: time.Minute, LimiterTTL: time.Minute})
|
||||
rl.UpdateConfig(&RateLimiterConfig{RequestsPerMinute: -1, Burst: 5, CleanupInterval: time.Minute, LimiterTTL: time.Minute})
|
||||
rl.UpdateConfig(&RateLimiterConfig{RequestsPerMinute: 60, Burst: -1, CleanupInterval: time.Minute, LimiterTTL: time.Minute})
|
||||
|
||||
rl.Reset("k")
|
||||
assert.True(t, rl.Allow("k"))
|
||||
assert.False(t, rl.Allow("k"), "burst should still be 1 — invalid UpdateConfig calls were ignored")
|
||||
}
|
||||
|
||||
func TestAPIRateLimiter_ConcurrentAllowAndUpdate(t *testing.T) {
|
||||
rl := NewAPIRateLimiter(&RateLimiterConfig{
|
||||
RequestsPerMinute: 600,
|
||||
Burst: 10,
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
defer rl.Stop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := fmt.Sprintf("k%d", id)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
rl.Allow(key)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 200; i++ {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
rl.UpdateConfig(&RateLimiterConfig{
|
||||
RequestsPerMinute: float64(30 + (i % 90)),
|
||||
Burst: 1 + (i % 20),
|
||||
CleanupInterval: time.Minute,
|
||||
LimiterTTL: time.Minute,
|
||||
})
|
||||
rl.SetEnabled(i%2 == 0)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestRateLimiterConfigFromEnv(t *testing.T) {
|
||||
t.Setenv(RateLimitingEnabledEnv, "true")
|
||||
t.Setenv(RateLimitingRPMEnv, "42")
|
||||
t.Setenv(RateLimitingBurstEnv, "7")
|
||||
|
||||
cfg, enabled := RateLimiterConfigFromEnv()
|
||||
assert.True(t, enabled)
|
||||
assert.Equal(t, float64(42), cfg.RequestsPerMinute)
|
||||
assert.Equal(t, 7, cfg.Burst)
|
||||
|
||||
t.Setenv(RateLimitingEnabledEnv, "false")
|
||||
_, enabled = RateLimiterConfigFromEnv()
|
||||
assert.False(t, enabled)
|
||||
|
||||
t.Setenv(RateLimitingEnabledEnv, "")
|
||||
t.Setenv(RateLimitingRPMEnv, "")
|
||||
t.Setenv(RateLimitingBurstEnv, "")
|
||||
cfg, enabled = RateLimiterConfigFromEnv()
|
||||
assert.False(t, enabled)
|
||||
assert.Equal(t, float64(defaultAPIRPM), cfg.RequestsPerMinute)
|
||||
assert.Equal(t, defaultAPIBurst, cfg.Burst)
|
||||
|
||||
t.Setenv(RateLimitingRPMEnv, "0")
|
||||
t.Setenv(RateLimitingBurstEnv, "-5")
|
||||
cfg, _ = RateLimiterConfigFromEnv()
|
||||
assert.Equal(t, float64(defaultAPIRPM), cfg.RequestsPerMinute, "non-positive rpm must fall back to default")
|
||||
assert.Equal(t, defaultAPIBurst, cfg.Burst, "non-positive burst must fall back to default")
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "")
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil)
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "")
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil)
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
|
||||
@@ -267,8 +267,8 @@ func Test_SyncProtocol(t *testing.T) {
|
||||
}
|
||||
|
||||
// expired peers come separately.
|
||||
if len(networkMap.GetOfflinePeers()) != 2 {
|
||||
t.Fatal("expecting SyncResponse to have NetworkMap with 2 offline peer")
|
||||
if len(networkMap.GetOfflinePeers()) != 1 {
|
||||
t.Fatal("expecting SyncResponse to have NetworkMap with 1 offline peer")
|
||||
}
|
||||
|
||||
expiredPeerPubKey := "RlSy2vzoG2HyMBTUImXOiVhCBiiBa5qD5xzMxkiFDW4="
|
||||
|
||||
@@ -1405,10 +1405,6 @@ func (am *DefaultAccountManager) getExpiredPeers(ctx context.Context, accountID
|
||||
|
||||
var peers []*nbpeer.Peer
|
||||
for _, peer := range peersWithExpiry {
|
||||
if peer.Status.LoginExpired {
|
||||
continue
|
||||
}
|
||||
|
||||
expired, _ := peer.LoginExpired(settings.PeerLoginExpiration)
|
||||
if expired {
|
||||
peers = append(peers, peer)
|
||||
|
||||
@@ -3310,7 +3310,7 @@ func (s *SqlStore) GetAccountPeersWithExpiration(ctx context.Context, lockStreng
|
||||
|
||||
var peers []*nbpeer.Peer
|
||||
result := tx.
|
||||
Where("login_expiration_enabled = ? AND peer_status_login_expired != ? AND user_id IS NOT NULL AND user_id != ''", true, true).
|
||||
Where("login_expiration_enabled = ? AND user_id IS NOT NULL AND user_id != ''", true).
|
||||
Find(&peers, accountIDCondition, accountID)
|
||||
if err := result.Error; err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get peers with expiration from the store: %s", result.Error)
|
||||
|
||||
@@ -2729,7 +2729,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) {
|
||||
{
|
||||
name: "should retrieve peers for an existing account ID",
|
||||
accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b",
|
||||
expectedCount: 5,
|
||||
expectedCount: 4,
|
||||
},
|
||||
{
|
||||
name: "should return no peers for a non-existing account ID",
|
||||
@@ -2751,7 +2751,7 @@ func TestSqlStore_GetAccountPeers(t *testing.T) {
|
||||
name: "should filter peers by partial name",
|
||||
accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b",
|
||||
nameFilter: "host",
|
||||
expectedCount: 4,
|
||||
expectedCount: 3,
|
||||
},
|
||||
{
|
||||
name: "should filter peers by ip",
|
||||
@@ -2777,16 +2777,14 @@ func TestSqlStore_GetAccountPeersWithExpiration(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accountID string
|
||||
expectedCount int
|
||||
expectedPeerIDs []string
|
||||
name string
|
||||
accountID string
|
||||
expectedCount int
|
||||
}{
|
||||
{
|
||||
name: "should retrieve only non-expired peers with expiration enabled",
|
||||
accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b",
|
||||
expectedCount: 1,
|
||||
expectedPeerIDs: []string{"notexpired01"},
|
||||
name: "should retrieve peers with expiration for an existing account ID",
|
||||
accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b",
|
||||
expectedCount: 1,
|
||||
},
|
||||
{
|
||||
name: "should return no peers with expiration for a non-existing account ID",
|
||||
@@ -2805,30 +2803,10 @@ func TestSqlStore_GetAccountPeersWithExpiration(t *testing.T) {
|
||||
peers, err := store.GetAccountPeersWithExpiration(context.Background(), LockingStrengthNone, tt.accountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, peers, tt.expectedCount)
|
||||
for i, peer := range peers {
|
||||
assert.Equal(t, tt.expectedPeerIDs[i], peer.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlStore_GetAccountPeersWithExpiration_ExcludesAlreadyExpired(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountID := "bf1c8084-ba50-4ce7-9439-34653001fc3b"
|
||||
|
||||
peers, err := store.GetAccountPeersWithExpiration(context.Background(), LockingStrengthNone, accountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the already-expired peer (cg05lnblo1hkg2j514p0) is not returned
|
||||
for _, peer := range peers {
|
||||
assert.NotEqual(t, "cg05lnblo1hkg2j514p0", peer.ID, "already expired peer should not be returned")
|
||||
assert.False(t, peer.Status.LoginExpired, "returned peers should not have LoginExpired set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqlStore_GetAccountPeersWithInactivity(t *testing.T) {
|
||||
store, cleanup, err := NewTestStoreFromSQL(context.Background(), "../testdata/store_with_expired_peers.sql", t.TempDir())
|
||||
t.Cleanup(cleanup)
|
||||
@@ -2909,7 +2887,7 @@ func TestSqlStore_GetUserPeers(t *testing.T) {
|
||||
name: "should retrieve peers for another valid account ID and user ID",
|
||||
accountID: "bf1c8084-ba50-4ce7-9439-34653001fc3b",
|
||||
userID: "edafee4e-63fb-11ec-90d6-0242ac120003",
|
||||
expectedCount: 3,
|
||||
expectedCount: 2,
|
||||
},
|
||||
{
|
||||
name: "should return no peers for existing account ID with empty user ID",
|
||||
|
||||
@@ -31,7 +31,6 @@ INSERT INTO peers VALUES('cfvprsrlo1hqoo49ohog','bf1c8084-ba50-4ce7-9439-3465300
|
||||
INSERT INTO peers VALUES('cg05lnblo1hkg2j514p0','bf1c8084-ba50-4ce7-9439-34653001fc3b','RlSy2vzoG2HyMBTUImXOiVhCBiiBa5qD5xzMxkiFDW4=','','"100.64.39.54"','expiredhost','linux','Linux','22.04','x86_64','Ubuntu','','development','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'expiredhost','expiredhost','2023-03-02 09:19:57.276717255+01:00',0,1,0,'edafee4e-63fb-11ec-90d6-0242ac120003','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMbK5ZXJsGOOWoBT4OmkPtgdPZe2Q7bDuS/zjn2CZxhK',0,1,0,'2023-03-02 09:14:21.791679181+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0);
|
||||
INSERT INTO peers VALUES('cg3161rlo1hs9cq94gdg','bf1c8084-ba50-4ce7-9439-34653001fc3b','mVABSKj28gv+JRsf7e0NEGKgSOGTfU/nPB2cpuG56HU=','','"100.64.117.96"','testhost','linux','Linux','22.04','x86_64','Ubuntu','','development','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'testhost','testhost','2023-03-06 18:21:27.252010027+01:00',0,0,0,'edafee4e-63fb-11ec-90d6-0242ac120003','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINWvvUkFFcrj48CWTkNUb/do/n52i1L5dH4DhGu+4ZuM',0,0,0,'2023-03-07 09:02:47.442857106+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0);
|
||||
INSERT INTO peers VALUES('csrnkiq7qv9d8aitqd50','bf1c8084-ba50-4ce7-9439-34653001fc3b','nVABSKj28gv+JRsf7e0NEGKgSOGTfU/nPB2cpuG56HX=','','"100.64.117.97"','testhost','linux','Linux','22.04','x86_64','Ubuntu','','development','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'testhost','testhost-1','2023-03-06 18:21:27.252010027+01:00',0,0,0,'f4f6d672-63fb-11ec-90d6-0242ac120003','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINWvvUkFFcrj48CWTkNUb/do/n52i1L5dH4DhGu+4ZuM',0,0,1,'2023-03-07 09:02:47.442857106+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0);
|
||||
INSERT INTO peers VALUES('notexpired01','bf1c8084-ba50-4ce7-9439-34653001fc3b','oVABSKj28gv+JRsf7e0NEGKgSOGTfU/nPB2cpuG56HY=','','"100.64.117.98"','activehost','linux','Linux','22.04','x86_64','Ubuntu','','development','','',NULL,'','','','{"Cloud":"","Platform":""}',NULL,'activehost','activehost','2023-03-06 18:21:27.252010027+01:00',0,0,0,'edafee4e-63fb-11ec-90d6-0242ac120003','ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINWvvUkFFcrj48CWTkNUb/do/n52i1L5dH4DhGu+4ZuM',0,1,0,'2023-03-07 09:02:47.442857106+01:00','2024-10-02 17:00:32.527947+02:00',0,'""','','',0);
|
||||
INSERT INTO users VALUES('f4f6d672-63fb-11ec-90d6-0242ac120003','bf1c8084-ba50-4ce7-9439-34653001fc3b','user',0,0,'','[]',0,NULL,'2024-10-02 17:00:32.528196+02:00','api',0,'');
|
||||
INSERT INTO users VALUES('edafee4e-63fb-11ec-90d6-0242ac120003','bf1c8084-ba50-4ce7-9439-34653001fc3b','admin',0,0,'','[]',0,NULL,'2024-10-02 17:00:32.528196+02:00','api',0,'');
|
||||
INSERT INTO installations VALUES(1,'');
|
||||
|
||||
@@ -2,12 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -104,27 +99,6 @@ var debugStopCmd = &cobra.Command{
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
var debugCaptureCmd = &cobra.Command{
|
||||
Use: "capture <account-id> [filter expression]",
|
||||
Short: "Capture packets on a client's WireGuard interface",
|
||||
Long: `Captures decrypted packets flowing through a client's WireGuard interface.
|
||||
|
||||
Default output is human-readable text. Use --pcap or --output for pcap binary.
|
||||
Filter arguments after the account ID use BPF-like syntax.
|
||||
|
||||
Examples:
|
||||
netbird-proxy debug capture <account-id>
|
||||
netbird-proxy debug capture <account-id> --duration 1m host 10.0.0.1
|
||||
netbird-proxy debug capture <account-id> host 10.0.0.1 and tcp port 443
|
||||
netbird-proxy debug capture <account-id> not port 22
|
||||
netbird-proxy debug capture <account-id> -o capture.pcap
|
||||
netbird-proxy debug capture <account-id> --pcap | tcpdump -r - -n
|
||||
netbird-proxy debug capture <account-id> --pcap | tshark -r -`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: runDebugCapture,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
debugCmd.PersistentFlags().StringVar(&debugAddr, "addr", envStringOrDefault("NB_PROXY_DEBUG_ADDRESS", "localhost:8444"), "Debug endpoint address")
|
||||
debugCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output JSON instead of pretty format")
|
||||
@@ -136,12 +110,6 @@ func init() {
|
||||
|
||||
debugPingCmd.Flags().StringVar(&pingTimeout, "timeout", "", "Ping timeout (e.g., 10s)")
|
||||
|
||||
debugCaptureCmd.Flags().DurationP("duration", "d", 0, "Capture duration (0 = server default)")
|
||||
debugCaptureCmd.Flags().Bool("pcap", false, "Force pcap binary output (default when --output is set)")
|
||||
debugCaptureCmd.Flags().BoolP("verbose", "v", false, "Show seq/ack, TTL, window, total length (text mode)")
|
||||
debugCaptureCmd.Flags().Bool("ascii", false, "Print payload as ASCII after each packet (text mode)")
|
||||
debugCaptureCmd.Flags().StringP("output", "o", "", "Write pcap to file instead of stdout")
|
||||
|
||||
debugCmd.AddCommand(debugHealthCmd)
|
||||
debugCmd.AddCommand(debugClientsCmd)
|
||||
debugCmd.AddCommand(debugStatusCmd)
|
||||
@@ -151,7 +119,6 @@ func init() {
|
||||
debugCmd.AddCommand(debugLogCmd)
|
||||
debugCmd.AddCommand(debugStartCmd)
|
||||
debugCmd.AddCommand(debugStopCmd)
|
||||
debugCmd.AddCommand(debugCaptureCmd)
|
||||
|
||||
rootCmd.AddCommand(debugCmd)
|
||||
}
|
||||
@@ -204,84 +171,3 @@ func runDebugStart(cmd *cobra.Command, args []string) error {
|
||||
func runDebugStop(cmd *cobra.Command, args []string) error {
|
||||
return getDebugClient(cmd).StopClient(cmd.Context(), args[0])
|
||||
}
|
||||
|
||||
func runDebugCapture(cmd *cobra.Command, args []string) error {
|
||||
duration, _ := cmd.Flags().GetDuration("duration")
|
||||
forcePcap, _ := cmd.Flags().GetBool("pcap")
|
||||
verbose, _ := cmd.Flags().GetBool("verbose")
|
||||
ascii, _ := cmd.Flags().GetBool("ascii")
|
||||
outPath, _ := cmd.Flags().GetString("output")
|
||||
|
||||
// Default to text. Use pcap when --pcap is set or --output is given.
|
||||
wantText := !forcePcap && outPath == ""
|
||||
|
||||
var filterExpr string
|
||||
if len(args) > 1 {
|
||||
filterExpr = strings.Join(args[1:], " ")
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
out, cleanup, err := captureOutputWriter(cmd, outPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
if wantText {
|
||||
cmd.PrintErrln("Capturing packets... Press Ctrl+C to stop.")
|
||||
} else {
|
||||
cmd.PrintErrln("Capturing packets (pcap)... Press Ctrl+C to stop.")
|
||||
}
|
||||
|
||||
var durationStr string
|
||||
if duration > 0 {
|
||||
durationStr = duration.String()
|
||||
}
|
||||
|
||||
err = getDebugClient(cmd).Capture(ctx, debug.CaptureOptions{
|
||||
AccountID: args[0],
|
||||
Duration: durationStr,
|
||||
FilterExpr: filterExpr,
|
||||
Text: wantText,
|
||||
Verbose: verbose,
|
||||
ASCII: ascii,
|
||||
Output: out,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.PrintErrln("\nCapture finished.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureOutputWriter returns the writer and cleanup function for capture output.
|
||||
func captureOutputWriter(cmd *cobra.Command, outPath string) (out *os.File, cleanup func(), err error) {
|
||||
if outPath != "" {
|
||||
f, err := os.CreateTemp(filepath.Dir(outPath), filepath.Base(outPath)+".*.tmp")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create output file: %w", err)
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
return f, func() {
|
||||
if err := f.Close(); err != nil {
|
||||
cmd.PrintErrf("close output file: %v\n", err)
|
||||
}
|
||||
if fi, err := os.Stat(tmpPath); err == nil && fi.Size() > 0 {
|
||||
if err := os.Rename(tmpPath, outPath); err != nil {
|
||||
cmd.PrintErrf("rename output file: %v\n", err)
|
||||
} else {
|
||||
cmd.PrintErrf("Wrote %s\n", outPath)
|
||||
}
|
||||
} else {
|
||||
os.Remove(tmpPath)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
return os.Stdout, func() {
|
||||
// no cleanup needed for stdout
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -433,7 +433,6 @@ func setSessionCookie(w http.ResponseWriter, token string, expiration time.Durat
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: auth.SessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
|
||||
@@ -391,15 +391,6 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
|
||||
assert.Equal(t, http.SameSiteLaxMode, sessionCookie.SameSite)
|
||||
}
|
||||
|
||||
func TestSetSessionCookieHasRootPath(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
setSessionCookie(w, "test-token", time.Hour)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
require.Len(t, cookies, 1)
|
||||
assert.Equal(t, "/", cookies[0].Path, "session cookie must be scoped to root so it applies to all paths")
|
||||
}
|
||||
|
||||
func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
@@ -310,76 +310,6 @@ func (c *Client) printError(data map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
// CaptureOptions configures a capture request.
|
||||
type CaptureOptions struct {
|
||||
AccountID string
|
||||
Duration string
|
||||
FilterExpr string
|
||||
Text bool
|
||||
Verbose bool
|
||||
ASCII bool
|
||||
Output io.Writer
|
||||
}
|
||||
|
||||
// Capture streams a packet capture from the debug endpoint. The response body
|
||||
// (pcap or text) is written directly to opts.Output until the server closes the
|
||||
// connection or the context is cancelled.
|
||||
func (c *Client) Capture(ctx context.Context, opts CaptureOptions) error {
|
||||
if opts.AccountID == "" {
|
||||
return fmt.Errorf("account ID is required")
|
||||
}
|
||||
if opts.Output == nil {
|
||||
return fmt.Errorf("output writer is required")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
if opts.Duration != "" {
|
||||
params.Set("duration", opts.Duration)
|
||||
}
|
||||
if opts.FilterExpr != "" {
|
||||
params.Set("filter", opts.FilterExpr)
|
||||
}
|
||||
if opts.Text {
|
||||
params.Set("format", "text")
|
||||
}
|
||||
if opts.Verbose {
|
||||
params.Set("verbose", "true")
|
||||
}
|
||||
if opts.ASCII {
|
||||
params.Set("ascii", "true")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/debug/clients/%s/capture", url.PathEscape(opts.AccountID))
|
||||
if len(params) > 0 {
|
||||
path += "?" + params.Encode()
|
||||
}
|
||||
|
||||
fullURL := c.baseURL + path
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
// Use a separate client without timeout since captures stream for their full duration.
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("server error (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
_, err = io.Copy(opts.Output, resp.Body)
|
||||
if err != nil && ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) fetchAndPrint(ctx context.Context, path string, printer func(map[string]any)) error {
|
||||
data, raw, err := c.fetch(ctx, path)
|
||||
if err != nil {
|
||||
|
||||
@@ -174,8 +174,6 @@ func (h *Handler) handleClientRoutes(w http.ResponseWriter, r *http.Request, pat
|
||||
h.handleClientStart(w, r, accountID)
|
||||
case "stop":
|
||||
h.handleClientStop(w, r, accountID)
|
||||
case "capture":
|
||||
h.handleCapture(w, r, accountID)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -634,81 +632,6 @@ func (h *Handler) handleClientStop(w http.ResponseWriter, r *http.Request, accou
|
||||
})
|
||||
}
|
||||
|
||||
const maxCaptureDuration = 30 * time.Minute
|
||||
|
||||
// handleCapture streams a pcap or text packet capture for the given client.
|
||||
//
|
||||
// Query params:
|
||||
//
|
||||
// duration: capture duration (0 or absent = max, capped at 30m)
|
||||
// format: "text" for human-readable output (default: pcap)
|
||||
// filter: BPF-like filter expression (e.g. "host 10.0.0.1 and tcp port 443")
|
||||
func (h *Handler) handleCapture(w http.ResponseWriter, r *http.Request, accountID types.AccountID) {
|
||||
client, ok := h.provider.GetClient(accountID)
|
||||
if !ok {
|
||||
http.Error(w, "client not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
duration := maxCaptureDuration
|
||||
if durationStr := r.URL.Query().Get("duration"); durationStr != "" {
|
||||
d, err := time.ParseDuration(durationStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid duration: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if d < 0 {
|
||||
http.Error(w, "duration must not be negative", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if d > 0 {
|
||||
duration = min(d, maxCaptureDuration)
|
||||
}
|
||||
}
|
||||
|
||||
filter := r.URL.Query().Get("filter")
|
||||
wantText := r.URL.Query().Get("format") == "text"
|
||||
verbose := r.URL.Query().Get("verbose") == "true"
|
||||
ascii := r.URL.Query().Get("ascii") == "true"
|
||||
|
||||
opts := nbembed.CaptureOptions{Filter: filter, Verbose: verbose, ASCII: ascii}
|
||||
if wantText {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
opts.TextOutput = w
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "application/vnd.tcpdump.pcap")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf("attachment; filename=capture-%s.pcap", accountID))
|
||||
opts.Output = w
|
||||
}
|
||||
|
||||
cs, err := client.StartCapture(opts)
|
||||
if err != nil {
|
||||
http.Error(w, "start capture: "+err.Error(), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer cs.Stop()
|
||||
|
||||
// Flush headers after setup succeeds so errors above can still set status codes.
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
cs.Stop()
|
||||
|
||||
stats := cs.Stats()
|
||||
h.logger.Infof("capture for %s finished: %d packets, %d bytes, %d dropped",
|
||||
accountID, stats.Packets, stats.Bytes, stats.Dropped)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request, wantJSON bool) {
|
||||
if !wantJSON {
|
||||
http.Redirect(w, r, "/debug", http.StatusSeeOther)
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// htons converts a uint16 from host to network (big-endian) byte order.
|
||||
func htons(v uint16) uint16 {
|
||||
var buf [2]byte
|
||||
binary.BigEndian.PutUint16(buf[:], v)
|
||||
return binary.NativeEndian.Uint16(buf[:])
|
||||
}
|
||||
|
||||
// AFPacketCapture reads raw packets from a network interface using an
|
||||
// AF_PACKET socket. This is the kernel-mode fallback when FilteredDevice is
|
||||
// not available (kernel WireGuard). Linux only.
|
||||
//
|
||||
// It implements device.PacketCapture so it can be set on a Session, but it
|
||||
// drives its own read loop rather than being called from FilteredDevice.
|
||||
// Call Start to begin and Stop to end.
|
||||
type AFPacketCapture struct {
|
||||
ifaceName string
|
||||
sess *Session
|
||||
fd int
|
||||
mu sync.Mutex
|
||||
stopped chan struct{}
|
||||
started atomic.Bool
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
// NewAFPacketCapture creates a capture bound to the given interface.
|
||||
// The session receives packets via Offer.
|
||||
func NewAFPacketCapture(ifaceName string, sess *Session) *AFPacketCapture {
|
||||
return &AFPacketCapture{
|
||||
ifaceName: ifaceName,
|
||||
sess: sess,
|
||||
fd: -1,
|
||||
stopped: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start opens the AF_PACKET socket and begins reading packets.
|
||||
// Packets are fed to the session via Offer. Returns immediately;
|
||||
// the read loop runs in a goroutine.
|
||||
func (c *AFPacketCapture) Start() error {
|
||||
if c.sess == nil {
|
||||
return errors.New("nil capture session")
|
||||
}
|
||||
if !c.started.CompareAndSwap(false, true) {
|
||||
return errors.New("capture already started")
|
||||
}
|
||||
if c.closed.Load() {
|
||||
c.started.Store(false)
|
||||
return errors.New("cannot restart stopped capture")
|
||||
}
|
||||
|
||||
iface, err := net.InterfaceByName(c.ifaceName)
|
||||
if err != nil {
|
||||
c.started.Store(false)
|
||||
return fmt.Errorf("interface %s: %w", c.ifaceName, err)
|
||||
}
|
||||
|
||||
fd, err := unix.Socket(unix.AF_PACKET, unix.SOCK_DGRAM|unix.SOCK_NONBLOCK|unix.SOCK_CLOEXEC, int(htons(unix.ETH_P_ALL)))
|
||||
if err != nil {
|
||||
c.started.Store(false)
|
||||
return fmt.Errorf("create AF_PACKET socket: %w", err)
|
||||
}
|
||||
|
||||
addr := &unix.SockaddrLinklayer{
|
||||
Protocol: htons(unix.ETH_P_ALL),
|
||||
Ifindex: iface.Index,
|
||||
}
|
||||
if err := unix.Bind(fd, addr); err != nil {
|
||||
unix.Close(fd)
|
||||
c.started.Store(false)
|
||||
return fmt.Errorf("bind to %s: %w", c.ifaceName, err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.fd = fd
|
||||
c.mu.Unlock()
|
||||
|
||||
go c.readLoop(fd)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop closes the socket and waits for the read loop to exit. Idempotent.
|
||||
func (c *AFPacketCapture) Stop() {
|
||||
if !c.closed.CompareAndSwap(false, true) {
|
||||
if c.started.Load() {
|
||||
<-c.stopped
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
fd := c.fd
|
||||
c.fd = -1
|
||||
c.mu.Unlock()
|
||||
|
||||
if fd >= 0 {
|
||||
unix.Close(fd)
|
||||
}
|
||||
|
||||
if c.started.Load() {
|
||||
<-c.stopped
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AFPacketCapture) readLoop(fd int) {
|
||||
defer close(c.stopped)
|
||||
|
||||
buf := make([]byte, 65536)
|
||||
pollFds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
|
||||
|
||||
for {
|
||||
if c.closed.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
ok, err := c.pollOnce(pollFds)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
c.recvAndOffer(fd, buf)
|
||||
}
|
||||
}
|
||||
|
||||
// pollOnce waits for data on the fd. Returns true if data is ready, false for timeout/retry.
|
||||
// Returns an error to signal the loop should exit.
|
||||
func (c *AFPacketCapture) pollOnce(pollFds []unix.PollFd) (bool, error) {
|
||||
n, err := unix.Poll(pollFds, 200)
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.EINTR) {
|
||||
return false, nil
|
||||
}
|
||||
if c.closed.Load() {
|
||||
return false, errors.New("closed")
|
||||
}
|
||||
log.Debugf("af_packet poll: %v", err)
|
||||
return false, err
|
||||
}
|
||||
if n == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if pollFds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
|
||||
return false, errors.New("fd error")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *AFPacketCapture) recvAndOffer(fd int, buf []byte) {
|
||||
nr, from, err := unix.Recvfrom(fd, buf, 0)
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) {
|
||||
return
|
||||
}
|
||||
if !c.closed.Load() {
|
||||
log.Debugf("af_packet recvfrom: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if nr < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
ver := buf[0] >> 4
|
||||
if ver != 4 && ver != 6 {
|
||||
return
|
||||
}
|
||||
|
||||
// The kernel sets Pkttype on AF_PACKET sockets:
|
||||
// PACKET_HOST(0) = addressed to us (inbound)
|
||||
// PACKET_OUTGOING(4) = sent by us (outbound)
|
||||
outbound := false
|
||||
if sa, ok := from.(*unix.SockaddrLinklayer); ok {
|
||||
outbound = sa.Pkttype == unix.PACKET_OUTGOING
|
||||
}
|
||||
c.sess.Offer(buf[:nr], outbound)
|
||||
}
|
||||
|
||||
// Offer satisfies device.PacketCapture but is unused: the AFPacketCapture
|
||||
// drives its own read loop. This exists only so the type signature is
|
||||
// compatible if someone tries to set it as a PacketCapture.
|
||||
func (c *AFPacketCapture) Offer([]byte, bool) {
|
||||
// unused: AFPacketCapture drives its own read loop
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package capture
|
||||
|
||||
import "errors"
|
||||
|
||||
// AFPacketCapture is not available on this platform.
|
||||
type AFPacketCapture struct{}
|
||||
|
||||
// NewAFPacketCapture returns nil on non-Linux platforms.
|
||||
func NewAFPacketCapture(string, *Session) *AFPacketCapture { return nil }
|
||||
|
||||
// Start returns an error on non-Linux platforms.
|
||||
func (c *AFPacketCapture) Start() error {
|
||||
return errors.New("AF_PACKET capture is only supported on Linux")
|
||||
}
|
||||
|
||||
// Stop is a no-op on non-Linux platforms.
|
||||
func (c *AFPacketCapture) Stop() {
|
||||
// no-op on non-Linux platforms
|
||||
}
|
||||
|
||||
// Offer is a no-op on non-Linux platforms.
|
||||
func (c *AFPacketCapture) Offer([]byte, bool) {
|
||||
// no-op on non-Linux platforms
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// Package capture provides userspace packet capture in pcap format.
|
||||
//
|
||||
// It taps decrypted WireGuard packets flowing through the FilteredDevice and
|
||||
// writes them as pcap (readable by tcpdump, tshark, Wireshark) or as
|
||||
// human-readable one-line-per-packet text.
|
||||
package capture
|
||||
|
||||
import "io"
|
||||
|
||||
// Direction indicates whether a packet is entering or leaving the host.
|
||||
type Direction uint8
|
||||
|
||||
const (
|
||||
// Inbound is a packet arriving from the network (FilteredDevice.Write path).
|
||||
Inbound Direction = iota
|
||||
// Outbound is a packet leaving the host (FilteredDevice.Read path).
|
||||
Outbound
|
||||
)
|
||||
|
||||
// String returns "IN" or "OUT".
|
||||
func (d Direction) String() string {
|
||||
if d == Outbound {
|
||||
return "OUT"
|
||||
}
|
||||
return "IN"
|
||||
}
|
||||
|
||||
const (
|
||||
protoICMP = 1
|
||||
protoTCP = 6
|
||||
protoUDP = 17
|
||||
protoICMPv6 = 58
|
||||
)
|
||||
|
||||
// Options configures a capture session.
|
||||
type Options struct {
|
||||
// Output receives pcap-formatted data. Nil disables pcap output.
|
||||
Output io.Writer
|
||||
// TextOutput receives human-readable packet summaries. Nil disables text output.
|
||||
TextOutput io.Writer
|
||||
// Matcher selects which packets to capture. Nil captures all.
|
||||
// Use ParseFilter("host 10.0.0.1 and tcp") or &Filter{...}.
|
||||
Matcher Matcher
|
||||
// Verbose adds seq/ack, TTL, window, total length to text output.
|
||||
Verbose bool
|
||||
// ASCII dumps transport payload as printable ASCII after each packet line.
|
||||
ASCII bool
|
||||
// SnapLen is the maximum bytes captured per packet. 0 means 65535.
|
||||
SnapLen uint32
|
||||
// BufSize is the internal channel buffer size. 0 means 256.
|
||||
BufSize int
|
||||
}
|
||||
|
||||
// Stats reports capture session counters.
|
||||
type Stats struct {
|
||||
Packets int64
|
||||
Bytes int64
|
||||
Dropped int64
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Matcher tests whether a raw packet should be captured.
|
||||
type Matcher interface {
|
||||
Match(data []byte) bool
|
||||
}
|
||||
|
||||
// Filter selects packets by flat AND'd criteria. Useful for structured APIs
|
||||
// (query params, proto fields). Implements Matcher.
|
||||
type Filter struct {
|
||||
SrcIP netip.Addr
|
||||
DstIP netip.Addr
|
||||
Host netip.Addr
|
||||
SrcPort uint16
|
||||
DstPort uint16
|
||||
Port uint16
|
||||
Proto uint8
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the filter has no criteria set.
|
||||
func (f *Filter) IsEmpty() bool {
|
||||
return !f.SrcIP.IsValid() && !f.DstIP.IsValid() && !f.Host.IsValid() &&
|
||||
f.SrcPort == 0 && f.DstPort == 0 && f.Port == 0 && f.Proto == 0
|
||||
}
|
||||
|
||||
// Match implements Matcher. All non-zero fields must match (AND).
|
||||
func (f *Filter) Match(data []byte) bool {
|
||||
if f.IsEmpty() {
|
||||
return true
|
||||
}
|
||||
info, ok := parsePacketInfo(data)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if f.Host.IsValid() && info.srcIP != f.Host && info.dstIP != f.Host {
|
||||
return false
|
||||
}
|
||||
if f.SrcIP.IsValid() && info.srcIP != f.SrcIP {
|
||||
return false
|
||||
}
|
||||
if f.DstIP.IsValid() && info.dstIP != f.DstIP {
|
||||
return false
|
||||
}
|
||||
if f.Proto != 0 && info.proto != f.Proto {
|
||||
return false
|
||||
}
|
||||
if f.Port != 0 && info.srcPort != f.Port && info.dstPort != f.Port {
|
||||
return false
|
||||
}
|
||||
if f.SrcPort != 0 && info.srcPort != f.SrcPort {
|
||||
return false
|
||||
}
|
||||
if f.DstPort != 0 && info.dstPort != f.DstPort {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// exprNode evaluates a filter condition against pre-parsed packet info.
|
||||
type exprNode func(info *packetInfo) bool
|
||||
|
||||
// exprMatcher wraps an expression tree. Parses the packet once, then walks the tree.
|
||||
type exprMatcher struct {
|
||||
root exprNode
|
||||
}
|
||||
|
||||
func (m *exprMatcher) Match(data []byte) bool {
|
||||
info, ok := parsePacketInfo(data)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return m.root(&info)
|
||||
}
|
||||
|
||||
func nodeAnd(a, b exprNode) exprNode {
|
||||
return func(info *packetInfo) bool { return a(info) && b(info) }
|
||||
}
|
||||
|
||||
func nodeOr(a, b exprNode) exprNode {
|
||||
return func(info *packetInfo) bool { return a(info) || b(info) }
|
||||
}
|
||||
|
||||
func nodeNot(n exprNode) exprNode {
|
||||
return func(info *packetInfo) bool { return !n(info) }
|
||||
}
|
||||
|
||||
func nodeHost(addr netip.Addr) exprNode {
|
||||
return func(info *packetInfo) bool { return info.srcIP == addr || info.dstIP == addr }
|
||||
}
|
||||
|
||||
func nodeSrcHost(addr netip.Addr) exprNode {
|
||||
return func(info *packetInfo) bool { return info.srcIP == addr }
|
||||
}
|
||||
|
||||
func nodeDstHost(addr netip.Addr) exprNode {
|
||||
return func(info *packetInfo) bool { return info.dstIP == addr }
|
||||
}
|
||||
|
||||
func nodePort(port uint16) exprNode {
|
||||
return func(info *packetInfo) bool { return info.srcPort == port || info.dstPort == port }
|
||||
}
|
||||
|
||||
func nodeSrcPort(port uint16) exprNode {
|
||||
return func(info *packetInfo) bool { return info.srcPort == port }
|
||||
}
|
||||
|
||||
func nodeDstPort(port uint16) exprNode {
|
||||
return func(info *packetInfo) bool { return info.dstPort == port }
|
||||
}
|
||||
|
||||
func nodeProto(proto uint8) exprNode {
|
||||
return func(info *packetInfo) bool { return info.proto == proto }
|
||||
}
|
||||
|
||||
func nodeFamily(family uint8) exprNode {
|
||||
return func(info *packetInfo) bool { return info.family == family }
|
||||
}
|
||||
|
||||
func nodeNet(prefix netip.Prefix) exprNode {
|
||||
return func(info *packetInfo) bool { return prefix.Contains(info.srcIP) || prefix.Contains(info.dstIP) }
|
||||
}
|
||||
|
||||
func nodeSrcNet(prefix netip.Prefix) exprNode {
|
||||
return func(info *packetInfo) bool { return prefix.Contains(info.srcIP) }
|
||||
}
|
||||
|
||||
func nodeDstNet(prefix netip.Prefix) exprNode {
|
||||
return func(info *packetInfo) bool { return prefix.Contains(info.dstIP) }
|
||||
}
|
||||
|
||||
// packetInfo holds parsed header fields for filtering and display.
|
||||
type packetInfo struct {
|
||||
family uint8
|
||||
srcIP netip.Addr
|
||||
dstIP netip.Addr
|
||||
proto uint8
|
||||
srcPort uint16
|
||||
dstPort uint16
|
||||
hdrLen int
|
||||
}
|
||||
|
||||
func parsePacketInfo(data []byte) (packetInfo, bool) {
|
||||
if len(data) < 1 {
|
||||
return packetInfo{}, false
|
||||
}
|
||||
switch data[0] >> 4 {
|
||||
case 4:
|
||||
return parseIPv4Info(data)
|
||||
case 6:
|
||||
return parseIPv6Info(data)
|
||||
default:
|
||||
return packetInfo{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPv4Info(data []byte) (packetInfo, bool) {
|
||||
if len(data) < 20 {
|
||||
return packetInfo{}, false
|
||||
}
|
||||
ihl := int(data[0]&0x0f) * 4
|
||||
if ihl < 20 || len(data) < ihl {
|
||||
return packetInfo{}, false
|
||||
}
|
||||
info := packetInfo{
|
||||
family: 4,
|
||||
srcIP: netip.AddrFrom4([4]byte{data[12], data[13], data[14], data[15]}),
|
||||
dstIP: netip.AddrFrom4([4]byte{data[16], data[17], data[18], data[19]}),
|
||||
proto: data[9],
|
||||
hdrLen: ihl,
|
||||
}
|
||||
if (info.proto == protoTCP || info.proto == protoUDP) && len(data) >= ihl+4 {
|
||||
info.srcPort = binary.BigEndian.Uint16(data[ihl:])
|
||||
info.dstPort = binary.BigEndian.Uint16(data[ihl+2:])
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// parseIPv6Info parses the fixed IPv6 header. It reads the Next Header field
|
||||
// directly, so packets with extension headers (hop-by-hop, routing, fragment,
|
||||
// etc.) will report the extension type as the protocol rather than the final
|
||||
// transport protocol. This is acceptable for a debug capture tool.
|
||||
func parseIPv6Info(data []byte) (packetInfo, bool) {
|
||||
if len(data) < 40 {
|
||||
return packetInfo{}, false
|
||||
}
|
||||
var src, dst [16]byte
|
||||
copy(src[:], data[8:24])
|
||||
copy(dst[:], data[24:40])
|
||||
info := packetInfo{
|
||||
family: 6,
|
||||
srcIP: netip.AddrFrom16(src),
|
||||
dstIP: netip.AddrFrom16(dst),
|
||||
proto: data[6],
|
||||
hdrLen: 40,
|
||||
}
|
||||
if (info.proto == protoTCP || info.proto == protoUDP) && len(data) >= 44 {
|
||||
info.srcPort = binary.BigEndian.Uint16(data[40:])
|
||||
info.dstPort = binary.BigEndian.Uint16(data[42:])
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// ParseFilter parses a BPF-like filter expression and returns a Matcher.
|
||||
// Returns nil Matcher for an empty expression (match all).
|
||||
//
|
||||
// Grammar (mirrors common tcpdump BPF syntax):
|
||||
//
|
||||
// orExpr = andExpr ("or" andExpr)*
|
||||
// andExpr = unary ("and" unary)*
|
||||
// unary = "not" unary | "(" orExpr ")" | term
|
||||
//
|
||||
// term = "host" IP | "src" target | "dst" target
|
||||
// | "port" NUM | "net" PREFIX
|
||||
// | "tcp" | "udp" | "icmp" | "icmp6"
|
||||
// | "ip" | "ip6" | "proto" NUM
|
||||
// target = "host" IP | "port" NUM | "net" PREFIX | IP
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// host 10.0.0.1 and tcp port 443
|
||||
// not port 22
|
||||
// (host 10.0.0.1 or host 10.0.0.2) and tcp
|
||||
// ip6 and icmp6
|
||||
// net 10.0.0.0/24
|
||||
// src host 10.0.0.1 or dst port 80
|
||||
func ParseFilter(expr string) (Matcher, error) {
|
||||
tokens := tokenize(expr)
|
||||
if len(tokens) == 0 {
|
||||
return nil, nil //nolint:nilnil // nil Matcher means "match all"
|
||||
}
|
||||
|
||||
p := &parser{tokens: tokens}
|
||||
node, err := p.parseOr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.pos < len(p.tokens) {
|
||||
return nil, fmt.Errorf("unexpected token %q at position %d", p.tokens[p.pos], p.pos)
|
||||
}
|
||||
return &exprMatcher{root: node}, nil
|
||||
}
|
||||
|
||||
func tokenize(expr string) []string {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return nil
|
||||
}
|
||||
// Split on whitespace but keep parens as separate tokens.
|
||||
var tokens []string
|
||||
for _, field := range strings.Fields(expr) {
|
||||
tokens = append(tokens, splitParens(field)...)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
// splitParens splits "(foo)" into "(", "foo", ")".
|
||||
func splitParens(s string) []string {
|
||||
var out []string
|
||||
for strings.HasPrefix(s, "(") {
|
||||
out = append(out, "(")
|
||||
s = s[1:]
|
||||
}
|
||||
var trail []string
|
||||
for strings.HasSuffix(s, ")") {
|
||||
trail = append(trail, ")")
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
out = append(out, trail...)
|
||||
return out
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
tokens []string
|
||||
pos int
|
||||
}
|
||||
|
||||
func (p *parser) peek() string {
|
||||
if p.pos >= len(p.tokens) {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(p.tokens[p.pos])
|
||||
}
|
||||
|
||||
func (p *parser) next() string {
|
||||
tok := p.peek()
|
||||
if tok != "" {
|
||||
p.pos++
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func (p *parser) expect(tok string) error {
|
||||
got := p.next()
|
||||
if got != tok {
|
||||
return fmt.Errorf("expected %q, got %q", tok, got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *parser) parseOr() (exprNode, error) {
|
||||
left, err := p.parseAnd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for p.peek() == "or" {
|
||||
p.next()
|
||||
right, err := p.parseAnd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left = nodeOr(left, right)
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseAnd() (exprNode, error) {
|
||||
left, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
tok := p.peek()
|
||||
if tok == "and" {
|
||||
p.next()
|
||||
right, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left = nodeAnd(left, right)
|
||||
continue
|
||||
}
|
||||
// Implicit AND: two atoms without "and" between them.
|
||||
// Only if the next token starts an atom (not "or", ")", or EOF).
|
||||
if tok != "" && tok != "or" && tok != ")" {
|
||||
right, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left = nodeAnd(left, right)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseUnary() (exprNode, error) {
|
||||
switch p.peek() {
|
||||
case "not":
|
||||
p.next()
|
||||
inner, err := p.parseUnary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nodeNot(inner), nil
|
||||
case "(":
|
||||
p.next()
|
||||
inner, err := p.parseOr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.expect(")"); err != nil {
|
||||
return nil, fmt.Errorf("unclosed parenthesis")
|
||||
}
|
||||
return inner, nil
|
||||
default:
|
||||
return p.parseAtom()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseAtom() (exprNode, error) {
|
||||
tok := p.next()
|
||||
if tok == "" {
|
||||
return nil, fmt.Errorf("unexpected end of expression")
|
||||
}
|
||||
|
||||
switch tok {
|
||||
case "host":
|
||||
addr, err := p.parseAddr()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("host: %w", err)
|
||||
}
|
||||
return nodeHost(addr), nil
|
||||
|
||||
case "port":
|
||||
port, err := p.parsePort()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("port: %w", err)
|
||||
}
|
||||
return nodePort(port), nil
|
||||
|
||||
case "net":
|
||||
prefix, err := p.parsePrefix()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("net: %w", err)
|
||||
}
|
||||
return nodeNet(prefix), nil
|
||||
|
||||
case "src":
|
||||
return p.parseDirTarget(true)
|
||||
|
||||
case "dst":
|
||||
return p.parseDirTarget(false)
|
||||
|
||||
case "tcp":
|
||||
return nodeProto(protoTCP), nil
|
||||
case "udp":
|
||||
return nodeProto(protoUDP), nil
|
||||
case "icmp":
|
||||
return nodeProto(protoICMP), nil
|
||||
case "icmp6":
|
||||
return nodeProto(protoICMPv6), nil
|
||||
case "ip":
|
||||
return nodeFamily(4), nil
|
||||
case "ip6":
|
||||
return nodeFamily(6), nil
|
||||
|
||||
case "proto":
|
||||
raw := p.next()
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("proto: missing number")
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 0 || n > 255 {
|
||||
return nil, fmt.Errorf("proto: invalid number %q", raw)
|
||||
}
|
||||
return nodeProto(uint8(n)), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown filter keyword %q", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseDirTarget(isSrc bool) (exprNode, error) {
|
||||
tok := p.peek()
|
||||
switch tok {
|
||||
case "host":
|
||||
p.next()
|
||||
addr, err := p.parseAddr()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isSrc {
|
||||
return nodeSrcHost(addr), nil
|
||||
}
|
||||
return nodeDstHost(addr), nil
|
||||
|
||||
case "port":
|
||||
p.next()
|
||||
port, err := p.parsePort()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isSrc {
|
||||
return nodeSrcPort(port), nil
|
||||
}
|
||||
return nodeDstPort(port), nil
|
||||
|
||||
case "net":
|
||||
p.next()
|
||||
prefix, err := p.parsePrefix()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isSrc {
|
||||
return nodeSrcNet(prefix), nil
|
||||
}
|
||||
return nodeDstNet(prefix), nil
|
||||
|
||||
default:
|
||||
// Try as bare IP: "src 10.0.0.1"
|
||||
addr, err := p.parseAddr()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expected host, port, net, or IP after src/dst, got %q", tok)
|
||||
}
|
||||
if isSrc {
|
||||
return nodeSrcHost(addr), nil
|
||||
}
|
||||
return nodeDstHost(addr), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *parser) parseAddr() (netip.Addr, error) {
|
||||
raw := p.next()
|
||||
if raw == "" {
|
||||
return netip.Addr{}, fmt.Errorf("missing IP address")
|
||||
}
|
||||
addr, err := netip.ParseAddr(raw)
|
||||
if err != nil {
|
||||
return netip.Addr{}, fmt.Errorf("invalid IP %q", raw)
|
||||
}
|
||||
return addr.Unmap(), nil
|
||||
}
|
||||
|
||||
func (p *parser) parsePort() (uint16, error) {
|
||||
raw := p.next()
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("missing port number")
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return 0, fmt.Errorf("invalid port %q", raw)
|
||||
}
|
||||
return uint16(n), nil
|
||||
}
|
||||
|
||||
func (p *parser) parsePrefix() (netip.Prefix, error) {
|
||||
raw := p.next()
|
||||
if raw == "" {
|
||||
return netip.Prefix{}, fmt.Errorf("missing network prefix")
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(raw)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("invalid prefix %q", raw)
|
||||
}
|
||||
return prefix, nil
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// buildIPv4Packet creates a minimal IPv4+TCP/UDP packet for filter testing.
|
||||
func buildIPv4Packet(t *testing.T, srcIP, dstIP netip.Addr, proto uint8, srcPort, dstPort uint16) []byte {
|
||||
t.Helper()
|
||||
|
||||
hdrLen := 20
|
||||
pkt := make([]byte, hdrLen+20)
|
||||
pkt[0] = 0x45
|
||||
pkt[9] = proto
|
||||
|
||||
src := srcIP.As4()
|
||||
dst := dstIP.As4()
|
||||
copy(pkt[12:16], src[:])
|
||||
copy(pkt[16:20], dst[:])
|
||||
|
||||
pkt[20] = byte(srcPort >> 8)
|
||||
pkt[21] = byte(srcPort)
|
||||
pkt[22] = byte(dstPort >> 8)
|
||||
pkt[23] = byte(dstPort)
|
||||
|
||||
return pkt
|
||||
}
|
||||
|
||||
// buildIPv6Packet creates a minimal IPv6+TCP/UDP packet for filter testing.
|
||||
func buildIPv6Packet(t *testing.T, srcIP, dstIP netip.Addr, proto uint8, srcPort, dstPort uint16) []byte {
|
||||
t.Helper()
|
||||
|
||||
pkt := make([]byte, 44) // 40 header + 4 ports
|
||||
pkt[0] = 0x60 // version 6
|
||||
pkt[6] = proto // next header
|
||||
|
||||
src := srcIP.As16()
|
||||
dst := dstIP.As16()
|
||||
copy(pkt[8:24], src[:])
|
||||
copy(pkt[24:40], dst[:])
|
||||
|
||||
pkt[40] = byte(srcPort >> 8)
|
||||
pkt[41] = byte(srcPort)
|
||||
pkt[42] = byte(dstPort >> 8)
|
||||
pkt[43] = byte(dstPort)
|
||||
|
||||
return pkt
|
||||
}
|
||||
|
||||
// ---- Filter struct tests ----
|
||||
|
||||
func TestFilter_Empty(t *testing.T) {
|
||||
f := Filter{}
|
||||
assert.True(t, f.IsEmpty())
|
||||
assert.True(t, f.Match(buildIPv4Packet(t,
|
||||
netip.MustParseAddr("10.0.0.1"),
|
||||
netip.MustParseAddr("10.0.0.2"),
|
||||
protoTCP, 12345, 443)))
|
||||
}
|
||||
|
||||
func TestFilter_Host(t *testing.T) {
|
||||
f := Filter{Host: netip.MustParseAddr("10.0.0.1")}
|
||||
assert.True(t, f.Match(buildIPv4Packet(t, netip.MustParseAddr("10.0.0.1"), netip.MustParseAddr("10.0.0.2"), protoTCP, 1234, 80)))
|
||||
assert.True(t, f.Match(buildIPv4Packet(t, netip.MustParseAddr("10.0.0.2"), netip.MustParseAddr("10.0.0.1"), protoTCP, 1234, 80)))
|
||||
assert.False(t, f.Match(buildIPv4Packet(t, netip.MustParseAddr("10.0.0.2"), netip.MustParseAddr("10.0.0.3"), protoTCP, 1234, 80)))
|
||||
}
|
||||
|
||||
func TestFilter_InvalidPacket(t *testing.T) {
|
||||
f := Filter{Host: netip.MustParseAddr("10.0.0.1")}
|
||||
assert.False(t, f.Match(nil))
|
||||
assert.False(t, f.Match([]byte{}))
|
||||
assert.False(t, f.Match([]byte{0x00}))
|
||||
}
|
||||
|
||||
func TestParsePacketInfo_IPv4(t *testing.T) {
|
||||
pkt := buildIPv4Packet(t, netip.MustParseAddr("192.168.1.1"), netip.MustParseAddr("10.0.0.1"), protoTCP, 54321, 80)
|
||||
info, ok := parsePacketInfo(pkt)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, uint8(4), info.family)
|
||||
assert.Equal(t, netip.MustParseAddr("192.168.1.1"), info.srcIP)
|
||||
assert.Equal(t, netip.MustParseAddr("10.0.0.1"), info.dstIP)
|
||||
assert.Equal(t, uint8(protoTCP), info.proto)
|
||||
assert.Equal(t, uint16(54321), info.srcPort)
|
||||
assert.Equal(t, uint16(80), info.dstPort)
|
||||
}
|
||||
|
||||
func TestParsePacketInfo_IPv6(t *testing.T) {
|
||||
pkt := buildIPv6Packet(t, netip.MustParseAddr("fd00::1"), netip.MustParseAddr("fd00::2"), protoUDP, 1234, 53)
|
||||
info, ok := parsePacketInfo(pkt)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, uint8(6), info.family)
|
||||
assert.Equal(t, netip.MustParseAddr("fd00::1"), info.srcIP)
|
||||
assert.Equal(t, netip.MustParseAddr("fd00::2"), info.dstIP)
|
||||
assert.Equal(t, uint8(protoUDP), info.proto)
|
||||
assert.Equal(t, uint16(1234), info.srcPort)
|
||||
assert.Equal(t, uint16(53), info.dstPort)
|
||||
}
|
||||
|
||||
// ---- ParseFilter expression tests ----
|
||||
|
||||
func matchV4(t *testing.T, m Matcher, srcIP, dstIP string, proto uint8, srcPort, dstPort uint16) bool {
|
||||
t.Helper()
|
||||
return m.Match(buildIPv4Packet(t, netip.MustParseAddr(srcIP), netip.MustParseAddr(dstIP), proto, srcPort, dstPort))
|
||||
}
|
||||
|
||||
func matchV6(t *testing.T, m Matcher, srcIP, dstIP string, proto uint8, srcPort, dstPort uint16) bool {
|
||||
t.Helper()
|
||||
return m.Match(buildIPv6Packet(t, netip.MustParseAddr(srcIP), netip.MustParseAddr(dstIP), proto, srcPort, dstPort))
|
||||
}
|
||||
|
||||
func TestParseFilter_Empty(t *testing.T) {
|
||||
m, err := ParseFilter("")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, m, "empty expression should return nil matcher")
|
||||
}
|
||||
|
||||
func TestParseFilter_Atoms(t *testing.T) {
|
||||
tests := []struct {
|
||||
expr string
|
||||
match bool
|
||||
}{
|
||||
{"tcp", true},
|
||||
{"udp", false},
|
||||
{"host 10.0.0.1", true},
|
||||
{"host 10.0.0.99", false},
|
||||
{"port 443", true},
|
||||
{"port 80", false},
|
||||
{"src host 10.0.0.1", true},
|
||||
{"dst host 10.0.0.2", true},
|
||||
{"dst host 10.0.0.1", false},
|
||||
{"src port 12345", true},
|
||||
{"dst port 443", true},
|
||||
{"dst port 80", false},
|
||||
{"proto 6", true},
|
||||
{"proto 17", false},
|
||||
}
|
||||
|
||||
pkt := buildIPv4Packet(t, netip.MustParseAddr("10.0.0.1"), netip.MustParseAddr("10.0.0.2"), protoTCP, 12345, 443)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expr, func(t *testing.T) {
|
||||
m, err := ParseFilter(tt.expr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.match, m.Match(pkt))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFilter_And(t *testing.T) {
|
||||
m, err := ParseFilter("host 10.0.0.1 and tcp port 443")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 55555, 443))
|
||||
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoUDP, 55555, 443), "wrong proto")
|
||||
assert.False(t, matchV4(t, m, "10.0.0.3", "10.0.0.2", protoTCP, 55555, 443), "wrong host")
|
||||
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 55555, 80), "wrong port")
|
||||
}
|
||||
|
||||
func TestParseFilter_ImplicitAnd(t *testing.T) {
|
||||
// "tcp port 443" = implicit AND between tcp and port 443
|
||||
m, err := ParseFilter("tcp port 443")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 443))
|
||||
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoUDP, 1, 443))
|
||||
}
|
||||
|
||||
func TestParseFilter_Or(t *testing.T) {
|
||||
m, err := ParseFilter("port 80 or port 443")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 80))
|
||||
assert.True(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 443))
|
||||
assert.False(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 8080))
|
||||
}
|
||||
|
||||
func TestParseFilter_Not(t *testing.T) {
|
||||
m, err := ParseFilter("not port 22")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 443))
|
||||
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 22))
|
||||
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 22, 80))
|
||||
}
|
||||
|
||||
func TestParseFilter_Parens(t *testing.T) {
|
||||
m, err := ParseFilter("(port 80 or port 443) and tcp")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 443))
|
||||
assert.False(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoUDP, 1, 443), "wrong proto")
|
||||
assert.False(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 8080), "wrong port")
|
||||
}
|
||||
|
||||
func TestParseFilter_Family(t *testing.T) {
|
||||
mV4, err := ParseFilter("ip")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV4(t, mV4, "10.0.0.1", "10.0.0.2", protoTCP, 1, 80))
|
||||
assert.False(t, matchV6(t, mV4, "fd00::1", "fd00::2", protoTCP, 1, 80))
|
||||
|
||||
mV6, err := ParseFilter("ip6")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, matchV4(t, mV6, "10.0.0.1", "10.0.0.2", protoTCP, 1, 80))
|
||||
assert.True(t, matchV6(t, mV6, "fd00::1", "fd00::2", protoTCP, 1, 80))
|
||||
}
|
||||
|
||||
func TestParseFilter_Net(t *testing.T) {
|
||||
m, err := ParseFilter("net 10.0.0.0/24")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV4(t, m, "10.0.0.1", "192.168.1.1", protoTCP, 1, 80), "src in net")
|
||||
assert.True(t, matchV4(t, m, "192.168.1.1", "10.0.0.200", protoTCP, 1, 80), "dst in net")
|
||||
assert.False(t, matchV4(t, m, "10.0.1.1", "192.168.1.1", protoTCP, 1, 80), "neither in net")
|
||||
}
|
||||
|
||||
func TestParseFilter_SrcDstNet(t *testing.T) {
|
||||
m, err := ParseFilter("src net 10.0.0.0/8 and dst net 192.168.0.0/16")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV4(t, m, "10.1.2.3", "192.168.1.1", protoTCP, 1, 80))
|
||||
assert.False(t, matchV4(t, m, "192.168.1.1", "10.1.2.3", protoTCP, 1, 80), "reversed")
|
||||
}
|
||||
|
||||
func TestParseFilter_Complex(t *testing.T) {
|
||||
// Real-world: capture HTTP(S) traffic to/from specific host, excluding SSH
|
||||
m, err := ParseFilter("host 10.0.0.1 and (port 80 or port 443) and not port 22")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 55555, 443))
|
||||
assert.True(t, matchV4(t, m, "10.0.0.2", "10.0.0.1", protoTCP, 55555, 80))
|
||||
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 22, 443), "port 22 excluded")
|
||||
assert.False(t, matchV4(t, m, "10.0.0.3", "10.0.0.2", protoTCP, 55555, 443), "wrong host")
|
||||
}
|
||||
|
||||
func TestParseFilter_IPv6Combined(t *testing.T) {
|
||||
m, err := ParseFilter("ip6 and icmp6")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV6(t, m, "fd00::1", "fd00::2", protoICMPv6, 0, 0))
|
||||
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoICMP, 0, 0), "wrong family")
|
||||
assert.False(t, matchV6(t, m, "fd00::1", "fd00::2", protoTCP, 1, 80), "wrong proto")
|
||||
}
|
||||
|
||||
func TestParseFilter_CaseInsensitive(t *testing.T) {
|
||||
m, err := ParseFilter("HOST 10.0.0.1 AND TCP PORT 443")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 443))
|
||||
}
|
||||
|
||||
func TestParseFilter_Errors(t *testing.T) {
|
||||
bad := []string{
|
||||
"badkeyword",
|
||||
"host",
|
||||
"port abc",
|
||||
"port 99999",
|
||||
"net invalid",
|
||||
"(",
|
||||
"(port 80",
|
||||
"not",
|
||||
"src",
|
||||
}
|
||||
for _, expr := range bad {
|
||||
t.Run(expr, func(t *testing.T) {
|
||||
_, err := ParseFilter(expr)
|
||||
assert.Error(t, err, "should fail for %q", expr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pcapMagic = 0xa1b2c3d4
|
||||
pcapVersionMaj = 2
|
||||
pcapVersionMin = 4
|
||||
// linkTypeRaw is LINKTYPE_RAW: raw IPv4/IPv6 packets without link-layer header.
|
||||
linkTypeRaw = 101
|
||||
defaultSnapLen = 65535
|
||||
)
|
||||
|
||||
// PcapWriter writes packets in pcap format to an underlying writer.
|
||||
// The global header is written lazily on the first WritePacket call so that
|
||||
// the writer can be used with unbuffered io.Pipes without deadlocking.
|
||||
// It is not safe for concurrent use; callers must serialize access.
|
||||
type PcapWriter struct {
|
||||
w io.Writer
|
||||
snapLen uint32
|
||||
headerWritten bool
|
||||
}
|
||||
|
||||
// NewPcapWriter creates a pcap writer. The global header is deferred until the
|
||||
// first WritePacket call.
|
||||
func NewPcapWriter(w io.Writer, snapLen uint32) *PcapWriter {
|
||||
if snapLen == 0 {
|
||||
snapLen = defaultSnapLen
|
||||
}
|
||||
return &PcapWriter{w: w, snapLen: snapLen}
|
||||
}
|
||||
|
||||
// writeGlobalHeader writes the 24-byte pcap file header.
|
||||
func (pw *PcapWriter) writeGlobalHeader() error {
|
||||
var hdr [24]byte
|
||||
binary.LittleEndian.PutUint32(hdr[0:4], pcapMagic)
|
||||
binary.LittleEndian.PutUint16(hdr[4:6], pcapVersionMaj)
|
||||
binary.LittleEndian.PutUint16(hdr[6:8], pcapVersionMin)
|
||||
binary.LittleEndian.PutUint32(hdr[16:20], pw.snapLen)
|
||||
binary.LittleEndian.PutUint32(hdr[20:24], linkTypeRaw)
|
||||
|
||||
_, err := pw.w.Write(hdr[:])
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteHeader writes the pcap global header. Safe to call multiple times.
|
||||
func (pw *PcapWriter) WriteHeader() error {
|
||||
if pw.headerWritten {
|
||||
return nil
|
||||
}
|
||||
if err := pw.writeGlobalHeader(); err != nil {
|
||||
return err
|
||||
}
|
||||
pw.headerWritten = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// WritePacket writes a single packet record, preceded by the global header
|
||||
// on the first call.
|
||||
func (pw *PcapWriter) WritePacket(ts time.Time, data []byte) error {
|
||||
if err := pw.WriteHeader(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
origLen := uint32(len(data))
|
||||
if origLen > pw.snapLen {
|
||||
data = data[:pw.snapLen]
|
||||
}
|
||||
|
||||
var hdr [16]byte
|
||||
binary.LittleEndian.PutUint32(hdr[0:4], uint32(ts.Unix()))
|
||||
binary.LittleEndian.PutUint32(hdr[4:8], uint32(ts.Nanosecond()/1000))
|
||||
binary.LittleEndian.PutUint32(hdr[8:12], uint32(len(data)))
|
||||
binary.LittleEndian.PutUint32(hdr[12:16], origLen)
|
||||
|
||||
if _, err := pw.w.Write(hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := pw.w.Write(data)
|
||||
return err
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPcapWriter_GlobalHeader(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pw := NewPcapWriter(&buf, 0)
|
||||
|
||||
// Header is lazy, so write a dummy packet to trigger it.
|
||||
err := pw.WritePacket(time.Now(), []byte{0x45, 0, 0, 20, 0, 0, 0, 0, 64, 1, 0, 0, 10, 0, 0, 1, 10, 0, 0, 2})
|
||||
require.NoError(t, err)
|
||||
|
||||
data := buf.Bytes()
|
||||
require.GreaterOrEqual(t, len(data), 24, "should contain global header")
|
||||
|
||||
assert.Equal(t, uint32(pcapMagic), binary.LittleEndian.Uint32(data[0:4]), "magic number")
|
||||
assert.Equal(t, uint16(pcapVersionMaj), binary.LittleEndian.Uint16(data[4:6]), "version major")
|
||||
assert.Equal(t, uint16(pcapVersionMin), binary.LittleEndian.Uint16(data[6:8]), "version minor")
|
||||
assert.Equal(t, uint32(defaultSnapLen), binary.LittleEndian.Uint32(data[16:20]), "snap length")
|
||||
assert.Equal(t, uint32(linkTypeRaw), binary.LittleEndian.Uint32(data[20:24]), "link type")
|
||||
}
|
||||
|
||||
func TestPcapWriter_WritePacket(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pw := NewPcapWriter(&buf, 100)
|
||||
|
||||
ts := time.Date(2025, 6, 15, 12, 30, 45, 123456000, time.UTC)
|
||||
payload := make([]byte, 50)
|
||||
for i := range payload {
|
||||
payload[i] = byte(i)
|
||||
}
|
||||
|
||||
err := pw.WritePacket(ts, payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := buf.Bytes()[24:] // skip global header
|
||||
require.Len(t, data, 16+50, "packet header + payload")
|
||||
|
||||
assert.Equal(t, uint32(ts.Unix()), binary.LittleEndian.Uint32(data[0:4]), "timestamp seconds")
|
||||
assert.Equal(t, uint32(123456), binary.LittleEndian.Uint32(data[4:8]), "timestamp microseconds")
|
||||
assert.Equal(t, uint32(50), binary.LittleEndian.Uint32(data[8:12]), "included length")
|
||||
assert.Equal(t, uint32(50), binary.LittleEndian.Uint32(data[12:16]), "original length")
|
||||
assert.Equal(t, payload, data[16:], "packet data")
|
||||
}
|
||||
|
||||
func TestPcapWriter_SnapLen(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pw := NewPcapWriter(&buf, 10)
|
||||
|
||||
ts := time.Now()
|
||||
payload := make([]byte, 50)
|
||||
|
||||
err := pw.WritePacket(ts, payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := buf.Bytes()[24:]
|
||||
assert.Equal(t, uint32(10), binary.LittleEndian.Uint32(data[8:12]), "included length should be truncated")
|
||||
assert.Equal(t, uint32(50), binary.LittleEndian.Uint32(data[12:16]), "original length preserved")
|
||||
assert.Len(t, data[16:], 10, "only snap_len bytes written")
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultBufSize = 256
|
||||
|
||||
type packetEntry struct {
|
||||
ts time.Time
|
||||
data []byte
|
||||
dir Direction
|
||||
}
|
||||
|
||||
// Session manages an active packet capture. Packets are offered via Offer,
|
||||
// buffered in a channel, and written to configured sinks by a background
|
||||
// goroutine. This keeps the hot path (FilteredDevice.Read/Write) non-blocking.
|
||||
//
|
||||
// The caller must call Stop when done to flush remaining packets and release
|
||||
// resources.
|
||||
type Session struct {
|
||||
pcapW *PcapWriter
|
||||
textW *TextWriter
|
||||
matcher Matcher
|
||||
snapLen uint32
|
||||
flushFn func()
|
||||
|
||||
ch chan packetEntry
|
||||
done chan struct{}
|
||||
stopped chan struct{}
|
||||
|
||||
closeOnce sync.Once
|
||||
closed atomic.Bool
|
||||
packets atomic.Int64
|
||||
bytes atomic.Int64
|
||||
dropped atomic.Int64
|
||||
started time.Time
|
||||
}
|
||||
|
||||
// NewSession creates and starts a capture session. At least one of
|
||||
// Options.Output or Options.TextOutput must be non-nil.
|
||||
func NewSession(opts Options) (*Session, error) {
|
||||
if opts.Output == nil && opts.TextOutput == nil {
|
||||
return nil, fmt.Errorf("at least one output sink required")
|
||||
}
|
||||
|
||||
snapLen := opts.SnapLen
|
||||
if snapLen == 0 {
|
||||
snapLen = defaultSnapLen
|
||||
}
|
||||
|
||||
bufSize := opts.BufSize
|
||||
if bufSize <= 0 {
|
||||
bufSize = defaultBufSize
|
||||
}
|
||||
|
||||
s := &Session{
|
||||
matcher: opts.Matcher,
|
||||
snapLen: snapLen,
|
||||
ch: make(chan packetEntry, bufSize),
|
||||
done: make(chan struct{}),
|
||||
stopped: make(chan struct{}),
|
||||
started: time.Now(),
|
||||
}
|
||||
|
||||
if opts.Output != nil {
|
||||
s.pcapW = NewPcapWriter(opts.Output, snapLen)
|
||||
}
|
||||
if opts.TextOutput != nil {
|
||||
s.textW = NewTextWriter(opts.TextOutput, opts.Verbose, opts.ASCII)
|
||||
}
|
||||
|
||||
s.flushFn = buildFlushFn(opts.Output, opts.TextOutput)
|
||||
|
||||
go s.run()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Offer submits a packet for capture. It returns immediately and never blocks
|
||||
// the caller. If the internal buffer is full the packet is dropped silently.
|
||||
//
|
||||
// outbound should be true for packets leaving the host (FilteredDevice.Read
|
||||
// path) and false for packets arriving (FilteredDevice.Write path).
|
||||
//
|
||||
// Offer satisfies the device.PacketCapture interface.
|
||||
func (s *Session) Offer(data []byte, outbound bool) {
|
||||
if s.closed.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
if s.matcher != nil && !s.matcher.Match(data) {
|
||||
return
|
||||
}
|
||||
|
||||
captureLen := len(data)
|
||||
if s.snapLen > 0 && uint32(captureLen) > s.snapLen {
|
||||
captureLen = int(s.snapLen)
|
||||
}
|
||||
|
||||
copied := make([]byte, captureLen)
|
||||
copy(copied, data)
|
||||
|
||||
dir := Inbound
|
||||
if outbound {
|
||||
dir = Outbound
|
||||
}
|
||||
|
||||
select {
|
||||
case s.ch <- packetEntry{ts: time.Now(), data: copied, dir: dir}:
|
||||
s.packets.Add(1)
|
||||
s.bytes.Add(int64(len(data)))
|
||||
default:
|
||||
s.dropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals the session to stop accepting packets, drains any buffered
|
||||
// packets to the sinks, and waits for the writer goroutine to exit.
|
||||
// It is safe to call multiple times.
|
||||
func (s *Session) Stop() {
|
||||
s.closeOnce.Do(func() {
|
||||
s.closed.Store(true)
|
||||
close(s.done)
|
||||
})
|
||||
<-s.stopped
|
||||
}
|
||||
|
||||
// Done returns a channel that is closed when the session's writer goroutine
|
||||
// has fully exited and all buffered packets have been flushed.
|
||||
func (s *Session) Done() <-chan struct{} {
|
||||
return s.stopped
|
||||
}
|
||||
|
||||
// Stats returns current capture counters.
|
||||
func (s *Session) Stats() Stats {
|
||||
return Stats{
|
||||
Packets: s.packets.Load(),
|
||||
Bytes: s.bytes.Load(),
|
||||
Dropped: s.dropped.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) run() {
|
||||
defer close(s.stopped)
|
||||
|
||||
for {
|
||||
select {
|
||||
case pkt := <-s.ch:
|
||||
s.write(pkt)
|
||||
case <-s.done:
|
||||
s.drain()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) drain() {
|
||||
for {
|
||||
select {
|
||||
case pkt := <-s.ch:
|
||||
s.write(pkt)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) write(pkt packetEntry) {
|
||||
if s.pcapW != nil {
|
||||
// Best-effort: if the writer fails (broken pipe etc.), discard silently.
|
||||
_ = s.pcapW.WritePacket(pkt.ts, pkt.data)
|
||||
}
|
||||
if s.textW != nil {
|
||||
_ = s.textW.WritePacket(pkt.ts, pkt.data, pkt.dir)
|
||||
}
|
||||
s.flushFn()
|
||||
}
|
||||
|
||||
// buildFlushFn returns a function that flushes all writers that support it.
|
||||
// This covers http.Flusher and similar streaming writers.
|
||||
func buildFlushFn(writers ...any) func() {
|
||||
type flusher interface {
|
||||
Flush()
|
||||
}
|
||||
|
||||
var fns []func()
|
||||
for _, w := range writers {
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
if f, ok := w.(flusher); ok {
|
||||
fns = append(fns, f.Flush)
|
||||
}
|
||||
}
|
||||
|
||||
switch len(fns) {
|
||||
case 0:
|
||||
return func() {
|
||||
// no writers to flush
|
||||
}
|
||||
case 1:
|
||||
return fns[0]
|
||||
default:
|
||||
return func() {
|
||||
for _, fn := range fns {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSession_PcapOutput(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sess, err := NewSession(Options{
|
||||
Output: &buf,
|
||||
BufSize: 16,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pkt := buildIPv4Packet(t,
|
||||
netip.MustParseAddr("10.0.0.1"),
|
||||
netip.MustParseAddr("10.0.0.2"),
|
||||
protoTCP, 12345, 443)
|
||||
|
||||
sess.Offer(pkt, true)
|
||||
sess.Stop()
|
||||
|
||||
data := buf.Bytes()
|
||||
require.Greater(t, len(data), 24, "should have global header + at least one packet")
|
||||
|
||||
// Verify global header
|
||||
assert.Equal(t, uint32(pcapMagic), binary.LittleEndian.Uint32(data[0:4]))
|
||||
assert.Equal(t, uint32(linkTypeRaw), binary.LittleEndian.Uint32(data[20:24]))
|
||||
|
||||
// Verify packet record
|
||||
pktData := data[24:]
|
||||
inclLen := binary.LittleEndian.Uint32(pktData[8:12])
|
||||
assert.Equal(t, uint32(len(pkt)), inclLen)
|
||||
|
||||
stats := sess.Stats()
|
||||
assert.Equal(t, int64(1), stats.Packets)
|
||||
assert.Equal(t, int64(len(pkt)), stats.Bytes)
|
||||
assert.Equal(t, int64(0), stats.Dropped)
|
||||
}
|
||||
|
||||
func TestSession_TextOutput(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sess, err := NewSession(Options{
|
||||
TextOutput: &buf,
|
||||
BufSize: 16,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pkt := buildIPv4Packet(t,
|
||||
netip.MustParseAddr("10.0.0.1"),
|
||||
netip.MustParseAddr("10.0.0.2"),
|
||||
protoTCP, 12345, 443)
|
||||
|
||||
sess.Offer(pkt, false)
|
||||
sess.Stop()
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "TCP")
|
||||
assert.Contains(t, output, "10.0.0.1")
|
||||
assert.Contains(t, output, "10.0.0.2")
|
||||
assert.Contains(t, output, "443")
|
||||
assert.Contains(t, output, "[IN TCP]")
|
||||
}
|
||||
|
||||
func TestSession_Filter(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sess, err := NewSession(Options{
|
||||
Output: &buf,
|
||||
Matcher: &Filter{Port: 443},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pktMatch := buildIPv4Packet(t,
|
||||
netip.MustParseAddr("10.0.0.1"),
|
||||
netip.MustParseAddr("10.0.0.2"),
|
||||
protoTCP, 12345, 443)
|
||||
pktNoMatch := buildIPv4Packet(t,
|
||||
netip.MustParseAddr("10.0.0.1"),
|
||||
netip.MustParseAddr("10.0.0.2"),
|
||||
protoTCP, 12345, 80)
|
||||
|
||||
sess.Offer(pktMatch, true)
|
||||
sess.Offer(pktNoMatch, true)
|
||||
sess.Stop()
|
||||
|
||||
stats := sess.Stats()
|
||||
assert.Equal(t, int64(1), stats.Packets, "only matching packet should be captured")
|
||||
}
|
||||
|
||||
func TestSession_StopIdempotent(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sess, err := NewSession(Options{Output: &buf})
|
||||
require.NoError(t, err)
|
||||
|
||||
sess.Stop()
|
||||
sess.Stop() // should not panic or deadlock
|
||||
}
|
||||
|
||||
func TestSession_OfferAfterStop(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sess, err := NewSession(Options{Output: &buf})
|
||||
require.NoError(t, err)
|
||||
sess.Stop()
|
||||
|
||||
pkt := buildIPv4Packet(t,
|
||||
netip.MustParseAddr("10.0.0.1"),
|
||||
netip.MustParseAddr("10.0.0.2"),
|
||||
protoTCP, 12345, 443)
|
||||
sess.Offer(pkt, true) // should not panic
|
||||
|
||||
assert.Equal(t, int64(0), sess.Stats().Packets)
|
||||
}
|
||||
|
||||
func TestSession_Done(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
sess, err := NewSession(Options{Output: &buf})
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case <-sess.Done():
|
||||
t.Fatal("Done should not be closed before Stop")
|
||||
default:
|
||||
}
|
||||
|
||||
sess.Stop()
|
||||
|
||||
select {
|
||||
case <-sess.Done():
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Done should be closed after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSession_RequiresOutput(t *testing.T) {
|
||||
_, err := NewSession(Options{})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -1,638 +0,0 @@
|
||||
package capture
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
)
|
||||
|
||||
// TextWriter writes human-readable one-line-per-packet summaries.
|
||||
// It is not safe for concurrent use; callers must serialize access.
|
||||
type TextWriter struct {
|
||||
w io.Writer
|
||||
verbose bool
|
||||
ascii bool
|
||||
flows map[dirKey]uint32
|
||||
}
|
||||
|
||||
type dirKey struct {
|
||||
src netip.AddrPort
|
||||
dst netip.AddrPort
|
||||
}
|
||||
|
||||
// NewTextWriter creates a text formatter that writes to w.
|
||||
func NewTextWriter(w io.Writer, verbose, ascii bool) *TextWriter {
|
||||
return &TextWriter{
|
||||
w: w,
|
||||
verbose: verbose,
|
||||
ascii: ascii,
|
||||
flows: make(map[dirKey]uint32),
|
||||
}
|
||||
}
|
||||
|
||||
// tag formats the fixed-width "[DIR PROTO]" prefix with right-aligned protocol.
|
||||
func tag(dir Direction, proto string) string {
|
||||
return fmt.Sprintf("[%-3s %4s]", dir, proto)
|
||||
}
|
||||
|
||||
// WritePacket formats and writes a single packet line.
|
||||
func (tw *TextWriter) WritePacket(ts time.Time, data []byte, dir Direction) error {
|
||||
ts = ts.Local()
|
||||
info, ok := parsePacketInfo(data)
|
||||
if !ok {
|
||||
_, err := fmt.Fprintf(tw.w, "%s [%-3s ?] ??? len=%d\n",
|
||||
ts.Format("15:04:05.000000"), dir, len(data))
|
||||
return err
|
||||
}
|
||||
|
||||
timeStr := ts.Format("15:04:05.000000")
|
||||
|
||||
var err error
|
||||
switch info.proto {
|
||||
case protoTCP:
|
||||
err = tw.writeTCP(timeStr, dir, &info, data)
|
||||
case protoUDP:
|
||||
err = tw.writeUDP(timeStr, dir, &info, data)
|
||||
case protoICMP:
|
||||
err = tw.writeICMPv4(timeStr, dir, &info, data)
|
||||
case protoICMPv6:
|
||||
err = tw.writeICMPv6(timeStr, dir, &info, data)
|
||||
default:
|
||||
var verbose string
|
||||
if tw.verbose {
|
||||
verbose = tw.verboseIP(data, info.family)
|
||||
}
|
||||
_, err = fmt.Fprintf(tw.w, "%s %s %s > %s length %d%s\n",
|
||||
timeStr, tag(dir, fmt.Sprintf("P%d", info.proto)),
|
||||
info.srcIP, info.dstIP, len(data)-info.hdrLen, verbose)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (tw *TextWriter) writeTCP(timeStr string, dir Direction, info *packetInfo, data []byte) error {
|
||||
tcp := &layers.TCP{}
|
||||
if err := tcp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
|
||||
return tw.writeFallback(timeStr, dir, "TCP", info, data)
|
||||
}
|
||||
|
||||
flags := tcpFlagsStr(tcp)
|
||||
plen := len(tcp.Payload)
|
||||
|
||||
// Protocol annotation
|
||||
var annotation string
|
||||
if plen > 0 {
|
||||
annotation = annotatePayload(tcp.Payload)
|
||||
}
|
||||
|
||||
if !tw.verbose {
|
||||
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s] length %d%s\n",
|
||||
timeStr, tag(dir, "TCP"),
|
||||
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
|
||||
flags, plen, annotation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tw.ascii && plen > 0 {
|
||||
return tw.writeASCII(tcp.Payload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
relSeq, relAck := tw.relativeSeqAck(info, tcp.Seq, tcp.Ack)
|
||||
|
||||
var seqStr string
|
||||
if plen > 0 {
|
||||
seqStr = fmt.Sprintf(", seq %d:%d", relSeq, relSeq+uint32(plen))
|
||||
} else {
|
||||
seqStr = fmt.Sprintf(", seq %d", relSeq)
|
||||
}
|
||||
|
||||
var ackStr string
|
||||
if tcp.ACK {
|
||||
ackStr = fmt.Sprintf(", ack %d", relAck)
|
||||
}
|
||||
|
||||
var opts string
|
||||
if s := formatTCPOptions(tcp.Options); s != "" {
|
||||
opts = ", options [" + s + "]"
|
||||
}
|
||||
|
||||
verbose := tw.verboseIP(data, info.family)
|
||||
|
||||
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s]%s%s, win %d%s, length %d%s%s\n",
|
||||
timeStr, tag(dir, "TCP"),
|
||||
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
|
||||
flags, seqStr, ackStr, tcp.Window, opts, plen, annotation, verbose)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tw.ascii && plen > 0 {
|
||||
return tw.writeASCII(tcp.Payload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo, data []byte) error {
|
||||
udp := &layers.UDP{}
|
||||
if err := udp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
|
||||
return tw.writeFallback(timeStr, dir, "UDP", info, data)
|
||||
}
|
||||
|
||||
plen := len(udp.Payload)
|
||||
|
||||
// DNS replaces the entire line format
|
||||
if plen > 0 && isDNSPort(info.srcPort, info.dstPort) {
|
||||
if s := formatDNSPayload(udp.Payload); s != "" {
|
||||
var verbose string
|
||||
if tw.verbose {
|
||||
verbose = tw.verboseIP(data, info.family)
|
||||
}
|
||||
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d %s%s\n",
|
||||
timeStr, tag(dir, "UDP"),
|
||||
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
|
||||
s, verbose)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var verbose string
|
||||
if tw.verbose {
|
||||
verbose = tw.verboseIP(data, info.family)
|
||||
}
|
||||
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d%s\n",
|
||||
timeStr, tag(dir, "UDP"),
|
||||
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
|
||||
plen, verbose)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tw.ascii && plen > 0 {
|
||||
return tw.writeASCII(udp.Payload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tw *TextWriter) writeICMPv4(timeStr string, dir Direction, info *packetInfo, data []byte) error {
|
||||
icmp := &layers.ICMPv4{}
|
||||
if err := icmp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
|
||||
return tw.writeFallback(timeStr, dir, "ICMP", info, data)
|
||||
}
|
||||
|
||||
var detail string
|
||||
if icmp.TypeCode.Type() == layers.ICMPv4TypeEchoRequest || icmp.TypeCode.Type() == layers.ICMPv4TypeEchoReply {
|
||||
detail = fmt.Sprintf("%s, id %d, seq %d", icmp.TypeCode.String(), icmp.Id, icmp.Seq)
|
||||
} else {
|
||||
detail = icmp.TypeCode.String()
|
||||
}
|
||||
|
||||
var verbose string
|
||||
if tw.verbose {
|
||||
verbose = tw.verboseIP(data, info.family)
|
||||
}
|
||||
_, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s, length %d%s\n",
|
||||
timeStr, tag(dir, "ICMP"), info.srcIP, info.dstIP, detail, len(data)-info.hdrLen, verbose)
|
||||
return err
|
||||
}
|
||||
|
||||
func (tw *TextWriter) writeICMPv6(timeStr string, dir Direction, info *packetInfo, data []byte) error {
|
||||
icmp := &layers.ICMPv6{}
|
||||
if err := icmp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
|
||||
return tw.writeFallback(timeStr, dir, "ICMP", info, data)
|
||||
}
|
||||
|
||||
var verbose string
|
||||
if tw.verbose {
|
||||
verbose = tw.verboseIP(data, info.family)
|
||||
}
|
||||
_, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s, length %d%s\n",
|
||||
timeStr, tag(dir, "ICMP"), info.srcIP, info.dstIP, icmp.TypeCode.String(), len(data)-info.hdrLen, verbose)
|
||||
return err
|
||||
}
|
||||
|
||||
func (tw *TextWriter) writeFallback(timeStr string, dir Direction, proto string, info *packetInfo, data []byte) error {
|
||||
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d\n",
|
||||
timeStr, tag(dir, proto),
|
||||
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
|
||||
len(data)-info.hdrLen)
|
||||
return err
|
||||
}
|
||||
|
||||
func (tw *TextWriter) verboseIP(data []byte, family uint8) string {
|
||||
return fmt.Sprintf(", ttl %d, id %d, iplen %d",
|
||||
ipTTL(data, family), ipID(data, family), len(data))
|
||||
}
|
||||
|
||||
// relativeSeqAck returns seq/ack relative to the first seen value per direction.
|
||||
func (tw *TextWriter) relativeSeqAck(info *packetInfo, seq, ack uint32) (relSeq, relAck uint32) {
|
||||
fwd := dirKey{
|
||||
src: netip.AddrPortFrom(info.srcIP, info.srcPort),
|
||||
dst: netip.AddrPortFrom(info.dstIP, info.dstPort),
|
||||
}
|
||||
rev := dirKey{
|
||||
src: netip.AddrPortFrom(info.dstIP, info.dstPort),
|
||||
dst: netip.AddrPortFrom(info.srcIP, info.srcPort),
|
||||
}
|
||||
|
||||
if isn, ok := tw.flows[fwd]; ok {
|
||||
relSeq = seq - isn
|
||||
} else {
|
||||
tw.flows[fwd] = seq
|
||||
}
|
||||
|
||||
if isn, ok := tw.flows[rev]; ok {
|
||||
relAck = ack - isn
|
||||
} else {
|
||||
relAck = ack
|
||||
}
|
||||
|
||||
return relSeq, relAck
|
||||
}
|
||||
|
||||
// writeASCII prints payload bytes as printable ASCII.
|
||||
func (tw *TextWriter) writeASCII(payload []byte) error {
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
buf := make([]byte, len(payload))
|
||||
for i, b := range payload {
|
||||
switch {
|
||||
case b >= 0x20 && b < 0x7f:
|
||||
buf[i] = b
|
||||
case b == '\n' || b == '\r' || b == '\t':
|
||||
buf[i] = b
|
||||
default:
|
||||
buf[i] = '.'
|
||||
}
|
||||
}
|
||||
_, err := fmt.Fprintf(tw.w, "%s\n", buf)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- TCP helpers ---
|
||||
|
||||
func ipTTL(data []byte, family uint8) uint8 {
|
||||
if family == 4 && len(data) > 8 {
|
||||
return data[8]
|
||||
}
|
||||
if family == 6 && len(data) > 7 {
|
||||
return data[7]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func ipID(data []byte, family uint8) uint16 {
|
||||
if family == 4 && len(data) >= 6 {
|
||||
return binary.BigEndian.Uint16(data[4:6])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func tcpFlagsStr(tcp *layers.TCP) string {
|
||||
var buf [6]byte
|
||||
n := 0
|
||||
if tcp.SYN {
|
||||
buf[n] = 'S'
|
||||
n++
|
||||
}
|
||||
if tcp.FIN {
|
||||
buf[n] = 'F'
|
||||
n++
|
||||
}
|
||||
if tcp.RST {
|
||||
buf[n] = 'R'
|
||||
n++
|
||||
}
|
||||
if tcp.PSH {
|
||||
buf[n] = 'P'
|
||||
n++
|
||||
}
|
||||
if tcp.ACK {
|
||||
buf[n] = '.'
|
||||
n++
|
||||
}
|
||||
if tcp.URG {
|
||||
buf[n] = 'U'
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
return "none"
|
||||
}
|
||||
return string(buf[:n])
|
||||
}
|
||||
|
||||
func formatTCPOptions(opts []layers.TCPOption) string {
|
||||
var parts []string
|
||||
for _, opt := range opts {
|
||||
switch opt.OptionType {
|
||||
case layers.TCPOptionKindEndList:
|
||||
return strings.Join(parts, ",")
|
||||
case layers.TCPOptionKindNop:
|
||||
parts = append(parts, "nop")
|
||||
case layers.TCPOptionKindMSS:
|
||||
if len(opt.OptionData) == 2 {
|
||||
parts = append(parts, fmt.Sprintf("mss %d", binary.BigEndian.Uint16(opt.OptionData)))
|
||||
}
|
||||
case layers.TCPOptionKindWindowScale:
|
||||
if len(opt.OptionData) == 1 {
|
||||
parts = append(parts, fmt.Sprintf("wscale %d", opt.OptionData[0]))
|
||||
}
|
||||
case layers.TCPOptionKindSACKPermitted:
|
||||
parts = append(parts, "sackOK")
|
||||
case layers.TCPOptionKindSACK:
|
||||
blocks := len(opt.OptionData) / 8
|
||||
parts = append(parts, fmt.Sprintf("sack %d", blocks))
|
||||
case layers.TCPOptionKindTimestamps:
|
||||
if len(opt.OptionData) == 8 {
|
||||
tsval := binary.BigEndian.Uint32(opt.OptionData[0:4])
|
||||
tsecr := binary.BigEndian.Uint32(opt.OptionData[4:8])
|
||||
parts = append(parts, fmt.Sprintf("TS val %d ecr %d", tsval, tsecr))
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// --- Protocol annotation ---
|
||||
|
||||
// annotatePayload returns a protocol annotation string for known application protocols.
|
||||
func annotatePayload(payload []byte) string {
|
||||
if len(payload) < 4 {
|
||||
return ""
|
||||
}
|
||||
|
||||
s := string(payload)
|
||||
|
||||
// SSH banner: "SSH-2.0-OpenSSH_9.6\r\n"
|
||||
if strings.HasPrefix(s, "SSH-") {
|
||||
if end := strings.IndexByte(s, '\r'); end > 0 && end < 256 {
|
||||
return ": " + s[:end]
|
||||
}
|
||||
}
|
||||
|
||||
// TLS records
|
||||
if ann := annotateTLS(payload); ann != "" {
|
||||
return ": " + ann
|
||||
}
|
||||
|
||||
// HTTP request or response
|
||||
for _, method := range [...]string{"GET ", "POST ", "PUT ", "DELETE ", "HEAD ", "PATCH ", "OPTIONS ", "CONNECT "} {
|
||||
if strings.HasPrefix(s, method) {
|
||||
if end := strings.IndexByte(s, '\r'); end > 0 && end < 200 {
|
||||
return ": " + s[:end]
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(s, "HTTP/") {
|
||||
if end := strings.IndexByte(s, '\r'); end > 0 && end < 200 {
|
||||
return ": " + s[:end]
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// annotateTLS returns a description for TLS handshake and alert records.
|
||||
func annotateTLS(data []byte) string {
|
||||
if len(data) < 6 {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch data[0] {
|
||||
case 0x16:
|
||||
return annotateTLSHandshake(data)
|
||||
case 0x15:
|
||||
return annotateTLSAlert(data)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func annotateTLSHandshake(data []byte) string {
|
||||
if len(data) < 10 {
|
||||
return ""
|
||||
}
|
||||
switch data[5] {
|
||||
case 0x01:
|
||||
if sni := extractSNI(data); sni != "" {
|
||||
return "TLS ClientHello SNI=" + sni
|
||||
}
|
||||
return "TLS ClientHello"
|
||||
case 0x02:
|
||||
return "TLS ServerHello"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func annotateTLSAlert(data []byte) string {
|
||||
if len(data) < 7 {
|
||||
return ""
|
||||
}
|
||||
severity := "warning"
|
||||
if data[5] == 2 {
|
||||
severity = "fatal"
|
||||
}
|
||||
return fmt.Sprintf("TLS Alert %s %s", severity, tlsAlertDesc(data[6]))
|
||||
}
|
||||
|
||||
func tlsAlertDesc(code byte) string {
|
||||
switch code {
|
||||
case 0:
|
||||
return "close_notify"
|
||||
case 10:
|
||||
return "unexpected_message"
|
||||
case 40:
|
||||
return "handshake_failure"
|
||||
case 42:
|
||||
return "bad_certificate"
|
||||
case 43:
|
||||
return "unsupported_certificate"
|
||||
case 44:
|
||||
return "certificate_revoked"
|
||||
case 45:
|
||||
return "certificate_expired"
|
||||
case 48:
|
||||
return "unknown_ca"
|
||||
case 49:
|
||||
return "access_denied"
|
||||
case 50:
|
||||
return "decode_error"
|
||||
case 70:
|
||||
return "protocol_version"
|
||||
case 80:
|
||||
return "internal_error"
|
||||
case 86:
|
||||
return "inappropriate_fallback"
|
||||
case 90:
|
||||
return "user_canceled"
|
||||
case 112:
|
||||
return "unrecognized_name"
|
||||
default:
|
||||
return fmt.Sprintf("alert(%d)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// extractSNI parses a TLS ClientHello and returns the SNI server name.
|
||||
func extractSNI(data []byte) string {
|
||||
if len(data) < 6 || data[0] != 0x16 {
|
||||
return ""
|
||||
}
|
||||
recordLen := int(binary.BigEndian.Uint16(data[3:5]))
|
||||
handshake := data[5:]
|
||||
if len(handshake) > recordLen {
|
||||
handshake = handshake[:recordLen]
|
||||
}
|
||||
|
||||
if len(handshake) < 4 || handshake[0] != 0x01 {
|
||||
return ""
|
||||
}
|
||||
hsLen := int(handshake[1])<<16 | int(handshake[2])<<8 | int(handshake[3])
|
||||
body := handshake[4:]
|
||||
if len(body) > hsLen {
|
||||
body = body[:hsLen]
|
||||
}
|
||||
|
||||
extPos := clientHelloExtensionsOffset(body)
|
||||
if extPos < 0 {
|
||||
return ""
|
||||
}
|
||||
return findSNIExtension(body, extPos)
|
||||
}
|
||||
|
||||
// clientHelloExtensionsOffset returns the byte offset where extensions begin
|
||||
// within the ClientHello body, or -1 if the body is too short.
|
||||
func clientHelloExtensionsOffset(body []byte) int {
|
||||
if len(body) < 38 {
|
||||
return -1
|
||||
}
|
||||
pos := 34
|
||||
|
||||
if pos >= len(body) {
|
||||
return -1
|
||||
}
|
||||
pos += 1 + int(body[pos]) // session ID
|
||||
|
||||
if pos+2 > len(body) {
|
||||
return -1
|
||||
}
|
||||
pos += 2 + int(binary.BigEndian.Uint16(body[pos:pos+2])) // cipher suites
|
||||
|
||||
if pos >= len(body) {
|
||||
return -1
|
||||
}
|
||||
pos += 1 + int(body[pos]) // compression methods
|
||||
|
||||
if pos+2 > len(body) {
|
||||
return -1
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
func findSNIExtension(body []byte, pos int) string {
|
||||
extLen := int(binary.BigEndian.Uint16(body[pos : pos+2]))
|
||||
pos += 2
|
||||
extEnd := pos + extLen
|
||||
if extEnd > len(body) {
|
||||
extEnd = len(body)
|
||||
}
|
||||
|
||||
for pos+4 <= extEnd {
|
||||
extType := binary.BigEndian.Uint16(body[pos : pos+2])
|
||||
eLen := int(binary.BigEndian.Uint16(body[pos+2 : pos+4]))
|
||||
pos += 4
|
||||
if pos+eLen > extEnd {
|
||||
break
|
||||
}
|
||||
if extType == 0 && eLen >= 5 {
|
||||
nameLen := int(binary.BigEndian.Uint16(body[pos+3 : pos+5]))
|
||||
if pos+5+nameLen <= extEnd {
|
||||
return string(body[pos+5 : pos+5+nameLen])
|
||||
}
|
||||
}
|
||||
pos += eLen
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isDNSPort(src, dst uint16) bool {
|
||||
return src == 53 || dst == 53 || src == 5353 || dst == 5353
|
||||
}
|
||||
|
||||
// formatDNSPayload parses DNS and returns a tcpdump-style summary.
|
||||
func formatDNSPayload(payload []byte) string {
|
||||
d := &layers.DNS{}
|
||||
if err := d.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
rd := ""
|
||||
if d.RD {
|
||||
rd = "+"
|
||||
}
|
||||
|
||||
if !d.QR {
|
||||
return formatDNSQuery(d, rd, len(payload))
|
||||
}
|
||||
return formatDNSResponse(d, rd, len(payload))
|
||||
}
|
||||
|
||||
func formatDNSQuery(d *layers.DNS, rd string, plen int) string {
|
||||
if len(d.Questions) == 0 {
|
||||
return fmt.Sprintf("%04x%s (%d)", d.ID, rd, plen)
|
||||
}
|
||||
q := d.Questions[0]
|
||||
return fmt.Sprintf("%04x%s %s? %s. (%d)", d.ID, rd, q.Type, q.Name, plen)
|
||||
}
|
||||
|
||||
func formatDNSResponse(d *layers.DNS, rd string, plen int) string {
|
||||
anCount := d.ANCount
|
||||
nsCount := d.NSCount
|
||||
arCount := d.ARCount
|
||||
|
||||
if d.ResponseCode != layers.DNSResponseCodeNoErr {
|
||||
return fmt.Sprintf("%04x %d/%d/%d %s (%d)", d.ID, anCount, nsCount, arCount, d.ResponseCode, plen)
|
||||
}
|
||||
|
||||
if anCount > 0 && len(d.Answers) > 0 {
|
||||
rr := d.Answers[0]
|
||||
if rdata := shortRData(&rr); rdata != "" {
|
||||
return fmt.Sprintf("%04x %d/%d/%d %s %s (%d)", d.ID, anCount, nsCount, arCount, rr.Type, rdata, plen)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%04x %d/%d/%d (%d)", d.ID, anCount, nsCount, arCount, plen)
|
||||
}
|
||||
|
||||
func shortRData(rr *layers.DNSResourceRecord) string {
|
||||
switch rr.Type {
|
||||
case layers.DNSTypeA, layers.DNSTypeAAAA:
|
||||
if rr.IP != nil {
|
||||
return rr.IP.String()
|
||||
}
|
||||
case layers.DNSTypeCNAME:
|
||||
if len(rr.CNAME) > 0 {
|
||||
return string(rr.CNAME) + "."
|
||||
}
|
||||
case layers.DNSTypePTR:
|
||||
if len(rr.PTR) > 0 {
|
||||
return string(rr.PTR) + "."
|
||||
}
|
||||
case layers.DNSTypeNS:
|
||||
if len(rr.NS) > 0 {
|
||||
return string(rr.NS) + "."
|
||||
}
|
||||
case layers.DNSTypeMX:
|
||||
return fmt.Sprintf("%d %s.", rr.MX.Preference, rr.MX.Name)
|
||||
case layers.DNSTypeTXT:
|
||||
if len(rr.TXTs) > 0 {
|
||||
return fmt.Sprintf("%q", string(rr.TXTs[0]))
|
||||
}
|
||||
case layers.DNSTypeSRV:
|
||||
return fmt.Sprintf("%d %d %d %s.", rr.SRV.Priority, rr.SRV.Weight, rr.SRV.Port, rr.SRV.Name)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user