diff --git a/internal/api/handler.go b/internal/api/handler.go index fc080106..7d89fe03 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -146,6 +146,7 @@ func NewHandler(requireAuth bool) *gin.Engine { proxmox := v1.Group("/proxmox") { + proxmox.GET("/journalctl/:node", proxmoxApi.Journalctl) proxmox.GET("/journalctl/:node/:vmid", proxmoxApi.Journalctl) proxmox.GET("/journalctl/:node/:vmid/:service", proxmoxApi.Journalctl) proxmox.GET("/stats/:node/:vmid", proxmoxApi.Stats) diff --git a/internal/api/v1/docs/swagger.json b/internal/api/v1/docs/swagger.json index 8b0f4df9..c8b56c46 100644 --- a/internal/api/v1/docs/swagger.json +++ b/internal/api/v1/docs/swagger.json @@ -2077,9 +2077,9 @@ "operationId": "uptime" } }, - "/proxmox/journalctl/{node}/{vmid}": { + "/proxmox/journalctl/{node}": { "get": { - "description": "Get journalctl output", + "description": "Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.", "consumes": [ "application/json" ], @@ -2094,19 +2094,85 @@ "parameters": [ { "type": "string", + "description": "Node name", "name": "node", "in": "path", "required": true }, { "type": "integer", - "name": "vmid", + "description": "Limit output lines (1-1000)", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Journalctl output", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "403": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "404": { + "description": "Node not found", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + }, + "x-id": "journalctl", + "operationId": "journalctl" + } + }, + "/proxmox/journalctl/{node}/{vmid}": { + "get": { + "description": "Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "proxmox", + "websocket" + ], + "summary": "Get journalctl output", + "parameters": [ + { + "type": "string", + "description": "Node name", + "name": "node", "in": "path", "required": true }, { "type": "integer", - "description": "limit", + "description": "Container VMID (optional - if not provided, streams node journalctl)", + "name": "vmid", + "in": "path" + }, + { + "type": "integer", + "description": "Limit output lines (1-1000)", "name": "limit", "in": "query" } @@ -2149,7 +2215,7 @@ }, "/proxmox/journalctl/{node}/{vmid}/{service}": { "get": { - "description": "Get journalctl output", + "description": "Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl.", "consumes": [ "application/json" ], @@ -2164,24 +2230,26 @@ "parameters": [ { "type": "string", + "description": "Node name", "name": "node", "in": "path", "required": true }, + { + "type": "integer", + "description": "Container VMID (optional - if not provided, streams node journalctl)", + "name": "vmid", + "in": "path" + }, { "type": "string", + "description": "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)", "name": "service", "in": "path" }, { "type": "integer", - "name": "vmid", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "limit", + "description": "Limit output lines (1-1000)", "name": "limit", "in": "query" } diff --git a/internal/api/v1/docs/swagger.yaml b/internal/api/v1/docs/swagger.yaml index 4e8d2b33..dbfe44ef 100644 --- a/internal/api/v1/docs/swagger.yaml +++ b/internal/api/v1/docs/swagger.yaml @@ -3361,21 +3361,67 @@ paths: - metrics - websocket x-id: uptime + /proxmox/journalctl/{node}: + get: + consumes: + - application/json + description: Get journalctl output for node or LXC container. If vmid is not + provided, streams node journalctl. + parameters: + - description: Node name + in: path + name: node + required: true + type: string + - description: Limit output lines (1-1000) + in: query + name: limit + type: integer + produces: + - application/json + responses: + "200": + description: Journalctl output + schema: + type: string + "400": + description: Invalid request + schema: + $ref: '#/definitions/ErrorResponse' + "403": + description: Unauthorized + schema: + $ref: '#/definitions/ErrorResponse' + "404": + description: Node not found + schema: + $ref: '#/definitions/ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/ErrorResponse' + summary: Get journalctl output + tags: + - proxmox + - websocket + x-id: journalctl /proxmox/journalctl/{node}/{vmid}: get: consumes: - application/json - description: Get journalctl output + description: Get journalctl output for node or LXC container. If vmid is not + provided, streams node journalctl. parameters: - - in: path + - description: Node name + in: path name: node required: true type: string - - in: path + - description: Container VMID (optional - if not provided, streams node journalctl) + in: path name: vmid - required: true type: integer - - description: limit + - description: Limit output lines (1-1000) in: query name: limit type: integer @@ -3411,20 +3457,24 @@ paths: get: consumes: - application/json - description: Get journalctl output + description: Get journalctl output for node or LXC container. If vmid is not + provided, streams node journalctl. parameters: - - in: path + - description: Node name + in: path name: node required: true type: string - - in: path + - description: Container VMID (optional - if not provided, streams node journalctl) + in: path + name: vmid + type: integer + - description: Service name (e.g., 'pveproxy' for node, 'container@.service' + format for LXC) + in: path name: service type: string - - in: path - name: vmid - required: true - type: integer - - description: limit + - description: Limit output lines (1-1000) in: query name: limit type: integer diff --git a/internal/api/v1/proxmox/journalctl.go b/internal/api/v1/proxmox/journalctl.go index 8a512b00..caabd067 100644 --- a/internal/api/v1/proxmox/journalctl.go +++ b/internal/api/v1/proxmox/journalctl.go @@ -12,7 +12,7 @@ import ( type JournalctlRequest struct { Node string `uri:"node" binding:"required"` - VMID int `uri:"vmid" binding:"required"` + VMID *int `uri:"vmid"` // optional - if not provided, streams node journalctl Service string `uri:"service"` Limit int `query:"limit" binding:"omitempty,min=1,max=1000"` } @@ -20,17 +20,20 @@ type JournalctlRequest struct { // @x-id "journalctl" // @BasePath /api/v1 // @Summary Get journalctl output -// @Description Get journalctl output +// @Description Get journalctl output for node or LXC container. If vmid is not provided, streams node journalctl. // @Tags proxmox,websocket // @Accept json // @Produce application/json -// @Param path path JournalctlRequest true "Request" -// @Param limit query int false "limit" -// @Success 200 string plain "Journalctl output" +// @Param node path string true "Node name" +// @Param vmid path int false "Container VMID (optional - if not provided, streams node journalctl)" +// @Param service path string false "Service name (e.g., 'pveproxy' for node, 'container@.service' format for LXC)" +// @Param limit query int false "Limit output lines (1-1000)" +// @Success 200 string plain "Journalctl output" // @Failure 400 {object} apitypes.ErrorResponse "Invalid request" // @Failure 403 {object} apitypes.ErrorResponse "Unauthorized" // @Failure 404 {object} apitypes.ErrorResponse "Node not found" // @Failure 500 {object} apitypes.ErrorResponse "Internal server error" +// @Router /proxmox/journalctl/{node} [get] // @Router /proxmox/journalctl/{node}/{vmid} [get] // @Router /proxmox/journalctl/{node}/{vmid}/{service} [get] func Journalctl(c *gin.Context) { @@ -53,7 +56,12 @@ func Journalctl(c *gin.Context) { } defer manager.Close() - reader, err := node.LXCJournalctl(c.Request.Context(), request.VMID, request.Service, request.Limit) + var reader io.ReadCloser + if request.VMID == nil { + reader, err = node.NodeJournalctl(c.Request.Context(), request.Service, request.Limit) + } else { + reader, err = node.LXCJournalctl(c.Request.Context(), *request.VMID, request.Service, request.Limit) + } if err != nil { c.Error(apitypes.InternalServerError(err, "failed to get journalctl output")) return diff --git a/internal/proxmox/client.go b/internal/proxmox/client.go index c49e20e0..94fc3735 100644 --- a/internal/proxmox/client.go +++ b/internal/proxmox/client.go @@ -147,6 +147,34 @@ func (c *Client) ReverseLookupResource(ip net.IP, hostname string, alias string) return nil, ErrResourceNotFound } +// ReverseLookupNode looks up a node by name or IP address. +// Returns the node name if found. +func (c *Client) ReverseLookupNode(hostname string, ip net.IP, alias string) string { + shouldCheckHostname := hostname != "" + shouldCheckIP := ip != nil && !ip.IsLoopback() && !ip.IsUnspecified() + shouldCheckAlias := alias != "" + + if shouldCheckHostname { + hostname, _, _ = strings.Cut(hostname, ".") + } + + for _, node := range c.Cluster.Nodes { + if shouldCheckHostname && node.Name == hostname { + return node.Name + } + if shouldCheckIP { + nodeIP := net.ParseIP(node.IP) + if nodeIP != nil && nodeIP.Equal(ip) { + return node.Name + } + } + if shouldCheckAlias && node.Name == alias { + return node.Name + } + } + return "" +} + // Key implements pool.Object func (c *Client) Key() string { return c.Cluster.ID diff --git a/internal/proxmox/node.go b/internal/proxmox/node.go index b145bdb4..0a97e3ce 100644 --- a/internal/proxmox/node.go +++ b/internal/proxmox/node.go @@ -186,3 +186,14 @@ func (n *Node) NodeCommand(ctx context.Context, command string) (io.ReadCloser, return pr, nil } + +func (n *Node) NodeJournalctl(ctx context.Context, service string, limit int) (io.ReadCloser, error) { + command := "journalctl -f" + if service != "" { + command = fmt.Sprintf("journalctl -u %q -f", service) + } + if limit > 0 { + command = fmt.Sprintf("%s -n %d", command, limit) + } + return n.NodeCommand(ctx, command) +} diff --git a/internal/route/route.go b/internal/route/route.go index 068c4d7d..ceb2bf03 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -196,68 +196,68 @@ func (r *Route) validate() gperr.Error { if nodeName == "" { return gperr.Errorf("node (proxmox node name) is required") } - if vmid <= 0 { - return gperr.Errorf("vmid (lxc id) is required") - } node, ok := proxmox.Nodes.Get(nodeName) if !ok { - return gperr.Errorf("proxmox node %s not found in pool", node) + return gperr.Errorf("proxmox node %s not found in pool", nodeName) } - res, err := node.Client().GetResource("lxc", vmid) - if err != nil { - return gperr.Wrap(err) // ErrResourceNotFound - } - - r.Proxmox.VMName = res.Name - - if r.Host == DefaultHost { - containerName := r.Idlewatcher.ContainerName() - // get ip addresses of the vmid - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - ips := res.IPs - if len(ips) == 0 { - return gperr.Multiline(). - Addf("no ip addresses found for %s", containerName). - Adds("make sure you have set static ip address for container instead of dhcp"). - Subject(containerName) - } - - l := log.With().Str("container", containerName).Logger() - - l.Info().Msg("checking if container is running") - running, err := node.LXCIsRunning(ctx, vmid) + // Node-level route (VMID = 0) - no container control needed + if vmid > 0 { + res, err := node.Client().GetResource("lxc", vmid) if err != nil { - return gperr.New("failed to check container state").With(err) + return gperr.Wrap(err) // ErrResourceNotFound } - if !running { - l.Info().Msg("starting container") - if err := node.LXCAction(ctx, vmid, proxmox.LXCStart); err != nil { - return gperr.New("failed to start container").With(err) - } - } + r.Proxmox.VMName = res.Name - l.Info().Msgf("finding reachable ip addresses") - errs := gperr.NewBuilder("failed to find reachable ip addresses") - for _, ip := range ips { - if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil { - errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy)) - } else { - r.Host = ip.String() - l.Info().Msgf("using ip %s", r.Host) - break - } - } if r.Host == DefaultHost { - return gperr.Multiline(). - Addf("no reachable ip addresses found, tried %d IPs", len(ips)). - With(errs.Error()). - Subject(containerName) + containerName := r.Idlewatcher.ContainerName() + // get ip addresses of the vmid + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ips := res.IPs + if len(ips) == 0 { + return gperr.Multiline(). + Addf("no ip addresses found for %s", containerName). + Adds("make sure you have set static ip address for container instead of dhcp"). + Subject(containerName) + } + + l := log.With().Str("container", containerName).Logger() + + l.Info().Msg("checking if container is running") + running, err := node.LXCIsRunning(ctx, vmid) + if err != nil { + return gperr.New("failed to check container state").With(err) + } + + if !running { + l.Info().Msg("starting container") + if err := node.LXCAction(ctx, vmid, proxmox.LXCStart); err != nil { + return gperr.New("failed to start container").With(err) + } + } + + l.Info().Msgf("finding reachable ip addresses") + errs := gperr.NewBuilder("failed to find reachable ip addresses") + for _, ip := range ips { + if err := netutils.PingTCP(ctx, ip, r.Port.Proxy); err != nil { + errs.Add(gperr.Unwrap(err).Subjectf("%s:%d", ip, r.Port.Proxy)) + } else { + r.Host = ip.String() + l.Info().Msgf("using ip %s", r.Host) + break + } + } + if r.Host == DefaultHost { + return gperr.Multiline(). + Addf("no reachable ip addresses found, tried %d IPs", len(ips)). + With(errs.Error()). + Subject(containerName) + } } } } @@ -337,8 +337,21 @@ func (r *Route) validate() gperr.Error { hostname := r.ProxyURL.Hostname() ip := net.ParseIP(hostname) for _, p := range config.WorkingState.Load().Value().Providers.Proxmox { + // First check if hostname, IP, or alias matches a node (node-level route) + if nodeName := p.Client().ReverseLookupNode(hostname, ip, r.Alias); nodeName != "" { + r.Proxmox = &proxmox.NodeConfig{ + Node: nodeName, + VMID: 0, // node-level route, no specific VM + VMName: "", + } + log.Info(). + Str("node", nodeName). + Msgf("found proxmox node for route %q", r.Alias) + break + } + + // Then check if hostname, IP, or alias matches a VM resource resource, _ := p.Client().ReverseLookupResource(ip, hostname, r.Alias) - // reverse lookup resource by ip address, hostname or alias if resource != nil { r.Proxmox = &proxmox.NodeConfig{ Node: resource.Node,