363 lines
8.9 KiB
Go
363 lines
8.9 KiB
Go
package report
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
maxReportedIncusInstances = 1000
|
|
incusStateConcurrency = 8
|
|
)
|
|
|
|
var externalGuestInterfacePattern = regexp.MustCompile(`^(eth[0-9]+|en(?:o|p|s|x)[a-z0-9]+)$`)
|
|
|
|
type incusAPIResponse struct {
|
|
Type string `json:"type"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
|
|
type incusInstanceSummary struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
StatusCode int `json:"status_code"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type incusInstanceState struct {
|
|
Status string `json:"status"`
|
|
Network map[string]incusNetworkDevice `json:"network"`
|
|
}
|
|
|
|
type incusNetworkDevice struct {
|
|
Addresses []incusNetworkAddress `json:"addresses"`
|
|
Hwaddr string `json:"hwaddr"`
|
|
Counters incusNetworkCounters `json:"counters"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
type incusNetworkAddress struct {
|
|
Family string `json:"family"`
|
|
Address string `json:"address"`
|
|
}
|
|
|
|
type incusNetworkCounters struct {
|
|
BytesReceived json.Number `json:"bytes_received"`
|
|
BytesSent json.Number `json:"bytes_sent"`
|
|
}
|
|
|
|
type trafficCounters struct {
|
|
rx uint64
|
|
tx uint64
|
|
}
|
|
|
|
func collectIncusInstanceReport() map[string]any {
|
|
reportedAt := time.Now().UTC().Format(time.RFC3339)
|
|
socketPath, ok := detectIncusSocket()
|
|
if !ok {
|
|
return map[string]any{
|
|
"available": false,
|
|
"reportedAt": reportedAt,
|
|
"total": 0,
|
|
"items": []any{},
|
|
}
|
|
}
|
|
|
|
// 只通过本机 Unix socket 做只读采集,不要求宿主机开放额外 Agent 端口。
|
|
client := newIncusUnixHTTPClient(socketPath)
|
|
instances, err := listIncusInstances(client)
|
|
if err != nil {
|
|
return map[string]any{
|
|
"available": false,
|
|
"reportedAt": reportedAt,
|
|
"total": 0,
|
|
"items": []any{},
|
|
"error": truncateReportError(err),
|
|
}
|
|
}
|
|
|
|
limitedInstances := instances
|
|
if len(limitedInstances) > maxReportedIncusInstances {
|
|
limitedInstances = limitedInstances[:maxReportedIncusInstances]
|
|
}
|
|
|
|
items := make([]map[string]any, len(limitedInstances))
|
|
var wg sync.WaitGroup
|
|
semaphore := make(chan struct{}, incusStateConcurrency)
|
|
|
|
for index, instance := range limitedInstances {
|
|
wg.Add(1)
|
|
go func(index int, instance incusInstanceSummary) {
|
|
defer wg.Done()
|
|
semaphore <- struct{}{}
|
|
defer func() { <-semaphore }()
|
|
|
|
items[index] = buildIncusInstanceReportItem(client, instance)
|
|
}(index, instance)
|
|
}
|
|
wg.Wait()
|
|
|
|
normalizedItems := make([]any, 0, len(items))
|
|
for _, item := range items {
|
|
if item != nil {
|
|
normalizedItems = append(normalizedItems, item)
|
|
}
|
|
}
|
|
|
|
return map[string]any{
|
|
"available": true,
|
|
"reportedAt": reportedAt,
|
|
"total": len(instances),
|
|
"truncated": len(instances) > len(limitedInstances),
|
|
"items": normalizedItems,
|
|
}
|
|
}
|
|
|
|
func newIncusUnixHTTPClient(socketPath string) *http.Client {
|
|
transport := &http.Transport{
|
|
DialContext: func(ctx context.Context, _network string, _addr string) (net.Conn, error) {
|
|
var dialer net.Dialer
|
|
return dialer.DialContext(ctx, "unix", socketPath)
|
|
},
|
|
DisableCompression: true,
|
|
DisableKeepAlives: true,
|
|
}
|
|
|
|
return &http.Client{
|
|
Transport: transport,
|
|
Timeout: 8 * time.Second,
|
|
}
|
|
}
|
|
|
|
func listIncusInstances(client *http.Client) ([]incusInstanceSummary, error) {
|
|
return incusRequest[[]incusInstanceSummary](client, "/1.0/instances?recursion=1")
|
|
}
|
|
|
|
func getIncusInstanceState(client *http.Client, name string) (incusInstanceState, error) {
|
|
return incusRequest[incusInstanceState](client, "/1.0/instances/"+url.PathEscape(name)+"/state")
|
|
}
|
|
|
|
func incusRequest[T any](client *http.Client, path string) (T, error) {
|
|
var zero T
|
|
request, err := http.NewRequest(http.MethodGet, "http://incus"+path, nil)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return zero, fmt.Errorf("incus request failed: path=%s status=%d", path, response.StatusCode)
|
|
}
|
|
|
|
var envelope incusAPIResponse
|
|
decoder := json.NewDecoder(response.Body)
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&envelope); err != nil {
|
|
return zero, err
|
|
}
|
|
if envelope.Type == "error" {
|
|
if envelope.Error != "" {
|
|
return zero, fmt.Errorf("incus error: %s", envelope.Error)
|
|
}
|
|
return zero, fmt.Errorf("incus error: status=%s", envelope.Status)
|
|
}
|
|
|
|
metadataDecoder := json.NewDecoder(bytes.NewReader(envelope.Metadata))
|
|
metadataDecoder.UseNumber()
|
|
if err := metadataDecoder.Decode(&zero); err != nil {
|
|
return zero, err
|
|
}
|
|
return zero, nil
|
|
}
|
|
|
|
func buildIncusInstanceReportItem(client *http.Client, instance incusInstanceSummary) map[string]any {
|
|
item := map[string]any{
|
|
"name": instance.Name,
|
|
"status": instance.Status,
|
|
"statusCode": instance.StatusCode,
|
|
"type": instance.Type,
|
|
}
|
|
|
|
if instance.Name == "" {
|
|
return item
|
|
}
|
|
|
|
state, err := getIncusInstanceState(client, instance.Name)
|
|
if err != nil {
|
|
item["error"] = truncateReportError(err)
|
|
return item
|
|
}
|
|
if state.Status != "" {
|
|
item["status"] = state.Status
|
|
}
|
|
|
|
counters := getTrafficCountersFromIncusState(instance.Name, state)
|
|
item["traffic"] = map[string]any{
|
|
"rxBytes": strconv.FormatUint(counters.rx, 10),
|
|
"txBytes": strconv.FormatUint(counters.tx, 10),
|
|
}
|
|
|
|
if ipv4, ipv6 := firstRoutableAddresses(state.Network); ipv4 != "" || ipv6 != "" {
|
|
network := map[string]any{}
|
|
if ipv4 != "" {
|
|
network["ipv4"] = ipv4
|
|
}
|
|
if ipv6 != "" {
|
|
network["ipv6"] = ipv6
|
|
}
|
|
item["network"] = network
|
|
}
|
|
|
|
return item
|
|
}
|
|
|
|
func getTrafficCountersFromIncusState(instanceName string, state incusInstanceState) trafficCounters {
|
|
billableVmMacs := generateBillableVmMacs(instanceName)
|
|
totals := trafficCounters{}
|
|
fallbackInterfaces := make([]incusNetworkDevice, 0)
|
|
hasStrictBillableInterface := false
|
|
|
|
// 与面板旧采集口径保持一致,避免 guest 内部 bridge/veth 被重复计费。
|
|
for ifName, ifData := range state.Network {
|
|
if isBillableNetworkInterface(ifName, ifData, billableVmMacs) {
|
|
hasStrictBillableInterface = true
|
|
addNetworkCounters(&totals, ifData.Counters)
|
|
continue
|
|
}
|
|
|
|
if isLikelyExternalGuestInterface(ifName) {
|
|
fallbackInterfaces = append(fallbackInterfaces, ifData)
|
|
}
|
|
}
|
|
|
|
if !hasStrictBillableInterface {
|
|
for _, ifData := range fallbackInterfaces {
|
|
addNetworkCounters(&totals, ifData.Counters)
|
|
}
|
|
}
|
|
|
|
return totals
|
|
}
|
|
|
|
func isBillableNetworkInterface(ifName string, ifData incusNetworkDevice, billableVmMacs map[string]struct{}) bool {
|
|
if ifName == "lo" {
|
|
return false
|
|
}
|
|
if ifName == "eth0" || ifName == "eth1" {
|
|
return true
|
|
}
|
|
|
|
hwaddr := strings.ToLower(strings.TrimSpace(ifData.Hwaddr))
|
|
if hwaddr == "" {
|
|
return false
|
|
}
|
|
_, ok := billableVmMacs[hwaddr]
|
|
return ok
|
|
}
|
|
|
|
func isLikelyExternalGuestInterface(ifName string) bool {
|
|
return externalGuestInterfacePattern.MatchString(strings.ToLower(ifName))
|
|
}
|
|
|
|
func addNetworkCounters(totals *trafficCounters, counters incusNetworkCounters) {
|
|
totals.rx += jsonNumberToUint64(counters.BytesReceived)
|
|
totals.tx += jsonNumberToUint64(counters.BytesSent)
|
|
}
|
|
|
|
func jsonNumberToUint64(value json.Number) uint64 {
|
|
raw := strings.TrimSpace(value.String())
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
parsed, err := strconv.ParseUint(raw, 10, 64)
|
|
if err == nil {
|
|
return parsed
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func generateBillableVmMacs(seed string) map[string]struct{} {
|
|
return map[string]struct{}{
|
|
generateVmNicMac(seed, "eth0"): {},
|
|
generateVmNicMac(seed, "eth1"): {},
|
|
}
|
|
}
|
|
|
|
func generateVmNicMac(seed string, nicLabel string) string {
|
|
hash := sha256.Sum256([]byte("incudal-vm-nic:" + seed + ":" + nicLabel))
|
|
bytes := []byte{0x02, hash[0], hash[1], hash[2], hash[3], hash[4]}
|
|
encoded := hex.EncodeToString(bytes)
|
|
return strings.Join([]string{
|
|
encoded[0:2],
|
|
encoded[2:4],
|
|
encoded[4:6],
|
|
encoded[6:8],
|
|
encoded[8:10],
|
|
encoded[10:12],
|
|
}, ":")
|
|
}
|
|
|
|
func firstRoutableAddresses(network map[string]incusNetworkDevice) (string, string) {
|
|
var ipv4 string
|
|
var ipv6 string
|
|
|
|
for _, ifData := range network {
|
|
for _, address := range ifData.Addresses {
|
|
if address.Address == "" {
|
|
continue
|
|
}
|
|
ip := net.ParseIP(address.Address)
|
|
if ip == nil || !isRoutableGuestIP(ip) {
|
|
continue
|
|
}
|
|
if ipv4 == "" && ip.To4() != nil && strings.EqualFold(address.Family, "inet") {
|
|
ipv4 = address.Address
|
|
continue
|
|
}
|
|
if ipv6 == "" && ip.To4() == nil && strings.EqualFold(address.Family, "inet6") {
|
|
ipv6 = address.Address
|
|
}
|
|
}
|
|
}
|
|
|
|
return ipv4, ipv6
|
|
}
|
|
|
|
func isRoutableGuestIP(ip net.IP) bool {
|
|
return !ip.IsLoopback() &&
|
|
!ip.IsUnspecified() &&
|
|
!ip.IsLinkLocalUnicast() &&
|
|
!ip.IsLinkLocalMulticast() &&
|
|
!ip.IsMulticast()
|
|
}
|
|
|
|
func truncateReportError(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
message := err.Error()
|
|
if len(message) > 200 {
|
|
return message[:200]
|
|
}
|
|
return message
|
|
}
|