diff --git a/cmd/agent_setup.go b/cmd/agent_setup.go
index e98e9ab5c..969dbab72 100644
--- a/cmd/agent_setup.go
+++ b/cmd/agent_setup.go
@@ -43,6 +43,7 @@ import (
"github.com/retr0h/osapi/internal/provider/node/disk"
nodeHost "github.com/retr0h/osapi/internal/provider/node/host"
"github.com/retr0h/osapi/internal/provider/node/load"
+ logProv "github.com/retr0h/osapi/internal/provider/node/log"
"github.com/retr0h/osapi/internal/provider/node/mem"
ntpProv "github.com/retr0h/osapi/internal/provider/node/ntp"
powerProv "github.com/retr0h/osapi/internal/provider/node/power"
@@ -205,6 +206,9 @@ func setupAgent(
// --- Package provider ---
packageProvider := createPackageProvider(log, execManager)
+ // --- Log provider ---
+ logProvider := createLogProvider(log, execManager)
+
// --- Build registry ---
registry := agent.NewProviderRegistry()
@@ -222,6 +226,7 @@ func setupAgent(
processProvider,
userProvider,
packageProvider,
+ logProvider,
appConfig,
log,
),
@@ -236,6 +241,7 @@ func setupAgent(
processProvider,
userProvider,
packageProvider,
+ logProvider,
)
registry.Register("network",
@@ -536,3 +542,26 @@ func createPackageProvider(
return aptProv.NewLinuxProvider()
}
}
+
+// createLogProvider creates a platform-specific log provider. On Debian, the
+// log provider queries journal logs via journalctl. In containers, journalctl
+// requires systemd which is not available, so the provider is disabled. On
+// other platforms, all operations return ErrUnsupported.
+func createLogProvider(
+ log *slog.Logger,
+ execManager exec.Manager,
+) logProv.Provider {
+ plat := platform.Detect()
+
+ switch plat {
+ case "debian":
+ if platform.IsContainer() {
+ return logProv.NewLinuxProvider()
+ }
+ return logProv.NewDebianProvider(log, execManager)
+ case "darwin":
+ return logProv.NewDarwinProvider()
+ default:
+ return logProv.NewLinuxProvider()
+ }
+}
diff --git a/cmd/client_node_log.go b/cmd/client_node_log.go
new file mode 100644
index 000000000..7c2042d1b
--- /dev/null
+++ b/cmd/client_node_log.go
@@ -0,0 +1,35 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+)
+
+// clientNodeLogCmd represents the clientNodeLog command.
+var clientNodeLogCmd = &cobra.Command{
+ Use: "log",
+ Short: "View journal logs",
+}
+
+func init() {
+ clientNodeCmd.AddCommand(clientNodeLogCmd)
+}
diff --git a/cmd/client_node_log_query.go b/cmd/client_node_log_query.go
new file mode 100644
index 000000000..0c7fe4a6c
--- /dev/null
+++ b/cmd/client_node_log_query.go
@@ -0,0 +1,125 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/retr0h/osapi/internal/cli"
+ "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+// clientNodeLogQueryCmd represents the log query command.
+var clientNodeLogQueryCmd = &cobra.Command{
+ Use: "query",
+ Short: "Query journal entries",
+ Long: `Query journal log entries on the target node.`,
+ Run: func(cmd *cobra.Command, _ []string) {
+ ctx := cmd.Context()
+ host, _ := cmd.Flags().GetString("target")
+
+ opts := client.LogQueryOpts{}
+
+ if cmd.Flags().Changed("lines") {
+ lines, _ := cmd.Flags().GetInt("lines")
+ opts.Lines = &lines
+ }
+
+ since, _ := cmd.Flags().GetString("since")
+ if since != "" {
+ opts.Since = &since
+ }
+
+ priority, _ := cmd.Flags().GetString("priority")
+ if priority != "" {
+ opts.Priority = &priority
+ }
+
+ resp, err := sdkClient.Log.Query(ctx, host, opts)
+ if err != nil {
+ cli.HandleError(err, logger)
+ return
+ }
+
+ if jsonOutput {
+ fmt.Println(string(resp.RawJSON()))
+ return
+ }
+
+ if resp.Data.JobID != "" {
+ fmt.Println()
+ cli.PrintKV("Job ID", resp.Data.JobID)
+ fmt.Println()
+ }
+
+ results := make([]cli.ResultRow, 0)
+ for _, r := range resp.Data.Results {
+ if r.Error != "" {
+ var errPtr *string
+ e := r.Error
+ errPtr = &e
+ results = append(results, cli.ResultRow{
+ Hostname: r.Hostname,
+ Status: r.Status,
+ Error: errPtr,
+ })
+
+ continue
+ }
+
+ for _, e := range r.Entries {
+ message := e.Message
+ if len(message) > 80 {
+ message = message[:77] + "..."
+ }
+
+ results = append(results, cli.ResultRow{
+ Hostname: r.Hostname,
+ Status: r.Status,
+ Fields: []string{
+ e.Timestamp,
+ e.Priority,
+ e.Unit,
+ message,
+ },
+ })
+ }
+ }
+ headers, rows := cli.BuildBroadcastTable(
+ results,
+ []string{"TIMESTAMP", "PRIORITY", "UNIT", "MESSAGE"},
+ )
+ cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}})
+ },
+}
+
+func init() {
+ clientNodeLogCmd.AddCommand(clientNodeLogQueryCmd)
+
+ clientNodeLogQueryCmd.PersistentFlags().
+ Int("lines", 100, "Maximum number of log lines to return")
+ clientNodeLogQueryCmd.PersistentFlags().
+ String("since", "", "Return entries since this time (e.g., '1h', '2026-01-01 00:00:00')")
+ clientNodeLogQueryCmd.PersistentFlags().
+ String("priority", "", "Filter by priority level (e.g., 'err', 'warning', 'info')")
+}
diff --git a/cmd/client_node_log_source.go b/cmd/client_node_log_source.go
new file mode 100644
index 000000000..249e9dbe9
--- /dev/null
+++ b/cmd/client_node_log_source.go
@@ -0,0 +1,89 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/retr0h/osapi/internal/cli"
+)
+
+// clientNodeLogSourceCmd represents the log source command.
+var clientNodeLogSourceCmd = &cobra.Command{
+ Use: "source",
+ Short: "List available log sources",
+ Long: `List unique syslog identifiers available in the journal on the target node.`,
+ Run: func(cmd *cobra.Command, _ []string) {
+ ctx := cmd.Context()
+ host, _ := cmd.Flags().GetString("target")
+
+ resp, err := sdkClient.Log.Sources(ctx, host)
+ if err != nil {
+ cli.HandleError(err, logger)
+ return
+ }
+
+ if jsonOutput {
+ fmt.Println(string(resp.RawJSON()))
+ return
+ }
+
+ if resp.Data.JobID != "" {
+ fmt.Println()
+ cli.PrintKV("Job ID", resp.Data.JobID)
+ fmt.Println()
+ }
+
+ results := make([]cli.ResultRow, 0)
+ for _, r := range resp.Data.Results {
+ if r.Error != "" {
+ e := r.Error
+ results = append(results, cli.ResultRow{
+ Hostname: r.Hostname,
+ Status: r.Status,
+ Error: &e,
+ })
+
+ continue
+ }
+
+ for _, source := range r.Sources {
+ results = append(results, cli.ResultRow{
+ Hostname: r.Hostname,
+ Status: r.Status,
+ Fields: []string{source},
+ })
+ }
+ }
+
+ headers, rows := cli.BuildBroadcastTable(
+ results,
+ []string{"SOURCE"},
+ )
+ cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}})
+ },
+}
+
+func init() {
+ clientNodeLogCmd.AddCommand(clientNodeLogSourceCmd)
+}
diff --git a/cmd/client_node_log_unit.go b/cmd/client_node_log_unit.go
new file mode 100644
index 000000000..1fe80d022
--- /dev/null
+++ b/cmd/client_node_log_unit.go
@@ -0,0 +1,130 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/retr0h/osapi/internal/cli"
+ "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+// clientNodeLogUnitCmd represents the log unit command.
+var clientNodeLogUnitCmd = &cobra.Command{
+ Use: "unit",
+ Short: "Query journal entries for a unit",
+ Long: `Query journal log entries for a specific systemd unit on the target node.`,
+ Run: func(cmd *cobra.Command, _ []string) {
+ ctx := cmd.Context()
+ host, _ := cmd.Flags().GetString("target")
+ unit, _ := cmd.Flags().GetString("name")
+
+ opts := client.LogQueryOpts{}
+
+ if cmd.Flags().Changed("lines") {
+ lines, _ := cmd.Flags().GetInt("lines")
+ opts.Lines = &lines
+ }
+
+ since, _ := cmd.Flags().GetString("since")
+ if since != "" {
+ opts.Since = &since
+ }
+
+ priority, _ := cmd.Flags().GetString("priority")
+ if priority != "" {
+ opts.Priority = &priority
+ }
+
+ resp, err := sdkClient.Log.QueryUnit(ctx, host, unit, opts)
+ if err != nil {
+ cli.HandleError(err, logger)
+ return
+ }
+
+ if jsonOutput {
+ fmt.Println(string(resp.RawJSON()))
+ return
+ }
+
+ if resp.Data.JobID != "" {
+ fmt.Println()
+ cli.PrintKV("Job ID", resp.Data.JobID)
+ fmt.Println()
+ }
+
+ results := make([]cli.ResultRow, 0)
+ for _, r := range resp.Data.Results {
+ if r.Error != "" {
+ var errPtr *string
+ e := r.Error
+ errPtr = &e
+ results = append(results, cli.ResultRow{
+ Hostname: r.Hostname,
+ Status: r.Status,
+ Error: errPtr,
+ })
+
+ continue
+ }
+
+ for _, e := range r.Entries {
+ message := e.Message
+ if len(message) > 80 {
+ message = message[:77] + "..."
+ }
+
+ results = append(results, cli.ResultRow{
+ Hostname: r.Hostname,
+ Status: r.Status,
+ Fields: []string{
+ e.Timestamp,
+ e.Priority,
+ e.Unit,
+ message,
+ },
+ })
+ }
+ }
+ headers, rows := cli.BuildBroadcastTable(
+ results,
+ []string{"TIMESTAMP", "PRIORITY", "UNIT", "MESSAGE"},
+ )
+ cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}})
+ },
+}
+
+func init() {
+ clientNodeLogCmd.AddCommand(clientNodeLogUnitCmd)
+
+ clientNodeLogUnitCmd.PersistentFlags().
+ String("name", "", "Systemd unit name (required)")
+ clientNodeLogUnitCmd.PersistentFlags().
+ Int("lines", 100, "Maximum number of log lines to return")
+ clientNodeLogUnitCmd.PersistentFlags().
+ String("since", "", "Return entries since this time (e.g., '1h', '2026-01-01 00:00:00')")
+ clientNodeLogUnitCmd.PersistentFlags().
+ String("priority", "", "Filter by priority level (e.g., 'err', 'warning', 'info')")
+
+ _ = clientNodeLogUnitCmd.MarkPersistentFlagRequired("name")
+}
diff --git a/cmd/controller_setup.go b/cmd/controller_setup.go
index 75089f2c4..69f445bc0 100644
--- a/cmd/controller_setup.go
+++ b/cmd/controller_setup.go
@@ -50,6 +50,7 @@ import (
dockerAPI "github.com/retr0h/osapi/internal/controller/api/node/docker"
nodeFileAPI "github.com/retr0h/osapi/internal/controller/api/node/file"
hostnameAPI "github.com/retr0h/osapi/internal/controller/api/node/hostname"
+ logAPI "github.com/retr0h/osapi/internal/controller/api/node/log"
networkAPI "github.com/retr0h/osapi/internal/controller/api/node/network"
ntpAPI "github.com/retr0h/osapi/internal/controller/api/node/ntp"
packageAPI "github.com/retr0h/osapi/internal/controller/api/node/package"
@@ -732,6 +733,7 @@ func registerControllerHandlers(
handlers = append(handlers, processAPI.Handler(log, jc, signingKey, customRoles)...)
handlers = append(handlers, userAPI.Handler(log, jc, signingKey, customRoles)...)
handlers = append(handlers, packageAPI.Handler(log, jc, signingKey, customRoles)...)
+ handlers = append(handlers, logAPI.Handler(log, jc, signingKey, customRoles)...)
handlers = append(handlers, nodeFileAPI.Handler(log, jc, signingKey, customRoles)...)
if auditStore != nil {
handlers = append(handlers, auditAPI.Handler(log, auditStore, signingKey, customRoles)...)
diff --git a/docs/docs/gen/api/get-node-log-source.api.mdx b/docs/docs/gen/api/get-node-log-source.api.mdx
new file mode 100644
index 000000000..4014cf53b
--- /dev/null
+++ b/docs/docs/gen/api/get-node-log-source.api.mdx
@@ -0,0 +1,520 @@
+---
+id: get-node-log-source
+title: "List log sources"
+description: "List unique syslog identifiers (log sources) available in the journal on the target node."
+sidebar_label: "List log sources"
+hide_title: true
+hide_table_of_contents: true
+api: eJztV0tv4zYQ/ivEnBKATpSts20F9JC2u4sUabHYTdBDahi0OJYZU6SWHHnjGvrvxVB+aG0V7aEBetiTRXme33zDGW1AYyyCqcl4BzncmUiiceZTgyKuo/WlMBodmbnBEMUZv4i+CQXGc6FWylg1syiME7RA8eSb4JQVvjuSCiWScF7jxR8OJJAqI+SPcOfL6a/KqRIrdDS9eX87tb6c+hqD4kAiTCTsT7cacniH9JvXeOfLj8k9SIhYNMHQGvLHDfyIKmC4aWjBDqwv84BKw6SdSKhVUBUShphEnaoQclj4SOlRguHUa0ULkBDwU2MCasgpNCiP8LnvUlIlOhI7C1IEjBhWqEXwDRlXipWyDYqzqXJrKabK2nMpfBBWzdCKiBYL8kGcLXGdJ9HzDqDnkVe1GRVeY4luhM8U1KhDbQMrZY1WxLHvgpSVcT9cyfTPtIMbWgmxWGClWIfWNctHCsaVIKEy7g5dyShdtYxNwFh7FzF5eJVl/DNACT8XvdJfgITCO0JHLK/q2poi1eryKbLS5jQEP3vCgkBCHbiyZDqXT342NXoo1LkPlSLIoWmMhpNCJLrNxO3PoomoBXlRB19gjIIWJgpGCCNxpPisqtqy7evrDL8bZ9kIX30/G42v9Hikvr16PRqPX7++vh6PsyzLGL+AsbEUe1GpENSamUJYxaGsjjDbYyU6W2Lug1AiGlda7OhzcYLFnpEDaJxmv5Pm2nC3bY1y+UlRE4esoGsq7g+/ZICVscjIxqWpa9TcdKduOmM7J/umTAklpDmOzm1HjgHUvrT68LfXS7o3TDzgcwz3NpG2lYAh+PDPSL1hMVFhjKpEYXpQiS7/i2Tt0PWP/Ythi+REAhlKFNpfQG8chTW0x8o76gyp/OQtN77x7sO261i/lTDOrk777sGphhY+mD9Ri5G4eX8rlrgWe2f/WQv+SyRvRO+840PSFbRQJHxRNCEwoP2We5tA5vYMSMHgaseoRBmNpIwdpOqRc60NPyortjpCzXxDhyAG3eoG2bVD+uzDUpCp0DcdW/mO7fk1jrDEMNhpXZKs8IWT6yzj4u3KnJh2UthvTgv71oeZ0RqdGIlbF5v53BSGCVljqEyMaQB+re7/v7rXQ+Oyu3CsiWkPeJmh+bWmL1TTVkKFtPC8cfIuJbutMIdLXmIvN7vR0F5aX17GwyYaVt12OTmspR+5mF29+svpPv4FUQ3bXY3PsyQEcvvwdrf+/PL7fRpRxs19Ut9Gf5OG2GGN5gkBEjiQDoiri+wirTO1j1SpxLDt7pt2uh43j0HcHJj60p8EHRiEz3RZW2UcB9wEyzF02D8CS4OEvDeYrWfGbQswkWlms+hmM1MRH4JtW379qcGw7sqyUsFwYOkTQJvIzxryubLxeMvvZ3/2YTtvz8UL7/6DSOwWKcdrVJKGHEDCEtf9T5h20kpYoNIYUn7d3zdFgTX1FE/uHP4C2BP+3Zt7kKC+ZOkRK5P1waA2m07i3i/Rte0+RuIzB9i2fwGZrQAf
+sidebar_class_name: "get api-method"
+info_path: gen/api/agent-management-api
+custom_edit_url: null
+---
+
+import ApiTabs from "@theme/ApiTabs";
+import DiscriminatorTabs from "@theme/DiscriminatorTabs";
+import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
+import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes";
+import MimeTabs from "@theme/MimeTabs";
+import ParamsItem from "@theme/ParamsItem";
+import ResponseSamples from "@theme/ResponseSamples";
+import SchemaItem from "@theme/SchemaItem";
+import SchemaTabs from "@theme/SchemaTabs";
+import Heading from "@theme/Heading";
+import OperationTabs from "@theme/OperationTabs";
+import TabItem from "@theme/TabItem";
+
+
+
+
+
+
+
+
+
+
+List unique syslog identifiers (log sources) available in the journal on the target node.
+
+
+
+
+
+
+
+
+ Path Parameters
+
+
+
+
+
+
+
+
+
+ List of log sources.
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+ results
+
+ object[]
+
+
+
+ required
+
+
+
+
-
+
+ Array [
+
+
+
+
+
+
+
+
+
+ -
+
+ ]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Unauthorized - API key required
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Forbidden - Insufficient permissions
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error listing log sources.
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/docs/gen/api/get-node-log-unit.api.mdx b/docs/docs/gen/api/get-node-log-unit.api.mdx
new file mode 100644
index 000000000..071feec99
--- /dev/null
+++ b/docs/docs/gen/api/get-node-log-unit.api.mdx
@@ -0,0 +1,654 @@
+---
+id: get-node-log-unit
+title: "Get log entries for a systemd unit"
+description: "Retrieve log entries for a specific systemd unit on the target node."
+sidebar_label: "Get log entries for a systemd unit"
+hide_title: true
+hide_table_of_contents: true
+api: eJztWG1v2zYQ/isEP6WA7Eipk20C+iHbmi5DOhRpigFLDYMWzzITilRJKo1n+L8PR0qyZLut3aXYPhQIEEkmj88998pbUg42M6J0Qiua0mtwRsADEKlzAgpfLJlpQxixJWRiJjJiF9ZBwUmlhCNaETcH4pjJwRGlOQzfKxpRx3JL01t6pfPJa6ZYDgUoNzl/czmROp/oEgzDMy0dR7R9u+Q0pa/A/aE5XOn8nRKORtRCVhnhFjS9XdKfgRkw55Wbo3ip89QA43S8Gke0ZIYV4MBYv1SxAmhK59o6/xhRgTqWzM1pRA18qIQBTlNnKog2iLgJCrEclCONhIgYsGAegBOjKydUTh6YrIAcTZhaRGTCpHwWEW2IZFOQxIKEzGlDju5hkfqlzwI9jwPNSjHINIcc1AAenWGDwNmSPjApOHOIvQEZFUK9SCL/yySQTVcRtdkcCoZ73KLE9dYZoXIa0UKoK1A5spSsVlFLxsFEvO1aG3eTIxjmw4i8pyoX6nGIhIgM3lP8ZO2cr7/8K2W/Tj8pFNhGwQ8VmEVPwxmTdkvF1+xRFFVBVFVMwRA9897vJRGniQFXGXWYKroQDorSLWrDFezxRRLHcbxTK6Ec5GAoApuxSjqaJnHslURgNE0iWgSQ/pc47qpshcrgQJWvvU69KPdiiJsLS5woYEjOswxKZ9t4x69tFgjBu/aFZB4c4CQ+ORvEySBOSByn/q/1g09Zs6tMaYT2sX6YPhdCOjBkuvAqNUKIhAeQa5BgTED5kRklVB5ehJrp8MRhWuV7wB0jHltqZcEb/iSO8V8f0tVGBsU0WbMHIZyGNKKZVg6Uw+2sLGVN7PGdRRnLbQx6egcZJsXSYNZ0IiC409OJ4LsCZaZNwRxNaVUJTrfS3BzInZ6Sy19JZYGju5dGZ2Bt8ASkHaxHCo+sKCXKPj2N4cdRHA/g5KfpYJTw0YD9kJwNRqOzs9PT0ajxcwO2ks52UDFjmDetg8Lu0mqbwiCkqUFC5RJCVh5ukdAm+h00bKvdrMZ4R9vUQjE+HXOV3SUFFEbgLdX3yCwTEpBSey/KEjhWsu1jgrDmkLbS1S4hrMfhj62dZQddn3Eso4sgpuVkT27PGzKbHLBohEEd8eROV0YxuU00ZgLrWFF+memrVnq7qe9MTcJITm+SOH2OCeMvZAMD5Mvie9XJzZlD9+VVhq6MrPij+wf2qhae1KacvZTp55a+aMwkKLEAa1m+hx+iwHoxcfC4EWdvsdUw6EHGASe2yjAwZ5WUC4+7F/DdGtI94k0dzoKDcph7zH48JSfPR6vogJj6bSOeaifazygfYTqIE6wE1AnnP13p/CUuvPSkYnwYo82XcbzEZS2rohPcJETs0EtbF5TbbodYx/64h+Pa5yCPhq42NzdZrr/lFy2x9xNaXdeVAneuIjqKk+1a8U6xys21EX8DJwNy/uaS3MOCtMc8WZ3Yk8Nz0nlvDOr3BnvqLKuMQSq7Rrzw9NYtU7hGBDp9euPgmJA70+rG4ZwLfGSS1HsIm+rKrUHsPJZXgEcrcB+1uffpRlchs2K7tkeo3LRK4obeIae+5WoN7H1sy7DPtw17oc1UcA6KDMilstVsJjKBrliCKYS1/gb03br/f+ue7mrxQqqp6cDboC9Dnab6CTu877b9Rrb19drNNY4e8FodhXtxSo9xmnG8bIrD6ljq/BgtfLz0734ygRUapw3j9ZjiLVo0GK07rGiVmDtXNtcLfJ/6RTSqHy6ahv33P298pfJdhe+6ggrnvpathypYLmhEEUhgIxnGQ9+Al9q6gnk3q69Xr8DtGu10uqhNbpdrB37C6VBgAlue41IyoXzHZyQeF9i/pbiaRjTtFGep0edqlKn/OA4tCm5YLqfMwjsjVyv8HG6OOAfiwrKp/NTdsavgAZOAnSrcw6Izg/ADH5pSivfb/UH8J3fzz6jTzBe+Up1vcTX/DNrOAGENeIwvRiDiAz3i6LruwZ6RbzwS3KlTcxFUPX0aXdvQOMwiHZ2eYLr3FbgD5jFeL4BxMN4m4afg2J1NWzUTjdkm7Fcvb2hEWT/BbiRUL30noOUyrLjR96BWqxafw3cEuFr9A6ec7TY=
+sidebar_class_name: "get api-method"
+info_path: gen/api/agent-management-api
+custom_edit_url: null
+---
+
+import ApiTabs from "@theme/ApiTabs";
+import DiscriminatorTabs from "@theme/DiscriminatorTabs";
+import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
+import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes";
+import MimeTabs from "@theme/MimeTabs";
+import ParamsItem from "@theme/ParamsItem";
+import ResponseSamples from "@theme/ResponseSamples";
+import SchemaItem from "@theme/SchemaItem";
+import SchemaTabs from "@theme/SchemaTabs";
+import Heading from "@theme/Heading";
+import OperationTabs from "@theme/OperationTabs";
+import TabItem from "@theme/TabItem";
+
+
+
+
+
+
+
+
+
+
+Retrieve log entries for a specific systemd unit on the target node.
+
+
+
+
+
+
+
+
+ Path Parameters
+
+
+
+
+
+ Query Parameters
+
+
+
+
+
+
+
+
+
+ Log entries for the specified unit.
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+ results
+
+ object[]
+
+
+
+ required
+
+
+
+
-
+
+ Array [
+
+
+
+
+
+
+
+
+
+
+ entries
+
+ object[]
+
+
+
+
+
+
+ Log entries from this agent.
+
+
+
-
+
+ Array [
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+ ]
+
+
+
+
+
+
+ -
+
+ ]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Unauthorized - API key required
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Forbidden - Insufficient permissions
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error retrieving unit log entries.
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/docs/gen/api/get-node-log.api.mdx b/docs/docs/gen/api/get-node-log.api.mdx
new file mode 100644
index 000000000..e559cffc5
--- /dev/null
+++ b/docs/docs/gen/api/get-node-log.api.mdx
@@ -0,0 +1,649 @@
+---
+id: get-node-log
+title: "Get system log entries"
+description: "Retrieve log entries from the target node's system journal."
+sidebar_label: "Get system log entries"
+hide_title: true
+hide_table_of_contents: true
+api: eJztWG1v2zYQ/isEvywFZEdKnWwT0A/Z1nYZ0qFIUwxYYhi0dJaZUKRKntJ4hv/7cKQky7HbOFuL7UMBA5Zs8vjcc+9c8hxcZmWF0mie8gtAK+EOmDIFA00vjs2sKRnOgaGwBSDTJofvHHMLh1CyG1NbLdTwWvOIoygcT6/4uSkmb4QWBZSgcXL69myiTDExFVhBRzk+jnj3dpbzlL8G/N3kcG4KHnEHWW0lLnh6teQ/gbBgT2uck2hlitSCyPl4NY54JawoAcE6v1SLEnjK58ahf4y4JLUqgXMecQsfamkh5ynaGqIHul8G7UQBGlkrIWIWHNg7yJk1NUpdsDuhamAHE6EXEZsIpZ5FzFimxBQUc6AgQ2PZwS0sUr/0WaDmfmBEJQeZyaEAPYB7tGIQ+FryO6FkLpCwtyCjUuoXSeT/mQTm+SriLptDKWgPLipa79BKTZyVUp+DLoilZLWKOjKU1OBaJj7UYBcbVMyEcltcvBH3sqxLputyCpaZmXcIL4mhYRawtvppeplSIpQVLhrFSnH/IonjON6pldQIBVhOwGaiVsjTJI69kgSMp0nEywDS/xPHfZWd1Bk8UeULr9OG43sxDOfSMZQlDNlplkGFrevn/lfmKsjkTGbBsdkBDIthxK55Mr/m9H0UH50M4mQQJyyOU/+55o1TfMqafWUqK42Phafp80oqBMumC69SK4QpuAO1BgnWBpQfhdVSF+FF6pkJTzlM62IPuGPC4yqjHXjDH8UxfW1COn8kqQx5xDOjETTSZlFVqqH18MaRhOU2AjO9gQx5xCtL+QRlOP/GTCcy3xUmM2NLgTzldS1zvpUE5sBuzJSd/cJqBzk5e2VNBs4FPyDSwSEhhXtRVopkHx/H8MMojgdw9ON0MEry0UB8n5wMRqOTk+Pj0aj1cguuVuh6qIS1whsWoXS7tNomMAhhM2OZIA8tFIScNdwioUuDO2jYVrtdTdFOhmmEUnSiwNrtkgKa4u+Km1tiVkgFRKm7lVUFOeX47WOCsPaQrgZ4hTzFhMMf27jKDroedSvp1pzsye1pS2abARZrH31Q6raIpjzgUJTV40yfd9K7TZvO1KaL5PgyidPnlC7+JDZqLfFx8e+azESrGc4FkvvmdUauTKz4ozcP1IXU90MqcjIDOqlLOHsps5lZNkVTHiGJJTgnij38kAQ2ixnC/YM4e0eF2JIHWYScuTqjwJzVSi087o2A71eQ/hFvm3CWOWiUMwl2P56So+ejVfSEmPr1QTw1TrSfUT7CdBAnVAc4SvQ/nZviJS0886RSfFhr7OM4XtKyjlXZC24WInbopa3LyVW/f2pif7yB48LnII+Grx5ubrPc5pafjaLOSBp90dQJ2rmK+ChOtivFey1qnBsr/4KcDdjp2zN2CwvWHfPF6sSeHJ6y3ntrUL832NNkWW0tUdk34itPb9Mwhb460OnTWw4opNqZVh8cnueSHoVizR4mpqbGNYidx+Y10NEa8KOxtz7dmDpkVmrW9giVy07JrKnO3SHHvuHqDOx9bMuwz7cN+8rYqcxz0GzAzrSrZzOZSXLFCmwpnfOzwTfr/v+te7yrwQuppqGDZqVeN/0Fm7tvZv1KZvWlGueG5nGaN6MwOaf8kNrzw2VbF1aHqhnTqSDT6D1ez+zvyIrBUP3JvQM+R6zaWYLep34Rj5qHV21//tsfl74w+SbCN1kB9qkvXevbBaoOPOIEJDCQDOOh77cr47AU3rWaWeo1YFuHe675kMPl2lH//Y1IUJqamcNKCal9L2cVnRLIveK0nUc87ZVd4nccug1asVxOhYP3Vq1W9HMYAenCI5dOTNWnhsC+Ik8Y6XdivoVF7zLB32zwlHMaVPcH8Z8M2Z9Rp70o+IfqfI0Z+zNoezcBa8BjerGSED/RIw4umnbqGfvKd187dWpnOr2hT6trFwurMXXdIHKwXr/wd3CS3satekLEdMns9ctLHnGxmYgeJB4vfSeo5TKsuDS3oFerDiPSOwFcrf4G1IRNvw==
+sidebar_class_name: "get api-method"
+info_path: gen/api/agent-management-api
+custom_edit_url: null
+---
+
+import ApiTabs from "@theme/ApiTabs";
+import DiscriminatorTabs from "@theme/DiscriminatorTabs";
+import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
+import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes";
+import MimeTabs from "@theme/MimeTabs";
+import ParamsItem from "@theme/ParamsItem";
+import ResponseSamples from "@theme/ResponseSamples";
+import SchemaItem from "@theme/SchemaItem";
+import SchemaTabs from "@theme/SchemaTabs";
+import Heading from "@theme/Heading";
+import OperationTabs from "@theme/OperationTabs";
+import TabItem from "@theme/TabItem";
+
+
+
+
+
+
+
+
+
+
+Retrieve log entries from the target node's system journal.
+
+
+
+
+
+
+
+
+ Path Parameters
+
+
+
+
+
+ Query Parameters
+
+
+
+
+
+
+
+
+
+ Log entries from the target node.
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+ results
+
+ object[]
+
+
+
+ required
+
+
+
+
-
+
+ Array [
+
+
+
+
+
+
+
+
+
+
+ entries
+
+ object[]
+
+
+
+
+
+
+ Log entries from this agent.
+
+
+
-
+
+ Array [
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+ ]
+
+
+
+
+
+
+ -
+
+ ]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Unauthorized - API key required
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Forbidden - Insufficient permissions
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error retrieving log entries.
+
+
+
+
+
+
+
+
+
+
+ Schema
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/docs/gen/api/log-management-api-log-operations.tag.mdx b/docs/docs/gen/api/log-management-api-log-operations.tag.mdx
new file mode 100644
index 000000000..a84080631
--- /dev/null
+++ b/docs/docs/gen/api/log-management-api-log-operations.tag.mdx
@@ -0,0 +1,20 @@
+---
+id: log-management-api-log-operations
+title: "Node/Log"
+description: "Node/Log"
+custom_edit_url: null
+---
+
+
+
+Log viewing operations on a target node.
+
+
+
+```mdx-code-block
+import DocCardList from '@theme/DocCardList';
+import {useCurrentSidebarCategory} from '@docusaurus/theme-common';
+
+
+```
+
\ No newline at end of file
diff --git a/docs/docs/gen/api/sidebar.ts b/docs/docs/gen/api/sidebar.ts
index aaf463cff..01f9466fa 100644
--- a/docs/docs/gen/api/sidebar.ts
+++ b/docs/docs/gen/api/sidebar.ts
@@ -408,6 +408,34 @@ const sidebar: SidebarsConfig = {
},
],
},
+ {
+ type: "category",
+ label: "Node/Log",
+ link: {
+ type: "doc",
+ id: "gen/api/log-management-api-log-operations",
+ },
+ items: [
+ {
+ type: "doc",
+ id: "gen/api/get-node-log",
+ label: "Get system log entries",
+ className: "api-method get",
+ },
+ {
+ type: "doc",
+ id: "gen/api/get-node-log-source",
+ label: "List log sources",
+ className: "api-method get",
+ },
+ {
+ type: "doc",
+ id: "gen/api/get-node-log-unit",
+ label: "Get log entries for a systemd unit",
+ className: "api-method get",
+ },
+ ],
+ },
{
type: "category",
label: "Node/Network",
diff --git a/docs/docs/sidebar/architecture/api-guidelines.md b/docs/docs/sidebar/architecture/api-guidelines.md
index 5d239768b..1a2fab77a 100644
--- a/docs/docs/sidebar/architecture/api-guidelines.md
+++ b/docs/docs/sidebar/architecture/api-guidelines.md
@@ -65,6 +65,9 @@ Sub-resources represent distinct capabilities of the node:
| `/node/{hostname}/package/{name}` | Package |
| `/node/{hostname}/package/update` | Package |
| `/node/{hostname}/package/updates` | Package |
+| `/node/{hostname}/log` | Log |
+| `/node/{hostname}/log/source` | Log |
+| `/node/{hostname}/log/unit/{name}` | Log |
6. **Path Parameters Over Query Parameters**
diff --git a/docs/docs/sidebar/architecture/architecture.md b/docs/docs/sidebar/architecture/architecture.md
index 8f31a2bd3..4c1ef07cd 100644
--- a/docs/docs/sidebar/architecture/architecture.md
+++ b/docs/docs/sidebar/architecture/architecture.md
@@ -166,6 +166,8 @@ configure them — see the Features section:
and group management
- [Package Management](../features/package-management.md) — system package
management
+- [Log Management](../features/log-management.md) — systemd journal query by
+ host or unit
- [Job System](../features/job-system.md) — async job processing and routing
- [Audit Logging](../features/audit-logging.md) — API audit trail and export
- [Health Checks](../features/health-checks.md) — liveness, readiness, status
diff --git a/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-log-management.md b/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-log-management.md
deleted file mode 100644
index f00b53ef2..000000000
--- a/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-log-management.md
+++ /dev/null
@@ -1,40 +0,0 @@
----
-title: Log viewing and management
-status: backlog
-created: 2026-02-15
-updated: 2026-02-15
----
-
-## Objective
-
-Add log viewing endpoints. Appliance operators need to inspect system and
-service logs for troubleshooting without SSH access.
-
-## API Endpoints
-
-```
-GET /log/journal - Query systemd journal entries
-GET /log/journal/unit/{name} - Get logs for specific unit
-GET /log/syslog - Get recent syslog entries
-```
-
-## Operations
-
-- `log.journal.get` (query)
-- `log.journal.unit.get` (query)
-- `log.syslog.get` (query)
-
-## Provider
-
-- `internal/provider/node/log/`
-- Use `journalctl` with JSON output for structured log parsing
-- Support query params: since, until, unit, priority, limit, grep
-- Return type: `LogEntry` with timestamp, unit, priority, message, PID, hostname
-
-## Notes
-
-- Logs can be very large — pagination and limits are essential
-- Support streaming in future (SSE or WebSocket) for tail -f equivalent
-- Read-only — no log deletion via API
-- Scopes: `log:read`
-- Consider security: some logs may contain sensitive info
diff --git a/docs/docs/sidebar/features/authentication.md b/docs/docs/sidebar/features/authentication.md
index d22d19034..0e430ac9e 100644
--- a/docs/docs/sidebar/features/authentication.md
+++ b/docs/docs/sidebar/features/authentication.md
@@ -60,11 +60,11 @@ flowchart TD
Built-in roles expand to these default permissions:
-| Role | Permissions |
-| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `admin` | `agent:read`, `agent:write`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `audit:read`, `command:execute`, `file:read`, `file:write`, `docker:read`, `docker:write`, `docker:execute`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `power:execute`, `process:read`, `process:execute`, `user:read`, `user:write`, `package:read`, `package:write` |
-| `write` | `agent:read`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `file:read`, `file:write`, `docker:read`, `docker:write`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `process:read`, `user:read`, `user:write`, `package:read`, `package:write` |
-| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read` |
+| Role | Permissions |
+| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `admin` | `agent:read`, `agent:write`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `audit:read`, `command:execute`, `file:read`, `file:write`, `docker:read`, `docker:write`, `docker:execute`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `power:execute`, `process:read`, `process:execute`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` |
+| `write` | `agent:read`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `file:read`, `file:write`, `docker:read`, `docker:write`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `process:read`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` |
+| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read`, `log:read` |
### Custom Roles
diff --git a/docs/docs/sidebar/features/features.md b/docs/docs/sidebar/features/features.md
index fa947c639..f20b95ffb 100644
--- a/docs/docs/sidebar/features/features.md
+++ b/docs/docs/sidebar/features/features.md
@@ -32,5 +32,6 @@ OSAPI provides a comprehensive set of features for managing Linux systems.
| 📡 | [Process Management](process-management.md) | List, inspect, and signal running processes |
| 👤 | [User & Group Management](user-management.md) | Local user account and group management |
| 📦 | [Package Management](package-management.md) | System package install, remove, update, and query |
+| 📄 | [Log Management](log-management.md) | Query systemd journal entries by host, unit, or source |
diff --git a/docs/docs/sidebar/features/log-management.md b/docs/docs/sidebar/features/log-management.md
new file mode 100644
index 000000000..39677aa9b
--- /dev/null
+++ b/docs/docs/sidebar/features/log-management.md
@@ -0,0 +1,116 @@
+---
+sidebar_position: 23
+---
+
+# Log Management
+
+OSAPI provides read-only access to the systemd journal on managed hosts. Log
+entries are retrieved via `journalctl` and returned as structured JSON with
+timestamp, unit, priority, message, PID, and hostname fields.
+
+## How It Works
+
+The log provider queries the systemd journal on the agent host at request time.
+Each request returns a live snapshot — up to the requested number of entries
+matching the given filters.
+
+### Query
+
+Returns journal log entries for the target host. The agent runs
+`journalctl --output=json` with optional `--lines`, `--since`, and `--priority`
+flags and returns the parsed entries.
+
+### QueryUnit
+
+Returns journal log entries scoped to a specific systemd unit. The agent adds
+the `-u ` flag to the journalctl invocation alongside the same optional
+filters.
+
+### Sources
+
+Returns a sorted list of unique syslog identifiers (log sources) available in
+the journal. The agent runs `journalctl --field=SYSLOG_IDENTIFIER`. Use this to
+discover what unit names are available before querying.
+
+## Operations
+
+| Operation | Description |
+| --------- | ---------------------------------------------- |
+| Query | Query journal entries for the host |
+| QueryUnit | Query journal entries for a specific unit name |
+| Sources | List available log sources (syslog IDs) |
+
+## CLI Usage
+
+```bash
+# Query last 100 log entries on a host (default)
+osapi client node log query --target web-01
+
+# Query last 50 error entries in the past hour
+osapi client node log query --target web-01 \
+ --lines 50 --since 1h --priority err
+
+# Query journal entries for the sshd unit
+osapi client node log unit --target web-01 --name sshd.service
+
+# List available log sources
+osapi client node log source --target web-01
+
+# Broadcast log query to all hosts
+osapi client node log query --target _all --lines 20
+```
+
+All commands support `--json` for raw JSON output.
+
+## Broadcast Support
+
+All log operations support broadcast targeting. Use `--target _all` to query
+logs on every registered agent, or use a label selector like
+`--target group:web` to target a subset.
+
+Responses always include per-host results:
+
+```
+ Job ID: 550e8400-e29b-41d4-a716-446655440000
+
+ web-01
+ TIMESTAMP PRIORITY UNIT MESSAGE
+ 2026-01-01T00:00:01+00:00 info sshd.service Accepted publickey ...
+ 2026-01-01T00:00:02+00:00 info sshd.service pam_unix(sshd:ses...
+```
+
+Skipped and failed hosts appear with their error in the output.
+
+## Supported Platforms
+
+| OS Family | Support |
+| --------- | ------- |
+| Debian | Full |
+| Darwin | Skipped |
+| Linux | Skipped |
+
+On unsupported platforms, log operations return `status: skipped` instead of
+failing. See [Platform Detection](../sdk/platform/detection.md) for details on
+OS family detection.
+
+## Container Behavior
+
+Log operations return `status: skipped` inside containers. `journalctl` requires
+a running systemd instance which is not available in standard container
+environments.
+
+## Permissions
+
+| Operation | Permission |
+| ------------------------- | ---------- |
+| Query, QueryUnit, Sources | `log:read` |
+
+Log querying requires `log:read`, included in all built-in roles (`admin`,
+`write`, `read`).
+
+## Related
+
+- [CLI Reference](../usage/cli/client/node/log/log.md) -- log commands
+- [SDK Reference](../sdk/client/operations/log.md) -- Log service
+- [Platform Detection](../sdk/platform/detection.md) -- OS family detection
+- [Configuration](../usage/configuration.md) -- full configuration reference
diff --git a/docs/docs/sidebar/sdk/client/client.md b/docs/docs/sidebar/sdk/client/client.md
index 8f5e77f82..3ed111a65 100644
--- a/docs/docs/sidebar/sdk/client/client.md
+++ b/docs/docs/sidebar/sdk/client/client.md
@@ -54,6 +54,7 @@ resp, err := client.Hostname.Get(ctx, "_any")
| [Command](operations/command.md) | Command execution (exec, shell) |
| [Power](operations/power.md) | Power management (reboot, shutdown) |
| [Process](operations/process.md) | Process management (list, get, signal) |
+| [Log](operations/log.md) | Log query (journal entries, by unit) |
### Containers & Scheduling
diff --git a/docs/docs/sidebar/sdk/client/operations/log.md b/docs/docs/sidebar/sdk/client/operations/log.md
new file mode 100644
index 000000000..9df4c8cb6
--- /dev/null
+++ b/docs/docs/sidebar/sdk/client/operations/log.md
@@ -0,0 +1,116 @@
+---
+sidebar_position: 4
+---
+
+# Log
+
+The `Log` service provides methods for querying the systemd journal on target
+hosts. Access via `client.Log`.
+
+## Methods
+
+| Method | Description |
+| -------------------------------------- | ----------------------------------------- |
+| `Query(ctx, hostname, opts)` | Query journal entries for the host |
+| `QueryUnit(ctx, hostname, unit, opts)` | Query journal entries for a specific unit |
+| `Sources(ctx, hostname)` | List available log sources (syslog IDs) |
+
+## Request Types
+
+| Type | Fields |
+| -------------- | ------------------------------------------------------------- |
+| `LogQueryOpts` | `Lines` (`*int`), `Since` (`*string`), `Priority` (`*string`) |
+
+## Usage
+
+```go
+import "github.com/retr0h/osapi/pkg/sdk/client"
+
+c := client.New("http://localhost:8080", token)
+
+// Query last 50 journal entries
+lines := 50
+resp, err := c.Log.Query(ctx, "web-01", client.LogQueryOpts{
+ Lines: &lines,
+})
+for _, r := range resp.Data.Results {
+ for _, e := range r.Entries {
+ fmt.Printf("[%s] %s %s: %s\n",
+ e.Timestamp, e.Priority, e.Unit, e.Message)
+ }
+}
+
+// Query only error entries from the past hour
+since := "1h"
+priority := "err"
+resp, err := c.Log.Query(ctx, "web-01", client.LogQueryOpts{
+ Since: &since,
+ Priority: &priority,
+})
+
+// Query entries for a specific systemd unit
+resp, err := c.Log.QueryUnit(ctx, "web-01", "sshd.service",
+ client.LogQueryOpts{})
+for _, r := range resp.Data.Results {
+ fmt.Printf("%s: %d entries\n", r.Hostname, len(r.Entries))
+ for _, e := range r.Entries {
+ fmt.Printf(" [%s] %s\n", e.Priority, e.Message)
+ }
+}
+
+// List available log sources on the host
+srcResp, err := c.Log.Sources(ctx, "web-01")
+for _, r := range srcResp.Data.Results {
+ for _, src := range r.Sources {
+ fmt.Println(src)
+ }
+}
+
+// Broadcast log query to all hosts
+resp, err := c.Log.Query(ctx, "_all", client.LogQueryOpts{})
+```
+
+## Result Types
+
+`LogEntryResult` is returned per host in the `Collection.Results` slice:
+
+| Field | Type | Description |
+| ---------- | ------------ | -------------------------------- |
+| `Hostname` | `string` | Target host |
+| `Status` | `string` | `ok`, `skipped`, or `failed` |
+| `Entries` | `[]LogEntry` | Journal entries (nil if none) |
+| `Error` | `string` | Error message if the call failed |
+
+`LogEntry` fields:
+
+| Field | Type | Description |
+| ----------- | -------- | ----------------------------------- |
+| `Timestamp` | `string` | ISO 8601 timestamp |
+| `Unit` | `string` | Systemd unit name |
+| `Priority` | `string` | Log priority (e.g., `info`, `err`) |
+| `Message` | `string` | Log message text |
+| `PID` | `int` | Process ID that generated the entry |
+| `Hostname` | `string` | Hostname from the journal entry |
+
+`LogSourceResult` is returned per host for the `Sources` method:
+
+| Field | Type | Description |
+| ---------- | ---------- | -------------------------------- |
+| `Hostname` | `string` | Target host |
+| `Status` | `string` | `ok`, `skipped`, or `failed` |
+| `Sources` | `[]string` | Syslog identifiers (sorted) |
+| `Error` | `string` | Error message if the call failed |
+
+## Example
+
+- [`examples/sdk/client/log.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/log.go)
+
+## Permissions
+
+| Operation | Permission |
+| ------------------------- | ---------- |
+| Query, QueryUnit, Sources | `log:read` |
+
+Log management is supported on the Debian OS family (Ubuntu, Debian, Raspbian).
+On unsupported platforms (Darwin, generic Linux) and inside containers,
+operations return `status: skipped`.
diff --git a/docs/docs/sidebar/usage/cli/client/node/log/log.md b/docs/docs/sidebar/usage/cli/client/node/log/log.md
new file mode 100644
index 000000000..b112abf60
--- /dev/null
+++ b/docs/docs/sidebar/usage/cli/client/node/log/log.md
@@ -0,0 +1,7 @@
+---
+sidebar_position: 1
+---
+
+# Log
+
+
diff --git a/docs/docs/sidebar/usage/cli/client/node/log/query.md b/docs/docs/sidebar/usage/cli/client/node/log/query.md
new file mode 100644
index 000000000..98da83441
--- /dev/null
+++ b/docs/docs/sidebar/usage/cli/client/node/log/query.md
@@ -0,0 +1,66 @@
+# Query
+
+Query journal log entries on a target host:
+
+```bash
+$ osapi client node log query --target web-01
+
+ Job ID: 550e8400-e29b-41d4-a716-446655440000
+
+ TIMESTAMP PRIORITY UNIT MESSAGE
+ 2026-01-01T00:00:01+00:00 info sshd.service Accepted publickey for ...
+ 2026-01-01T00:00:02+00:00 notice kernel Linux version 6.1.0 ...
+ 2026-01-01T00:00:03+00:00 err nginx.service connect() failed (111...
+```
+
+Filter by priority and time window:
+
+```bash
+$ osapi client node log query --target web-01 \
+ --lines 50 --since 1h --priority err
+
+ Job ID: 550e8400-e29b-41d4-a716-446655440000
+
+ TIMESTAMP PRIORITY UNIT MESSAGE
+ 2026-01-01T00:59:01+00:00 err nginx.service connect() failed (111...
+```
+
+When targeting all hosts:
+
+```bash
+$ osapi client node log query --target _all --lines 5
+
+ Job ID: 550e8400-e29b-41d4-a716-446655440000
+
+ web-01
+ TIMESTAMP PRIORITY UNIT MESSAGE
+ 2026-01-01T00:00:01+00:00 info sshd.service Accepted publickey for ...
+
+ web-02
+ TIMESTAMP PRIORITY UNIT MESSAGE
+ 2026-01-01T00:00:02+00:00 notice kernel Linux version 6.1.0 ...
+```
+
+## JSON Output
+
+Use `--json` to get the full API response:
+
+```bash
+$ osapi client node log query --target web-01 --lines 1 --json
+{"results":[{"hostname":"web-01","status":"ok","entries":[{"timestamp":
+"2026-01-01T00:00:01+00:00","unit":"sshd.service","priority":"info",
+"message":"Accepted publickey for user from 1.2.3.4 port 22 ssh2",
+"pid":1234,"hostname":"web-01"}]}],"job_id":"..."}
+```
+
+## Flags
+
+| Flag | Description | Default |
+| -------------- | -------------------------------------------------------- | ------- |
+| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_any` |
+| `--lines` | Maximum number of log lines to return | `100` |
+| `--since` | Return entries since this time (e.g., `1h`, | |
+| | `2026-01-01 00:00:00`) | |
+| `--priority` | Filter by priority level (e.g., `err`, `warning`, | |
+| | `info`) | |
+| `-j, --json` | Output raw JSON response | |
diff --git a/docs/docs/sidebar/usage/cli/client/node/log/source.md b/docs/docs/sidebar/usage/cli/client/node/log/source.md
new file mode 100644
index 000000000..1071a93b4
--- /dev/null
+++ b/docs/docs/sidebar/usage/cli/client/node/log/source.md
@@ -0,0 +1,53 @@
+# Source
+
+List available log sources (syslog identifiers) on a target host:
+
+```bash
+$ osapi client node log source --target web-01
+
+ Job ID: 550e8400-e29b-41d4-a716-446655440000
+
+ SOURCE
+ cron
+ kernel
+ nginx
+ sshd
+ systemd
+```
+
+When targeting all hosts:
+
+```bash
+$ osapi client node log source --target _all
+
+ Job ID: 550e8400-e29b-41d4-a716-446655440000
+
+ web-01
+ SOURCE
+ cron
+ kernel
+ nginx
+ sshd
+
+ web-02
+ SOURCE
+ kernel
+ systemd
+```
+
+## JSON Output
+
+Use `--json` to get the full API response:
+
+```bash
+$ osapi client node log source --target web-01 --json
+{"results":[{"hostname":"web-01","status":"ok","sources":["cron",
+"kernel","nginx","sshd","systemd"]}],"job_id":"..."}
+```
+
+## Flags
+
+| Flag | Description | Default |
+| -------------- | -------------------------------------------------------- | ------- |
+| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_any` |
+| `-j, --json` | Output raw JSON response | |
diff --git a/docs/docs/sidebar/usage/cli/client/node/log/unit.md b/docs/docs/sidebar/usage/cli/client/node/log/unit.md
new file mode 100644
index 000000000..ff4161e72
--- /dev/null
+++ b/docs/docs/sidebar/usage/cli/client/node/log/unit.md
@@ -0,0 +1,68 @@
+# Unit
+
+Query journal log entries for a specific systemd unit on a target host:
+
+```bash
+$ osapi client node log unit --target web-01 --name sshd.service
+
+ Job ID: 550e8400-e29b-41d4-a716-446655440000
+
+ TIMESTAMP PRIORITY UNIT MESSAGE
+ 2026-01-01T00:00:01+00:00 info sshd.service Accepted publickey for ...
+ 2026-01-01T00:00:02+00:00 info sshd.service pam_unix(sshd:session): ...
+ 2026-01-01T00:00:03+00:00 info sshd.service Disconnected from user ...
+```
+
+Filter to recent errors only:
+
+```bash
+$ osapi client node log unit --target web-01 --name nginx.service \
+ --lines 20 --since 30m --priority err
+
+ Job ID: 550e8400-e29b-41d4-a716-446655440000
+
+ TIMESTAMP PRIORITY UNIT MESSAGE
+ 2026-01-01T00:31:15+00:00 err nginx.service connect() failed (111...
+```
+
+When targeting all hosts:
+
+```bash
+$ osapi client node log unit --target _all --name sshd.service --lines 5
+
+ Job ID: 550e8400-e29b-41d4-a716-446655440000
+
+ web-01
+ TIMESTAMP PRIORITY UNIT MESSAGE
+ 2026-01-01T00:00:01+00:00 info sshd.service Accepted publickey for ...
+
+ web-02
+ TIMESTAMP PRIORITY UNIT MESSAGE
+ 2026-01-01T00:00:02+00:00 info sshd.service Accepted publickey for ...
+```
+
+## JSON Output
+
+Use `--json` to get the full API response:
+
+```bash
+$ osapi client node log unit --target web-01 --name sshd.service \
+ --lines 1 --json
+{"results":[{"hostname":"web-01","status":"ok","entries":[{"timestamp":
+"2026-01-01T00:00:01+00:00","unit":"sshd.service","priority":"info",
+"message":"Accepted publickey for user from 1.2.3.4 port 22 ssh2",
+"pid":1234,"hostname":"web-01"}]}],"job_id":"..."}
+```
+
+## Flags
+
+| Flag | Description | Default |
+| -------------- | -------------------------------------------------------- | ------- |
+| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_any` |
+| `--name` | Systemd unit name (required, e.g., `sshd.service`) | |
+| `--lines` | Maximum number of log lines to return | `100` |
+| `--since` | Return entries since this time (e.g., `1h`, | |
+| | `2026-01-01 00:00:00`) | |
+| `--priority` | Filter by priority level (e.g., `err`, `warning`, | |
+| | `info`) | |
+| `-j, --json` | Output raw JSON response | |
diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md
index 4469d2109..7292abfbf 100644
--- a/docs/docs/sidebar/usage/configuration.md
+++ b/docs/docs/sidebar/usage/configuration.md
@@ -154,11 +154,11 @@ OSAPI uses fine-grained `resource:verb` permissions for access control. Each API
endpoint requires a specific permission. Built-in roles expand to a default set
of permissions:
-| Role | Permissions |
-| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `admin` | `agent:read`, `agent:write`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `audit:read`, `command:execute`, `file:read`, `file:write`, `docker:read`, `docker:write`, `docker:execute`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `power:execute`, `process:read`, `process:execute`, `user:read`, `user:write`, `package:read`, `package:write` |
-| `write` | `agent:read`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `file:read`, `file:write`, `docker:read`, `docker:write`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `process:read`, `user:read`, `user:write`, `package:read`, `package:write` |
-| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read` |
+| Role | Permissions |
+| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `admin` | `agent:read`, `agent:write`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `audit:read`, `command:execute`, `file:read`, `file:write`, `docker:read`, `docker:write`, `docker:execute`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `power:execute`, `process:read`, `process:execute`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` |
+| `write` | `agent:read`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `file:read`, `file:write`, `docker:read`, `docker:write`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `process:read`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` |
+| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read`, `log:read` |
### Custom Roles
@@ -260,7 +260,8 @@ controller:
# cron:read, cron:write, sysctl:read, sysctl:write,
# ntp:read, ntp:write, timezone:read, timezone:write,
# power:execute, process:read, process:execute,
- # user:read, user:write, package:read, package:write
+ # user:read, user:write, package:read, package:write,
+ # log:read
# roles:
# ops:
# permissions:
diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts
index 4b5020021..e7b180fd3 100644
--- a/docs/docusaurus.config.ts
+++ b/docs/docusaurus.config.ts
@@ -188,6 +188,11 @@ const config: Config = {
type: 'doc',
label: 'Package Management',
docId: 'sidebar/features/package-management'
+ },
+ {
+ type: 'doc',
+ label: 'Log Management',
+ docId: 'sidebar/features/log-management'
}
]
},
@@ -317,6 +322,11 @@ const config: Config = {
label: 'Process',
docId: 'sidebar/sdk/client/operations/process'
},
+ {
+ type: 'doc',
+ label: 'Log',
+ docId: 'sidebar/sdk/client/operations/log'
+ },
{
type: 'html',
value:
diff --git a/docs/plans/2026-03-31-log-management-provider-design.md b/docs/plans/2026-03-31-log-management-provider-design.md
new file mode 100644
index 000000000..7d27bac1e
--- /dev/null
+++ b/docs/plans/2026-03-31-log-management-provider-design.md
@@ -0,0 +1,123 @@
+# Log Viewing Provider Design
+
+## Overview
+
+Add log viewing to OSAPI. Query systemd journal entries with optional filtering
+by lines, time range, and priority. Read-only — no write operations. Uses
+`journalctl --output=json` for structured parsing.
+
+## Architecture
+
+Direct provider at `internal/provider/node/log/`.
+
+- **Category**: `node`
+- **Path prefix**: `/node/{hostname}/log`
+- **Permissions**: `log:read`
+- **Provider type**: direct (exec.Manager)
+
+## Provider Interface
+
+```go
+type Provider interface {
+ Query(ctx context.Context, opts QueryOpts) ([]Entry, error)
+ QueryUnit(ctx context.Context, unit string, opts QueryOpts) ([]Entry, error)
+}
+```
+
+## Data Types
+
+```go
+type QueryOpts struct {
+ Lines int `json:"lines,omitempty"`
+ Since string `json:"since,omitempty"`
+ Priority string `json:"priority,omitempty"`
+}
+
+type Entry struct {
+ Timestamp string `json:"timestamp"`
+ Unit string `json:"unit,omitempty"`
+ Priority string `json:"priority"`
+ Message string `json:"message"`
+ PID int `json:"pid,omitempty"`
+ Hostname string `json:"hostname,omitempty"`
+}
+```
+
+## Debian Implementation
+
+- **Query**: run `journalctl --output=json -n ` with optional
+ `--since=` and `--priority=`. Parse JSON lines output — each
+ line is a JSON object with fields `__REALTIME_TIMESTAMP`, `SYSLOG_IDENTIFIER`,
+ `PRIORITY`, `MESSAGE`, `_PID`, `_HOSTNAME`.
+- **QueryUnit**: same but with `-u ` flag.
+
+Default `lines` is 100 if not specified. `since` uses journalctl format (e.g.,
+`"1 hour ago"`, `"2026-03-31"`). `priority` uses journalctl levels (0-7 or names
+like `err`, `warning`).
+
+## Platform Implementations
+
+| Platform | Implementation |
+| -------- | ------------------------ |
+| Debian | journalctl --output=json |
+| Darwin | ErrUnsupported |
+| Linux | ErrUnsupported |
+
+## Container Behavior
+
+Return `ErrUnsupported` in containers — `journalctl` requires systemd which
+isn't available in containers.
+
+## API Endpoints
+
+| Method | Path | Permission | Description |
+| ------ | ---------------------------------- | ---------- | ------------------------ |
+| `GET` | `/node/{hostname}/log` | `log:read` | Query journal entries |
+| `GET` | `/node/{hostname}/log/unit/{name}` | `log:read` | Query entries for a unit |
+
+All endpoints support broadcast targeting.
+
+### Query Parameters
+
+| Param | Type | Default | Description |
+| ---------- | ------- | ------- | -------------------------------------- |
+| `lines` | integer | 100 | Number of entries to return |
+| `since` | string | | Time filter (e.g., "1 hour ago") |
+| `priority` | string | | Minimum priority (emerg..debug or 0-7) |
+
+### Response Shape
+
+```json
+{
+ "job_id": "...",
+ "results": [
+ {
+ "hostname": "web-01",
+ "status": "ok",
+ "entries": [
+ {
+ "timestamp": "2026-03-31T22:30:45.123Z",
+ "unit": "nginx.service",
+ "priority": "info",
+ "message": "Started nginx",
+ "pid": 1234,
+ "hostname": "web-01"
+ }
+ ]
+ }
+ ]
+}
+```
+
+## SDK
+
+```go
+client.Log.Query(ctx, host, opts)
+client.Log.QueryUnit(ctx, host, unit, opts)
+```
+
+`LogQueryOpts` struct with optional `Lines`, `Since`, `Priority`.
+
+## Permissions
+
+- `log:read` — query journal entries. Added to admin, write, and read roles.
diff --git a/docs/plans/2026-03-31-log-management-provider.md b/docs/plans/2026-03-31-log-management-provider.md
new file mode 100644
index 000000000..77a26a3aa
--- /dev/null
+++ b/docs/plans/2026-03-31-log-management-provider.md
@@ -0,0 +1,2771 @@
+# Log Management Provider Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use
+> superpowers:subagent-driven-development (recommended) or
+> superpowers:executing-plans to implement this plan task-by-task. Steps use
+> checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add read-only log viewing to OSAPI via `journalctl --output=json`,
+with optional filtering by lines, time range, and priority.
+
+**Architecture:** Direct provider at `internal/provider/node/log/` using
+`exec.Manager` to run `journalctl`. Two API endpoints: query all journal entries
+and query by unit name. Read-only with `log:read` permission added to all
+built-in roles.
+
+**Tech Stack:** Go, exec.Manager, journalctl JSON output, oapi-codegen
+strict-server
+
+---
+
+## File Structure
+
+### Provider Layer
+
+- Create: `internal/provider/node/log/types.go` — Provider interface + domain
+ types
+- Create: `internal/provider/node/log/debian.go` — journalctl implementation
+- Create: `internal/provider/node/log/debian_query.go` — shared query logic
+- Create: `internal/provider/node/log/darwin.go` — macOS stub
+- Create: `internal/provider/node/log/linux.go` — generic Linux stub
+- Create: `internal/provider/node/log/mocks/generate.go` — mockgen directive
+- Test: `internal/provider/node/log/debian_public_test.go`
+- Test: `internal/provider/node/log/darwin_public_test.go`
+- Test: `internal/provider/node/log/linux_public_test.go`
+
+### Agent Layer
+
+- Create: `internal/agent/processor_log.go` — log operation dispatcher
+- Modify: `internal/agent/processor.go` — add `log` case + logProvider param
+- Modify: `cmd/agent_setup.go` — create log provider factory, wire into registry
+- Test: `internal/agent/processor_log_public_test.go`
+
+### API Layer
+
+- Create: `internal/controller/api/node/log/gen/api.yaml` — OpenAPI spec
+- Create: `internal/controller/api/node/log/gen/cfg.yaml` — oapi-codegen config
+- Create: `internal/controller/api/node/log/gen/generate.go` — go:generate
+- Create: `internal/controller/api/node/log/types.go` — handler struct
+- Create: `internal/controller/api/node/log/log.go` — New(), compile-time check
+- Create: `internal/controller/api/node/log/validate.go` — validateHostname
+- Create: `internal/controller/api/node/log/log_query_get.go` — query handler
+- Create: `internal/controller/api/node/log/log_unit_get.go` — query unit
+ handler
+- Create: `internal/controller/api/node/log/handler.go` — Handler() registration
+- Modify: `cmd/controller_setup.go` — register log handler
+- Test: `internal/controller/api/node/log/log_query_get_public_test.go`
+- Test: `internal/controller/api/node/log/log_unit_get_public_test.go`
+- Test: `internal/controller/api/node/log/handler_public_test.go`
+
+### Operations & Permissions
+
+- Modify: `pkg/sdk/client/operations.go` — add log operation constants
+- Modify: `internal/job/types.go` — add log operation aliases
+- Modify: `pkg/sdk/client/permissions.go` — add `PermLogRead`
+- Modify: `internal/authtoken/permissions.go` — add `PermLogRead` to all roles
+
+### SDK Layer
+
+- Create: `pkg/sdk/client/log.go` — LogService methods
+- Create: `pkg/sdk/client/log_types.go` — SDK result types + conversions
+- Modify: `pkg/sdk/client/osapi.go` — add Log field
+- Test: `pkg/sdk/client/log_public_test.go`
+- Test: `pkg/sdk/client/log_types_public_test.go`
+
+### CLI Layer
+
+- Create: `cmd/client_node_log.go` — parent command
+- Create: `cmd/client_node_log_query.go` — query subcommand
+- Create: `cmd/client_node_log_unit.go` — query-unit subcommand
+
+### Documentation
+
+- Create: `docs/docs/sidebar/features/log-management.md` — feature page
+- Create: `docs/docs/sidebar/usage/cli/client/node/log/log.md` — CLI landing
+- Create: `docs/docs/sidebar/usage/cli/client/node/log/query.md` — query CLI doc
+- Create: `docs/docs/sidebar/usage/cli/client/node/log/unit.md` — unit CLI doc
+- Create: `docs/docs/sidebar/sdk/client/operations/log.md` — SDK doc
+- Create: `examples/sdk/client/log.go` — SDK example
+- Modify: `docs/docs/sidebar/features/features.md` — add log to table
+- Modify: `docs/docs/sidebar/features/authentication.md` — add log:read to roles
+- Modify: `docs/docs/sidebar/usage/configuration.md` — add log:read to
+ permissions
+- Modify: `docs/docs/sidebar/architecture/architecture.md` — add log feature
+ link
+- Modify: `docs/docs/sidebar/architecture/api-guidelines.md` — add log endpoints
+- Modify: `docs/docusaurus.config.ts` — add SDK dropdown + features dropdown
+
+### Integration Test
+
+- Create: `test/integration/log_test.go` — smoke test
+
+---
+
+### Task 1: Provider Interface and Types
+
+**Files:**
+
+- Create: `internal/provider/node/log/types.go`
+
+- [ ] **Step 1: Create provider interface and types**
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package log provides log viewing operations.
+package log
+
+import (
+ "context"
+)
+
+// Provider implements log viewing operations.
+type Provider interface {
+ // Query returns journal entries with optional filtering.
+ Query(ctx context.Context, opts QueryOpts) ([]Entry, error)
+ // QueryUnit returns journal entries for a specific systemd unit.
+ QueryUnit(ctx context.Context, unit string, opts QueryOpts) ([]Entry, error)
+}
+
+// QueryOpts contains optional filters for log queries.
+type QueryOpts struct {
+ Lines int `json:"lines,omitempty"`
+ Since string `json:"since,omitempty"`
+ Priority string `json:"priority,omitempty"`
+}
+
+// Entry represents a single journal entry.
+type Entry struct {
+ Timestamp string `json:"timestamp"`
+ Unit string `json:"unit,omitempty"`
+ Priority string `json:"priority"`
+ Message string `json:"message"`
+ PID int `json:"pid,omitempty"`
+ Hostname string `json:"hostname,omitempty"`
+}
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+Run: `go build ./internal/provider/node/log/...` Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/provider/node/log/types.go
+git commit -m "feat(log): add provider interface and types"
+```
+
+---
+
+### Task 2: Platform Stubs (Darwin + Linux)
+
+**Files:**
+
+- Create: `internal/provider/node/log/darwin.go`
+- Create: `internal/provider/node/log/linux.go`
+- Test: `internal/provider/node/log/darwin_public_test.go`
+- Test: `internal/provider/node/log/linux_public_test.go`
+
+- [ ] **Step 1: Write darwin stub tests**
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ logProv "github.com/retr0h/osapi/internal/provider/node/log"
+ "github.com/retr0h/osapi/internal/provider"
+)
+
+type DarwinPublicTestSuite struct {
+ suite.Suite
+
+ provider *logProv.Darwin
+}
+
+func (s *DarwinPublicTestSuite) SetupTest() {
+ s.provider = logProv.NewDarwinProvider()
+}
+
+func (s *DarwinPublicTestSuite) TestQuery() {
+ _, err := s.provider.Query(context.Background(), logProv.QueryOpts{})
+
+ assert.ErrorIs(s.T(), err, provider.ErrUnsupported)
+}
+
+func (s *DarwinPublicTestSuite) TestQueryUnit() {
+ _, err := s.provider.QueryUnit(context.Background(), "nginx.service", logProv.QueryOpts{})
+
+ assert.ErrorIs(s.T(), err, provider.ErrUnsupported)
+}
+
+func TestDarwinPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(DarwinPublicTestSuite))
+}
+```
+
+- [ ] **Step 2: Write linux stub tests**
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ logProv "github.com/retr0h/osapi/internal/provider/node/log"
+ "github.com/retr0h/osapi/internal/provider"
+)
+
+type LinuxPublicTestSuite struct {
+ suite.Suite
+
+ provider *logProv.Linux
+}
+
+func (s *LinuxPublicTestSuite) SetupTest() {
+ s.provider = logProv.NewLinuxProvider()
+}
+
+func (s *LinuxPublicTestSuite) TestQuery() {
+ _, err := s.provider.Query(context.Background(), logProv.QueryOpts{})
+
+ assert.ErrorIs(s.T(), err, provider.ErrUnsupported)
+}
+
+func (s *LinuxPublicTestSuite) TestQueryUnit() {
+ _, err := s.provider.QueryUnit(context.Background(), "nginx.service", logProv.QueryOpts{})
+
+ assert.ErrorIs(s.T(), err, provider.ErrUnsupported)
+}
+
+func TestLinuxPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(LinuxPublicTestSuite))
+}
+```
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run: `go test -v ./internal/provider/node/log/...` Expected: FAIL — Darwin and
+Linux types don't exist yet
+
+- [ ] **Step 4: Implement darwin stub**
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/retr0h/osapi/internal/provider"
+)
+
+// Darwin implements the Provider interface for Darwin (macOS).
+// All methods return ErrUnsupported as log viewing is not available on macOS.
+type Darwin struct{}
+
+// NewDarwinProvider factory to create a new Darwin instance.
+func NewDarwinProvider() *Darwin {
+ return &Darwin{}
+}
+
+// Query returns ErrUnsupported on Darwin.
+func (d *Darwin) Query(
+ _ context.Context,
+ _ QueryOpts,
+) ([]Entry, error) {
+ return nil, fmt.Errorf("log: %w", provider.ErrUnsupported)
+}
+
+// QueryUnit returns ErrUnsupported on Darwin.
+func (d *Darwin) QueryUnit(
+ _ context.Context,
+ _ string,
+ _ QueryOpts,
+) ([]Entry, error) {
+ return nil, fmt.Errorf("log: %w", provider.ErrUnsupported)
+}
+```
+
+- [ ] **Step 5: Implement linux stub**
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/retr0h/osapi/internal/provider"
+)
+
+// Linux implements the Provider interface for generic Linux.
+// All methods return ErrUnsupported as this is a generic Linux stub.
+type Linux struct{}
+
+// NewLinuxProvider factory to create a new Linux instance.
+func NewLinuxProvider() *Linux {
+ return &Linux{}
+}
+
+// Query returns ErrUnsupported on generic Linux.
+func (l *Linux) Query(
+ _ context.Context,
+ _ QueryOpts,
+) ([]Entry, error) {
+ return nil, fmt.Errorf("log: %w", provider.ErrUnsupported)
+}
+
+// QueryUnit returns ErrUnsupported on generic Linux.
+func (l *Linux) QueryUnit(
+ _ context.Context,
+ _ string,
+ _ QueryOpts,
+) ([]Entry, error) {
+ return nil, fmt.Errorf("log: %w", provider.ErrUnsupported)
+}
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `go test -v ./internal/provider/node/log/...` Expected: PASS — all 4 tests
+pass
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/provider/node/log/darwin.go internal/provider/node/log/linux.go \
+ internal/provider/node/log/darwin_public_test.go internal/provider/node/log/linux_public_test.go
+git commit -m "feat(log): add darwin and linux provider stubs"
+```
+
+---
+
+### Task 3: Debian Provider Implementation
+
+**Files:**
+
+- Create: `internal/provider/node/log/debian.go`
+- Create: `internal/provider/node/log/debian_query.go`
+- Create: `internal/provider/node/log/mocks/generate.go`
+- Test: `internal/provider/node/log/debian_public_test.go`
+
+- [ ] **Step 1: Create mock generator**
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package mocks provides mock implementations for testing.
+package mocks
+
+//go:generate go tool github.com/golang/mock/mockgen -source=../types.go -destination=provider.gen.go -package=mocks
+```
+
+- [ ] **Step 2: Generate mocks**
+
+Run: `go generate ./internal/provider/node/log/mocks/...` Expected: generates
+`provider.gen.go`
+
+- [ ] **Step 3: Write debian provider tests**
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log_test
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/internal/exec"
+ execMocks "github.com/retr0h/osapi/internal/exec/mocks"
+ logProv "github.com/retr0h/osapi/internal/provider/node/log"
+)
+
+type DebianPublicTestSuite struct {
+ suite.Suite
+
+ mockCtrl *gomock.Controller
+ mockExecManager *execMocks.MockManager
+ provider *logProv.Debian
+ ctx context.Context
+ logger *slog.Logger
+}
+
+func (s *DebianPublicTestSuite) SetupTest() {
+ s.mockCtrl = gomock.NewController(s.T())
+ s.mockExecManager = execMocks.NewMockManager(s.mockCtrl)
+ s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil))
+ s.provider = logProv.NewDebianProvider(s.logger, s.mockExecManager)
+ s.ctx = context.Background()
+}
+
+func (s *DebianPublicTestSuite) TearDownTest() {
+ s.mockCtrl.Finish()
+}
+
+func (s *DebianPublicTestSuite) TestQuery() {
+ journalLine1 := `{"__REALTIME_TIMESTAMP":"1711929045123456","SYSLOG_IDENTIFIER":"nginx","PRIORITY":"6","MESSAGE":"Started nginx","_PID":"1234","_HOSTNAME":"web-01"}`
+ journalLine2 := `{"__REALTIME_TIMESTAMP":"1711929046000000","SYSLOG_IDENTIFIER":"sshd","PRIORITY":"4","MESSAGE":"Connection closed","_PID":"5678","_HOSTNAME":"web-01"}`
+ journalOutput := journalLine1 + "\n" + journalLine2 + "\n"
+
+ tests := []struct {
+ name string
+ opts logProv.QueryOpts
+ setupMock func()
+ validateFunc func(entries []logProv.Entry, err error)
+ }{
+ {
+ name: "default query with no options",
+ opts: logProv.QueryOpts{},
+ setupMock: func() {
+ s.mockExecManager.EXPECT().
+ RunCmd("journalctl", []string{"--output=json", "-n", "100"}).
+ Return(journalOutput, nil)
+ },
+ validateFunc: func(entries []logProv.Entry, err error) {
+ assert.NoError(s.T(), err)
+ assert.Len(s.T(), entries, 2)
+ assert.Equal(s.T(), "nginx", entries[0].Unit)
+ assert.Equal(s.T(), "info", entries[0].Priority)
+ assert.Equal(s.T(), "Started nginx", entries[0].Message)
+ assert.Equal(s.T(), 1234, entries[0].PID)
+ assert.Equal(s.T(), "web-01", entries[0].Hostname)
+ assert.Equal(s.T(), "sshd", entries[1].Unit)
+ assert.Equal(s.T(), "warning", entries[1].Priority)
+ },
+ },
+ {
+ name: "query with all options",
+ opts: logProv.QueryOpts{
+ Lines: 50,
+ Since: "1 hour ago",
+ Priority: "err",
+ },
+ setupMock: func() {
+ s.mockExecManager.EXPECT().
+ RunCmd("journalctl", []string{
+ "--output=json",
+ "-n", "50",
+ "--since=1 hour ago",
+ "--priority=err",
+ }).
+ Return(journalOutput, nil)
+ },
+ validateFunc: func(entries []logProv.Entry, err error) {
+ assert.NoError(s.T(), err)
+ assert.Len(s.T(), entries, 2)
+ },
+ },
+ {
+ name: "query with custom lines",
+ opts: logProv.QueryOpts{Lines: 10},
+ setupMock: func() {
+ s.mockExecManager.EXPECT().
+ RunCmd("journalctl", []string{"--output=json", "-n", "10"}).
+ Return(journalOutput, nil)
+ },
+ validateFunc: func(entries []logProv.Entry, err error) {
+ assert.NoError(s.T(), err)
+ assert.Len(s.T(), entries, 2)
+ },
+ },
+ {
+ name: "exec error",
+ opts: logProv.QueryOpts{},
+ setupMock: func() {
+ s.mockExecManager.EXPECT().
+ RunCmd("journalctl", []string{"--output=json", "-n", "100"}).
+ Return("", fmt.Errorf("exec failed"))
+ },
+ validateFunc: func(entries []logProv.Entry, err error) {
+ assert.Error(s.T(), err)
+ assert.Nil(s.T(), entries)
+ assert.Contains(s.T(), err.Error(), "log: query")
+ },
+ },
+ {
+ name: "empty output",
+ opts: logProv.QueryOpts{},
+ setupMock: func() {
+ s.mockExecManager.EXPECT().
+ RunCmd("journalctl", []string{"--output=json", "-n", "100"}).
+ Return("", nil)
+ },
+ validateFunc: func(entries []logProv.Entry, err error) {
+ assert.NoError(s.T(), err)
+ assert.Empty(s.T(), entries)
+ },
+ },
+ {
+ name: "malformed JSON line skipped",
+ opts: logProv.QueryOpts{},
+ setupMock: func() {
+ output := "not json\n" + journalLine1 + "\n"
+ s.mockExecManager.EXPECT().
+ RunCmd("journalctl", []string{"--output=json", "-n", "100"}).
+ Return(output, nil)
+ },
+ validateFunc: func(entries []logProv.Entry, err error) {
+ assert.NoError(s.T(), err)
+ assert.Len(s.T(), entries, 1)
+ assert.Equal(s.T(), "nginx", entries[0].Unit)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ s.Run(tc.name, func() {
+ tc.setupMock()
+ entries, err := s.provider.Query(s.ctx, tc.opts)
+ tc.validateFunc(entries, err)
+ })
+ }
+}
+
+func (s *DebianPublicTestSuite) TestQueryUnit() {
+ journalLine := `{"__REALTIME_TIMESTAMP":"1711929045123456","SYSLOG_IDENTIFIER":"nginx","PRIORITY":"6","MESSAGE":"Started nginx","_PID":"1234","_HOSTNAME":"web-01"}`
+
+ tests := []struct {
+ name string
+ unit string
+ opts logProv.QueryOpts
+ setupMock func()
+ validateFunc func(entries []logProv.Entry, err error)
+ }{
+ {
+ name: "query unit with defaults",
+ unit: "nginx.service",
+ opts: logProv.QueryOpts{},
+ setupMock: func() {
+ s.mockExecManager.EXPECT().
+ RunCmd("journalctl", []string{
+ "--output=json",
+ "-u", "nginx.service",
+ "-n", "100",
+ }).
+ Return(journalLine+"\n", nil)
+ },
+ validateFunc: func(entries []logProv.Entry, err error) {
+ assert.NoError(s.T(), err)
+ assert.Len(s.T(), entries, 1)
+ assert.Equal(s.T(), "nginx", entries[0].Unit)
+ },
+ },
+ {
+ name: "query unit with all options",
+ unit: "sshd.service",
+ opts: logProv.QueryOpts{
+ Lines: 25,
+ Since: "2026-03-31",
+ Priority: "warning",
+ },
+ setupMock: func() {
+ s.mockExecManager.EXPECT().
+ RunCmd("journalctl", []string{
+ "--output=json",
+ "-u", "sshd.service",
+ "-n", "25",
+ "--since=2026-03-31",
+ "--priority=warning",
+ }).
+ Return(journalLine+"\n", nil)
+ },
+ validateFunc: func(entries []logProv.Entry, err error) {
+ assert.NoError(s.T(), err)
+ assert.Len(s.T(), entries, 1)
+ },
+ },
+ {
+ name: "exec error",
+ unit: "nginx.service",
+ opts: logProv.QueryOpts{},
+ setupMock: func() {
+ s.mockExecManager.EXPECT().
+ RunCmd("journalctl", gomock.Any()).
+ Return("", fmt.Errorf("exec failed"))
+ },
+ validateFunc: func(entries []logProv.Entry, err error) {
+ assert.Error(s.T(), err)
+ assert.Nil(s.T(), entries)
+ assert.Contains(s.T(), err.Error(), "log: query unit")
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ s.Run(tc.name, func() {
+ tc.setupMock()
+ entries, err := s.provider.QueryUnit(s.ctx, tc.unit, tc.opts)
+ tc.validateFunc(entries, err)
+ })
+ }
+}
+
+func TestDebianPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(DebianPublicTestSuite))
+}
+```
+
+- [ ] **Step 4: Run tests to verify they fail**
+
+Run: `go test -v ./internal/provider/node/log/...` Expected: FAIL — Debian type
+doesn't exist yet
+
+- [ ] **Step 5: Implement debian.go**
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ "github.com/retr0h/osapi/internal/exec"
+ "github.com/retr0h/osapi/internal/provider"
+)
+
+// Compile-time checks.
+var (
+ _ Provider = (*Debian)(nil)
+ _ provider.FactsSetter = (*Debian)(nil)
+)
+
+// Debian implements the Provider interface for Debian-family systems.
+type Debian struct {
+ provider.FactsAware
+ logger *slog.Logger
+ execManager exec.Manager
+}
+
+// NewDebianProvider factory to create a new Debian instance.
+func NewDebianProvider(
+ logger *slog.Logger,
+ execManager exec.Manager,
+) *Debian {
+ return &Debian{
+ logger: logger.With(slog.String("subsystem", "provider.log")),
+ execManager: execManager,
+ }
+}
+
+// Query returns journal entries with optional filtering.
+func (d *Debian) Query(
+ _ context.Context,
+ opts QueryOpts,
+) ([]Entry, error) {
+ d.logger.Debug("executing log.Query")
+
+ args := buildArgs(opts)
+
+ output, err := d.execManager.RunCmd("journalctl", args)
+ if err != nil {
+ return nil, fmt.Errorf("log: query: %w", err)
+ }
+
+ return parseJournalOutput(output, d.logger), nil
+}
+
+// QueryUnit returns journal entries for a specific systemd unit.
+func (d *Debian) QueryUnit(
+ _ context.Context,
+ unit string,
+ opts QueryOpts,
+) ([]Entry, error) {
+ d.logger.Debug("executing log.QueryUnit",
+ slog.String("unit", unit),
+ )
+
+ args := buildUnitArgs(unit, opts)
+
+ output, err := d.execManager.RunCmd("journalctl", args)
+ if err != nil {
+ return nil, fmt.Errorf("log: query unit: %w", err)
+ }
+
+ return parseJournalOutput(output, d.logger), nil
+}
+```
+
+- [ ] **Step 6: Implement debian_query.go**
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// journalEntry represents the raw JSON output from journalctl --output=json.
+type journalEntry struct {
+ RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"`
+ SyslogIdentifier string `json:"SYSLOG_IDENTIFIER"`
+ Priority string `json:"PRIORITY"`
+ Message string `json:"MESSAGE"`
+ PID string `json:"_PID"`
+ Hostname string `json:"_HOSTNAME"`
+}
+
+// priorityNames maps journalctl numeric priorities to human-readable names.
+var priorityNames = map[string]string{
+ "0": "emerg",
+ "1": "alert",
+ "2": "crit",
+ "3": "err",
+ "4": "warning",
+ "5": "notice",
+ "6": "info",
+ "7": "debug",
+}
+
+// buildArgs constructs the journalctl command arguments for a general query.
+func buildArgs(
+ opts QueryOpts,
+) []string {
+ args := []string{"--output=json"}
+
+ lines := opts.Lines
+ if lines <= 0 {
+ lines = 100
+ }
+ args = append(args, "-n", fmt.Sprintf("%d", lines))
+
+ if opts.Since != "" {
+ args = append(args, "--since="+opts.Since)
+ }
+
+ if opts.Priority != "" {
+ args = append(args, "--priority="+opts.Priority)
+ }
+
+ return args
+}
+
+// buildUnitArgs constructs the journalctl command arguments for a unit query.
+func buildUnitArgs(
+ unit string,
+ opts QueryOpts,
+) []string {
+ args := []string{"--output=json", "-u", unit}
+
+ lines := opts.Lines
+ if lines <= 0 {
+ lines = 100
+ }
+ args = append(args, "-n", fmt.Sprintf("%d", lines))
+
+ if opts.Since != "" {
+ args = append(args, "--since="+opts.Since)
+ }
+
+ if opts.Priority != "" {
+ args = append(args, "--priority="+opts.Priority)
+ }
+
+ return args
+}
+
+// parseJournalOutput parses the JSON lines output from journalctl.
+func parseJournalOutput(
+ output string,
+ logger *slog.Logger,
+) []Entry {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ var entries []Entry
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ var je journalEntry
+ if err := json.Unmarshal([]byte(line), &je); err != nil {
+ logger.Debug("skipping malformed journal line",
+ slog.String("error", err.Error()),
+ )
+ continue
+ }
+
+ entries = append(entries, journalEntryToEntry(je))
+ }
+
+ return entries
+}
+
+// journalEntryToEntry converts a raw journal entry to the provider Entry type.
+func journalEntryToEntry(
+ je journalEntry,
+) Entry {
+ ts := parseTimestamp(je.RealtimeTimestamp)
+ priority := priorityNames[je.Priority]
+ if priority == "" {
+ priority = je.Priority
+ }
+
+ var pid int
+ if je.PID != "" {
+ pid, _ = strconv.Atoi(je.PID)
+ }
+
+ return Entry{
+ Timestamp: ts,
+ Unit: je.SyslogIdentifier,
+ Priority: priority,
+ Message: je.Message,
+ PID: pid,
+ Hostname: je.Hostname,
+ }
+}
+
+// parseTimestamp converts a journalctl __REALTIME_TIMESTAMP (microseconds
+// since epoch) to RFC3339 format.
+func parseTimestamp(
+ usec string,
+) string {
+ us, err := strconv.ParseInt(usec, 10, 64)
+ if err != nil {
+ return usec
+ }
+
+ t := time.UnixMicro(us).UTC()
+
+ return t.Format(time.RFC3339Nano)
+}
+```
+
+- [ ] **Step 7: Run tests to verify they pass**
+
+Run: `go test -v ./internal/provider/node/log/...` Expected: PASS — all tests
+pass
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/provider/node/log/debian.go internal/provider/node/log/debian_query.go \
+ internal/provider/node/log/debian_public_test.go \
+ internal/provider/node/log/mocks/
+git commit -m "feat(log): add debian provider with journalctl parsing"
+```
+
+---
+
+### Task 4: Operations, Permissions, and Agent Wiring
+
+**Files:**
+
+- Modify: `pkg/sdk/client/operations.go` — add log operations
+- Modify: `internal/job/types.go` — add log operation aliases
+- Modify: `pkg/sdk/client/permissions.go` — add `PermLogRead`
+- Modify: `internal/authtoken/permissions.go` — add `PermLogRead` to all roles
+- Create: `internal/agent/processor_log.go` — log dispatcher
+- Modify: `internal/agent/processor.go` — add `log` case + logProvider param
+- Modify: `cmd/agent_setup.go` — create log provider, wire into registry
+- Test: `internal/agent/processor_log_public_test.go`
+
+- [ ] **Step 1: Add operation constants to SDK**
+
+Add to `pkg/sdk/client/operations.go` after the Package operations block:
+
+```go
+// Log operations.
+const (
+ OpLogQuery JobOperation = "node.log.query"
+ OpLogQueryUnit JobOperation = "node.log.queryUnit"
+)
+```
+
+- [ ] **Step 2: Add operation aliases to job types**
+
+Add to `internal/job/types.go` after the Package operations block:
+
+```go
+// Log operations.
+const (
+ OperationLogQuery = client.OpLogQuery
+ OperationLogQueryUnit = client.OpLogQueryUnit
+)
+```
+
+- [ ] **Step 3: Add permission constant to SDK**
+
+Add to `pkg/sdk/client/permissions.go` after the Package permissions:
+
+```go
+ PermLogRead Permission = "log:read"
+```
+
+- [ ] **Step 4: Add permission to authtoken**
+
+Add to `internal/authtoken/permissions.go`:
+
+1. Add constant after PackageWrite:
+
+```go
+ PermLogRead = client.PermLogRead
+```
+
+2. Add to `AllPermissions` slice:
+
+```go
+ PermLogRead,
+```
+
+3. Add to admin role after `PermPackageWrite`:
+
+```go
+ PermLogRead,
+```
+
+4. Add to write role after `PermPackageWrite`:
+
+```go
+ PermLogRead,
+```
+
+5. Add to read role after `PermPackageRead`:
+
+```go
+ PermLogRead,
+```
+
+- [ ] **Step 5: Write agent processor tests**
+
+Create `internal/agent/processor_log_public_test.go`. The tests exercise
+`processLogOperation` via the node processor's `log` case. Follow the same
+pattern as `processor_process_public_test.go` — table-driven tests with gomock
+for the log provider mock. Test cases:
+
+- `log.query` with default opts (empty data)
+- `log.query` with all options (lines, since, priority)
+- `log.queryUnit` with unit name
+- unsupported sub-operation (`log.invalid`)
+- invalid operation format (`log` with no sub-op)
+- nil log provider
+
+The tests construct a `job.Request` with `Operation: "log.query"` (note: the
+node processor strips the base operation from the dotted format `"hostname.get"`
+→ `"hostname"`, but for log the operation string passed to `processLogOperation`
+is already `"log.query"`, `"log.queryUnit"` etc. The node processor matches on
+`baseOperation` which is `"log"`, then delegates to `processLogOperation` which
+splits on `.` to get the sub-op).
+
+- [ ] **Step 6: Run tests to verify they fail**
+
+Run: `go test -run TestProcessorLogPublicTestSuite -v ./internal/agent/...`
+Expected: FAIL — `processLogOperation` doesn't exist
+
+- [ ] **Step 7: Implement processor_log.go**
+
+Create `internal/agent/processor_log.go`:
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/retr0h/osapi/internal/job"
+ logProv "github.com/retr0h/osapi/internal/provider/node/log"
+)
+
+// processLogOperation dispatches log sub-operations.
+func processLogOperation(
+ logProvider logProv.Provider,
+ logger *slog.Logger,
+ jobRequest job.Request,
+) (json.RawMessage, error) {
+ if logProvider == nil {
+ return nil, fmt.Errorf("log provider not available")
+ }
+
+ parts := strings.Split(jobRequest.Operation, ".")
+ if len(parts) < 2 {
+ return nil, fmt.Errorf("invalid log operation: %s", jobRequest.Operation)
+ }
+ subOp := parts[1]
+
+ ctx := context.Background()
+
+ switch subOp {
+ case "query":
+ return processLogQuery(ctx, logProvider, logger, jobRequest)
+ case "queryUnit":
+ return processLogQueryUnit(ctx, logProvider, logger, jobRequest)
+ default:
+ return nil, fmt.Errorf("unsupported log operation: %s", jobRequest.Operation)
+ }
+}
+
+// processLogQuery handles the log.query operation.
+func processLogQuery(
+ ctx context.Context,
+ logProvider logProv.Provider,
+ logger *slog.Logger,
+ jobRequest job.Request,
+) (json.RawMessage, error) {
+ logger.Debug("executing log.Query")
+
+ var opts logProv.QueryOpts
+ if jobRequest.Data != nil {
+ _ = json.Unmarshal(jobRequest.Data, &opts)
+ }
+
+ result, err := logProvider.Query(ctx, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return json.Marshal(result)
+}
+
+// processLogQueryUnit handles the log.queryUnit operation.
+func processLogQueryUnit(
+ ctx context.Context,
+ logProvider logProv.Provider,
+ logger *slog.Logger,
+ jobRequest job.Request,
+) (json.RawMessage, error) {
+ logger.Debug("executing log.QueryUnit")
+
+ var data struct {
+ Unit string `json:"unit"`
+ logProv.QueryOpts
+ }
+ if err := json.Unmarshal(jobRequest.Data, &data); err != nil {
+ return nil, fmt.Errorf("unmarshal log query unit data: %w", err)
+ }
+
+ result, err := logProvider.QueryUnit(ctx, data.Unit, data.QueryOpts)
+ if err != nil {
+ return nil, err
+ }
+
+ return json.Marshal(result)
+}
+```
+
+- [ ] **Step 8: Wire log into NewNodeProcessor**
+
+Add `logProvider logProv.Provider` parameter to `NewNodeProcessor` in
+`internal/agent/processor.go`. Add the import:
+
+```go
+logProv "github.com/retr0h/osapi/internal/provider/node/log"
+```
+
+Add the case in the switch:
+
+```go
+ case "log":
+ return processLogOperation(logProvider, logger, req)
+```
+
+- [ ] **Step 9: Create log provider factory in agent_setup.go**
+
+Add import:
+
+```go
+logProv "github.com/retr0h/osapi/internal/provider/node/log"
+```
+
+Add factory function:
+
+```go
+// createLogProvider creates a platform-specific log provider. On Debian, the
+// log provider reads journal entries via journalctl. In containers, journalctl
+// is not available — returns ErrUnsupported. On other platforms, all operations
+// return ErrUnsupported.
+func createLogProvider(
+ log *slog.Logger,
+ execManager exec.Manager,
+) logProv.Provider {
+ plat := platform.Detect()
+
+ switch plat {
+ case "debian":
+ if platform.IsContainer() {
+ log.Info("running in container, log operations disabled")
+ return logProv.NewLinuxProvider()
+ }
+ return logProv.NewDebianProvider(log, execManager)
+ case "darwin":
+ return logProv.NewDarwinProvider()
+ default:
+ return logProv.NewLinuxProvider()
+ }
+}
+```
+
+Add to `setupAgent` after `packageProvider`:
+
+```go
+ // --- Log provider ---
+ logProvider := createLogProvider(log, execManager)
+```
+
+Add `logProvider` to the `NewNodeProcessor` call and the providers list in
+`registry.Register("node", ...)`.
+
+- [ ] **Step 10: Run tests**
+
+Run: `go test -v ./internal/agent/... && go build ./...` Expected: PASS
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add pkg/sdk/client/operations.go internal/job/types.go \
+ pkg/sdk/client/permissions.go internal/authtoken/permissions.go \
+ internal/agent/processor_log.go internal/agent/processor_log_public_test.go \
+ internal/agent/processor.go cmd/agent_setup.go
+git commit -m "feat(log): add operations, permissions, and agent wiring"
+```
+
+---
+
+### Task 5: OpenAPI Spec and Code Generation
+
+**Files:**
+
+- Create: `internal/controller/api/node/log/gen/api.yaml`
+- Create: `internal/controller/api/node/log/gen/cfg.yaml`
+- Create: `internal/controller/api/node/log/gen/generate.go`
+
+- [ ] **Step 1: Create OpenAPI spec**
+
+Create `internal/controller/api/node/log/gen/api.yaml`:
+
+```yaml
+# Copyright (c) 2026 John Dewey
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+# DEALINGS IN THE SOFTWARE.
+
+---
+openapi: 3.0.0
+info:
+ title: Log Management API
+ version: 1.0.0
+tags:
+ - name: log_operations
+ x-displayName: Node/Log
+ description: Log viewing operations on a target node.
+
+paths:
+ /node/{hostname}/log:
+ get:
+ summary: Query journal entries
+ description: >
+ Query systemd journal entries on the target node with optional filtering
+ by lines, time range, and priority.
+ tags:
+ - log_operations
+ operationId: GetNodeLog
+ security:
+ - BearerAuth:
+ - log:read
+ parameters:
+ - $ref: '#/components/parameters/Hostname'
+ - name: lines
+ in: query
+ required: false
+ description: Number of entries to return (default 100).
+ x-oapi-codegen-extra-tags:
+ validate: omitempty,min=1,max=10000
+ schema:
+ type: integer
+ default: 100
+ minimum: 1
+ maximum: 10000
+ - name: since
+ in: query
+ required: false
+ description: >
+ Time filter in journalctl format (e.g., "1 hour ago", "2026-03-31").
+ schema:
+ type: string
+ - name: priority
+ in: query
+ required: false
+ description: >
+ Minimum priority level (0-7 or name: emerg, alert, crit, err,
+ warning, notice, info, debug).
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Journal entries.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LogCollectionResponse'
+ '401':
+ description: Unauthorized - API key required
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+ '403':
+ description: Forbidden - Insufficient permissions
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+ '500':
+ description: Error querying journal.
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+
+ /node/{hostname}/log/unit/{name}:
+ get:
+ summary: Query journal entries for a unit
+ description: >
+ Query systemd journal entries for a specific unit on the target node
+ with optional filtering.
+ tags:
+ - log_operations
+ operationId: GetNodeLogUnit
+ security:
+ - BearerAuth:
+ - log:read
+ parameters:
+ - $ref: '#/components/parameters/Hostname'
+ - $ref: '#/components/parameters/UnitName'
+ - name: lines
+ in: query
+ required: false
+ description: Number of entries to return (default 100).
+ x-oapi-codegen-extra-tags:
+ validate: omitempty,min=1,max=10000
+ schema:
+ type: integer
+ default: 100
+ minimum: 1
+ maximum: 10000
+ - name: since
+ in: query
+ required: false
+ description: >
+ Time filter in journalctl format (e.g., "1 hour ago", "2026-03-31").
+ schema:
+ type: string
+ - name: priority
+ in: query
+ required: false
+ description: >
+ Minimum priority level (0-7 or name: emerg, alert, crit, err,
+ warning, notice, info, debug).
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Journal entries for the unit.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LogCollectionResponse'
+ '401':
+ description: Unauthorized - API key required
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+ '403':
+ description: Forbidden - Insufficient permissions
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+ '500':
+ description: Error querying journal.
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+
+# -- Reusable components --
+
+components:
+ parameters:
+ Hostname:
+ name: hostname
+ in: path
+ required: true
+ description: >
+ Target agent hostname, reserved routing value (_any, _all), or label
+ selector (key:value).
+ # NOTE: x-oapi-codegen-extra-tags on path params do not generate
+ # validate tags in strict-server mode. Validation is handled
+ # manually in handlers via validateHostname().
+ x-oapi-codegen-extra-tags:
+ validate: required,min=1,valid_target
+ schema:
+ type: string
+ minLength: 1
+
+ UnitName:
+ name: name
+ in: path
+ required: true
+ description: >
+ Systemd unit name (e.g., nginx.service, sshd.service).
+ # NOTE: x-oapi-codegen-extra-tags on path params do not generate
+ # validate tags in strict-server mode. Validation is handled
+ # manually in the handler.
+ x-oapi-codegen-extra-tags:
+ validate: required,min=1
+ schema:
+ type: string
+ minLength: 1
+
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+
+ schemas:
+ ErrorResponse:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+
+ # -- Response schemas --
+
+ LogEntryInfo:
+ type: object
+ description: A single journal entry.
+ properties:
+ timestamp:
+ type: string
+ description: Entry timestamp in RFC3339 format.
+ example: '2026-03-31T22:30:45.123456Z'
+ unit:
+ type: string
+ description: Systemd unit or syslog identifier.
+ example: 'nginx.service'
+ priority:
+ type: string
+ description: Priority level name.
+ example: 'info'
+ message:
+ type: string
+ description: Log message.
+ example: 'Started nginx'
+ pid:
+ type: integer
+ description: Process ID that generated the entry.
+ example: 1234
+ hostname:
+ type: string
+ description: Hostname where the entry originated.
+ example: 'web-01'
+
+ LogResultEntry:
+ type: object
+ description: Log query result for a single agent.
+ properties:
+ hostname:
+ type: string
+ description: The hostname of the agent.
+ status:
+ type: string
+ enum: [ok, failed, skipped]
+ description: The status of the operation for this host.
+ entries:
+ type: array
+ description: Journal entries from this agent.
+ items:
+ $ref: '#/components/schemas/LogEntryInfo'
+ error:
+ type: string
+ description: Error message if the agent failed.
+ required:
+ - hostname
+ - status
+
+ # -- Collection responses --
+
+ LogCollectionResponse:
+ type: object
+ properties:
+ job_id:
+ type: string
+ format: uuid
+ description: The job ID used to process this request.
+ example: '550e8400-e29b-41d4-a716-446655440000'
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/LogResultEntry'
+ required:
+ - results
+```
+
+- [ ] **Step 2: Create oapi-codegen config**
+
+Create `internal/controller/api/node/log/gen/cfg.yaml`:
+
+```yaml
+# Copyright (c) 2026 John Dewey
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+# DEALINGS IN THE SOFTWARE.
+
+---
+package: gen
+output: log.gen.go
+generate:
+ models: true
+ echo-server: true
+ strict-server: true
+import-mapping:
+ ../../../common/gen/api.yaml: github.com/retr0h/osapi/internal/controller/api/common/gen
+output-options:
+ # to make sure that all types are generated
+ skip-prune: true
+```
+
+- [ ] **Step 3: Create generate.go**
+
+Create `internal/controller/api/node/log/gen/generate.go`:
+
+```go
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package gen contains generated code for the log API.
+package gen
+
+//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml api.yaml
+```
+
+- [ ] **Step 4: Generate code**
+
+Run: `go generate ./internal/controller/api/node/log/gen/...` Expected:
+generates `log.gen.go`
+
+- [ ] **Step 5: Regenerate combined spec**
+
+Run: `just generate` Expected: combined spec updated, all code regenerates
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/controller/api/node/log/gen/
+git commit -m "feat(log): add OpenAPI spec and generated code"
+```
+
+---
+
+### Task 6: API Handler Implementation
+
+**Files:**
+
+- Create: `internal/controller/api/node/log/types.go`
+- Create: `internal/controller/api/node/log/log.go`
+- Create: `internal/controller/api/node/log/validate.go`
+- Create: `internal/controller/api/node/log/log_query_get.go`
+- Create: `internal/controller/api/node/log/log_unit_get.go`
+- Create: `internal/controller/api/node/log/handler.go`
+- Modify: `cmd/controller_setup.go`
+- Test: `internal/controller/api/node/log/log_query_get_public_test.go`
+- Test: `internal/controller/api/node/log/log_unit_get_public_test.go`
+- Test: `internal/controller/api/node/log/handler_public_test.go`
+
+- [ ] **Step 1: Create handler types, factory, and validate**
+
+Create `internal/controller/api/node/log/types.go`:
+
+```go
+package log
+
+import (
+ "log/slog"
+
+ "github.com/retr0h/osapi/internal/job/client"
+)
+
+// Log implementation of the Log APIs operations.
+type Log struct {
+ // JobClient provides job-based operations for log management.
+ JobClient client.JobClient
+ logger *slog.Logger
+}
+```
+
+Create `internal/controller/api/node/log/log.go`:
+
+```go
+package log
+
+import (
+ "log/slog"
+
+ "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job/client"
+)
+
+// ensure that we've conformed to the `StrictServerInterface` with a compile-time check
+var _ gen.StrictServerInterface = (*Log)(nil)
+
+// New factory to create a new instance.
+func New(
+ logger *slog.Logger,
+ jobClient client.JobClient,
+) *Log {
+ return &Log{
+ JobClient: jobClient,
+ logger: logger.With(slog.String("subsystem", "api.log")),
+ }
+}
+```
+
+Create `internal/controller/api/node/log/validate.go`:
+
+```go
+package log
+
+import "github.com/retr0h/osapi/internal/validation"
+
+// validateHostname validates a hostname path parameter using the shared
+// validator. Returns the error message and false if invalid.
+//
+// This exists because oapi-codegen does not generate validate tags on
+// path parameters in strict-server mode (upstream limitation).
+func validateHostname(
+ hostname string,
+) (string, bool) {
+ return validation.Var(hostname, "required,min=1,valid_target")
+}
+```
+
+(All files need full license headers — follow existing patterns.)
+
+- [ ] **Step 2: Write handler tests for GetNodeLog**
+
+Create `internal/controller/api/node/log/log_query_get_public_test.go` — follow
+the same pattern as `process_list_get_public_test.go`. Test cases:
+
+- success (single target)
+- skipped (single target)
+- broadcast success
+- broadcast with failed/skipped hosts
+- validation error (invalid hostname)
+- job client error
+- TestGetNodeLogHTTP (raw HTTP through middleware)
+- TestGetNodeLogRBACHTTP (auth: 401, 403, 200)
+
+The test should mock `s.mockJobClient.EXPECT().Query(...)` with category
+`"node"` and operation `job.OperationLogQuery`. The handler must pass query
+params (`lines`, `since`, `priority`) as JSON data.
+
+- [ ] **Step 3: Write handler tests for GetNodeLogUnit**
+
+Create `internal/controller/api/node/log/log_unit_get_public_test.go` — same
+pattern. Test cases:
+
+- success (single target)
+- skipped (single target)
+- broadcast success
+- validation error
+- job client error
+- TestGetNodeLogUnitHTTP
+- TestGetNodeLogUnitRBACHTTP
+
+The handler must pass `unit` (from path param) and query params as JSON data
+with operation `job.OperationLogQueryUnit`.
+
+- [ ] **Step 4: Implement GetNodeLog handler**
+
+Create `internal/controller/api/node/log/log_query_get.go`:
+
+```go
+package log
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+
+ "github.com/google/uuid"
+
+ "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job"
+ logProv "github.com/retr0h/osapi/internal/provider/node/log"
+)
+
+// GetNodeLog queries journal entries on a target node.
+func (s *Log) GetNodeLog(
+ ctx context.Context,
+ request gen.GetNodeLogRequestObject,
+) (gen.GetNodeLogResponseObject, error) {
+ if errMsg, ok := validateHostname(request.Hostname); !ok {
+ return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil
+ }
+
+ hostname := request.Hostname
+
+ // Defense in depth: current query fields use omitempty so validation
+ // always passes, but guards against future field additions.
+ if errMsg, ok := validation.Struct(request.Params); !ok {
+ return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil
+ }
+
+ s.logger.Debug("log query",
+ slog.String("target", hostname),
+ slog.Bool("broadcast", job.IsBroadcastTarget(hostname)),
+ )
+
+ opts := logProv.QueryOpts{}
+ if request.Params.Lines != nil {
+ opts.Lines = *request.Params.Lines
+ }
+ if request.Params.Since != nil {
+ opts.Since = *request.Params.Since
+ }
+ if request.Params.Priority != nil {
+ opts.Priority = *request.Params.Priority
+ }
+
+ data, _ := json.Marshal(opts)
+
+ if job.IsBroadcastTarget(hostname) {
+ return s.getNodeLogBroadcast(ctx, hostname, data)
+ }
+
+ jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogQuery, data)
+ if err != nil {
+ errMsg := err.Error()
+ return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil
+ }
+
+ if resp.Status == job.StatusSkipped {
+ e := resp.Error
+ jobUUID := uuid.MustParse(jobID)
+ return gen.GetNodeLog200JSONResponse{
+ JobId: &jobUUID,
+ Results: []gen.LogResultEntry{
+ {
+ Hostname: resp.Hostname,
+ Status: gen.LogResultEntryStatusSkipped,
+ Error: &e,
+ },
+ },
+ }, nil
+ }
+
+ entries := logEntriesFromResponse(resp)
+ jobUUID := uuid.MustParse(jobID)
+
+ return gen.GetNodeLog200JSONResponse{
+ JobId: &jobUUID,
+ Results: []gen.LogResultEntry{
+ {
+ Hostname: resp.Hostname,
+ Status: gen.LogResultEntryStatusOk,
+ Entries: &entries,
+ },
+ },
+ }, nil
+}
+
+// logEntriesFromResponse extracts LogEntryInfo slice from a job response.
+func logEntriesFromResponse(
+ resp *job.Response,
+) []gen.LogEntryInfo {
+ var provEntries []logProv.Entry
+ if resp.Data != nil {
+ _ = json.Unmarshal(resp.Data, &provEntries)
+ }
+
+ result := make([]gen.LogEntryInfo, 0, len(provEntries))
+ for _, e := range provEntries {
+ result = append(result, logEntryToGen(e))
+ }
+
+ return result
+}
+
+// logEntryToGen converts a provider Entry to a gen.LogEntryInfo.
+func logEntryToGen(
+ e logProv.Entry,
+) gen.LogEntryInfo {
+ ts := e.Timestamp
+ unit := e.Unit
+ priority := e.Priority
+ message := e.Message
+ pid := e.PID
+ hostname := e.Hostname
+
+ return gen.LogEntryInfo{
+ Timestamp: &ts,
+ Unit: stringPtrOrNil(unit),
+ Priority: &priority,
+ Message: &message,
+ Pid: intPtrOrNil(pid),
+ Hostname: stringPtrOrNil(hostname),
+ }
+}
+
+func stringPtrOrNil(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+
+func intPtrOrNil(i int) *int {
+ if i == 0 {
+ return nil
+ }
+ return &i
+}
+
+// getNodeLogBroadcast handles broadcast targets for log query.
+func (s *Log) getNodeLogBroadcast(
+ ctx context.Context,
+ target string,
+ data json.RawMessage,
+) (gen.GetNodeLogResponseObject, error) {
+ jobID, responses, err := s.JobClient.QueryBroadcast(
+ ctx,
+ target,
+ "node",
+ job.OperationLogQuery,
+ data,
+ )
+ if err != nil {
+ errMsg := err.Error()
+ return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil
+ }
+
+ var items []gen.LogResultEntry
+ for host, resp := range responses {
+ item := gen.LogResultEntry{
+ Hostname: host,
+ }
+ switch resp.Status {
+ case job.StatusFailed:
+ item.Status = gen.LogResultEntryStatusFailed
+ e := resp.Error
+ item.Error = &e
+ case job.StatusSkipped:
+ item.Status = gen.LogResultEntryStatusSkipped
+ e := resp.Error
+ item.Error = &e
+ default:
+ item.Status = gen.LogResultEntryStatusOk
+ entries := logEntriesFromResponse(resp)
+ item.Entries = &entries
+ }
+ items = append(items, item)
+ }
+
+ jobUUID := uuid.MustParse(jobID)
+ return gen.GetNodeLog200JSONResponse{
+ JobId: &jobUUID,
+ Results: items,
+ }, nil
+}
+```
+
+Note: The import for `validation` is
+`"github.com/retr0h/osapi/internal/validation"`. All files need full license
+headers.
+
+- [ ] **Step 5: Implement GetNodeLogUnit handler**
+
+Create `internal/controller/api/node/log/log_unit_get.go` — same pattern as
+`log_query_get.go` but adds unit from `request.Name` path param. Passes
+`{"unit":"...","lines":...,"since":"...","priority":"..."}` as job data. Uses
+`job.OperationLogQueryUnit`.
+
+- [ ] **Step 6: Implement handler.go**
+
+Create `internal/controller/api/node/log/handler.go` — same pattern as
+`internal/controller/api/node/process/handler.go`:
+
+```go
+package log
+
+import (
+ "log/slog"
+
+ "github.com/labstack/echo/v4"
+ strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo"
+
+ "github.com/retr0h/osapi/internal/authtoken"
+ "github.com/retr0h/osapi/internal/controller/api"
+ gen "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job/client"
+)
+
+// Handler returns Log route registration functions.
+func Handler(
+ logger *slog.Logger,
+ jobClient client.JobClient,
+ signingKey string,
+ customRoles map[string][]string,
+) []func(e *echo.Echo) {
+ var tokenManager api.TokenValidator = authtoken.New(logger)
+
+ logHandler := New(logger, jobClient)
+
+ strictHandler := gen.NewStrictHandler(
+ logHandler,
+ []gen.StrictMiddlewareFunc{
+ func(handler strictecho.StrictEchoHandlerFunc, _ string) strictecho.StrictEchoHandlerFunc {
+ return api.ScopeMiddleware(
+ handler,
+ tokenManager,
+ signingKey,
+ gen.BearerAuthScopes,
+ customRoles,
+ )
+ },
+ },
+ )
+
+ return []func(e *echo.Echo){
+ func(e *echo.Echo) {
+ gen.RegisterHandlers(e, strictHandler)
+ },
+ }
+}
+```
+
+- [ ] **Step 7: Register handler in controller_setup.go**
+
+Add import:
+
+```go
+logAPI "github.com/retr0h/osapi/internal/controller/api/node/log"
+```
+
+Add after the `packageAPI.Handler(...)` line:
+
+```go
+ handlers = append(handlers, logAPI.Handler(log, jc, signingKey, customRoles)...)
+```
+
+- [ ] **Step 8: Write handler_public_test.go**
+
+Test route registration and middleware execution (same pattern as other handler
+tests).
+
+- [ ] **Step 9: Run tests**
+
+Run: `go test -v ./internal/controller/api/node/log/... && go build ./...`
+Expected: PASS
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add internal/controller/api/node/log/ cmd/controller_setup.go
+git commit -m "feat(log): add API handlers with broadcast support"
+```
+
+---
+
+### Task 7: SDK Service
+
+**Files:**
+
+- Create: `pkg/sdk/client/log.go`
+- Create: `pkg/sdk/client/log_types.go`
+- Modify: `pkg/sdk/client/osapi.go`
+- Test: `pkg/sdk/client/log_public_test.go`
+- Test: `pkg/sdk/client/log_types_public_test.go`
+
+- [ ] **Step 1: Write SDK service tests**
+
+Create `pkg/sdk/client/log_public_test.go` — test with `httptest.Server`. Test
+cases for `Query` and `QueryUnit`:
+
+- success (200)
+- auth error (401, 403)
+- server error (500)
+- nil response body
+- transport error
+
+- [ ] **Step 2: Write SDK types tests**
+
+Create `pkg/sdk/client/log_types_public_test.go` — test conversion functions:
+
+- `logCollectionFromGen` with full data
+- `logCollectionFromGen` with error entries
+- `logEntryInfoFromGen` field mapping
+- Nil/empty fields
+
+- [ ] **Step 3: Implement log_types.go**
+
+```go
+package client
+
+import (
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
+)
+
+// LogEntryResult represents the result of a log query for one host.
+type LogEntryResult struct {
+ Hostname string `json:"hostname"`
+ Status string `json:"status"`
+ Entries []LogEntry `json:"entries,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// LogEntry represents a single journal entry.
+type LogEntry struct {
+ Timestamp string `json:"timestamp,omitempty"`
+ Unit string `json:"unit,omitempty"`
+ Priority string `json:"priority,omitempty"`
+ Message string `json:"message,omitempty"`
+ PID int `json:"pid,omitempty"`
+ Hostname string `json:"hostname,omitempty"`
+}
+
+// LogQueryOpts contains options for log query operations.
+type LogQueryOpts struct {
+ Lines *int
+ Since *string
+ Priority *string
+}
+
+// logCollectionFromGen converts a gen.LogCollectionResponse
+// to a Collection[LogEntryResult].
+func logCollectionFromGen(
+ g *gen.LogCollectionResponse,
+) Collection[LogEntryResult] {
+ results := make([]LogEntryResult, 0, len(g.Results))
+ for _, r := range g.Results {
+ results = append(results, logEntryResultFromGen(r))
+ }
+
+ return Collection[LogEntryResult]{
+ Results: results,
+ JobID: jobIDFromGen(g.JobId),
+ }
+}
+
+// logEntryResultFromGen converts a gen.LogResultEntry to a LogEntryResult.
+func logEntryResultFromGen(
+ r gen.LogResultEntry,
+) LogEntryResult {
+ result := LogEntryResult{
+ Hostname: r.Hostname,
+ Status: string(r.Status),
+ Error: derefString(r.Error),
+ }
+
+ if r.Entries != nil {
+ entries := make([]LogEntry, 0, len(*r.Entries))
+ for _, e := range *r.Entries {
+ entries = append(entries, logEntryInfoFromGen(e))
+ }
+ result.Entries = entries
+ }
+
+ return result
+}
+
+// logEntryInfoFromGen converts a gen.LogEntryInfo to a LogEntry.
+func logEntryInfoFromGen(
+ e gen.LogEntryInfo,
+) LogEntry {
+ return LogEntry{
+ Timestamp: derefString(e.Timestamp),
+ Unit: derefString(e.Unit),
+ Priority: derefString(e.Priority),
+ Message: derefString(e.Message),
+ PID: derefInt(e.Pid),
+ Hostname: derefString(e.Hostname),
+ }
+}
+```
+
+- [ ] **Step 4: Implement log.go**
+
+```go
+package client
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
+)
+
+// LogService provides log viewing operations.
+type LogService struct {
+ client *gen.ClientWithResponses
+}
+
+// Query returns journal entries from the target host.
+func (s *LogService) Query(
+ ctx context.Context,
+ hostname string,
+ opts LogQueryOpts,
+) (*Response[Collection[LogEntryResult]], error) {
+ params := &gen.GetNodeLogParams{
+ Lines: opts.Lines,
+ Since: opts.Since,
+ Priority: opts.Priority,
+ }
+
+ resp, err := s.client.GetNodeLogWithResponse(ctx, hostname, params)
+ if err != nil {
+ return nil, fmt.Errorf("log query: %w", err)
+ }
+
+ if err := checkError(
+ resp.StatusCode(),
+ resp.JSON401,
+ resp.JSON403,
+ resp.JSON500,
+ ); err != nil {
+ return nil, err
+ }
+
+ if resp.JSON200 == nil {
+ return nil, &UnexpectedStatusError{APIError{
+ StatusCode: resp.StatusCode(),
+ Message: "nil response body",
+ }}
+ }
+
+ return NewResponse(logCollectionFromGen(resp.JSON200), resp.Body), nil
+}
+
+// QueryUnit returns journal entries for a specific unit on the target host.
+func (s *LogService) QueryUnit(
+ ctx context.Context,
+ hostname string,
+ unit string,
+ opts LogQueryOpts,
+) (*Response[Collection[LogEntryResult]], error) {
+ params := &gen.GetNodeLogUnitParams{
+ Lines: opts.Lines,
+ Since: opts.Since,
+ Priority: opts.Priority,
+ }
+
+ resp, err := s.client.GetNodeLogUnitWithResponse(ctx, hostname, unit, params)
+ if err != nil {
+ return nil, fmt.Errorf("log query unit: %w", err)
+ }
+
+ if err := checkError(
+ resp.StatusCode(),
+ resp.JSON401,
+ resp.JSON403,
+ resp.JSON500,
+ ); err != nil {
+ return nil, err
+ }
+
+ if resp.JSON200 == nil {
+ return nil, &UnexpectedStatusError{APIError{
+ StatusCode: resp.StatusCode(),
+ Message: "nil response body",
+ }}
+ }
+
+ return NewResponse(logCollectionFromGen(resp.JSON200), resp.Body), nil
+}
+```
+
+- [ ] **Step 5: Wire LogService in osapi.go**
+
+Add field to Client struct:
+
+```go
+ // Log provides log viewing operations (query journal entries).
+ Log *LogService
+```
+
+Add initialization in `New()`:
+
+```go
+ c.Log = &LogService{client: httpClient}
+```
+
+- [ ] **Step 6: Regenerate SDK client**
+
+Run: `go generate ./pkg/sdk/client/gen/...` Expected: SDK client picks up log
+endpoints
+
+- [ ] **Step 7: Run tests**
+
+Run: `go test -v ./pkg/sdk/client/...` Expected: PASS
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add pkg/sdk/client/log.go pkg/sdk/client/log_types.go \
+ pkg/sdk/client/log_public_test.go pkg/sdk/client/log_types_public_test.go \
+ pkg/sdk/client/osapi.go pkg/sdk/client/gen/
+git commit -m "feat(log): add SDK service with tests"
+```
+
+---
+
+### Task 8: CLI Commands
+
+**Files:**
+
+- Create: `cmd/client_node_log.go`
+- Create: `cmd/client_node_log_query.go`
+- Create: `cmd/client_node_log_unit.go`
+
+- [ ] **Step 1: Create parent command**
+
+Create `cmd/client_node_log.go`:
+
+```go
+package cmd
+
+import (
+ "github.com/spf13/cobra"
+)
+
+// clientNodeLogCmd represents the clientNodeLog command.
+var clientNodeLogCmd = &cobra.Command{
+ Use: "log",
+ Short: "View journal logs",
+}
+
+func init() {
+ clientNodeCmd.AddCommand(clientNodeLogCmd)
+}
+```
+
+- [ ] **Step 2: Create query subcommand**
+
+Create `cmd/client_node_log_query.go`:
+
+```go
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/retr0h/osapi/internal/cli"
+ "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+// clientNodeLogQueryCmd represents the log query command.
+var clientNodeLogQueryCmd = &cobra.Command{
+ Use: "query",
+ Short: "Query journal entries",
+ Long: `Query systemd journal entries on the target node.`,
+ Run: func(cmd *cobra.Command, _ []string) {
+ ctx := cmd.Context()
+ host, _ := cmd.Flags().GetString("target")
+ lines, _ := cmd.Flags().GetInt("lines")
+ since, _ := cmd.Flags().GetString("since")
+ priority, _ := cmd.Flags().GetString("priority")
+
+ opts := client.LogQueryOpts{}
+ if cmd.Flags().Changed("lines") {
+ opts.Lines = &lines
+ }
+ if since != "" {
+ opts.Since = &since
+ }
+ if priority != "" {
+ opts.Priority = &priority
+ }
+
+ resp, err := sdkClient.Log.Query(ctx, host, opts)
+ if err != nil {
+ cli.HandleError(err, logger)
+ return
+ }
+
+ if jsonOutput {
+ fmt.Println(string(resp.RawJSON()))
+ return
+ }
+
+ if resp.Data.JobID != "" {
+ fmt.Println()
+ cli.PrintKV("Job ID", resp.Data.JobID)
+ fmt.Println()
+ }
+
+ results := make([]cli.ResultRow, 0)
+ for _, r := range resp.Data.Results {
+ if r.Error != "" {
+ var errPtr *string
+ e := r.Error
+ errPtr = &e
+ results = append(results, cli.ResultRow{
+ Hostname: r.Hostname,
+ Status: r.Status,
+ Error: errPtr,
+ })
+
+ continue
+ }
+
+ for _, entry := range r.Entries {
+ message := entry.Message
+ if len(message) > 80 {
+ message = message[:77] + "..."
+ }
+
+ results = append(results, cli.ResultRow{
+ Hostname: r.Hostname,
+ Status: r.Status,
+ Fields: []string{
+ entry.Timestamp,
+ entry.Priority,
+ entry.Unit,
+ message,
+ },
+ })
+ }
+ }
+ headers, rows := cli.BuildBroadcastTable(
+ results,
+ []string{"TIMESTAMP", "PRIORITY", "UNIT", "MESSAGE"},
+ )
+ cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}})
+ },
+}
+
+func init() {
+ clientNodeLogCmd.AddCommand(clientNodeLogQueryCmd)
+
+ clientNodeLogQueryCmd.PersistentFlags().
+ Int("lines", 100, "Number of entries to return")
+ clientNodeLogQueryCmd.PersistentFlags().
+ String("since", "", "Time filter (e.g., \"1 hour ago\")")
+ clientNodeLogQueryCmd.PersistentFlags().
+ String("priority", "", "Minimum priority (emerg..debug or 0-7)")
+}
+```
+
+- [ ] **Step 3: Create query-unit subcommand**
+
+Create `cmd/client_node_log_unit.go`:
+
+```go
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ "github.com/retr0h/osapi/internal/cli"
+ "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+// clientNodeLogUnitCmd represents the log unit command.
+var clientNodeLogUnitCmd = &cobra.Command{
+ Use: "unit",
+ Short: "Query journal entries for a unit",
+ Long: `Query systemd journal entries for a specific unit on the target node.`,
+ Run: func(cmd *cobra.Command, _ []string) {
+ ctx := cmd.Context()
+ host, _ := cmd.Flags().GetString("target")
+ unit, _ := cmd.Flags().GetString("name")
+ lines, _ := cmd.Flags().GetInt("lines")
+ since, _ := cmd.Flags().GetString("since")
+ priority, _ := cmd.Flags().GetString("priority")
+
+ opts := client.LogQueryOpts{}
+ if cmd.Flags().Changed("lines") {
+ opts.Lines = &lines
+ }
+ if since != "" {
+ opts.Since = &since
+ }
+ if priority != "" {
+ opts.Priority = &priority
+ }
+
+ resp, err := sdkClient.Log.QueryUnit(ctx, host, unit, opts)
+ if err != nil {
+ cli.HandleError(err, logger)
+ return
+ }
+
+ if jsonOutput {
+ fmt.Println(string(resp.RawJSON()))
+ return
+ }
+
+ if resp.Data.JobID != "" {
+ fmt.Println()
+ cli.PrintKV("Job ID", resp.Data.JobID)
+ fmt.Println()
+ }
+
+ results := make([]cli.ResultRow, 0)
+ for _, r := range resp.Data.Results {
+ if r.Error != "" {
+ var errPtr *string
+ e := r.Error
+ errPtr = &e
+ results = append(results, cli.ResultRow{
+ Hostname: r.Hostname,
+ Status: r.Status,
+ Error: errPtr,
+ })
+
+ continue
+ }
+
+ for _, entry := range r.Entries {
+ message := entry.Message
+ if len(message) > 80 {
+ message = message[:77] + "..."
+ }
+
+ results = append(results, cli.ResultRow{
+ Hostname: r.Hostname,
+ Status: r.Status,
+ Fields: []string{
+ entry.Timestamp,
+ entry.Priority,
+ entry.Unit,
+ message,
+ },
+ })
+ }
+ }
+ headers, rows := cli.BuildBroadcastTable(
+ results,
+ []string{"TIMESTAMP", "PRIORITY", "UNIT", "MESSAGE"},
+ )
+ cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}})
+ },
+}
+
+func init() {
+ clientNodeLogCmd.AddCommand(clientNodeLogUnitCmd)
+
+ clientNodeLogUnitCmd.PersistentFlags().
+ String("name", "", "Systemd unit name (required)")
+ clientNodeLogUnitCmd.PersistentFlags().
+ Int("lines", 100, "Number of entries to return")
+ clientNodeLogUnitCmd.PersistentFlags().
+ String("since", "", "Time filter (e.g., \"1 hour ago\")")
+ clientNodeLogUnitCmd.PersistentFlags().
+ String("priority", "", "Minimum priority (emerg..debug or 0-7)")
+
+ _ = clientNodeLogUnitCmd.MarkPersistentFlagRequired("name")
+}
+```
+
+- [ ] **Step 4: Build and verify**
+
+Run: `go build ./... && go run main.go client node log --help` Expected: shows
+`query` and `unit` subcommands
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cmd/client_node_log.go cmd/client_node_log_query.go cmd/client_node_log_unit.go
+git commit -m "feat(log): add CLI commands for journal log viewing"
+```
+
+---
+
+### Task 9: Documentation and SDK Example
+
+**Files:**
+
+- Create: `docs/docs/sidebar/features/log-management.md`
+- Create: `docs/docs/sidebar/usage/cli/client/node/log/log.md`
+- Create: `docs/docs/sidebar/usage/cli/client/node/log/query.md`
+- Create: `docs/docs/sidebar/usage/cli/client/node/log/unit.md`
+- Create: `docs/docs/sidebar/sdk/client/operations/log.md`
+- Create: `examples/sdk/client/log.go`
+- Modify: `docs/docs/sidebar/features/features.md`
+- Modify: `docs/docs/sidebar/features/authentication.md`
+- Modify: `docs/docs/sidebar/usage/configuration.md`
+- Modify: `docs/docs/sidebar/architecture/architecture.md`
+- Modify: `docs/docs/sidebar/architecture/api-guidelines.md`
+- Modify: `docs/docusaurus.config.ts`
+
+- [ ] **Step 1: Create feature page**
+
+Create `docs/docs/sidebar/features/log-management.md` following the
+process-management.md template. Include:
+
+- How It Works (Query, QueryUnit)
+- Operations table
+- CLI Usage examples
+- Broadcast Support section
+- Supported Platforms table (Debian: Full, Darwin: Skipped, Linux: Skipped)
+- Container Behavior (skipped — journalctl requires systemd)
+- Permissions table (log:read for all operations)
+- Related links
+
+- [ ] **Step 2: Create CLI doc pages**
+
+Create landing page `docs/docs/sidebar/usage/cli/client/node/log/log.md`:
+
+```markdown
+---
+sidebar_position: 1
+---
+
+# Log
+
+
+```
+
+Create `query.md` and `unit.md` pages with usage examples, flags, and output
+samples.
+
+- [ ] **Step 3: Create SDK doc page**
+
+Create `docs/docs/sidebar/sdk/client/operations/log.md` following existing SDK
+doc patterns. Document `Query` and `QueryUnit` methods with code examples.
+
+- [ ] **Step 4: Create SDK example**
+
+Create `examples/sdk/client/log.go` — demonstrate `Query` and `QueryUnit` with
+error handling and result printing. Under ~100 lines.
+
+- [ ] **Step 5: Update cross-references**
+
+Update these files to add log management:
+
+- `features/features.md` — add row to features table
+- `features/authentication.md` — add `log:read` to all three role tables
+- `usage/configuration.md` — add `log:read` to permissions comments and role
+ tables
+- `architecture/architecture.md` — add log feature link
+- `architecture/api-guidelines.md` — add log endpoint rows to path pattern table
+- `docusaurus.config.ts` — add to Features dropdown and SDK dropdown
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add docs/ examples/sdk/client/log.go
+git commit -m "docs: add log management feature docs, SDK example, and cross-references"
+```
+
+---
+
+### Task 10: Integration Test
+
+**Files:**
+
+- Create: `test/integration/log_test.go`
+
+- [ ] **Step 1: Write integration test**
+
+Create `test/integration/log_test.go` with `//go:build integration` tag. Follow
+the pattern of existing integration tests. Test:
+
+- `osapi client node log query --target _any --json` → verify JSON output
+- `osapi client node log unit --target _any --name sshd.service --json` → verify
+ JSON output or graceful error
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add test/integration/log_test.go
+git commit -m "test(log): add integration test"
+```
+
+---
+
+### Task 11: Final Verification
+
+- [ ] **Step 1: Run full test suite**
+
+```bash
+just generate
+go build ./...
+just go::unit
+just go::vet
+```
+
+Expected: all pass, lint clean
+
+- [ ] **Step 2: Commit any fixes**
+
+If `just generate` produces diffs (combined spec, formatting), commit them:
+
+```bash
+git add -A
+git commit -m "chore(log): regenerate specs and fix formatting"
+```
diff --git a/examples/sdk/client/log.go b/examples/sdk/client/log.go
new file mode 100644
index 000000000..b6a10fea5
--- /dev/null
+++ b/examples/sdk/client/log.go
@@ -0,0 +1,127 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates log management: query journal entries and
+// query entries scoped to a specific systemd unit.
+//
+// All responses return Collection[LogEntryResult] with per-host results
+// when targeting broadcast targets (_all, _any) or a single hostname. Use
+// .Data.Results to iterate over the entries.
+//
+// Run with: OSAPI_TOKEN="" go run log.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+func logExample() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+ ctx := context.Background()
+ hostname := "_any"
+
+ // Query the last 10 journal entries from the target host.
+ // Returns Collection[LogEntryResult] with per-host results.
+ fmt.Println("=== Querying journal entries ===")
+ lines := 10
+ queryResp, err := c.Log.Query(ctx, hostname, client.LogQueryOpts{
+ Lines: &lines,
+ })
+ if err != nil {
+ log.Fatalf("log query failed: %v", err)
+ }
+
+ for _, r := range queryResp.Data.Results {
+ if r.Error != "" {
+ fmt.Printf(" %s: ERROR %s\n", r.Hostname, r.Error)
+ continue
+ }
+ fmt.Printf(" %s: %d entries\n", r.Hostname, len(r.Entries))
+ for _, e := range r.Entries[:min(3, len(r.Entries))] {
+ fmt.Printf(" [%s] %s %s: %s\n",
+ e.Timestamp, e.Priority, e.Unit, e.Message)
+ }
+ if len(r.Entries) > 3 {
+ fmt.Printf(" ... and %d more\n", len(r.Entries)-3)
+ }
+ }
+
+ // List available log sources on the host.
+ fmt.Println("\n=== Listing log sources ===")
+ srcResp, err := c.Log.Sources(ctx, hostname)
+ if err != nil {
+ log.Fatalf("log sources failed: %v", err)
+ }
+
+ for _, r := range srcResp.Data.Results {
+ if r.Error != "" {
+ fmt.Printf(" %s: ERROR %s\n", r.Hostname, r.Error)
+ continue
+ }
+ fmt.Printf(" %s: %d sources\n", r.Hostname, len(r.Sources))
+ for _, src := range r.Sources[:min(10, len(r.Sources))] {
+ fmt.Printf(" %s\n", src)
+ }
+ if len(r.Sources) > 10 {
+ fmt.Printf(" ... and %d more\n", len(r.Sources)-10)
+ }
+ }
+
+ // Query entries for the sshd systemd unit.
+ fmt.Println("\n=== Querying sshd.service entries ===")
+ unitResp, err := c.Log.QueryUnit(ctx, hostname, "sshd.service",
+ client.LogQueryOpts{Lines: &lines})
+ if err != nil {
+ fmt.Printf("log unit query failed (sshd may not be running): %v\n",
+ err)
+ return
+ }
+
+ for _, r := range unitResp.Data.Results {
+ if r.Error != "" {
+ fmt.Printf(" %s: ERROR %s\n", r.Hostname, r.Error)
+ continue
+ }
+ fmt.Printf(" %s: %d sshd entries\n", r.Hostname, len(r.Entries))
+ for _, e := range r.Entries {
+ fmt.Printf(" [%s] pid=%d %s\n",
+ e.Priority, e.PID, e.Message)
+ }
+ }
+}
+
+func main() {
+ logExample()
+}
diff --git a/internal/agent/export_test.go b/internal/agent/export_test.go
index 5a056e05d..74ff9041b 100644
--- a/internal/agent/export_test.go
+++ b/internal/agent/export_test.go
@@ -364,6 +364,7 @@ func SetAgentAppConfig(
nil,
nil,
nil,
+ nil,
cfg,
a.logger,
)
@@ -400,6 +401,7 @@ func SetAgentHostProvider(
nil,
nil,
nil,
+ nil,
a.appConfig,
a.logger,
)
diff --git a/internal/agent/fixture_public_test.go b/internal/agent/fixture_public_test.go
index 7da902c25..88aeb810f 100644
--- a/internal/agent/fixture_public_test.go
+++ b/internal/agent/fixture_public_test.go
@@ -104,6 +104,7 @@ func newTestAgent(p newTestAgentParams) *agent.Agent {
nil,
nil,
nil,
+ nil,
p.appConfig,
logger,
),
diff --git a/internal/agent/processor.go b/internal/agent/processor.go
index 08505c568..66e9ca749 100644
--- a/internal/agent/processor.go
+++ b/internal/agent/processor.go
@@ -32,6 +32,7 @@ import (
"github.com/retr0h/osapi/internal/provider/node/disk"
nodeHost "github.com/retr0h/osapi/internal/provider/node/host"
"github.com/retr0h/osapi/internal/provider/node/load"
+ logProv "github.com/retr0h/osapi/internal/provider/node/log"
"github.com/retr0h/osapi/internal/provider/node/mem"
"github.com/retr0h/osapi/internal/provider/node/ntp"
"github.com/retr0h/osapi/internal/provider/node/power"
@@ -66,6 +67,7 @@ func NewNodeProcessor(
processProvider processProv.Provider,
userProvider user.Provider,
packageProvider apt.Provider,
+ logProvider logProv.Provider,
appConfig config.Config,
logger *slog.Logger,
) ProcessorFunc {
@@ -107,6 +109,8 @@ func NewNodeProcessor(
return processGroupOperation(userProvider, logger, req)
case "package":
return processPackageOperation(packageProvider, logger, req)
+ case "log":
+ return processLogOperation(logProvider, logger, req)
default:
return nil, fmt.Errorf("unsupported node operation: %s", req.Operation)
}
diff --git a/internal/agent/processor_log.go b/internal/agent/processor_log.go
new file mode 100644
index 000000000..8478bb2a5
--- /dev/null
+++ b/internal/agent/processor_log.go
@@ -0,0 +1,128 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/retr0h/osapi/internal/job"
+ logProv "github.com/retr0h/osapi/internal/provider/node/log"
+)
+
+// processLogOperation dispatches log management sub-operations.
+func processLogOperation(
+ logProvider logProv.Provider,
+ logger *slog.Logger,
+ jobRequest job.Request,
+) (json.RawMessage, error) {
+ if logProvider == nil {
+ return nil, fmt.Errorf("log provider not available")
+ }
+
+ // Extract sub-operation: "log.query" -> "query"
+ parts := strings.Split(jobRequest.Operation, ".")
+ if len(parts) < 2 {
+ return nil, fmt.Errorf("invalid log operation: %s", jobRequest.Operation)
+ }
+ subOp := parts[1]
+
+ ctx := context.Background()
+
+ switch subOp {
+ case "query":
+ return processLogQuery(ctx, logProvider, logger, jobRequest)
+ case "queryUnit":
+ return processLogQueryUnit(ctx, logProvider, logger, jobRequest)
+ case "sources":
+ return processLogSources(ctx, logProvider, logger)
+ default:
+ return nil, fmt.Errorf("unsupported log operation: %s", jobRequest.Operation)
+ }
+}
+
+// processLogQuery retrieves journal entries with optional filtering.
+func processLogQuery(
+ ctx context.Context,
+ logProvider logProv.Provider,
+ logger *slog.Logger,
+ jobRequest job.Request,
+) (json.RawMessage, error) {
+ logger.Debug("executing log.Query")
+
+ var opts logProv.QueryOpts
+ if len(jobRequest.Data) > 0 {
+ if err := json.Unmarshal(jobRequest.Data, &opts); err != nil {
+ return nil, fmt.Errorf("unmarshal log query data: %w", err)
+ }
+ }
+
+ result, err := logProvider.Query(ctx, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return json.Marshal(result)
+}
+
+// processLogSources retrieves unique syslog identifiers from the journal.
+func processLogSources(
+ ctx context.Context,
+ logProvider logProv.Provider,
+ logger *slog.Logger,
+) (json.RawMessage, error) {
+ logger.Debug("executing log.ListSources")
+
+ result, err := logProvider.ListSources(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ return json.Marshal(result)
+}
+
+// processLogQueryUnit retrieves journal entries for a specific systemd unit.
+func processLogQueryUnit(
+ ctx context.Context,
+ logProvider logProv.Provider,
+ logger *slog.Logger,
+ jobRequest job.Request,
+) (json.RawMessage, error) {
+ logger.Debug("executing log.QueryUnit")
+
+ var data struct {
+ Unit string `json:"unit"`
+ logProv.QueryOpts
+ }
+ if err := json.Unmarshal(jobRequest.Data, &data); err != nil {
+ return nil, fmt.Errorf("unmarshal log query unit data: %w", err)
+ }
+
+ result, err := logProvider.QueryUnit(ctx, data.Unit, data.QueryOpts)
+ if err != nil {
+ return nil, err
+ }
+
+ return json.Marshal(result)
+}
diff --git a/internal/agent/processor_log_public_test.go b/internal/agent/processor_log_public_test.go
new file mode 100644
index 000000000..b61e1a342
--- /dev/null
+++ b/internal/agent/processor_log_public_test.go
@@ -0,0 +1,428 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package agent_test
+
+import (
+ "encoding/json"
+ "errors"
+ "log/slog"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/internal/agent"
+ "github.com/retr0h/osapi/internal/config"
+ "github.com/retr0h/osapi/internal/job"
+ "github.com/retr0h/osapi/internal/provider/node/log"
+ logMocks "github.com/retr0h/osapi/internal/provider/node/log/mocks"
+)
+
+type ProcessorLogPublicTestSuite struct {
+ suite.Suite
+
+ mockCtrl *gomock.Controller
+}
+
+func (s *ProcessorLogPublicTestSuite) SetupTest() {
+ s.mockCtrl = gomock.NewController(s.T())
+}
+
+func (s *ProcessorLogPublicTestSuite) TearDownTest() {
+ s.mockCtrl.Finish()
+}
+
+func (s *ProcessorLogPublicTestSuite) newProcessor(
+ logProvider log.Provider,
+) agent.ProcessorFunc {
+ return agent.NewNodeProcessor(
+ nil, nil, nil, nil,
+ nil, nil, nil, nil,
+ nil,
+ nil,
+ nil,
+ logProvider,
+ config.Config{},
+ slog.Default(),
+ )
+}
+
+func (s *ProcessorLogPublicTestSuite) TestProcessLogOperation() {
+ tests := []struct {
+ name string
+ jobRequest job.Request
+ setupMock func() log.Provider
+ expectError bool
+ errorMsg string
+ validate func(json.RawMessage)
+ }{
+ {
+ name: "nil provider returns error",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.query",
+ },
+ setupMock: nil,
+ expectError: true,
+ errorMsg: "log provider not available",
+ },
+ {
+ name: "invalid operation format (no sub-operation)",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log",
+ },
+ setupMock: func() log.Provider {
+ return logMocks.NewMockProvider(s.mockCtrl)
+ },
+ expectError: true,
+ errorMsg: "invalid log operation: log",
+ },
+ {
+ name: "unsupported log sub-operation",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.invalid",
+ },
+ setupMock: func() log.Provider {
+ return logMocks.NewMockProvider(s.mockCtrl)
+ },
+ expectError: true,
+ errorMsg: "unsupported log operation: log.invalid",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ var logProvider log.Provider
+ if tt.setupMock != nil {
+ logProvider = tt.setupMock()
+ }
+
+ processor := s.newProcessor(logProvider)
+ result, err := processor(tt.jobRequest)
+
+ if tt.expectError {
+ s.Error(err)
+ s.Contains(err.Error(), tt.errorMsg)
+ s.Nil(result)
+ } else {
+ s.NoError(err)
+ s.NotNil(result)
+ if tt.validate != nil {
+ tt.validate(result)
+ }
+ }
+ })
+ }
+}
+
+func (s *ProcessorLogPublicTestSuite) TestProcessLogQuery() {
+ tests := []struct {
+ name string
+ jobRequest job.Request
+ setupMock func() log.Provider
+ expectError bool
+ errorMsg string
+ validate func(json.RawMessage)
+ }{
+ {
+ name: "query with default opts (empty data)",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.query",
+ Data: json.RawMessage(`{}`),
+ },
+ setupMock: func() log.Provider {
+ m := logMocks.NewMockProvider(s.mockCtrl)
+ m.EXPECT().Query(gomock.Any(), log.QueryOpts{}).Return([]log.Entry{
+ {
+ Timestamp: "2026-01-01T00:00:00Z",
+ Unit: "systemd",
+ Priority: "info",
+ Message: "Started system",
+ },
+ }, nil)
+ return m
+ },
+ validate: func(result json.RawMessage) {
+ var entries []log.Entry
+ err := json.Unmarshal(result, &entries)
+ s.NoError(err)
+ s.Len(entries, 1)
+ s.Equal("Started system", entries[0].Message)
+ },
+ },
+ {
+ name: "query with all options",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.query",
+ Data: json.RawMessage(`{"lines":50,"since":"1 hour ago","priority":"err"}`),
+ },
+ setupMock: func() log.Provider {
+ m := logMocks.NewMockProvider(s.mockCtrl)
+ m.EXPECT().Query(gomock.Any(), log.QueryOpts{
+ Lines: 50,
+ Since: "1 hour ago",
+ Priority: "err",
+ }).Return([]log.Entry{
+ {
+ Timestamp: "2026-01-01T00:00:00Z",
+ Priority: "err",
+ Message: "error occurred",
+ },
+ }, nil)
+ return m
+ },
+ validate: func(result json.RawMessage) {
+ var entries []log.Entry
+ err := json.Unmarshal(result, &entries)
+ s.NoError(err)
+ s.Len(entries, 1)
+ s.Equal("err", entries[0].Priority)
+ },
+ },
+ {
+ name: "query unmarshal error",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.query",
+ Data: json.RawMessage(`invalid json`),
+ },
+ setupMock: func() log.Provider {
+ return logMocks.NewMockProvider(s.mockCtrl)
+ },
+ expectError: true,
+ errorMsg: "unmarshal log query data",
+ },
+ {
+ name: "query provider error",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.query",
+ Data: json.RawMessage(`{}`),
+ },
+ setupMock: func() log.Provider {
+ m := logMocks.NewMockProvider(s.mockCtrl)
+ m.EXPECT().
+ Query(gomock.Any(), gomock.Any()).
+ Return(nil, errors.New("journalctl failed"))
+ return m
+ },
+ expectError: true,
+ errorMsg: "journalctl failed",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ processor := s.newProcessor(tt.setupMock())
+ result, err := processor(tt.jobRequest)
+
+ if tt.expectError {
+ s.Error(err)
+ s.Contains(err.Error(), tt.errorMsg)
+ s.Nil(result)
+ } else {
+ s.NoError(err)
+ s.NotNil(result)
+ if tt.validate != nil {
+ tt.validate(result)
+ }
+ }
+ })
+ }
+}
+
+func (s *ProcessorLogPublicTestSuite) TestProcessLogQueryUnit() {
+ tests := []struct {
+ name string
+ jobRequest job.Request
+ setupMock func() log.Provider
+ expectError bool
+ errorMsg string
+ validate func(json.RawMessage)
+ }{
+ {
+ name: "queryUnit with unit name",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.queryUnit",
+ Data: json.RawMessage(`{"unit":"nginx.service"}`),
+ },
+ setupMock: func() log.Provider {
+ m := logMocks.NewMockProvider(s.mockCtrl)
+ m.EXPECT().
+ QueryUnit(gomock.Any(), "nginx.service", log.QueryOpts{}).
+ Return([]log.Entry{
+ {
+ Timestamp: "2026-01-01T00:00:00Z",
+ Unit: "nginx.service",
+ Priority: "info",
+ Message: "nginx started",
+ },
+ }, nil)
+ return m
+ },
+ validate: func(result json.RawMessage) {
+ var entries []log.Entry
+ err := json.Unmarshal(result, &entries)
+ s.NoError(err)
+ s.Len(entries, 1)
+ s.Equal("nginx.service", entries[0].Unit)
+ s.Equal("nginx started", entries[0].Message)
+ },
+ },
+ {
+ name: "queryUnit unmarshal error",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.queryUnit",
+ Data: json.RawMessage(`invalid json`),
+ },
+ setupMock: func() log.Provider {
+ return logMocks.NewMockProvider(s.mockCtrl)
+ },
+ expectError: true,
+ errorMsg: "unmarshal log query unit data",
+ },
+ {
+ name: "queryUnit provider error",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.queryUnit",
+ Data: json.RawMessage(`{"unit":"nginx.service"}`),
+ },
+ setupMock: func() log.Provider {
+ m := logMocks.NewMockProvider(s.mockCtrl)
+ m.EXPECT().
+ QueryUnit(gomock.Any(), "nginx.service", gomock.Any()).
+ Return(nil, errors.New("unit not found"))
+ return m
+ },
+ expectError: true,
+ errorMsg: "unit not found",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ processor := s.newProcessor(tt.setupMock())
+ result, err := processor(tt.jobRequest)
+
+ if tt.expectError {
+ s.Error(err)
+ s.Contains(err.Error(), tt.errorMsg)
+ s.Nil(result)
+ } else {
+ s.NoError(err)
+ s.NotNil(result)
+ if tt.validate != nil {
+ tt.validate(result)
+ }
+ }
+ })
+ }
+}
+
+func (s *ProcessorLogPublicTestSuite) TestProcessLogSources() {
+ tests := []struct {
+ name string
+ jobRequest job.Request
+ setupMock func() log.Provider
+ expectError bool
+ errorMsg string
+ validate func(json.RawMessage)
+ }{
+ {
+ name: "sources success",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.sources",
+ },
+ setupMock: func() log.Provider {
+ m := logMocks.NewMockProvider(s.mockCtrl)
+ m.EXPECT().
+ ListSources(gomock.Any()).
+ Return([]string{"nginx", "sshd", "systemd"}, nil)
+ return m
+ },
+ validate: func(result json.RawMessage) {
+ var sources []string
+ err := json.Unmarshal(result, &sources)
+ s.NoError(err)
+ s.Equal([]string{"nginx", "sshd", "systemd"}, sources)
+ },
+ },
+ {
+ name: "sources provider error",
+ jobRequest: job.Request{
+ Type: job.TypeQuery,
+ Category: "node",
+ Operation: "log.sources",
+ },
+ setupMock: func() log.Provider {
+ m := logMocks.NewMockProvider(s.mockCtrl)
+ m.EXPECT().
+ ListSources(gomock.Any()).
+ Return(nil, errors.New("journalctl failed"))
+ return m
+ },
+ expectError: true,
+ errorMsg: "journalctl failed",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ processor := s.newProcessor(tt.setupMock())
+ result, err := processor(tt.jobRequest)
+
+ if tt.expectError {
+ s.Error(err)
+ s.Contains(err.Error(), tt.errorMsg)
+ s.Nil(result)
+ } else {
+ s.NoError(err)
+ s.NotNil(result)
+ if tt.validate != nil {
+ tt.validate(result)
+ }
+ }
+ })
+ }
+}
+
+func TestProcessorLogPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(ProcessorLogPublicTestSuite))
+}
diff --git a/internal/agent/processor_ntp_public_test.go b/internal/agent/processor_ntp_public_test.go
index 81f96dd1a..debc4a71b 100644
--- a/internal/agent/processor_ntp_public_test.go
+++ b/internal/agent/processor_ntp_public_test.go
@@ -114,6 +114,7 @@ func (s *ProcessorNtpPublicTestSuite) TestProcessNtpOperation() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -197,6 +198,7 @@ func (s *ProcessorNtpPublicTestSuite) TestProcessNtpGet() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -292,6 +294,7 @@ func (s *ProcessorNtpPublicTestSuite) TestProcessNtpCreate() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -387,6 +390,7 @@ func (s *ProcessorNtpPublicTestSuite) TestProcessNtpUpdate() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -464,6 +468,7 @@ func (s *ProcessorNtpPublicTestSuite) TestProcessNtpDelete() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
diff --git a/internal/agent/processor_package_public_test.go b/internal/agent/processor_package_public_test.go
index fc247e730..e54518114 100644
--- a/internal/agent/processor_package_public_test.go
+++ b/internal/agent/processor_package_public_test.go
@@ -59,6 +59,7 @@ func (s *ProcessorPackagePublicTestSuite) newProcessor(
nil,
nil,
packageProvider,
+ nil,
config.Config{},
slog.Default(),
)
diff --git a/internal/agent/processor_power_public_test.go b/internal/agent/processor_power_public_test.go
index 1e7f7a0d5..969c6b84c 100644
--- a/internal/agent/processor_power_public_test.go
+++ b/internal/agent/processor_power_public_test.go
@@ -115,6 +115,7 @@ func (s *ProcessorPowerPublicTestSuite) TestProcessPowerOperation() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -241,6 +242,7 @@ func (s *ProcessorPowerPublicTestSuite) TestProcessPowerReboot() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -367,6 +369,7 @@ func (s *ProcessorPowerPublicTestSuite) TestProcessPowerShutdown() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
diff --git a/internal/agent/processor_process_public_test.go b/internal/agent/processor_process_public_test.go
index bc4755e15..e4f90dbb4 100644
--- a/internal/agent/processor_process_public_test.go
+++ b/internal/agent/processor_process_public_test.go
@@ -111,6 +111,7 @@ func (s *ProcessorProcessPublicTestSuite) TestProcessProcessOperation() {
processProvider,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -202,6 +203,7 @@ func (s *ProcessorProcessPublicTestSuite) TestProcessProcessList() {
tt.setupMock(),
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -299,6 +301,7 @@ func (s *ProcessorProcessPublicTestSuite) TestProcessProcessGet() {
tt.setupMock(),
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -396,6 +399,7 @@ func (s *ProcessorProcessPublicTestSuite) TestProcessProcessSignal() {
tt.setupMock(),
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
diff --git a/internal/agent/processor_sysctl_public_test.go b/internal/agent/processor_sysctl_public_test.go
index 5c4f79e4d..b72c45a55 100644
--- a/internal/agent/processor_sysctl_public_test.go
+++ b/internal/agent/processor_sysctl_public_test.go
@@ -115,6 +115,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlOperation() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -196,6 +197,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlList() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -290,6 +292,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlGet() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -389,6 +392,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlCreate() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -488,6 +492,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlUpdate() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -582,6 +587,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlDelete() {
nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
diff --git a/internal/agent/processor_timezone_public_test.go b/internal/agent/processor_timezone_public_test.go
index 233a761d0..bd288b93c 100644
--- a/internal/agent/processor_timezone_public_test.go
+++ b/internal/agent/processor_timezone_public_test.go
@@ -114,6 +114,7 @@ func (s *ProcessorTimezonePublicTestSuite) TestProcessTimezoneOperation() {
nil, nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -193,6 +194,7 @@ func (s *ProcessorTimezonePublicTestSuite) TestProcessTimezoneGet() {
nil, nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
@@ -288,6 +290,7 @@ func (s *ProcessorTimezonePublicTestSuite) TestProcessTimezoneUpdate() {
nil, nil,
nil,
nil,
+ nil,
config.Config{},
slog.Default(),
)
diff --git a/internal/agent/processor_user_public_test.go b/internal/agent/processor_user_public_test.go
index 5f3c73dd8..2d6aa3af0 100644
--- a/internal/agent/processor_user_public_test.go
+++ b/internal/agent/processor_user_public_test.go
@@ -59,6 +59,7 @@ func (s *ProcessorUserPublicTestSuite) newProcessor(
nil,
userProvider,
nil,
+ nil,
config.Config{},
slog.Default(),
)
diff --git a/internal/authtoken/permissions.go b/internal/authtoken/permissions.go
index 716272872..28c4dcb09 100644
--- a/internal/authtoken/permissions.go
+++ b/internal/authtoken/permissions.go
@@ -58,6 +58,7 @@ const (
PermUserWrite = client.PermUserWrite
PermPackageRead = client.PermPackageRead
PermPackageWrite = client.PermPackageWrite
+ PermLogRead = client.PermLogRead
)
// AllPermissions is the full set of known permissions.
@@ -93,6 +94,7 @@ var AllPermissions = []Permission{
PermUserWrite,
PermPackageRead,
PermPackageWrite,
+ PermLogRead,
}
// DefaultRolePermissions maps built-in role names to their granted permissions.
@@ -129,6 +131,7 @@ var DefaultRolePermissions = map[string][]Permission{
PermUserWrite,
PermPackageRead,
PermPackageWrite,
+ PermLogRead,
},
client.RoleWrite: {
PermAgentRead,
@@ -156,6 +159,7 @@ var DefaultRolePermissions = map[string][]Permission{
PermUserWrite,
PermPackageRead,
PermPackageWrite,
+ PermLogRead,
},
client.RoleRead: {
PermAgentRead,
@@ -172,6 +176,7 @@ var DefaultRolePermissions = map[string][]Permission{
PermProcessRead,
PermUserRead,
PermPackageRead,
+ PermLogRead,
},
}
diff --git a/internal/controller/api/gen/api.yaml b/internal/controller/api/gen/api.yaml
index 6c891cc79..7a3451022 100644
--- a/internal/controller/api/gen/api.yaml
+++ b/internal/controller/api/gen/api.yaml
@@ -48,6 +48,9 @@ tags:
- name: Hostname_Management_API_hostname_operations
x-displayName: Node/Hostname
description: Hostname operations on a target node.
+ - name: Log_Management_API_log_operations
+ x-displayName: Node/Log
+ description: Log viewing operations on a target node.
- name: Network_Management_API_network_operations
x-displayName: Node/Network
description: Network operations on a target node.
@@ -2199,6 +2202,181 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
+ /node/{hostname}/log:
+ servers: []
+ get:
+ summary: Get system log entries
+ description: |
+ Retrieve log entries from the target node's system journal.
+ tags:
+ - Log_Management_API_log_operations
+ operationId: GetNodeLog
+ security:
+ - BearerAuth:
+ - log:read
+ parameters:
+ - $ref: '#/components/parameters/Hostname'
+ - name: lines
+ in: query
+ required: false
+ description: |
+ Maximum number of log lines to return.
+ x-oapi-codegen-extra-tags:
+ validate: omitempty,min=1,max=10000
+ schema:
+ type: integer
+ default: 100
+ minimum: 1
+ maximum: 10000
+ - name: since
+ in: query
+ required: false
+ description: >
+ Return log entries since this time. Accepts systemd time
+ specifications (e.g., "1h", "2026-01-01 00:00:00").
+ schema:
+ type: string
+ - name: priority
+ in: query
+ required: false
+ description: >
+ Filter by log priority level (e.g., "err", "warning", "info",
+ "debug").
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Log entries from the target node.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LogCollectionResponse'
+ '401':
+ description: Unauthorized - API key required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '403':
+ description: Forbidden - Insufficient permissions
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Error retrieving log entries.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ /node/{hostname}/log/source:
+ servers: []
+ get:
+ summary: List log sources
+ description: >
+ List unique syslog identifiers (log sources) available in the journal on
+ the target node.
+ tags:
+ - Log_Management_API_log_operations
+ operationId: GetNodeLogSource
+ security:
+ - BearerAuth:
+ - log:read
+ parameters:
+ - $ref: '#/components/parameters/Hostname'
+ responses:
+ '200':
+ description: List of log sources.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LogSourceCollectionResponse'
+ '401':
+ description: Unauthorized - API key required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '403':
+ description: Forbidden - Insufficient permissions
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Error listing log sources.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ /node/{hostname}/log/unit/{name}:
+ servers: []
+ get:
+ summary: Get log entries for a systemd unit
+ description: |
+ Retrieve log entries for a specific systemd unit on the target node.
+ tags:
+ - Log_Management_API_log_operations
+ operationId: GetNodeLogUnit
+ security:
+ - BearerAuth:
+ - log:read
+ parameters:
+ - $ref: '#/components/parameters/Hostname'
+ - $ref: '#/components/parameters/UnitName'
+ - name: lines
+ in: query
+ required: false
+ description: |
+ Maximum number of log lines to return.
+ x-oapi-codegen-extra-tags:
+ validate: omitempty,min=1,max=10000
+ schema:
+ type: integer
+ default: 100
+ minimum: 1
+ maximum: 10000
+ - name: since
+ in: query
+ required: false
+ description: >
+ Return log entries since this time. Accepts systemd time
+ specifications (e.g., "1h", "2026-01-01 00:00:00").
+ schema:
+ type: string
+ - name: priority
+ in: query
+ required: false
+ description: >
+ Filter by log priority level (e.g., "err", "warning", "info",
+ "debug").
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Log entries for the specified unit.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LogCollectionResponse'
+ '401':
+ description: Unauthorized - API key required
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '403':
+ description: Forbidden - Insufficient permissions
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
+ '500':
+ description: Error retrieving unit log entries.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorResponse'
/node/{hostname}/network/ping:
servers: []
post:
@@ -6192,6 +6370,112 @@ components:
$ref: '#/components/schemas/HostnameUpdateResultItem'
required:
- results
+ LogEntryInfo:
+ type: object
+ description: A single log entry from the system journal.
+ properties:
+ timestamp:
+ type: string
+ description: Log entry timestamp.
+ example: '2026-01-15T10:30:00Z'
+ unit:
+ type: string
+ description: Systemd unit that produced this entry.
+ example: nginx.service
+ priority:
+ type: string
+ description: Log priority level.
+ example: info
+ message:
+ type: string
+ description: Log message text.
+ example: Server started successfully
+ pid:
+ type: integer
+ description: Process identifier that produced this entry.
+ example: 1234
+ hostname:
+ type: string
+ description: Hostname of the system that produced this entry.
+ example: web-01
+ LogResultEntry:
+ type: object
+ description: Log result for a single agent.
+ properties:
+ hostname:
+ type: string
+ description: The hostname of the agent.
+ status:
+ type: string
+ enum:
+ - ok
+ - failed
+ - skipped
+ description: The status of the operation for this host.
+ entries:
+ type: array
+ description: Log entries from this agent.
+ items:
+ $ref: '#/components/schemas/LogEntryInfo'
+ error:
+ type: string
+ description: Error message if the agent failed.
+ required:
+ - hostname
+ - status
+ LogSourceEntry:
+ type: object
+ description: Log source result for a single agent.
+ properties:
+ hostname:
+ type: string
+ description: The hostname of the agent.
+ status:
+ type: string
+ enum:
+ - ok
+ - failed
+ - skipped
+ description: The status of the operation for this host.
+ sources:
+ type: array
+ description: Unique syslog identifiers on this agent.
+ items:
+ type: string
+ error:
+ type: string
+ description: Error message if the agent failed.
+ required:
+ - hostname
+ - status
+ LogSourceCollectionResponse:
+ type: object
+ properties:
+ job_id:
+ type: string
+ format: uuid
+ description: The job ID used to process this request.
+ example: 550e8400-e29b-41d4-a716-446655440000
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/LogSourceEntry'
+ required:
+ - results
+ LogCollectionResponse:
+ type: object
+ properties:
+ job_id:
+ type: string
+ format: uuid
+ description: The job ID used to process this request.
+ example: 550e8400-e29b-41d4-a716-446655440000
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/LogResultEntry'
+ required:
+ - results
PingResponse:
type: object
properties:
@@ -7730,6 +8014,17 @@ components:
type: string
minLength: 1
pattern: ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$
+ UnitName:
+ name: name
+ in: path
+ required: true
+ description: |
+ Systemd unit name (e.g., "nginx.service", "sshd.service").
+ x-oapi-codegen-extra-tags:
+ validate: required,min=1
+ schema:
+ type: string
+ minLength: 1
PackageName:
name: name
in: path
@@ -7839,6 +8134,9 @@ x-tagGroups:
- name: Hostname Management API
tags:
- Hostname_Management_API_hostname_operations
+ - name: Log Management API
+ tags:
+ - Log_Management_API_log_operations
- name: Network Management API
tags:
- Network_Management_API_network_operations
diff --git a/internal/controller/api/node/log/gen/api.yaml b/internal/controller/api/node/log/gen/api.yaml
new file mode 100644
index 000000000..1817683df
--- /dev/null
+++ b/internal/controller/api/node/log/gen/api.yaml
@@ -0,0 +1,359 @@
+# Copyright (c) 2024 John Dewey
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+# DEALINGS IN THE SOFTWARE.
+
+---
+openapi: 3.0.0
+info:
+ title: Log Management API
+ version: 1.0.0
+tags:
+ - name: log_operations
+ x-displayName: Node/Log
+ description: Log viewing operations on a target node.
+
+paths:
+ /node/{hostname}/log:
+ get:
+ summary: Get system log entries
+ description: >
+ Retrieve log entries from the target node's system journal.
+ tags:
+ - log_operations
+ operationId: GetNodeLog
+ security:
+ - BearerAuth:
+ - log:read
+ parameters:
+ - $ref: '#/components/parameters/Hostname'
+ - name: lines
+ in: query
+ required: false
+ description: >
+ Maximum number of log lines to return.
+ x-oapi-codegen-extra-tags:
+ validate: omitempty,min=1,max=10000
+ schema:
+ type: integer
+ default: 100
+ minimum: 1
+ maximum: 10000
+ - name: since
+ in: query
+ required: false
+ description: >
+ Return log entries since this time. Accepts systemd time
+ specifications (e.g., "1h", "2026-01-01 00:00:00").
+ schema:
+ type: string
+ - name: priority
+ in: query
+ required: false
+ description: >
+ Filter by log priority level (e.g., "err", "warning", "info",
+ "debug").
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Log entries from the target node.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LogCollectionResponse'
+ '401':
+ description: Unauthorized - API key required
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+ '403':
+ description: Forbidden - Insufficient permissions
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+ '500':
+ description: Error retrieving log entries.
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+
+ /node/{hostname}/log/source:
+ get:
+ summary: List log sources
+ description: >
+ List unique syslog identifiers (log sources) available in the
+ journal on the target node.
+ tags:
+ - log_operations
+ operationId: GetNodeLogSource
+ security:
+ - BearerAuth:
+ - log:read
+ parameters:
+ - $ref: '#/components/parameters/Hostname'
+ responses:
+ '200':
+ description: List of log sources.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LogSourceCollectionResponse'
+ '401':
+ description: Unauthorized - API key required
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+ '403':
+ description: Forbidden - Insufficient permissions
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+ '500':
+ description: Error listing log sources.
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+
+ /node/{hostname}/log/unit/{name}:
+ get:
+ summary: Get log entries for a systemd unit
+ description: >
+ Retrieve log entries for a specific systemd unit on the target node.
+ tags:
+ - log_operations
+ operationId: GetNodeLogUnit
+ security:
+ - BearerAuth:
+ - log:read
+ parameters:
+ - $ref: '#/components/parameters/Hostname'
+ - $ref: '#/components/parameters/UnitName'
+ - name: lines
+ in: query
+ required: false
+ description: >
+ Maximum number of log lines to return.
+ x-oapi-codegen-extra-tags:
+ validate: omitempty,min=1,max=10000
+ schema:
+ type: integer
+ default: 100
+ minimum: 1
+ maximum: 10000
+ - name: since
+ in: query
+ required: false
+ description: >
+ Return log entries since this time. Accepts systemd time
+ specifications (e.g., "1h", "2026-01-01 00:00:00").
+ schema:
+ type: string
+ - name: priority
+ in: query
+ required: false
+ description: >
+ Filter by log priority level (e.g., "err", "warning", "info",
+ "debug").
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Log entries for the specified unit.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LogCollectionResponse'
+ '401':
+ description: Unauthorized - API key required
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+ '403':
+ description: Forbidden - Insufficient permissions
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+ '500':
+ description: Error retrieving unit log entries.
+ content:
+ application/json:
+ schema:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+
+# -- Reusable components --
+
+components:
+ parameters:
+ Hostname:
+ name: hostname
+ in: path
+ required: true
+ description: >
+ Target agent hostname, reserved routing value (_any, _all),
+ or label selector (key:value).
+ # NOTE: x-oapi-codegen-extra-tags on path params do not generate
+ # validate tags in strict-server mode. Validation is handled
+ # manually in handlers via validateHostname().
+ x-oapi-codegen-extra-tags:
+ validate: required,min=1,valid_target
+ schema:
+ type: string
+ minLength: 1
+
+ UnitName:
+ name: name
+ in: path
+ required: true
+ description: >
+ Systemd unit name (e.g., "nginx.service", "sshd.service").
+ # NOTE: x-oapi-codegen-extra-tags on path params do not generate
+ # validate tags in strict-server mode. Validation is handled
+ # manually in the handler.
+ x-oapi-codegen-extra-tags:
+ validate: required,min=1
+ schema:
+ type: string
+ minLength: 1
+
+ securitySchemes:
+ BearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+
+ schemas:
+ ErrorResponse:
+ $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse'
+
+ # -- Response schemas --
+
+ LogEntryInfo:
+ type: object
+ description: A single log entry from the system journal.
+ properties:
+ timestamp:
+ type: string
+ description: Log entry timestamp.
+ example: "2026-01-15T10:30:00Z"
+ unit:
+ type: string
+ description: Systemd unit that produced this entry.
+ example: "nginx.service"
+ priority:
+ type: string
+ description: Log priority level.
+ example: "info"
+ message:
+ type: string
+ description: Log message text.
+ example: "Server started successfully"
+ pid:
+ type: integer
+ description: Process identifier that produced this entry.
+ example: 1234
+ hostname:
+ type: string
+ description: Hostname of the system that produced this entry.
+ example: "web-01"
+
+ LogResultEntry:
+ type: object
+ description: Log result for a single agent.
+ properties:
+ hostname:
+ type: string
+ description: The hostname of the agent.
+ status:
+ type: string
+ enum: [ok, failed, skipped]
+ description: The status of the operation for this host.
+ entries:
+ type: array
+ description: Log entries from this agent.
+ items:
+ $ref: '#/components/schemas/LogEntryInfo'
+ error:
+ type: string
+ description: Error message if the agent failed.
+ required:
+ - hostname
+ - status
+
+ LogSourceEntry:
+ type: object
+ description: Log source result for a single agent.
+ properties:
+ hostname:
+ type: string
+ description: The hostname of the agent.
+ status:
+ type: string
+ enum: [ok, failed, skipped]
+ description: The status of the operation for this host.
+ sources:
+ type: array
+ description: Unique syslog identifiers on this agent.
+ items:
+ type: string
+ error:
+ type: string
+ description: Error message if the agent failed.
+ required:
+ - hostname
+ - status
+
+ LogSourceCollectionResponse:
+ type: object
+ properties:
+ job_id:
+ type: string
+ format: uuid
+ description: The job ID used to process this request.
+ example: "550e8400-e29b-41d4-a716-446655440000"
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/LogSourceEntry'
+ required:
+ - results
+
+ # -- Collection responses --
+
+ LogCollectionResponse:
+ type: object
+ properties:
+ job_id:
+ type: string
+ format: uuid
+ description: The job ID used to process this request.
+ example: "550e8400-e29b-41d4-a716-446655440000"
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/LogResultEntry'
+ required:
+ - results
diff --git a/internal/controller/api/node/log/gen/cfg.yaml b/internal/controller/api/node/log/gen/cfg.yaml
new file mode 100644
index 000000000..be6c98be7
--- /dev/null
+++ b/internal/controller/api/node/log/gen/cfg.yaml
@@ -0,0 +1,32 @@
+# Copyright (c) 2024 John Dewey
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to
+# deal in the Software without restriction, including without limitation the
+# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+# sell copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+# DEALINGS IN THE SOFTWARE.
+
+---
+package: gen
+output: log.gen.go
+generate:
+ models: true
+ echo-server: true
+ strict-server: true
+import-mapping:
+ ../../../common/gen/api.yaml: github.com/retr0h/osapi/internal/controller/api/common/gen
+output-options:
+ # to make sure that all types are generated
+ skip-prune: true
diff --git a/internal/controller/api/node/log/gen/generate.go b/internal/controller/api/node/log/gen/generate.go
new file mode 100644
index 000000000..e3b68476d
--- /dev/null
+++ b/internal/controller/api/node/log/gen/generate.go
@@ -0,0 +1,24 @@
+// Copyright (c) 2024 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package gen contains generated code for the log API.
+package gen
+
+//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml api.yaml
diff --git a/internal/controller/api/node/log/gen/log.gen.go b/internal/controller/api/node/log/gen/log.gen.go
new file mode 100644
index 000000000..c7ee53d44
--- /dev/null
+++ b/internal/controller/api/node/log/gen/log.gen.go
@@ -0,0 +1,537 @@
+// Package gen provides primitives to interact with the openapi HTTP API.
+//
+// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT.
+package gen
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "github.com/labstack/echo/v4"
+ "github.com/oapi-codegen/runtime"
+ strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo"
+ openapi_types "github.com/oapi-codegen/runtime/types"
+ externalRef0 "github.com/retr0h/osapi/internal/controller/api/common/gen"
+)
+
+const (
+ BearerAuthScopes = "BearerAuth.Scopes"
+)
+
+// Defines values for LogResultEntryStatus.
+const (
+ LogResultEntryStatusFailed LogResultEntryStatus = "failed"
+ LogResultEntryStatusOk LogResultEntryStatus = "ok"
+ LogResultEntryStatusSkipped LogResultEntryStatus = "skipped"
+)
+
+// Defines values for LogSourceEntryStatus.
+const (
+ LogSourceEntryStatusFailed LogSourceEntryStatus = "failed"
+ LogSourceEntryStatusOk LogSourceEntryStatus = "ok"
+ LogSourceEntryStatusSkipped LogSourceEntryStatus = "skipped"
+)
+
+// ErrorResponse defines model for ErrorResponse.
+type ErrorResponse = externalRef0.ErrorResponse
+
+// LogCollectionResponse defines model for LogCollectionResponse.
+type LogCollectionResponse struct {
+ // JobId The job ID used to process this request.
+ JobId *openapi_types.UUID `json:"job_id,omitempty"`
+ Results []LogResultEntry `json:"results"`
+}
+
+// LogEntryInfo A single log entry from the system journal.
+type LogEntryInfo struct {
+ // Hostname Hostname of the system that produced this entry.
+ Hostname *string `json:"hostname,omitempty"`
+
+ // Message Log message text.
+ Message *string `json:"message,omitempty"`
+
+ // Pid Process identifier that produced this entry.
+ Pid *int `json:"pid,omitempty"`
+
+ // Priority Log priority level.
+ Priority *string `json:"priority,omitempty"`
+
+ // Timestamp Log entry timestamp.
+ Timestamp *string `json:"timestamp,omitempty"`
+
+ // Unit Systemd unit that produced this entry.
+ Unit *string `json:"unit,omitempty"`
+}
+
+// LogResultEntry Log result for a single agent.
+type LogResultEntry struct {
+ // Entries Log entries from this agent.
+ Entries *[]LogEntryInfo `json:"entries,omitempty"`
+
+ // Error Error message if the agent failed.
+ Error *string `json:"error,omitempty"`
+
+ // Hostname The hostname of the agent.
+ Hostname string `json:"hostname"`
+
+ // Status The status of the operation for this host.
+ Status LogResultEntryStatus `json:"status"`
+}
+
+// LogResultEntryStatus The status of the operation for this host.
+type LogResultEntryStatus string
+
+// LogSourceCollectionResponse defines model for LogSourceCollectionResponse.
+type LogSourceCollectionResponse struct {
+ // JobId The job ID used to process this request.
+ JobId *openapi_types.UUID `json:"job_id,omitempty"`
+ Results []LogSourceEntry `json:"results"`
+}
+
+// LogSourceEntry Log source result for a single agent.
+type LogSourceEntry struct {
+ // Error Error message if the agent failed.
+ Error *string `json:"error,omitempty"`
+
+ // Hostname The hostname of the agent.
+ Hostname string `json:"hostname"`
+
+ // Sources Unique syslog identifiers on this agent.
+ Sources *[]string `json:"sources,omitempty"`
+
+ // Status The status of the operation for this host.
+ Status LogSourceEntryStatus `json:"status"`
+}
+
+// LogSourceEntryStatus The status of the operation for this host.
+type LogSourceEntryStatus string
+
+// Hostname defines model for Hostname.
+type Hostname = string
+
+// UnitName defines model for UnitName.
+type UnitName = string
+
+// GetNodeLogParams defines parameters for GetNodeLog.
+type GetNodeLogParams struct {
+ // Lines Maximum number of log lines to return.
+ Lines *int `form:"lines,omitempty" json:"lines,omitempty" validate:"omitempty,min=1,max=10000"`
+
+ // Since Return log entries since this time. Accepts systemd time specifications (e.g., "1h", "2026-01-01 00:00:00").
+ Since *string `form:"since,omitempty" json:"since,omitempty"`
+
+ // Priority Filter by log priority level (e.g., "err", "warning", "info", "debug").
+ Priority *string `form:"priority,omitempty" json:"priority,omitempty"`
+}
+
+// GetNodeLogUnitParams defines parameters for GetNodeLogUnit.
+type GetNodeLogUnitParams struct {
+ // Lines Maximum number of log lines to return.
+ Lines *int `form:"lines,omitempty" json:"lines,omitempty" validate:"omitempty,min=1,max=10000"`
+
+ // Since Return log entries since this time. Accepts systemd time specifications (e.g., "1h", "2026-01-01 00:00:00").
+ Since *string `form:"since,omitempty" json:"since,omitempty"`
+
+ // Priority Filter by log priority level (e.g., "err", "warning", "info", "debug").
+ Priority *string `form:"priority,omitempty" json:"priority,omitempty"`
+}
+
+// ServerInterface represents all server handlers.
+type ServerInterface interface {
+ // Get system log entries
+ // (GET /node/{hostname}/log)
+ GetNodeLog(ctx echo.Context, hostname Hostname, params GetNodeLogParams) error
+ // List log sources
+ // (GET /node/{hostname}/log/source)
+ GetNodeLogSource(ctx echo.Context, hostname Hostname) error
+ // Get log entries for a systemd unit
+ // (GET /node/{hostname}/log/unit/{name})
+ GetNodeLogUnit(ctx echo.Context, hostname Hostname, name UnitName, params GetNodeLogUnitParams) error
+}
+
+// ServerInterfaceWrapper converts echo contexts to parameters.
+type ServerInterfaceWrapper struct {
+ Handler ServerInterface
+}
+
+// GetNodeLog converts echo context to params.
+func (w *ServerInterfaceWrapper) GetNodeLog(ctx echo.Context) error {
+ var err error
+ // ------------- Path parameter "hostname" -------------
+ var hostname Hostname
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err))
+ }
+
+ ctx.Set(BearerAuthScopes, []string{"log:read"})
+
+ // Parameter object where we will unmarshal all parameters from the context
+ var params GetNodeLogParams
+ // ------------- Optional query parameter "lines" -------------
+
+ err = runtime.BindQueryParameter("form", true, false, "lines", ctx.QueryParams(), ¶ms.Lines)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter lines: %s", err))
+ }
+
+ // ------------- Optional query parameter "since" -------------
+
+ err = runtime.BindQueryParameter("form", true, false, "since", ctx.QueryParams(), ¶ms.Since)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter since: %s", err))
+ }
+
+ // ------------- Optional query parameter "priority" -------------
+
+ err = runtime.BindQueryParameter("form", true, false, "priority", ctx.QueryParams(), ¶ms.Priority)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter priority: %s", err))
+ }
+
+ // Invoke the callback with all the unmarshaled arguments
+ err = w.Handler.GetNodeLog(ctx, hostname, params)
+ return err
+}
+
+// GetNodeLogSource converts echo context to params.
+func (w *ServerInterfaceWrapper) GetNodeLogSource(ctx echo.Context) error {
+ var err error
+ // ------------- Path parameter "hostname" -------------
+ var hostname Hostname
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err))
+ }
+
+ ctx.Set(BearerAuthScopes, []string{"log:read"})
+
+ // Invoke the callback with all the unmarshaled arguments
+ err = w.Handler.GetNodeLogSource(ctx, hostname)
+ return err
+}
+
+// GetNodeLogUnit converts echo context to params.
+func (w *ServerInterfaceWrapper) GetNodeLogUnit(ctx echo.Context) error {
+ var err error
+ // ------------- Path parameter "hostname" -------------
+ var hostname Hostname
+
+ err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err))
+ }
+
+ // ------------- Path parameter "name" -------------
+ var name UnitName
+
+ err = runtime.BindStyledParameterWithOptions("simple", "name", ctx.Param("name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true})
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter name: %s", err))
+ }
+
+ ctx.Set(BearerAuthScopes, []string{"log:read"})
+
+ // Parameter object where we will unmarshal all parameters from the context
+ var params GetNodeLogUnitParams
+ // ------------- Optional query parameter "lines" -------------
+
+ err = runtime.BindQueryParameter("form", true, false, "lines", ctx.QueryParams(), ¶ms.Lines)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter lines: %s", err))
+ }
+
+ // ------------- Optional query parameter "since" -------------
+
+ err = runtime.BindQueryParameter("form", true, false, "since", ctx.QueryParams(), ¶ms.Since)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter since: %s", err))
+ }
+
+ // ------------- Optional query parameter "priority" -------------
+
+ err = runtime.BindQueryParameter("form", true, false, "priority", ctx.QueryParams(), ¶ms.Priority)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter priority: %s", err))
+ }
+
+ // Invoke the callback with all the unmarshaled arguments
+ err = w.Handler.GetNodeLogUnit(ctx, hostname, name, params)
+ return err
+}
+
+// This is a simple interface which specifies echo.Route addition functions which
+// are present on both echo.Echo and echo.Group, since we want to allow using
+// either of them for path registration
+type EchoRouter interface {
+ CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+ TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route
+}
+
+// RegisterHandlers adds each server route to the EchoRouter.
+func RegisterHandlers(router EchoRouter, si ServerInterface) {
+ RegisterHandlersWithBaseURL(router, si, "")
+}
+
+// Registers handlers, and prepends BaseURL to the paths, so that the paths
+// can be served under a prefix.
+func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) {
+
+ wrapper := ServerInterfaceWrapper{
+ Handler: si,
+ }
+
+ router.GET(baseURL+"/node/:hostname/log", wrapper.GetNodeLog)
+ router.GET(baseURL+"/node/:hostname/log/source", wrapper.GetNodeLogSource)
+ router.GET(baseURL+"/node/:hostname/log/unit/:name", wrapper.GetNodeLogUnit)
+
+}
+
+type GetNodeLogRequestObject struct {
+ Hostname Hostname `json:"hostname"`
+ Params GetNodeLogParams
+}
+
+type GetNodeLogResponseObject interface {
+ VisitGetNodeLogResponse(w http.ResponseWriter) error
+}
+
+type GetNodeLog200JSONResponse LogCollectionResponse
+
+func (response GetNodeLog200JSONResponse) VisitGetNodeLogResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLog401JSONResponse externalRef0.ErrorResponse
+
+func (response GetNodeLog401JSONResponse) VisitGetNodeLogResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(401)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLog403JSONResponse externalRef0.ErrorResponse
+
+func (response GetNodeLog403JSONResponse) VisitGetNodeLogResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(403)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLog500JSONResponse externalRef0.ErrorResponse
+
+func (response GetNodeLog500JSONResponse) VisitGetNodeLogResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(500)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLogSourceRequestObject struct {
+ Hostname Hostname `json:"hostname"`
+}
+
+type GetNodeLogSourceResponseObject interface {
+ VisitGetNodeLogSourceResponse(w http.ResponseWriter) error
+}
+
+type GetNodeLogSource200JSONResponse LogSourceCollectionResponse
+
+func (response GetNodeLogSource200JSONResponse) VisitGetNodeLogSourceResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLogSource401JSONResponse externalRef0.ErrorResponse
+
+func (response GetNodeLogSource401JSONResponse) VisitGetNodeLogSourceResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(401)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLogSource403JSONResponse externalRef0.ErrorResponse
+
+func (response GetNodeLogSource403JSONResponse) VisitGetNodeLogSourceResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(403)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLogSource500JSONResponse externalRef0.ErrorResponse
+
+func (response GetNodeLogSource500JSONResponse) VisitGetNodeLogSourceResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(500)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLogUnitRequestObject struct {
+ Hostname Hostname `json:"hostname"`
+ Name UnitName `json:"name"`
+ Params GetNodeLogUnitParams
+}
+
+type GetNodeLogUnitResponseObject interface {
+ VisitGetNodeLogUnitResponse(w http.ResponseWriter) error
+}
+
+type GetNodeLogUnit200JSONResponse LogCollectionResponse
+
+func (response GetNodeLogUnit200JSONResponse) VisitGetNodeLogUnitResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(200)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLogUnit401JSONResponse externalRef0.ErrorResponse
+
+func (response GetNodeLogUnit401JSONResponse) VisitGetNodeLogUnitResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(401)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLogUnit403JSONResponse externalRef0.ErrorResponse
+
+func (response GetNodeLogUnit403JSONResponse) VisitGetNodeLogUnitResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(403)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+type GetNodeLogUnit500JSONResponse externalRef0.ErrorResponse
+
+func (response GetNodeLogUnit500JSONResponse) VisitGetNodeLogUnitResponse(w http.ResponseWriter) error {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(500)
+
+ return json.NewEncoder(w).Encode(response)
+}
+
+// StrictServerInterface represents all server handlers.
+type StrictServerInterface interface {
+ // Get system log entries
+ // (GET /node/{hostname}/log)
+ GetNodeLog(ctx context.Context, request GetNodeLogRequestObject) (GetNodeLogResponseObject, error)
+ // List log sources
+ // (GET /node/{hostname}/log/source)
+ GetNodeLogSource(ctx context.Context, request GetNodeLogSourceRequestObject) (GetNodeLogSourceResponseObject, error)
+ // Get log entries for a systemd unit
+ // (GET /node/{hostname}/log/unit/{name})
+ GetNodeLogUnit(ctx context.Context, request GetNodeLogUnitRequestObject) (GetNodeLogUnitResponseObject, error)
+}
+
+type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc
+type StrictMiddlewareFunc = strictecho.StrictEchoMiddlewareFunc
+
+func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface {
+ return &strictHandler{ssi: ssi, middlewares: middlewares}
+}
+
+type strictHandler struct {
+ ssi StrictServerInterface
+ middlewares []StrictMiddlewareFunc
+}
+
+// GetNodeLog operation middleware
+func (sh *strictHandler) GetNodeLog(ctx echo.Context, hostname Hostname, params GetNodeLogParams) error {
+ var request GetNodeLogRequestObject
+
+ request.Hostname = hostname
+ request.Params = params
+
+ handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
+ return sh.ssi.GetNodeLog(ctx.Request().Context(), request.(GetNodeLogRequestObject))
+ }
+ for _, middleware := range sh.middlewares {
+ handler = middleware(handler, "GetNodeLog")
+ }
+
+ response, err := handler(ctx, request)
+
+ if err != nil {
+ return err
+ } else if validResponse, ok := response.(GetNodeLogResponseObject); ok {
+ return validResponse.VisitGetNodeLogResponse(ctx.Response())
+ } else if response != nil {
+ return fmt.Errorf("unexpected response type: %T", response)
+ }
+ return nil
+}
+
+// GetNodeLogSource operation middleware
+func (sh *strictHandler) GetNodeLogSource(ctx echo.Context, hostname Hostname) error {
+ var request GetNodeLogSourceRequestObject
+
+ request.Hostname = hostname
+
+ handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
+ return sh.ssi.GetNodeLogSource(ctx.Request().Context(), request.(GetNodeLogSourceRequestObject))
+ }
+ for _, middleware := range sh.middlewares {
+ handler = middleware(handler, "GetNodeLogSource")
+ }
+
+ response, err := handler(ctx, request)
+
+ if err != nil {
+ return err
+ } else if validResponse, ok := response.(GetNodeLogSourceResponseObject); ok {
+ return validResponse.VisitGetNodeLogSourceResponse(ctx.Response())
+ } else if response != nil {
+ return fmt.Errorf("unexpected response type: %T", response)
+ }
+ return nil
+}
+
+// GetNodeLogUnit operation middleware
+func (sh *strictHandler) GetNodeLogUnit(ctx echo.Context, hostname Hostname, name UnitName, params GetNodeLogUnitParams) error {
+ var request GetNodeLogUnitRequestObject
+
+ request.Hostname = hostname
+ request.Name = name
+ request.Params = params
+
+ handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
+ return sh.ssi.GetNodeLogUnit(ctx.Request().Context(), request.(GetNodeLogUnitRequestObject))
+ }
+ for _, middleware := range sh.middlewares {
+ handler = middleware(handler, "GetNodeLogUnit")
+ }
+
+ response, err := handler(ctx, request)
+
+ if err != nil {
+ return err
+ } else if validResponse, ok := response.(GetNodeLogUnitResponseObject); ok {
+ return validResponse.VisitGetNodeLogUnitResponse(ctx.Response())
+ } else if response != nil {
+ return fmt.Errorf("unexpected response type: %T", response)
+ }
+ return nil
+}
diff --git a/internal/controller/api/node/log/handler.go b/internal/controller/api/node/log/handler.go
new file mode 100644
index 000000000..58bdd4fe8
--- /dev/null
+++ b/internal/controller/api/node/log/handler.go
@@ -0,0 +1,66 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "log/slog"
+
+ "github.com/labstack/echo/v4"
+ strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo"
+
+ "github.com/retr0h/osapi/internal/authtoken"
+ "github.com/retr0h/osapi/internal/controller/api"
+ gen "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job/client"
+)
+
+// Handler returns Log route registration functions.
+func Handler(
+ logger *slog.Logger,
+ jobClient client.JobClient,
+ signingKey string,
+ customRoles map[string][]string,
+) []func(e *echo.Echo) {
+ var tokenManager api.TokenValidator = authtoken.New(logger)
+
+ logHandler := New(logger, jobClient)
+
+ strictHandler := gen.NewStrictHandler(
+ logHandler,
+ []gen.StrictMiddlewareFunc{
+ func(handler strictecho.StrictEchoHandlerFunc, _ string) strictecho.StrictEchoHandlerFunc {
+ return api.ScopeMiddleware(
+ handler,
+ tokenManager,
+ signingKey,
+ gen.BearerAuthScopes,
+ customRoles,
+ )
+ },
+ },
+ )
+
+ return []func(e *echo.Echo){
+ func(e *echo.Echo) {
+ gen.RegisterHandlers(e, strictHandler)
+ },
+ }
+}
diff --git a/internal/controller/api/node/log/handler_public_test.go b/internal/controller/api/node/log/handler_public_test.go
new file mode 100644
index 000000000..f284558d3
--- /dev/null
+++ b/internal/controller/api/node/log/handler_public_test.go
@@ -0,0 +1,96 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log_test
+
+import (
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/labstack/echo/v4"
+ "github.com/stretchr/testify/suite"
+
+ logAPI "github.com/retr0h/osapi/internal/controller/api/node/log"
+ "github.com/retr0h/osapi/internal/job/mocks"
+)
+
+type LogHandlerPublicTestSuite struct {
+ suite.Suite
+
+ mockCtrl *gomock.Controller
+ mockJobClient *mocks.MockJobClient
+}
+
+func (s *LogHandlerPublicTestSuite) SetupTest() {
+ s.mockCtrl = gomock.NewController(s.T())
+ s.mockJobClient = mocks.NewMockJobClient(s.mockCtrl)
+}
+
+func (s *LogHandlerPublicTestSuite) TearDownTest() {
+ s.mockCtrl.Finish()
+}
+
+func (s *LogHandlerPublicTestSuite) TestHandler() {
+ tests := []struct {
+ name string
+ validate func([]func(e *echo.Echo))
+ }{
+ {
+ name: "returns handler functions",
+ validate: func(handlers []func(e *echo.Echo)) {
+ s.NotEmpty(handlers)
+ },
+ },
+ {
+ name: "closure registers routes and middleware executes",
+ validate: func(handlers []func(e *echo.Echo)) {
+ e := echo.New()
+ for _, h := range handlers {
+ h(e)
+ }
+ s.NotEmpty(e.Routes())
+
+ req := httptest.NewRequest(http.MethodGet, "/node/hostname/log", nil)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ handlers := logAPI.Handler(
+ slog.Default(),
+ s.mockJobClient,
+ "test-signing-key",
+ nil,
+ )
+
+ tt.validate(handlers)
+ })
+ }
+}
+
+func TestLogHandlerPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(LogHandlerPublicTestSuite))
+}
diff --git a/internal/controller/api/node/log/log.go b/internal/controller/api/node/log/log.go
new file mode 100644
index 000000000..b04b31b84
--- /dev/null
+++ b/internal/controller/api/node/log/log.go
@@ -0,0 +1,43 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package log provides log management API handlers.
+package log
+
+import (
+ "log/slog"
+
+ "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job/client"
+)
+
+// ensure that we've conformed to the `StrictServerInterface` with a compile-time check
+var _ gen.StrictServerInterface = (*Log)(nil)
+
+// New factory to create a new instance.
+func New(
+ logger *slog.Logger,
+ jobClient client.JobClient,
+) *Log {
+ return &Log{
+ JobClient: jobClient,
+ logger: logger.With(slog.String("subsystem", "api.log")),
+ }
+}
diff --git a/internal/controller/api/node/log/log_query_get.go b/internal/controller/api/node/log/log_query_get.go
new file mode 100644
index 000000000..218188e38
--- /dev/null
+++ b/internal/controller/api/node/log/log_query_get.go
@@ -0,0 +1,198 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+
+ "github.com/google/uuid"
+
+ "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job"
+ logProv "github.com/retr0h/osapi/internal/provider/node/log"
+)
+
+// GetNodeLog returns system log entries from a target node.
+func (s *Log) GetNodeLog(
+ ctx context.Context,
+ request gen.GetNodeLogRequestObject,
+) (gen.GetNodeLogResponseObject, error) {
+ if errMsg, ok := validateHostname(request.Hostname); !ok {
+ return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil
+ }
+
+ hostname := request.Hostname
+
+ s.logger.Debug("log query",
+ slog.String("target", hostname),
+ slog.Bool("broadcast", job.IsBroadcastTarget(hostname)),
+ )
+
+ opts := logProv.QueryOpts{}
+ if request.Params.Lines != nil {
+ opts.Lines = *request.Params.Lines
+ }
+ if request.Params.Since != nil {
+ opts.Since = *request.Params.Since
+ }
+ if request.Params.Priority != nil {
+ opts.Priority = *request.Params.Priority
+ }
+
+ if job.IsBroadcastTarget(hostname) {
+ return s.getNodeLogBroadcast(ctx, hostname, opts)
+ }
+
+ jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogQuery, opts)
+ if err != nil {
+ errMsg := err.Error()
+ return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil
+ }
+
+ if resp.Status == job.StatusSkipped {
+ e := resp.Error
+ jobUUID := uuid.MustParse(jobID)
+ return gen.GetNodeLog200JSONResponse{
+ JobId: &jobUUID,
+ Results: []gen.LogResultEntry{
+ {
+ Hostname: resp.Hostname,
+ Status: gen.LogResultEntryStatusSkipped,
+ Error: &e,
+ },
+ },
+ }, nil
+ }
+
+ entries := logEntriesFromResponse(resp)
+ jobUUID := uuid.MustParse(jobID)
+
+ return gen.GetNodeLog200JSONResponse{
+ JobId: &jobUUID,
+ Results: []gen.LogResultEntry{
+ {
+ Hostname: resp.Hostname,
+ Status: gen.LogResultEntryStatusOk,
+ Entries: &entries,
+ },
+ },
+ }, nil
+}
+
+// logEntriesFromResponse extracts LogEntryInfo slice from a job response.
+func logEntriesFromResponse(
+ resp *job.Response,
+) []gen.LogEntryInfo {
+ var entries []logProv.Entry
+ if resp.Data != nil {
+ _ = json.Unmarshal(resp.Data, &entries)
+ }
+
+ result := make([]gen.LogEntryInfo, 0, len(entries))
+ for _, e := range entries {
+ result = append(result, logEntryToGen(e))
+ }
+
+ return result
+}
+
+// logEntryToGen converts a provider log.Entry to a gen.LogEntryInfo.
+func logEntryToGen(
+ e logProv.Entry,
+) gen.LogEntryInfo {
+ return gen.LogEntryInfo{
+ Timestamp: stringPtrOrNil(e.Timestamp),
+ Unit: stringPtrOrNil(e.Unit),
+ Priority: stringPtrOrNil(e.Priority),
+ Message: stringPtrOrNil(e.Message),
+ Pid: intPtrOrNil(e.PID),
+ Hostname: stringPtrOrNil(e.Hostname),
+ }
+}
+
+// stringPtrOrNil returns nil if the string is empty, otherwise a pointer.
+func stringPtrOrNil(
+ s string,
+) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+
+// intPtrOrNil returns nil if the int is zero, otherwise a pointer.
+func intPtrOrNil(
+ i int,
+) *int {
+ if i == 0 {
+ return nil
+ }
+ return &i
+}
+
+// getNodeLogBroadcast handles broadcast targets for log query.
+func (s *Log) getNodeLogBroadcast(
+ ctx context.Context,
+ target string,
+ opts logProv.QueryOpts,
+) (gen.GetNodeLogResponseObject, error) {
+ jobID, responses, err := s.JobClient.QueryBroadcast(
+ ctx,
+ target,
+ "node",
+ job.OperationLogQuery,
+ opts,
+ )
+ if err != nil {
+ errMsg := err.Error()
+ return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil
+ }
+
+ var items []gen.LogResultEntry
+ for host, resp := range responses {
+ item := gen.LogResultEntry{
+ Hostname: host,
+ }
+ switch resp.Status {
+ case job.StatusFailed:
+ item.Status = gen.LogResultEntryStatusFailed
+ e := resp.Error
+ item.Error = &e
+ case job.StatusSkipped:
+ item.Status = gen.LogResultEntryStatusSkipped
+ e := resp.Error
+ item.Error = &e
+ default:
+ item.Status = gen.LogResultEntryStatusOk
+ entries := logEntriesFromResponse(resp)
+ item.Entries = &entries
+ }
+ items = append(items, item)
+ }
+
+ jobUUID := uuid.MustParse(jobID)
+ return gen.GetNodeLog200JSONResponse{
+ JobId: &jobUUID,
+ Results: items,
+ }, nil
+}
diff --git a/internal/controller/api/node/log/log_query_get_public_test.go b/internal/controller/api/node/log/log_query_get_public_test.go
new file mode 100644
index 000000000..8df14d26d
--- /dev/null
+++ b/internal/controller/api/node/log/log_query_get_public_test.go
@@ -0,0 +1,527 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/internal/authtoken"
+ "github.com/retr0h/osapi/internal/config"
+ "github.com/retr0h/osapi/internal/controller/api"
+ logAPI "github.com/retr0h/osapi/internal/controller/api/node/log"
+ "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job"
+ jobmocks "github.com/retr0h/osapi/internal/job/mocks"
+ "github.com/retr0h/osapi/internal/validation"
+)
+
+type LogQueryPublicTestSuite struct {
+ suite.Suite
+
+ mockCtrl *gomock.Controller
+ mockJobClient *jobmocks.MockJobClient
+ handler *logAPI.Log
+ ctx context.Context
+ appConfig config.Config
+ logger *slog.Logger
+}
+
+func (s *LogQueryPublicTestSuite) SetupSuite() {
+ validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) {
+ return []validation.AgentTarget{
+ {Hostname: "server1", Labels: map[string]string{"group": "web"}},
+ {Hostname: "server2"},
+ }, nil
+ })
+}
+
+func (s *LogQueryPublicTestSuite) SetupTest() {
+ s.mockCtrl = gomock.NewController(s.T())
+ s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl)
+ s.handler = logAPI.New(slog.Default(), s.mockJobClient)
+ s.ctx = context.Background()
+ s.appConfig = config.Config{}
+ s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil))
+}
+
+func (s *LogQueryPublicTestSuite) TearDownTest() {
+ s.mockCtrl.Finish()
+}
+
+func (s *LogQueryPublicTestSuite) TestGetNodeLog() {
+ tests := []struct {
+ name string
+ request gen.GetNodeLogRequestObject
+ setupMock func()
+ validateFunc func(resp gen.GetNodeLogResponseObject)
+ }{
+ {
+ name: "success",
+ request: gen.GetNodeLogRequestObject{
+ Hostname: "server1",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogQuery,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(
+ `[{"timestamp":"2026-01-01T00:00:00Z","unit":"sshd.service","priority":"info","message":"Started OpenSSH server","pid":1234,"hostname":"agent1"}]`,
+ ),
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogResponseObject) {
+ r, ok := resp.(gen.GetNodeLog200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Equal("agent1", r.Results[0].Hostname)
+ s.Equal(gen.LogResultEntryStatusOk, r.Results[0].Status)
+ s.Require().NotNil(r.Results[0].Entries)
+ s.Len(*r.Results[0].Entries, 1)
+ e := (*r.Results[0].Entries)[0]
+ s.Equal("2026-01-01T00:00:00Z", *e.Timestamp)
+ s.Equal("Started OpenSSH server", *e.Message)
+ },
+ },
+ {
+ name: "success with query params",
+ request: gen.GetNodeLogRequestObject{
+ Hostname: "server1",
+ Params: gen.GetNodeLogParams{
+ Lines: intPtr(50),
+ Since: stringPtr("1 hour ago"),
+ Priority: stringPtr("err"),
+ },
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogQuery,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(`[]`),
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogResponseObject) {
+ r, ok := resp.(gen.GetNodeLog200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Equal(gen.LogResultEntryStatusOk, r.Results[0].Status)
+ },
+ },
+ {
+ name: "validation error empty hostname",
+ request: gen.GetNodeLogRequestObject{
+ Hostname: "",
+ },
+ setupMock: func() {},
+ validateFunc: func(resp gen.GetNodeLogResponseObject) {
+ r, ok := resp.(gen.GetNodeLog500JSONResponse)
+ s.True(ok)
+ s.Require().NotNil(r.Error)
+ s.Contains(*r.Error, "required")
+ },
+ },
+ {
+ name: "success with nil response data",
+ request: gen.GetNodeLogRequestObject{
+ Hostname: "server1",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogQuery,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: nil,
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogResponseObject) {
+ r, ok := resp.(gen.GetNodeLog200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Equal("agent1", r.Results[0].Hostname)
+ s.Require().NotNil(r.Results[0].Entries)
+ s.Empty(*r.Results[0].Entries)
+ },
+ },
+ {
+ name: "job client error",
+ request: gen.GetNodeLogRequestObject{
+ Hostname: "server1",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogQuery,
+ gomock.Any(),
+ ).
+ Return("", nil, assert.AnError)
+ },
+ validateFunc: func(resp gen.GetNodeLogResponseObject) {
+ _, ok := resp.(gen.GetNodeLog500JSONResponse)
+ s.True(ok)
+ },
+ },
+ {
+ name: "when job skipped",
+ request: gen.GetNodeLogRequestObject{
+ Hostname: "server1",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogQuery,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ Status: job.StatusSkipped,
+ Hostname: "server1",
+ Error: "unsupported",
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogResponseObject) {
+ r, ok := resp.(gen.GetNodeLog200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Equal(gen.LogResultEntryStatusSkipped, r.Results[0].Status)
+ s.Require().NotNil(r.Results[0].Error)
+ s.Equal("unsupported", *r.Results[0].Error)
+ },
+ },
+ {
+ name: "broadcast success",
+ request: gen.GetNodeLogRequestObject{
+ Hostname: "_all",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ QueryBroadcast(
+ gomock.Any(),
+ "_all",
+ "node",
+ job.OperationLogQuery,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{
+ "server1": {
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "server1",
+ Data: json.RawMessage(
+ `[{"timestamp":"2026-01-01T00:00:00Z","priority":"info","message":"hello"}]`,
+ ),
+ },
+ "server2": {
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "server2",
+ Data: json.RawMessage(`[]`),
+ },
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogResponseObject) {
+ r, ok := resp.(gen.GetNodeLog200JSONResponse)
+ s.True(ok)
+ s.Require().NotNil(r.JobId)
+ s.Len(r.Results, 2)
+ },
+ },
+ {
+ name: "broadcast with failed and skipped hosts",
+ request: gen.GetNodeLogRequestObject{
+ Hostname: "_all",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ QueryBroadcast(
+ gomock.Any(),
+ "_all",
+ "node",
+ job.OperationLogQuery,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{
+ "server1": {
+ Hostname: "server1",
+ Data: json.RawMessage(`[]`),
+ },
+ "server2": {
+ Status: job.StatusFailed,
+ Error: "agent unreachable",
+ Hostname: "server2",
+ },
+ "server3": {
+ Status: job.StatusSkipped,
+ Error: "log: operation not supported on this OS family",
+ Hostname: "server3",
+ },
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogResponseObject) {
+ r, ok := resp.(gen.GetNodeLog200JSONResponse)
+ s.True(ok)
+ s.Len(r.Results, 3)
+ },
+ },
+ {
+ name: "broadcast error collecting responses",
+ request: gen.GetNodeLogRequestObject{
+ Hostname: "_all",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ QueryBroadcast(
+ gomock.Any(),
+ "_all",
+ "node",
+ job.OperationLogQuery,
+ gomock.Any(),
+ ).
+ Return("", nil, assert.AnError)
+ },
+ validateFunc: func(resp gen.GetNodeLogResponseObject) {
+ _, ok := resp.(gen.GetNodeLog500JSONResponse)
+ s.True(ok)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ tt.setupMock()
+
+ resp, err := s.handler.GetNodeLog(s.ctx, tt.request)
+ s.NoError(err)
+ tt.validateFunc(resp)
+ })
+ }
+}
+
+func (s *LogQueryPublicTestSuite) TestGetNodeLogHTTP() {
+ tests := []struct {
+ name string
+ path string
+ setupJobMock func() *jobmocks.MockJobClient
+ wantCode int
+ wantContains []string
+ }{
+ {
+ name: "when valid request",
+ path: "/node/server1/log",
+ setupJobMock: func() *jobmocks.MockJobClient {
+ mock := jobmocks.NewMockJobClient(s.mockCtrl)
+ mock.EXPECT().
+ Query(gomock.Any(), "server1", "node", job.OperationLogQuery, gomock.Any()).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(`[]`),
+ }, nil)
+ return mock
+ },
+ wantCode: http.StatusOK,
+ wantContains: []string{`"job_id"`, `"results"`},
+ },
+ {
+ name: "when target agent not found",
+ path: "/node/nonexistent/log",
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusInternalServerError,
+ wantContains: []string{`"error"`, "valid_target", "not found"},
+ },
+ }
+
+ for _, tc := range tests {
+ s.Run(tc.name, func() {
+ jobMock := tc.setupJobMock()
+
+ logHandler := logAPI.New(s.logger, jobMock)
+ strictHandler := gen.NewStrictHandler(logHandler, nil)
+
+ a := api.New(s.appConfig, s.logger)
+ gen.RegisterHandlers(a.Echo, strictHandler)
+
+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
+ rec := httptest.NewRecorder()
+
+ a.Echo.ServeHTTP(rec, req)
+
+ s.Equal(tc.wantCode, rec.Code)
+ for _, str := range tc.wantContains {
+ s.Contains(rec.Body.String(), str)
+ }
+ })
+ }
+}
+
+const rbacLogQueryTestSigningKey = "test-signing-key-for-rbac-log-query"
+
+func (s *LogQueryPublicTestSuite) TestGetNodeLogRBACHTTP() {
+ tokenManager := authtoken.New(s.logger)
+
+ tests := []struct {
+ name string
+ setupAuth func(req *http.Request)
+ setupJobMock func() *jobmocks.MockJobClient
+ wantCode int
+ wantContains []string
+ }{
+ {
+ name: "when no token returns 401",
+ setupAuth: func(_ *http.Request) {
+ // No auth header set
+ },
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusUnauthorized,
+ wantContains: []string{"Bearer token required"},
+ },
+ {
+ name: "when insufficient permissions returns 403",
+ setupAuth: func(req *http.Request) {
+ token, err := tokenManager.Generate(
+ rbacLogQueryTestSigningKey,
+ []string{"write"},
+ "test-user",
+ []string{"docker:write"},
+ )
+ s.Require().NoError(err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+ },
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusForbidden,
+ wantContains: []string{"Insufficient permissions"},
+ },
+ {
+ name: "when valid token with log:read returns 200",
+ setupAuth: func(req *http.Request) {
+ token, err := tokenManager.Generate(
+ rbacLogQueryTestSigningKey,
+ []string{"admin"},
+ "test-user",
+ nil,
+ )
+ s.Require().NoError(err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+ },
+ setupJobMock: func() *jobmocks.MockJobClient {
+ mock := jobmocks.NewMockJobClient(s.mockCtrl)
+ mock.EXPECT().
+ Query(gomock.Any(), "server1", "node", job.OperationLogQuery, gomock.Any()).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(`[]`),
+ }, nil)
+ return mock
+ },
+ wantCode: http.StatusOK,
+ wantContains: []string{`"job_id"`, `"results"`},
+ },
+ }
+
+ for _, tc := range tests {
+ s.Run(tc.name, func() {
+ jobMock := tc.setupJobMock()
+
+ appConfig := config.Config{
+ Controller: config.Controller{
+ API: config.APIServer{
+ Security: config.ServerSecurity{
+ SigningKey: rbacLogQueryTestSigningKey,
+ },
+ },
+ },
+ }
+
+ server := api.New(appConfig, s.logger)
+ handlers := logAPI.Handler(
+ s.logger,
+ jobMock,
+ appConfig.Controller.API.Security.SigningKey,
+ nil,
+ )
+ server.RegisterHandlers(handlers)
+
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/node/server1/log",
+ nil,
+ )
+ tc.setupAuth(req)
+ rec := httptest.NewRecorder()
+
+ server.Echo.ServeHTTP(rec, req)
+
+ s.Equal(tc.wantCode, rec.Code)
+ for _, str := range tc.wantContains {
+ s.Contains(rec.Body.String(), str)
+ }
+ })
+ }
+}
+
+func intPtr(i int) *int { return &i }
+func stringPtr(s string) *string { return &s }
+
+func TestLogQueryPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(LogQueryPublicTestSuite))
+}
diff --git a/internal/controller/api/node/log/log_source_get.go b/internal/controller/api/node/log/log_source_get.go
new file mode 100644
index 000000000..01adedc9c
--- /dev/null
+++ b/internal/controller/api/node/log/log_source_get.go
@@ -0,0 +1,155 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+
+ "github.com/google/uuid"
+
+ "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job"
+)
+
+// GetNodeLogSource returns unique syslog identifiers from a target node.
+func (s *Log) GetNodeLogSource(
+ ctx context.Context,
+ request gen.GetNodeLogSourceRequestObject,
+) (gen.GetNodeLogSourceResponseObject, error) {
+ if errMsg, ok := validateHostname(request.Hostname); !ok {
+ return gen.GetNodeLogSource500JSONResponse{Error: &errMsg}, nil
+ }
+
+ hostname := request.Hostname
+
+ s.logger.Debug("log list sources",
+ slog.String("target", hostname),
+ slog.Bool("broadcast", job.IsBroadcastTarget(hostname)),
+ )
+
+ if job.IsBroadcastTarget(hostname) {
+ return s.getNodeLogSourceBroadcast(ctx, hostname)
+ }
+
+ jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogSources, nil)
+ if err != nil {
+ errMsg := err.Error()
+ return gen.GetNodeLogSource500JSONResponse{Error: &errMsg}, nil
+ }
+
+ if resp.Status == job.StatusSkipped {
+ e := resp.Error
+ jobUUID := uuid.MustParse(jobID)
+
+ return gen.GetNodeLogSource200JSONResponse{
+ JobId: &jobUUID,
+ Results: []gen.LogSourceEntry{
+ {
+ Hostname: resp.Hostname,
+ Status: gen.LogSourceEntryStatusSkipped,
+ Error: &e,
+ },
+ },
+ }, nil
+ }
+
+ sources := logSourcesFromResponse(resp)
+ jobUUID := uuid.MustParse(jobID)
+
+ return gen.GetNodeLogSource200JSONResponse{
+ JobId: &jobUUID,
+ Results: []gen.LogSourceEntry{
+ {
+ Hostname: resp.Hostname,
+ Status: gen.LogSourceEntryStatusOk,
+ Sources: &sources,
+ },
+ },
+ }, nil
+}
+
+// logSourcesFromResponse extracts a []string of syslog identifiers from a job
+// response.
+func logSourcesFromResponse(
+ resp *job.Response,
+) []string {
+ var sources []string
+ if resp.Data != nil {
+ _ = json.Unmarshal(resp.Data, &sources)
+ }
+
+ if sources == nil {
+ sources = []string{}
+ }
+
+ return sources
+}
+
+// getNodeLogSourceBroadcast handles broadcast targets for log source listing.
+func (s *Log) getNodeLogSourceBroadcast(
+ ctx context.Context,
+ target string,
+) (gen.GetNodeLogSourceResponseObject, error) {
+ jobID, responses, err := s.JobClient.QueryBroadcast(
+ ctx,
+ target,
+ "node",
+ job.OperationLogSources,
+ nil,
+ )
+ if err != nil {
+ errMsg := err.Error()
+ return gen.GetNodeLogSource500JSONResponse{Error: &errMsg}, nil
+ }
+
+ var items []gen.LogSourceEntry
+ for host, resp := range responses {
+ item := gen.LogSourceEntry{
+ Hostname: host,
+ }
+
+ switch resp.Status {
+ case job.StatusFailed:
+ item.Status = gen.LogSourceEntryStatusFailed
+ e := resp.Error
+ item.Error = &e
+ case job.StatusSkipped:
+ item.Status = gen.LogSourceEntryStatusSkipped
+ e := resp.Error
+ item.Error = &e
+ default:
+ item.Status = gen.LogSourceEntryStatusOk
+ sources := logSourcesFromResponse(resp)
+ item.Sources = &sources
+ }
+
+ items = append(items, item)
+ }
+
+ jobUUID := uuid.MustParse(jobID)
+
+ return gen.GetNodeLogSource200JSONResponse{
+ JobId: &jobUUID,
+ Results: items,
+ }, nil
+}
diff --git a/internal/controller/api/node/log/log_source_get_public_test.go b/internal/controller/api/node/log/log_source_get_public_test.go
new file mode 100644
index 000000000..f0335c24f
--- /dev/null
+++ b/internal/controller/api/node/log/log_source_get_public_test.go
@@ -0,0 +1,485 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/internal/authtoken"
+ "github.com/retr0h/osapi/internal/config"
+ "github.com/retr0h/osapi/internal/controller/api"
+ logAPI "github.com/retr0h/osapi/internal/controller/api/node/log"
+ "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job"
+ jobmocks "github.com/retr0h/osapi/internal/job/mocks"
+ "github.com/retr0h/osapi/internal/validation"
+)
+
+type LogSourcePublicTestSuite struct {
+ suite.Suite
+
+ mockCtrl *gomock.Controller
+ mockJobClient *jobmocks.MockJobClient
+ handler *logAPI.Log
+ ctx context.Context
+ appConfig config.Config
+ logger *slog.Logger
+}
+
+func (s *LogSourcePublicTestSuite) SetupSuite() {
+ validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) {
+ return []validation.AgentTarget{
+ {Hostname: "server1", Labels: map[string]string{"group": "web"}},
+ {Hostname: "server2"},
+ }, nil
+ })
+}
+
+func (s *LogSourcePublicTestSuite) SetupTest() {
+ s.mockCtrl = gomock.NewController(s.T())
+ s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl)
+ s.handler = logAPI.New(slog.Default(), s.mockJobClient)
+ s.ctx = context.Background()
+ s.appConfig = config.Config{}
+ s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil))
+}
+
+func (s *LogSourcePublicTestSuite) TearDownTest() {
+ s.mockCtrl.Finish()
+}
+
+func (s *LogSourcePublicTestSuite) TestGetNodeLogSource() {
+ tests := []struct {
+ name string
+ request gen.GetNodeLogSourceRequestObject
+ setupMock func()
+ validateFunc func(resp gen.GetNodeLogSourceResponseObject)
+ }{
+ {
+ name: "success",
+ request: gen.GetNodeLogSourceRequestObject{
+ Hostname: "server1",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogSources,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(`["cron","nginx","sshd"]`),
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogSourceResponseObject) {
+ r, ok := resp.(gen.GetNodeLogSource200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Equal("agent1", r.Results[0].Hostname)
+ s.Equal(gen.LogSourceEntryStatusOk, r.Results[0].Status)
+ s.Require().NotNil(r.Results[0].Sources)
+ s.Equal([]string{"cron", "nginx", "sshd"}, *r.Results[0].Sources)
+ },
+ },
+ {
+ name: "validation error empty hostname",
+ request: gen.GetNodeLogSourceRequestObject{
+ Hostname: "",
+ },
+ setupMock: func() {},
+ validateFunc: func(resp gen.GetNodeLogSourceResponseObject) {
+ r, ok := resp.(gen.GetNodeLogSource500JSONResponse)
+ s.True(ok)
+ s.Require().NotNil(r.Error)
+ s.Contains(*r.Error, "required")
+ },
+ },
+ {
+ name: "success with nil response data",
+ request: gen.GetNodeLogSourceRequestObject{
+ Hostname: "server1",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogSources,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: nil,
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogSourceResponseObject) {
+ r, ok := resp.(gen.GetNodeLogSource200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Equal("agent1", r.Results[0].Hostname)
+ s.Require().NotNil(r.Results[0].Sources)
+ s.Empty(*r.Results[0].Sources)
+ },
+ },
+ {
+ name: "job client error",
+ request: gen.GetNodeLogSourceRequestObject{
+ Hostname: "server1",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogSources,
+ gomock.Any(),
+ ).
+ Return("", nil, assert.AnError)
+ },
+ validateFunc: func(resp gen.GetNodeLogSourceResponseObject) {
+ _, ok := resp.(gen.GetNodeLogSource500JSONResponse)
+ s.True(ok)
+ },
+ },
+ {
+ name: "when job skipped",
+ request: gen.GetNodeLogSourceRequestObject{
+ Hostname: "server1",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogSources,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ Status: job.StatusSkipped,
+ Hostname: "server1",
+ Error: "unsupported",
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogSourceResponseObject) {
+ r, ok := resp.(gen.GetNodeLogSource200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Equal(gen.LogSourceEntryStatusSkipped, r.Results[0].Status)
+ s.Require().NotNil(r.Results[0].Error)
+ s.Equal("unsupported", *r.Results[0].Error)
+ },
+ },
+ {
+ name: "broadcast success",
+ request: gen.GetNodeLogSourceRequestObject{
+ Hostname: "_all",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ QueryBroadcast(
+ gomock.Any(),
+ "_all",
+ "node",
+ job.OperationLogSources,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{
+ "server1": {
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "server1",
+ Data: json.RawMessage(`["nginx","sshd"]`),
+ },
+ "server2": {
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "server2",
+ Data: json.RawMessage(`[]`),
+ },
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogSourceResponseObject) {
+ r, ok := resp.(gen.GetNodeLogSource200JSONResponse)
+ s.True(ok)
+ s.Require().NotNil(r.JobId)
+ s.Len(r.Results, 2)
+ },
+ },
+ {
+ name: "broadcast with failed and skipped hosts",
+ request: gen.GetNodeLogSourceRequestObject{
+ Hostname: "_all",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ QueryBroadcast(
+ gomock.Any(),
+ "_all",
+ "node",
+ job.OperationLogSources,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{
+ "server1": {
+ Hostname: "server1",
+ Data: json.RawMessage(`["sshd"]`),
+ },
+ "server2": {
+ Status: job.StatusFailed,
+ Error: "agent unreachable",
+ Hostname: "server2",
+ },
+ "server3": {
+ Status: job.StatusSkipped,
+ Error: "log: operation not supported on this OS family",
+ Hostname: "server3",
+ },
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogSourceResponseObject) {
+ r, ok := resp.(gen.GetNodeLogSource200JSONResponse)
+ s.True(ok)
+ s.Len(r.Results, 3)
+ },
+ },
+ {
+ name: "broadcast error collecting responses",
+ request: gen.GetNodeLogSourceRequestObject{
+ Hostname: "_all",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ QueryBroadcast(
+ gomock.Any(),
+ "_all",
+ "node",
+ job.OperationLogSources,
+ gomock.Any(),
+ ).
+ Return("", nil, assert.AnError)
+ },
+ validateFunc: func(resp gen.GetNodeLogSourceResponseObject) {
+ _, ok := resp.(gen.GetNodeLogSource500JSONResponse)
+ s.True(ok)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ tt.setupMock()
+
+ resp, err := s.handler.GetNodeLogSource(s.ctx, tt.request)
+ s.NoError(err)
+ tt.validateFunc(resp)
+ })
+ }
+}
+
+func (s *LogSourcePublicTestSuite) TestGetNodeLogSourceHTTP() {
+ tests := []struct {
+ name string
+ path string
+ setupJobMock func() *jobmocks.MockJobClient
+ wantCode int
+ wantContains []string
+ }{
+ {
+ name: "when valid request",
+ path: "/node/server1/log/source",
+ setupJobMock: func() *jobmocks.MockJobClient {
+ mock := jobmocks.NewMockJobClient(s.mockCtrl)
+ mock.EXPECT().
+ Query(gomock.Any(), "server1", "node", job.OperationLogSources, gomock.Any()).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(`[]`),
+ }, nil)
+ return mock
+ },
+ wantCode: http.StatusOK,
+ wantContains: []string{`"job_id"`, `"results"`},
+ },
+ {
+ name: "when target agent not found",
+ path: "/node/nonexistent/log/source",
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusInternalServerError,
+ wantContains: []string{`"error"`, "valid_target", "not found"},
+ },
+ }
+
+ for _, tc := range tests {
+ s.Run(tc.name, func() {
+ jobMock := tc.setupJobMock()
+
+ logHandler := logAPI.New(s.logger, jobMock)
+ strictHandler := gen.NewStrictHandler(logHandler, nil)
+
+ a := api.New(s.appConfig, s.logger)
+ gen.RegisterHandlers(a.Echo, strictHandler)
+
+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
+ rec := httptest.NewRecorder()
+
+ a.Echo.ServeHTTP(rec, req)
+
+ s.Equal(tc.wantCode, rec.Code)
+ for _, str := range tc.wantContains {
+ s.Contains(rec.Body.String(), str)
+ }
+ })
+ }
+}
+
+const rbacLogSourceTestSigningKey = "test-signing-key-for-rbac-log-source"
+
+func (s *LogSourcePublicTestSuite) TestGetNodeLogSourceRBACHTTP() {
+ tokenManager := authtoken.New(s.logger)
+
+ tests := []struct {
+ name string
+ setupAuth func(req *http.Request)
+ setupJobMock func() *jobmocks.MockJobClient
+ wantCode int
+ wantContains []string
+ }{
+ {
+ name: "when no token returns 401",
+ setupAuth: func(_ *http.Request) {
+ // No auth header set
+ },
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusUnauthorized,
+ wantContains: []string{"Bearer token required"},
+ },
+ {
+ name: "when insufficient permissions returns 403",
+ setupAuth: func(req *http.Request) {
+ token, err := tokenManager.Generate(
+ rbacLogSourceTestSigningKey,
+ []string{"write"},
+ "test-user",
+ []string{"docker:write"},
+ )
+ s.Require().NoError(err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+ },
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusForbidden,
+ wantContains: []string{"Insufficient permissions"},
+ },
+ {
+ name: "when valid token with log:read returns 200",
+ setupAuth: func(req *http.Request) {
+ token, err := tokenManager.Generate(
+ rbacLogSourceTestSigningKey,
+ []string{"admin"},
+ "test-user",
+ nil,
+ )
+ s.Require().NoError(err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+ },
+ setupJobMock: func() *jobmocks.MockJobClient {
+ mock := jobmocks.NewMockJobClient(s.mockCtrl)
+ mock.EXPECT().
+ Query(gomock.Any(), "server1", "node", job.OperationLogSources, gomock.Any()).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(`[]`),
+ }, nil)
+ return mock
+ },
+ wantCode: http.StatusOK,
+ wantContains: []string{`"job_id"`, `"results"`},
+ },
+ }
+
+ for _, tc := range tests {
+ s.Run(tc.name, func() {
+ jobMock := tc.setupJobMock()
+
+ appConfig := config.Config{
+ Controller: config.Controller{
+ API: config.APIServer{
+ Security: config.ServerSecurity{
+ SigningKey: rbacLogSourceTestSigningKey,
+ },
+ },
+ },
+ }
+
+ server := api.New(appConfig, s.logger)
+ handlers := logAPI.Handler(
+ s.logger,
+ jobMock,
+ appConfig.Controller.API.Security.SigningKey,
+ nil,
+ )
+ server.RegisterHandlers(handlers)
+
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/node/server1/log/source",
+ nil,
+ )
+ tc.setupAuth(req)
+ rec := httptest.NewRecorder()
+
+ server.Echo.ServeHTTP(rec, req)
+
+ s.Equal(tc.wantCode, rec.Code)
+ for _, str := range tc.wantContains {
+ s.Contains(rec.Body.String(), str)
+ }
+ })
+ }
+}
+
+func TestLogSourcePublicTestSuite(t *testing.T) {
+ suite.Run(t, new(LogSourcePublicTestSuite))
+}
diff --git a/internal/controller/api/node/log/log_unit_get.go b/internal/controller/api/node/log/log_unit_get.go
new file mode 100644
index 000000000..7b99dd597
--- /dev/null
+++ b/internal/controller/api/node/log/log_unit_get.go
@@ -0,0 +1,175 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+
+ "github.com/google/uuid"
+
+ "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job"
+ logProv "github.com/retr0h/osapi/internal/provider/node/log"
+)
+
+// unitQueryPayload is the JSON payload sent to the agent for unit log queries.
+type unitQueryPayload struct {
+ Unit string `json:"unit"`
+ Lines int `json:"lines,omitempty"`
+ Since string `json:"since,omitempty"`
+ Priority string `json:"priority,omitempty"`
+}
+
+// GetNodeLogUnit returns log entries for a specific systemd unit from a target node.
+func (s *Log) GetNodeLogUnit(
+ ctx context.Context,
+ request gen.GetNodeLogUnitRequestObject,
+) (gen.GetNodeLogUnitResponseObject, error) {
+ if errMsg, ok := validateHostname(request.Hostname); !ok {
+ return gen.GetNodeLogUnit500JSONResponse{Error: &errMsg}, nil
+ }
+
+ hostname := request.Hostname
+
+ s.logger.Debug("log unit query",
+ slog.String("target", hostname),
+ slog.String("unit", request.Name),
+ slog.Bool("broadcast", job.IsBroadcastTarget(hostname)),
+ )
+
+ payload := unitQueryPayload{
+ Unit: request.Name,
+ }
+ if request.Params.Lines != nil {
+ payload.Lines = *request.Params.Lines
+ }
+ if request.Params.Since != nil {
+ payload.Since = *request.Params.Since
+ }
+ if request.Params.Priority != nil {
+ payload.Priority = *request.Params.Priority
+ }
+
+ if job.IsBroadcastTarget(hostname) {
+ return s.getNodeLogUnitBroadcast(ctx, hostname, payload)
+ }
+
+ jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogQueryUnit, payload)
+ if err != nil {
+ errMsg := err.Error()
+ return gen.GetNodeLogUnit500JSONResponse{Error: &errMsg}, nil
+ }
+
+ if resp.Status == job.StatusSkipped {
+ e := resp.Error
+ jobUUID := uuid.MustParse(jobID)
+ return gen.GetNodeLogUnit200JSONResponse{
+ JobId: &jobUUID,
+ Results: []gen.LogResultEntry{
+ {
+ Hostname: resp.Hostname,
+ Status: gen.LogResultEntryStatusSkipped,
+ Error: &e,
+ },
+ },
+ }, nil
+ }
+
+ entries := logEntriesFromUnitResponse(resp)
+ jobUUID := uuid.MustParse(jobID)
+
+ return gen.GetNodeLogUnit200JSONResponse{
+ JobId: &jobUUID,
+ Results: []gen.LogResultEntry{
+ {
+ Hostname: resp.Hostname,
+ Status: gen.LogResultEntryStatusOk,
+ Entries: &entries,
+ },
+ },
+ }, nil
+}
+
+// logEntriesFromUnitResponse extracts LogEntryInfo slice from a unit job response.
+func logEntriesFromUnitResponse(
+ resp *job.Response,
+) []gen.LogEntryInfo {
+ var entries []logProv.Entry
+ if resp.Data != nil {
+ _ = json.Unmarshal(resp.Data, &entries)
+ }
+
+ result := make([]gen.LogEntryInfo, 0, len(entries))
+ for _, e := range entries {
+ result = append(result, logEntryToGen(e))
+ }
+
+ return result
+}
+
+// getNodeLogUnitBroadcast handles broadcast targets for unit log query.
+func (s *Log) getNodeLogUnitBroadcast(
+ ctx context.Context,
+ target string,
+ payload unitQueryPayload,
+) (gen.GetNodeLogUnitResponseObject, error) {
+ jobID, responses, err := s.JobClient.QueryBroadcast(
+ ctx,
+ target,
+ "node",
+ job.OperationLogQueryUnit,
+ payload,
+ )
+ if err != nil {
+ errMsg := err.Error()
+ return gen.GetNodeLogUnit500JSONResponse{Error: &errMsg}, nil
+ }
+
+ var items []gen.LogResultEntry
+ for host, resp := range responses {
+ item := gen.LogResultEntry{
+ Hostname: host,
+ }
+ switch resp.Status {
+ case job.StatusFailed:
+ item.Status = gen.LogResultEntryStatusFailed
+ e := resp.Error
+ item.Error = &e
+ case job.StatusSkipped:
+ item.Status = gen.LogResultEntryStatusSkipped
+ e := resp.Error
+ item.Error = &e
+ default:
+ item.Status = gen.LogResultEntryStatusOk
+ entries := logEntriesFromUnitResponse(resp)
+ item.Entries = &entries
+ }
+ items = append(items, item)
+ }
+
+ jobUUID := uuid.MustParse(jobID)
+ return gen.GetNodeLogUnit200JSONResponse{
+ JobId: &jobUUID,
+ Results: items,
+ }, nil
+}
diff --git a/internal/controller/api/node/log/log_unit_get_public_test.go b/internal/controller/api/node/log/log_unit_get_public_test.go
new file mode 100644
index 000000000..b0831bc5f
--- /dev/null
+++ b/internal/controller/api/node/log/log_unit_get_public_test.go
@@ -0,0 +1,520 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/internal/authtoken"
+ "github.com/retr0h/osapi/internal/config"
+ "github.com/retr0h/osapi/internal/controller/api"
+ logAPI "github.com/retr0h/osapi/internal/controller/api/node/log"
+ "github.com/retr0h/osapi/internal/controller/api/node/log/gen"
+ "github.com/retr0h/osapi/internal/job"
+ jobmocks "github.com/retr0h/osapi/internal/job/mocks"
+)
+
+type LogUnitPublicTestSuite struct {
+ suite.Suite
+
+ mockCtrl *gomock.Controller
+ mockJobClient *jobmocks.MockJobClient
+ handler *logAPI.Log
+ ctx context.Context
+ appConfig config.Config
+ logger *slog.Logger
+}
+
+func (s *LogUnitPublicTestSuite) SetupTest() {
+ s.mockCtrl = gomock.NewController(s.T())
+ s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl)
+ s.handler = logAPI.New(slog.Default(), s.mockJobClient)
+ s.ctx = context.Background()
+ s.appConfig = config.Config{}
+ s.logger = slog.Default()
+}
+
+func (s *LogUnitPublicTestSuite) TearDownTest() {
+ s.mockCtrl.Finish()
+}
+
+func (s *LogUnitPublicTestSuite) TestGetNodeLogUnit() {
+ tests := []struct {
+ name string
+ request gen.GetNodeLogUnitRequestObject
+ setupMock func()
+ validateFunc func(resp gen.GetNodeLogUnitResponseObject)
+ }{
+ {
+ name: "success",
+ request: gen.GetNodeLogUnitRequestObject{
+ Hostname: "server1",
+ Name: "sshd.service",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogQueryUnit,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(
+ `[{"timestamp":"2026-01-01T00:00:00Z","unit":"sshd.service","priority":"info","message":"Started OpenSSH server","pid":1234,"hostname":"agent1"}]`,
+ ),
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogUnitResponseObject) {
+ r, ok := resp.(gen.GetNodeLogUnit200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Equal("agent1", r.Results[0].Hostname)
+ s.Equal(gen.LogResultEntryStatusOk, r.Results[0].Status)
+ s.Require().NotNil(r.Results[0].Entries)
+ s.Len(*r.Results[0].Entries, 1)
+ e := (*r.Results[0].Entries)[0]
+ s.Equal("sshd.service", *e.Unit)
+ },
+ },
+ {
+ name: "success with query params",
+ request: gen.GetNodeLogUnitRequestObject{
+ Hostname: "server1",
+ Name: "nginx.service",
+ Params: gen.GetNodeLogUnitParams{
+ Lines: intPtr(25),
+ Since: stringPtr("2026-03-31"),
+ Priority: stringPtr("warning"),
+ },
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogQueryUnit,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(`[]`),
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogUnitResponseObject) {
+ r, ok := resp.(gen.GetNodeLogUnit200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Equal(gen.LogResultEntryStatusOk, r.Results[0].Status)
+ },
+ },
+ {
+ name: "validation error empty hostname",
+ request: gen.GetNodeLogUnitRequestObject{
+ Hostname: "",
+ Name: "sshd.service",
+ },
+ setupMock: func() {},
+ validateFunc: func(resp gen.GetNodeLogUnitResponseObject) {
+ r, ok := resp.(gen.GetNodeLogUnit500JSONResponse)
+ s.True(ok)
+ s.Require().NotNil(r.Error)
+ s.Contains(*r.Error, "required")
+ },
+ },
+ {
+ name: "success with nil response data",
+ request: gen.GetNodeLogUnitRequestObject{
+ Hostname: "server1",
+ Name: "sshd.service",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogQueryUnit,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: nil,
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogUnitResponseObject) {
+ r, ok := resp.(gen.GetNodeLogUnit200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Require().NotNil(r.Results[0].Entries)
+ s.Empty(*r.Results[0].Entries)
+ },
+ },
+ {
+ name: "job client error",
+ request: gen.GetNodeLogUnitRequestObject{
+ Hostname: "server1",
+ Name: "sshd.service",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogQueryUnit,
+ gomock.Any(),
+ ).
+ Return("", nil, assert.AnError)
+ },
+ validateFunc: func(resp gen.GetNodeLogUnitResponseObject) {
+ _, ok := resp.(gen.GetNodeLogUnit500JSONResponse)
+ s.True(ok)
+ },
+ },
+ {
+ name: "when job skipped",
+ request: gen.GetNodeLogUnitRequestObject{
+ Hostname: "server1",
+ Name: "sshd.service",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ Query(
+ gomock.Any(),
+ "server1",
+ "node",
+ job.OperationLogQueryUnit,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ Status: job.StatusSkipped,
+ Hostname: "server1",
+ Error: "unsupported",
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogUnitResponseObject) {
+ r, ok := resp.(gen.GetNodeLogUnit200JSONResponse)
+ s.True(ok)
+ s.Require().Len(r.Results, 1)
+ s.Equal(gen.LogResultEntryStatusSkipped, r.Results[0].Status)
+ s.Require().NotNil(r.Results[0].Error)
+ s.Equal("unsupported", *r.Results[0].Error)
+ },
+ },
+ {
+ name: "broadcast success",
+ request: gen.GetNodeLogUnitRequestObject{
+ Hostname: "_all",
+ Name: "nginx.service",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ QueryBroadcast(
+ gomock.Any(),
+ "_all",
+ "node",
+ job.OperationLogQueryUnit,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{
+ "server1": {
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "server1",
+ Data: json.RawMessage(
+ `[{"timestamp":"2026-01-01T00:00:00Z","priority":"info","message":"nginx started"}]`,
+ ),
+ },
+ "server2": {
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "server2",
+ Data: json.RawMessage(`[]`),
+ },
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogUnitResponseObject) {
+ r, ok := resp.(gen.GetNodeLogUnit200JSONResponse)
+ s.True(ok)
+ s.Require().NotNil(r.JobId)
+ s.Len(r.Results, 2)
+ },
+ },
+ {
+ name: "broadcast with failed and skipped hosts",
+ request: gen.GetNodeLogUnitRequestObject{
+ Hostname: "_all",
+ Name: "nginx.service",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ QueryBroadcast(
+ gomock.Any(),
+ "_all",
+ "node",
+ job.OperationLogQueryUnit,
+ gomock.Any(),
+ ).
+ Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{
+ "server1": {
+ Hostname: "server1",
+ Data: json.RawMessage(`[]`),
+ },
+ "server2": {
+ Status: job.StatusFailed,
+ Error: "agent unreachable",
+ Hostname: "server2",
+ },
+ "server3": {
+ Status: job.StatusSkipped,
+ Error: "log: operation not supported on this OS family",
+ Hostname: "server3",
+ },
+ }, nil)
+ },
+ validateFunc: func(resp gen.GetNodeLogUnitResponseObject) {
+ r, ok := resp.(gen.GetNodeLogUnit200JSONResponse)
+ s.True(ok)
+ s.Len(r.Results, 3)
+ },
+ },
+ {
+ name: "broadcast error collecting responses",
+ request: gen.GetNodeLogUnitRequestObject{
+ Hostname: "_all",
+ Name: "nginx.service",
+ },
+ setupMock: func() {
+ s.mockJobClient.EXPECT().
+ QueryBroadcast(
+ gomock.Any(),
+ "_all",
+ "node",
+ job.OperationLogQueryUnit,
+ gomock.Any(),
+ ).
+ Return("", nil, assert.AnError)
+ },
+ validateFunc: func(resp gen.GetNodeLogUnitResponseObject) {
+ _, ok := resp.(gen.GetNodeLogUnit500JSONResponse)
+ s.True(ok)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ tt.setupMock()
+
+ resp, err := s.handler.GetNodeLogUnit(s.ctx, tt.request)
+ s.NoError(err)
+ tt.validateFunc(resp)
+ })
+ }
+}
+
+func (s *LogUnitPublicTestSuite) TestGetNodeLogUnitHTTP() {
+ tests := []struct {
+ name string
+ path string
+ setupJobMock func() *jobmocks.MockJobClient
+ wantCode int
+ wantContains []string
+ }{
+ {
+ name: "when valid request",
+ path: "/node/server1/log/unit/sshd.service",
+ setupJobMock: func() *jobmocks.MockJobClient {
+ mock := jobmocks.NewMockJobClient(s.mockCtrl)
+ mock.EXPECT().
+ Query(gomock.Any(), "server1", "node", job.OperationLogQueryUnit, gomock.Any()).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(`[]`),
+ }, nil)
+ return mock
+ },
+ wantCode: http.StatusOK,
+ wantContains: []string{`"job_id"`, `"results"`},
+ },
+ {
+ name: "when target agent not found",
+ path: "/node/nonexistent/log/unit/sshd.service",
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusInternalServerError,
+ wantContains: []string{`"error"`, "valid_target", "not found"},
+ },
+ }
+
+ for _, tc := range tests {
+ s.Run(tc.name, func() {
+ jobMock := tc.setupJobMock()
+
+ logHandler := logAPI.New(s.logger, jobMock)
+ strictHandler := gen.NewStrictHandler(logHandler, nil)
+
+ a := api.New(s.appConfig, s.logger)
+ gen.RegisterHandlers(a.Echo, strictHandler)
+
+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
+ rec := httptest.NewRecorder()
+
+ a.Echo.ServeHTTP(rec, req)
+
+ s.Equal(tc.wantCode, rec.Code)
+ for _, str := range tc.wantContains {
+ s.Contains(rec.Body.String(), str)
+ }
+ })
+ }
+}
+
+const rbacLogUnitTestSigningKey = "test-signing-key-for-rbac-log-unit"
+
+func (s *LogUnitPublicTestSuite) TestGetNodeLogUnitRBACHTTP() {
+ tokenManager := authtoken.New(s.logger)
+
+ tests := []struct {
+ name string
+ setupAuth func(req *http.Request)
+ setupJobMock func() *jobmocks.MockJobClient
+ wantCode int
+ wantContains []string
+ }{
+ {
+ name: "when no token returns 401",
+ setupAuth: func(_ *http.Request) {
+ // No auth header set
+ },
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusUnauthorized,
+ wantContains: []string{"Bearer token required"},
+ },
+ {
+ name: "when insufficient permissions returns 403",
+ setupAuth: func(req *http.Request) {
+ token, err := tokenManager.Generate(
+ rbacLogUnitTestSigningKey,
+ []string{"write"},
+ "test-user",
+ []string{"docker:write"},
+ )
+ s.Require().NoError(err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+ },
+ setupJobMock: func() *jobmocks.MockJobClient {
+ return jobmocks.NewMockJobClient(s.mockCtrl)
+ },
+ wantCode: http.StatusForbidden,
+ wantContains: []string{"Insufficient permissions"},
+ },
+ {
+ name: "when valid token with log:read returns 200",
+ setupAuth: func(req *http.Request) {
+ token, err := tokenManager.Generate(
+ rbacLogUnitTestSigningKey,
+ []string{"admin"},
+ "test-user",
+ nil,
+ )
+ s.Require().NoError(err)
+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+ },
+ setupJobMock: func() *jobmocks.MockJobClient {
+ mock := jobmocks.NewMockJobClient(s.mockCtrl)
+ mock.EXPECT().
+ Query(gomock.Any(), "server1", "node", job.OperationLogQueryUnit, gomock.Any()).
+ Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
+ JobID: "550e8400-e29b-41d4-a716-446655440000",
+ Hostname: "agent1",
+ Data: json.RawMessage(`[]`),
+ }, nil)
+ return mock
+ },
+ wantCode: http.StatusOK,
+ wantContains: []string{`"job_id"`, `"results"`},
+ },
+ }
+
+ for _, tc := range tests {
+ s.Run(tc.name, func() {
+ jobMock := tc.setupJobMock()
+
+ appConfig := config.Config{
+ Controller: config.Controller{
+ API: config.APIServer{
+ Security: config.ServerSecurity{
+ SigningKey: rbacLogUnitTestSigningKey,
+ },
+ },
+ },
+ }
+
+ server := api.New(appConfig, s.logger)
+ handlers := logAPI.Handler(
+ s.logger,
+ jobMock,
+ appConfig.Controller.API.Security.SigningKey,
+ nil,
+ )
+ server.RegisterHandlers(handlers)
+
+ req := httptest.NewRequest(
+ http.MethodGet,
+ "/node/server1/log/unit/sshd.service",
+ nil,
+ )
+ tc.setupAuth(req)
+ rec := httptest.NewRecorder()
+
+ server.Echo.ServeHTTP(rec, req)
+
+ s.Equal(tc.wantCode, rec.Code)
+ for _, str := range tc.wantContains {
+ s.Contains(rec.Body.String(), str)
+ }
+ })
+ }
+}
+
+func TestLogUnitPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(LogUnitPublicTestSuite))
+}
diff --git a/internal/controller/api/node/log/types.go b/internal/controller/api/node/log/types.go
new file mode 100644
index 000000000..56d730d27
--- /dev/null
+++ b/internal/controller/api/node/log/types.go
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "log/slog"
+
+ "github.com/retr0h/osapi/internal/job/client"
+)
+
+// Log implementation of the Log APIs operations.
+type Log struct {
+ // JobClient provides job-based operations for log management.
+ JobClient client.JobClient
+ logger *slog.Logger
+}
diff --git a/internal/controller/api/node/log/validate.go b/internal/controller/api/node/log/validate.go
new file mode 100644
index 000000000..2be520527
--- /dev/null
+++ b/internal/controller/api/node/log/validate.go
@@ -0,0 +1,34 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import "github.com/retr0h/osapi/internal/validation"
+
+// validateHostname validates a hostname path parameter using the shared
+// validator. Returns the error message and false if invalid.
+//
+// This exists because oapi-codegen does not generate validate tags on
+// path parameters in strict-server mode (upstream limitation).
+func validateHostname(
+ hostname string,
+) (string, bool) {
+ return validation.Var(hostname, "required,min=1,valid_target")
+}
diff --git a/internal/controller/api/node/package/package_get_public_test.go b/internal/controller/api/node/package/package_get_public_test.go
index 9414a6f52..60c593331 100644
--- a/internal/controller/api/node/package/package_get_public_test.go
+++ b/internal/controller/api/node/package/package_get_public_test.go
@@ -102,7 +102,7 @@ func (s *PackageGetPublicTestSuite) TestGetNodePackageByName() {
Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{
Hostname: "agent1",
Data: json.RawMessage(
- `{"name":"curl","version":"7.68.0","status":"installed","description":"command line tool"}`,
+ `{"name":"curl","version":"7.68.0","status":"installed","description":"command line tool","size":1024}`,
),
}, nil)
},
@@ -114,8 +114,11 @@ func (s *PackageGetPublicTestSuite) TestGetNodePackageByName() {
s.Equal("agent1", r.Results[0].Hostname)
s.Require().NotNil(r.Results[0].Packages)
s.Require().Len(*r.Results[0].Packages, 1)
- s.Equal("curl", *(*r.Results[0].Packages)[0].Name)
- s.Equal("7.68.0", *(*r.Results[0].Packages)[0].Version)
+ pkg := (*r.Results[0].Packages)[0]
+ s.Equal("curl", *pkg.Name)
+ s.Equal("7.68.0", *pkg.Version)
+ s.Require().NotNil(pkg.Size)
+ s.Equal(int64(1024), *pkg.Size)
},
},
{
@@ -256,7 +259,7 @@ func (s *PackageGetPublicTestSuite) TestGetNodePackageByName() {
Hostname: "server1",
Status: job.StatusCompleted,
Data: json.RawMessage(
- `{"name":"curl","version":"7.68.0","status":"installed"}`,
+ `{"name":"curl","version":"7.68.0","status":"installed","description":"curl","size":2048}`,
),
},
"server2": {
diff --git a/internal/controller/api/node/package/package_list_get_public_test.go b/internal/controller/api/node/package/package_list_get_public_test.go
index d22e4fb01..4b58e3aae 100644
--- a/internal/controller/api/node/package/package_list_get_public_test.go
+++ b/internal/controller/api/node/package/package_list_get_public_test.go
@@ -102,7 +102,7 @@ func (s *PackageListGetPublicTestSuite) TestGetNodePackage() {
JobID: "550e8400-e29b-41d4-a716-446655440000",
Hostname: "agent1",
Data: json.RawMessage(
- `[{"name":"curl","version":"7.68.0","status":"installed","description":"command line tool for transferring data"}]`,
+ `[{"name":"curl","version":"7.68.0","status":"installed","description":"command line tool for transferring data","size":512}]`,
),
}, nil)
},
diff --git a/internal/job/types.go b/internal/job/types.go
index 068810766..a37825689 100644
--- a/internal/job/types.go
+++ b/internal/job/types.go
@@ -219,6 +219,13 @@ const (
OperationPackageListUpdates = client.OpPackageListUpdates
)
+// Log operations.
+const (
+ OperationLogQuery = client.OpLogQuery
+ OperationLogQueryUnit = client.OpLogQueryUnit
+ OperationLogSources = client.OpLogSources
+)
+
// Operation represents an operation in the new hierarchical format
type Operation struct {
// Type specifies the type of operation using hierarchical format
diff --git a/internal/provider/node/log/darwin.go b/internal/provider/node/log/darwin.go
new file mode 100644
index 000000000..145c42287
--- /dev/null
+++ b/internal/provider/node/log/darwin.go
@@ -0,0 +1,61 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/retr0h/osapi/internal/provider"
+)
+
+// Darwin implements the Provider interface for Darwin (macOS).
+// All methods return ErrUnsupported as log management is not available on macOS.
+type Darwin struct{}
+
+// NewDarwinProvider factory to create a new Darwin instance.
+func NewDarwinProvider() *Darwin {
+ return &Darwin{}
+}
+
+// Query returns ErrUnsupported on Darwin.
+func (d *Darwin) Query(
+ _ context.Context,
+ _ QueryOpts,
+) ([]Entry, error) {
+ return nil, fmt.Errorf("log: %w", provider.ErrUnsupported)
+}
+
+// QueryUnit returns ErrUnsupported on Darwin.
+func (d *Darwin) QueryUnit(
+ _ context.Context,
+ _ string,
+ _ QueryOpts,
+) ([]Entry, error) {
+ return nil, fmt.Errorf("log: %w", provider.ErrUnsupported)
+}
+
+// ListSources returns ErrUnsupported on Darwin.
+func (d *Darwin) ListSources(
+ _ context.Context,
+) ([]string, error) {
+ return nil, fmt.Errorf("log: %w", provider.ErrUnsupported)
+}
diff --git a/internal/provider/node/log/darwin_public_test.go b/internal/provider/node/log/darwin_public_test.go
new file mode 100644
index 000000000..c23077780
--- /dev/null
+++ b/internal/provider/node/log/darwin_public_test.go
@@ -0,0 +1,108 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/internal/provider"
+ oslog "github.com/retr0h/osapi/internal/provider/node/log"
+)
+
+type DarwinPublicTestSuite struct {
+ suite.Suite
+
+ provider *oslog.Darwin
+}
+
+func (suite *DarwinPublicTestSuite) SetupTest() {
+ suite.provider = oslog.NewDarwinProvider()
+}
+
+func (suite *DarwinPublicTestSuite) TestQuery() {
+ tests := []struct {
+ name string
+ }{
+ {
+ name: "returns not implemented error",
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ got, err := suite.provider.Query(context.Background(), oslog.QueryOpts{})
+
+ suite.Nil(got)
+ suite.ErrorIs(err, provider.ErrUnsupported)
+ })
+ }
+}
+
+func (suite *DarwinPublicTestSuite) TestQueryUnit() {
+ tests := []struct {
+ name string
+ }{
+ {
+ name: "returns not implemented error",
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ got, err := suite.provider.QueryUnit(
+ context.Background(),
+ "nginx.service",
+ oslog.QueryOpts{},
+ )
+
+ suite.Nil(got)
+ suite.ErrorIs(err, provider.ErrUnsupported)
+ })
+ }
+}
+
+func (suite *DarwinPublicTestSuite) TestListSources() {
+ tests := []struct {
+ name string
+ }{
+ {
+ name: "returns not implemented error",
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ got, err := suite.provider.ListSources(context.Background())
+
+ suite.Nil(got)
+ suite.ErrorIs(err, provider.ErrUnsupported)
+ })
+ }
+}
+
+// In order for `go test` to run this suite, we need to create
+// a normal test function and pass our suite to suite.Run.
+func TestDarwinPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(DarwinPublicTestSuite))
+}
diff --git a/internal/provider/node/log/debian.go b/internal/provider/node/log/debian.go
new file mode 100644
index 000000000..acc2e7675
--- /dev/null
+++ b/internal/provider/node/log/debian.go
@@ -0,0 +1,104 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+
+ "github.com/retr0h/osapi/internal/exec"
+ "github.com/retr0h/osapi/internal/provider"
+)
+
+// Compile-time checks.
+var (
+ _ Provider = (*Debian)(nil)
+ _ provider.FactsSetter = (*Debian)(nil)
+)
+
+// Debian implements the Provider interface for Debian-family systems
+// using journalctl for log querying.
+type Debian struct {
+ provider.FactsAware
+ logger *slog.Logger
+ execManager exec.Manager
+}
+
+// NewDebianProvider factory to create a new Debian instance.
+func NewDebianProvider(
+ logger *slog.Logger,
+ execManager exec.Manager,
+) *Debian {
+ return &Debian{
+ logger: logger.With(slog.String("subsystem", "provider.log")),
+ execManager: execManager,
+ }
+}
+
+// Query returns journal entries with optional filtering.
+func (d *Debian) Query(
+ _ context.Context,
+ opts QueryOpts,
+) ([]Entry, error) {
+ d.logger.Debug("executing log.Query")
+ args := buildArgs(opts)
+
+ output, err := d.execManager.RunCmd("journalctl", args)
+ if err != nil {
+ return nil, fmt.Errorf("log: query: %w", err)
+ }
+
+ return parseJournalOutput(output, d.logger), nil
+}
+
+// QueryUnit returns journal entries for a specific systemd unit.
+func (d *Debian) QueryUnit(
+ _ context.Context,
+ unit string,
+ opts QueryOpts,
+) ([]Entry, error) {
+ d.logger.Debug("executing log.QueryUnit",
+ slog.String("unit", unit),
+ )
+ args := buildUnitArgs(unit, opts)
+
+ output, err := d.execManager.RunCmd("journalctl", args)
+ if err != nil {
+ return nil, fmt.Errorf("log: query unit: %w", err)
+ }
+
+ return parseJournalOutput(output, d.logger), nil
+}
+
+// ListSources returns unique syslog identifiers from the journal.
+func (d *Debian) ListSources(
+ _ context.Context,
+) ([]string, error) {
+ d.logger.Debug("executing log.ListSources")
+
+ output, err := d.execManager.RunCmd("journalctl", []string{"--field=SYSLOG_IDENTIFIER"})
+ if err != nil {
+ return nil, fmt.Errorf("log: list sources: %w", err)
+ }
+
+ return parseSources(output), nil
+}
diff --git a/internal/provider/node/log/debian_public_test.go b/internal/provider/node/log/debian_public_test.go
new file mode 100644
index 000000000..6ee8cdab7
--- /dev/null
+++ b/internal/provider/node/log/debian_public_test.go
@@ -0,0 +1,352 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log_test
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "os"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/suite"
+
+ execmocks "github.com/retr0h/osapi/internal/exec/mocks"
+ oslog "github.com/retr0h/osapi/internal/provider/node/log"
+)
+
+// singleEntry is a valid journalctl JSON line used across tests.
+const singleEntry = `{"__REALTIME_TIMESTAMP":"1711929045123456","SYSLOG_IDENTIFIER":"nginx","PRIORITY":"6","MESSAGE":"Started nginx","_PID":"1234","_HOSTNAME":"web-01"}`
+
+// twoEntries is two valid journalctl JSON lines.
+const twoEntries = `{"__REALTIME_TIMESTAMP":"1711929045123456","SYSLOG_IDENTIFIER":"nginx","PRIORITY":"6","MESSAGE":"Started nginx","_PID":"1234","_HOSTNAME":"web-01"}
+{"__REALTIME_TIMESTAMP":"1711929046000000","SYSLOG_IDENTIFIER":"sshd","PRIORITY":"5","MESSAGE":"Accepted key","_PID":"5678","_HOSTNAME":"web-01"}`
+
+type DebianPublicTestSuite struct {
+ suite.Suite
+
+ ctrl *gomock.Controller
+ mockManager *execmocks.MockManager
+ provider *oslog.Debian
+}
+
+func (suite *DebianPublicTestSuite) SetupTest() {
+ suite.ctrl = gomock.NewController(suite.T())
+ suite.mockManager = execmocks.NewMockManager(suite.ctrl)
+ suite.provider = oslog.NewDebianProvider(
+ slog.New(slog.NewTextHandler(os.Stdout, nil)),
+ suite.mockManager,
+ )
+}
+
+func (suite *DebianPublicTestSuite) TearDownTest() {
+ suite.ctrl.Finish()
+}
+
+func (suite *DebianPublicTestSuite) TestQuery() {
+ tests := []struct {
+ name string
+ opts oslog.QueryOpts
+ setupMock func()
+ wantErr bool
+ wantErrMsg string
+ validateFunc func(result []oslog.Entry)
+ }{
+ {
+ name: "when default query returns entries",
+ opts: oslog.QueryOpts{},
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", []string{"--output=json", "-n", "100"}).
+ Return(twoEntries, nil)
+ },
+ validateFunc: func(result []oslog.Entry) {
+ suite.Len(result, 2)
+ suite.Equal("nginx", result[0].Unit)
+ suite.Equal("info", result[0].Priority)
+ suite.Equal("Started nginx", result[0].Message)
+ suite.Equal(1234, result[0].PID)
+ suite.Equal("web-01", result[0].Hostname)
+ suite.NotEmpty(result[0].Timestamp)
+ suite.Equal("sshd", result[1].Unit)
+ suite.Equal("notice", result[1].Priority)
+ },
+ },
+ {
+ name: "when all options set uses correct args",
+ opts: oslog.QueryOpts{
+ Lines: 50,
+ Since: "1 hour ago",
+ Priority: "err",
+ },
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", []string{"--output=json", "--since", "1 hour ago", "--priority", "err", "-n", "50"}).
+ Return(singleEntry, nil)
+ },
+ validateFunc: func(result []oslog.Entry) {
+ suite.Len(result, 1)
+ suite.Equal("nginx", result[0].Unit)
+ },
+ },
+ {
+ name: "when custom lines only uses correct args",
+ opts: oslog.QueryOpts{Lines: 25},
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", []string{"--output=json", "-n", "25"}).
+ Return(singleEntry, nil)
+ },
+ validateFunc: func(result []oslog.Entry) {
+ suite.Len(result, 1)
+ },
+ },
+ {
+ name: "when exec errors returns error",
+ opts: oslog.QueryOpts{},
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", gomock.Any()).
+ Return("", errors.New("journalctl not found"))
+ },
+ wantErr: true,
+ wantErrMsg: "log: query: journalctl not found",
+ },
+ {
+ name: "when empty output returns empty slice",
+ opts: oslog.QueryOpts{},
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", gomock.Any()).
+ Return("", nil)
+ },
+ validateFunc: func(result []oslog.Entry) {
+ suite.Empty(result)
+ },
+ },
+ {
+ name: "when malformed JSON line is skipped",
+ opts: oslog.QueryOpts{},
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", gomock.Any()).
+ Return("not-valid-json\n"+singleEntry, nil)
+ },
+ validateFunc: func(result []oslog.Entry) {
+ suite.Len(result, 1)
+ suite.Equal("nginx", result[0].Unit)
+ },
+ },
+ {
+ name: "when entry has empty timestamp",
+ opts: oslog.QueryOpts{},
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", gomock.Any()).
+ Return(`{"__REALTIME_TIMESTAMP":"","SYSLOG_IDENTIFIER":"test","PRIORITY":"6","MESSAGE":"hello"}`, nil)
+ },
+ validateFunc: func(result []oslog.Entry) {
+ suite.Len(result, 1)
+ suite.Equal("", result[0].Timestamp)
+ },
+ },
+ {
+ name: "when entry has non-numeric timestamp",
+ opts: oslog.QueryOpts{},
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", gomock.Any()).
+ Return(`{"__REALTIME_TIMESTAMP":"not-a-number","SYSLOG_IDENTIFIER":"test","PRIORITY":"6","MESSAGE":"hello"}`, nil)
+ },
+ validateFunc: func(result []oslog.Entry) {
+ suite.Len(result, 1)
+ suite.Equal("not-a-number", result[0].Timestamp)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ tc.setupMock()
+
+ got, err := suite.provider.Query(context.Background(), tc.opts)
+
+ if tc.wantErr {
+ suite.Error(err)
+ suite.Contains(err.Error(), tc.wantErrMsg)
+ suite.Nil(got)
+
+ return
+ }
+
+ suite.NoError(err)
+ tc.validateFunc(got)
+ })
+ }
+}
+
+func (suite *DebianPublicTestSuite) TestQueryUnit() {
+ tests := []struct {
+ name string
+ unit string
+ opts oslog.QueryOpts
+ setupMock func()
+ wantErr bool
+ wantErrMsg string
+ validateFunc func(result []oslog.Entry)
+ }{
+ {
+ name: "when default unit query returns entries",
+ unit: "nginx.service",
+ opts: oslog.QueryOpts{},
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", []string{"--output=json", "-u", "nginx.service", "-n", "100"}).
+ Return(singleEntry, nil)
+ },
+ validateFunc: func(result []oslog.Entry) {
+ suite.Len(result, 1)
+ suite.Equal("nginx", result[0].Unit)
+ suite.Equal("info", result[0].Priority)
+ suite.Equal("Started nginx", result[0].Message)
+ suite.Equal(1234, result[0].PID)
+ suite.Equal("web-01", result[0].Hostname)
+ },
+ },
+ {
+ name: "when all options set uses correct args",
+ unit: "sshd.service",
+ opts: oslog.QueryOpts{
+ Lines: 20,
+ Since: "30 minutes ago",
+ Priority: "warning",
+ },
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", []string{"--output=json", "-u", "sshd.service", "--since", "30 minutes ago", "--priority", "warning", "-n", "20"}).
+ Return(singleEntry, nil)
+ },
+ validateFunc: func(result []oslog.Entry) {
+ suite.Len(result, 1)
+ },
+ },
+ {
+ name: "when exec errors returns error",
+ unit: "nginx.service",
+ opts: oslog.QueryOpts{},
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", gomock.Any()).
+ Return("", errors.New("journalctl failed"))
+ },
+ wantErr: true,
+ wantErrMsg: "log: query unit: journalctl failed",
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ tc.setupMock()
+
+ got, err := suite.provider.QueryUnit(context.Background(), tc.unit, tc.opts)
+
+ if tc.wantErr {
+ suite.Error(err)
+ suite.Contains(err.Error(), tc.wantErrMsg)
+ suite.Nil(got)
+
+ return
+ }
+
+ suite.NoError(err)
+ tc.validateFunc(got)
+ })
+ }
+}
+
+func (suite *DebianPublicTestSuite) TestListSources() {
+ tests := []struct {
+ name string
+ setupMock func()
+ wantErr bool
+ wantErrMsg string
+ validateFunc func(result []string)
+ }{
+ {
+ name: "when sources returned sorted list",
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", []string{"--field=SYSLOG_IDENTIFIER"}).
+ Return("sshd\nnginx\ncron\n", nil)
+ },
+ validateFunc: func(result []string) {
+ suite.Equal([]string{"cron", "nginx", "sshd"}, result)
+ },
+ },
+ {
+ name: "when exec errors returns error",
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", []string{"--field=SYSLOG_IDENTIFIER"}).
+ Return("", errors.New("journalctl not found"))
+ },
+ wantErr: true,
+ wantErrMsg: "log: list sources: journalctl not found",
+ },
+ {
+ name: "when empty output returns nil",
+ setupMock: func() {
+ suite.mockManager.EXPECT().
+ RunCmd("journalctl", []string{"--field=SYSLOG_IDENTIFIER"}).
+ Return("", nil)
+ },
+ validateFunc: func(result []string) {
+ suite.Nil(result)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ tc.setupMock()
+
+ got, err := suite.provider.ListSources(context.Background())
+
+ if tc.wantErr {
+ suite.Error(err)
+ suite.Contains(err.Error(), tc.wantErrMsg)
+ suite.Nil(got)
+
+ return
+ }
+
+ suite.NoError(err)
+ tc.validateFunc(got)
+ })
+ }
+}
+
+// In order for `go test` to run this suite, we need to create
+// a normal test function and pass our suite to suite.Run.
+func TestDebianPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(DebianPublicTestSuite))
+}
diff --git a/internal/provider/node/log/debian_query.go b/internal/provider/node/log/debian_query.go
new file mode 100644
index 000000000..e87b6eff9
--- /dev/null
+++ b/internal/provider/node/log/debian_query.go
@@ -0,0 +1,201 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "encoding/json"
+ "log/slog"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// journalEntry represents the raw JSON structure of a journalctl --output=json line.
+type journalEntry struct {
+ Timestamp string `json:"__REALTIME_TIMESTAMP"`
+ Unit string `json:"SYSLOG_IDENTIFIER"`
+ Priority string `json:"PRIORITY"`
+ Message string `json:"MESSAGE"`
+ PID string `json:"_PID"`
+ Hostname string `json:"_HOSTNAME"`
+}
+
+// priorityNames maps journald priority numbers to human-readable names.
+var priorityNames = map[string]string{
+ "0": "emerg",
+ "1": "alert",
+ "2": "crit",
+ "3": "err",
+ "4": "warning",
+ "5": "notice",
+ "6": "info",
+ "7": "debug",
+}
+
+// buildArgs constructs journalctl arguments for a general query.
+// Always includes --output=json. Defaults to 100 lines if opts.Lines <= 0.
+func buildArgs(
+ opts QueryOpts,
+) []string {
+ lines := opts.Lines
+ if lines <= 0 {
+ lines = 100
+ }
+
+ args := []string{"--output=json"}
+
+ if opts.Since != "" {
+ args = append(args, "--since", opts.Since)
+ }
+
+ if opts.Priority != "" {
+ args = append(args, "--priority", opts.Priority)
+ }
+
+ args = append(args, "-n", strconv.Itoa(lines))
+
+ return args
+}
+
+// buildUnitArgs constructs journalctl arguments for a unit-specific query.
+// Adds -u before the line count argument.
+func buildUnitArgs(
+ unit string,
+ opts QueryOpts,
+) []string {
+ lines := opts.Lines
+ if lines <= 0 {
+ lines = 100
+ }
+
+ args := []string{"--output=json", "-u", unit}
+
+ if opts.Since != "" {
+ args = append(args, "--since", opts.Since)
+ }
+
+ if opts.Priority != "" {
+ args = append(args, "--priority", opts.Priority)
+ }
+
+ args = append(args, "-n", strconv.Itoa(lines))
+
+ return args
+}
+
+// parseJournalOutput parses newline-delimited JSON output from journalctl.
+// Malformed or empty lines are skipped with a debug log entry.
+func parseJournalOutput(
+ output string,
+ logger *slog.Logger,
+) []Entry {
+ lines := strings.Split(output, "\n")
+ entries := make([]Entry, 0, len(lines))
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ var je journalEntry
+ if err := json.Unmarshal([]byte(line), &je); err != nil {
+ logger.Debug(
+ "skipping malformed journal line",
+ slog.String("error", err.Error()),
+ )
+
+ continue
+ }
+
+ entries = append(entries, journalEntryToEntry(je))
+ }
+
+ return entries
+}
+
+// journalEntryToEntry converts a raw journalEntry to an Entry.
+func journalEntryToEntry(
+ je journalEntry,
+) Entry {
+ ts := parseTimestamp(je.Timestamp)
+
+ priority := je.Priority
+ if name, ok := priorityNames[je.Priority]; ok {
+ priority = name
+ }
+
+ pid := 0
+
+ if je.PID != "" {
+ if p, err := strconv.Atoi(je.PID); err == nil {
+ pid = p
+ }
+ }
+
+ return Entry{
+ Timestamp: ts,
+ Unit: je.Unit,
+ Priority: priority,
+ Message: je.Message,
+ PID: pid,
+ Hostname: je.Hostname,
+ }
+}
+
+// parseSources parses newline-delimited syslog identifier output from
+// journalctl --field=SYSLOG_IDENTIFIER and returns a sorted, deduplicated
+// list of non-empty identifiers.
+func parseSources(
+ output string,
+) []string {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ var sources []string
+
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ sources = append(sources, line)
+ }
+ }
+
+ sort.Strings(sources)
+
+ return sources
+}
+
+// parseTimestamp converts a journald microsecond timestamp string to RFC3339Nano.
+// Returns the original string if parsing fails.
+func parseTimestamp(
+ usec string,
+) string {
+ if usec == "" {
+ return ""
+ }
+
+ micros, err := strconv.ParseInt(usec, 10, 64)
+ if err != nil {
+ return usec
+ }
+
+ return time.UnixMicro(micros).UTC().Format(time.RFC3339Nano)
+}
diff --git a/internal/provider/node/log/linux.go b/internal/provider/node/log/linux.go
new file mode 100644
index 000000000..2f359421c
--- /dev/null
+++ b/internal/provider/node/log/linux.go
@@ -0,0 +1,61 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/retr0h/osapi/internal/provider"
+)
+
+// Linux implements the Provider interface for generic Linux.
+// All methods return ErrUnsupported as this is a generic Linux stub.
+type Linux struct{}
+
+// NewLinuxProvider factory to create a new Linux instance.
+func NewLinuxProvider() *Linux {
+ return &Linux{}
+}
+
+// Query returns ErrUnsupported on generic Linux.
+func (l *Linux) Query(
+ _ context.Context,
+ _ QueryOpts,
+) ([]Entry, error) {
+ return nil, fmt.Errorf("log: %w", provider.ErrUnsupported)
+}
+
+// QueryUnit returns ErrUnsupported on generic Linux.
+func (l *Linux) QueryUnit(
+ _ context.Context,
+ _ string,
+ _ QueryOpts,
+) ([]Entry, error) {
+ return nil, fmt.Errorf("log: %w", provider.ErrUnsupported)
+}
+
+// ListSources returns ErrUnsupported on generic Linux.
+func (l *Linux) ListSources(
+ _ context.Context,
+) ([]string, error) {
+ return nil, fmt.Errorf("log: %w", provider.ErrUnsupported)
+}
diff --git a/internal/provider/node/log/linux_public_test.go b/internal/provider/node/log/linux_public_test.go
new file mode 100644
index 000000000..bbba3339c
--- /dev/null
+++ b/internal/provider/node/log/linux_public_test.go
@@ -0,0 +1,108 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package log_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/internal/provider"
+ oslog "github.com/retr0h/osapi/internal/provider/node/log"
+)
+
+type LinuxPublicTestSuite struct {
+ suite.Suite
+
+ provider *oslog.Linux
+}
+
+func (suite *LinuxPublicTestSuite) SetupTest() {
+ suite.provider = oslog.NewLinuxProvider()
+}
+
+func (suite *LinuxPublicTestSuite) TestQuery() {
+ tests := []struct {
+ name string
+ }{
+ {
+ name: "returns not implemented error",
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ got, err := suite.provider.Query(context.Background(), oslog.QueryOpts{})
+
+ suite.Nil(got)
+ suite.ErrorIs(err, provider.ErrUnsupported)
+ })
+ }
+}
+
+func (suite *LinuxPublicTestSuite) TestQueryUnit() {
+ tests := []struct {
+ name string
+ }{
+ {
+ name: "returns not implemented error",
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ got, err := suite.provider.QueryUnit(
+ context.Background(),
+ "nginx.service",
+ oslog.QueryOpts{},
+ )
+
+ suite.Nil(got)
+ suite.ErrorIs(err, provider.ErrUnsupported)
+ })
+ }
+}
+
+func (suite *LinuxPublicTestSuite) TestListSources() {
+ tests := []struct {
+ name string
+ }{
+ {
+ name: "returns not implemented error",
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ got, err := suite.provider.ListSources(context.Background())
+
+ suite.Nil(got)
+ suite.ErrorIs(err, provider.ErrUnsupported)
+ })
+ }
+}
+
+// In order for `go test` to run this suite, we need to create
+// a normal test function and pass our suite to suite.Run.
+func TestLinuxPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(LinuxPublicTestSuite))
+}
diff --git a/internal/provider/node/log/mocks/generate.go b/internal/provider/node/log/mocks/generate.go
new file mode 100644
index 000000000..2c118cc16
--- /dev/null
+++ b/internal/provider/node/log/mocks/generate.go
@@ -0,0 +1,24 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package mocks provides mock implementations for testing.
+package mocks
+
+//go:generate go tool github.com/golang/mock/mockgen -source=../types.go -destination=provider.gen.go -package=mocks
diff --git a/internal/provider/node/log/mocks/provider.gen.go b/internal/provider/node/log/mocks/provider.gen.go
new file mode 100644
index 000000000..b6afbc46e
--- /dev/null
+++ b/internal/provider/node/log/mocks/provider.gen.go
@@ -0,0 +1,81 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ../types.go
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+ context "context"
+ reflect "reflect"
+
+ gomock "github.com/golang/mock/gomock"
+ log "github.com/retr0h/osapi/internal/provider/node/log"
+)
+
+// MockProvider is a mock of Provider interface.
+type MockProvider struct {
+ ctrl *gomock.Controller
+ recorder *MockProviderMockRecorder
+}
+
+// MockProviderMockRecorder is the mock recorder for MockProvider.
+type MockProviderMockRecorder struct {
+ mock *MockProvider
+}
+
+// NewMockProvider creates a new mock instance.
+func NewMockProvider(ctrl *gomock.Controller) *MockProvider {
+ mock := &MockProvider{ctrl: ctrl}
+ mock.recorder = &MockProviderMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockProvider) EXPECT() *MockProviderMockRecorder {
+ return m.recorder
+}
+
+// ListSources mocks base method.
+func (m *MockProvider) ListSources(ctx context.Context) ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ListSources", ctx)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// ListSources indicates an expected call of ListSources.
+func (mr *MockProviderMockRecorder) ListSources(ctx interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSources", reflect.TypeOf((*MockProvider)(nil).ListSources), ctx)
+}
+
+// Query mocks base method.
+func (m *MockProvider) Query(ctx context.Context, opts log.QueryOpts) ([]log.Entry, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Query", ctx, opts)
+ ret0, _ := ret[0].([]log.Entry)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Query indicates an expected call of Query.
+func (mr *MockProviderMockRecorder) Query(ctx, opts interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Query", reflect.TypeOf((*MockProvider)(nil).Query), ctx, opts)
+}
+
+// QueryUnit mocks base method.
+func (m *MockProvider) QueryUnit(ctx context.Context, unit string, opts log.QueryOpts) ([]log.Entry, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "QueryUnit", ctx, unit, opts)
+ ret0, _ := ret[0].([]log.Entry)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// QueryUnit indicates an expected call of QueryUnit.
+func (mr *MockProviderMockRecorder) QueryUnit(ctx, unit, opts interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryUnit", reflect.TypeOf((*MockProvider)(nil).QueryUnit), ctx, unit, opts)
+}
diff --git a/internal/provider/node/log/types.go b/internal/provider/node/log/types.go
new file mode 100644
index 000000000..3c678a3dd
--- /dev/null
+++ b/internal/provider/node/log/types.go
@@ -0,0 +1,53 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package log provides log viewing operations.
+package log
+
+import (
+ "context"
+)
+
+// Provider implements log viewing operations.
+type Provider interface {
+ // Query returns journal entries with optional filtering.
+ Query(ctx context.Context, opts QueryOpts) ([]Entry, error)
+ // QueryUnit returns journal entries for a specific systemd unit.
+ QueryUnit(ctx context.Context, unit string, opts QueryOpts) ([]Entry, error)
+ // ListSources returns unique syslog identifiers from the journal.
+ ListSources(ctx context.Context) ([]string, error)
+}
+
+// QueryOpts contains optional filters for log queries.
+type QueryOpts struct {
+ Lines int `json:"lines,omitempty"`
+ Since string `json:"since,omitempty"`
+ Priority string `json:"priority,omitempty"`
+}
+
+// Entry represents a single journal entry.
+type Entry struct {
+ Timestamp string `json:"timestamp"`
+ Unit string `json:"unit,omitempty"`
+ Priority string `json:"priority"`
+ Message string `json:"message"`
+ PID int `json:"pid,omitempty"`
+ Hostname string `json:"hostname,omitempty"`
+}
diff --git a/pkg/sdk/client/export_test.go b/pkg/sdk/client/export_test.go
index 20608ae61..f6ea86029 100644
--- a/pkg/sdk/client/export_test.go
+++ b/pkg/sdk/client/export_test.go
@@ -653,3 +653,17 @@ func ExportUpdateInfosFromGen(
) []UpdateInfo {
return updateInfosFromGen(input)
}
+
+// LogCollectionFromGen exposes the private logCollectionFromGen for testing.
+func LogCollectionFromGen(
+ input *gen.LogCollectionResponse,
+) Collection[LogEntryResult] {
+ return logCollectionFromGen(input)
+}
+
+// LogEntryInfoFromGen exposes the private logEntryInfoFromGen for testing.
+func LogEntryInfoFromGen(
+ input gen.LogEntryInfo,
+) LogEntry {
+ return logEntryInfoFromGen(input)
+}
diff --git a/pkg/sdk/client/gen/client.gen.go b/pkg/sdk/client/gen/client.gen.go
index 7087276b5..3bc9b1d91 100644
--- a/pkg/sdk/client/gen/client.gen.go
+++ b/pkg/sdk/client/gen/client.gen.go
@@ -202,6 +202,20 @@ const (
LoadResultItemStatusSkipped LoadResultItemStatus = "skipped"
)
+// Defines values for LogResultEntryStatus.
+const (
+ LogResultEntryStatusFailed LogResultEntryStatus = "failed"
+ LogResultEntryStatusOk LogResultEntryStatus = "ok"
+ LogResultEntryStatusSkipped LogResultEntryStatus = "skipped"
+)
+
+// Defines values for LogSourceEntryStatus.
+const (
+ LogSourceEntryStatusFailed LogSourceEntryStatus = "failed"
+ LogSourceEntryStatusOk LogSourceEntryStatus = "ok"
+ LogSourceEntryStatusSkipped LogSourceEntryStatus = "skipped"
+)
+
// Defines values for MemoryResultItemStatus.
const (
MemoryResultItemStatusFailed MemoryResultItemStatus = "failed"
@@ -1728,6 +1742,77 @@ type LoadResultItem struct {
// LoadResultItemStatus The status of the operation for this host.
type LoadResultItemStatus string
+// LogCollectionResponse defines model for LogCollectionResponse.
+type LogCollectionResponse struct {
+ // JobId The job ID used to process this request.
+ JobId *openapi_types.UUID `json:"job_id,omitempty"`
+ Results []LogResultEntry `json:"results"`
+}
+
+// LogEntryInfo A single log entry from the system journal.
+type LogEntryInfo struct {
+ // Hostname Hostname of the system that produced this entry.
+ Hostname *string `json:"hostname,omitempty"`
+
+ // Message Log message text.
+ Message *string `json:"message,omitempty"`
+
+ // Pid Process identifier that produced this entry.
+ Pid *int `json:"pid,omitempty"`
+
+ // Priority Log priority level.
+ Priority *string `json:"priority,omitempty"`
+
+ // Timestamp Log entry timestamp.
+ Timestamp *string `json:"timestamp,omitempty"`
+
+ // Unit Systemd unit that produced this entry.
+ Unit *string `json:"unit,omitempty"`
+}
+
+// LogResultEntry Log result for a single agent.
+type LogResultEntry struct {
+ // Entries Log entries from this agent.
+ Entries *[]LogEntryInfo `json:"entries,omitempty"`
+
+ // Error Error message if the agent failed.
+ Error *string `json:"error,omitempty"`
+
+ // Hostname The hostname of the agent.
+ Hostname string `json:"hostname"`
+
+ // Status The status of the operation for this host.
+ Status LogResultEntryStatus `json:"status"`
+}
+
+// LogResultEntryStatus The status of the operation for this host.
+type LogResultEntryStatus string
+
+// LogSourceCollectionResponse defines model for LogSourceCollectionResponse.
+type LogSourceCollectionResponse struct {
+ // JobId The job ID used to process this request.
+ JobId *openapi_types.UUID `json:"job_id,omitempty"`
+ Results []LogSourceEntry `json:"results"`
+}
+
+// LogSourceEntry Log source result for a single agent.
+type LogSourceEntry struct {
+ // Error Error message if the agent failed.
+ Error *string `json:"error,omitempty"`
+
+ // Hostname The hostname of the agent.
+ Hostname string `json:"hostname"`
+
+ // Sources Unique syslog identifiers on this agent.
+ Sources *[]string `json:"sources,omitempty"`
+
+ // Status The status of the operation for this host.
+ Status LogSourceEntryStatus `json:"status"`
+}
+
+// LogSourceEntryStatus The status of the operation for this host.
+type LogSourceEntryStatus string
+
// MemoryCollectionResponse defines model for MemoryCollectionResponse.
type MemoryCollectionResponse struct {
// JobId The job ID used to process this request.
@@ -2722,6 +2807,9 @@ type Pid = int
// SysctlKey defines model for SysctlKey.
type SysctlKey = string
+// UnitName defines model for UnitName.
+type UnitName = string
+
// UserName defines model for UserName.
type UserName = string
@@ -2793,6 +2881,30 @@ type DeleteNodeContainerDockerByIDParams struct {
Force *bool `form:"force,omitempty" json:"force,omitempty" validate:"omitempty"`
}
+// GetNodeLogParams defines parameters for GetNodeLog.
+type GetNodeLogParams struct {
+ // Lines Maximum number of log lines to return.
+ Lines *int `form:"lines,omitempty" json:"lines,omitempty" validate:"omitempty,min=1,max=10000"`
+
+ // Since Return log entries since this time. Accepts systemd time specifications (e.g., "1h", "2026-01-01 00:00:00").
+ Since *string `form:"since,omitempty" json:"since,omitempty"`
+
+ // Priority Filter by log priority level (e.g., "err", "warning", "info", "debug").
+ Priority *string `form:"priority,omitempty" json:"priority,omitempty"`
+}
+
+// GetNodeLogUnitParams defines parameters for GetNodeLogUnit.
+type GetNodeLogUnitParams struct {
+ // Lines Maximum number of log lines to return.
+ Lines *int `form:"lines,omitempty" json:"lines,omitempty" validate:"omitempty,min=1,max=10000"`
+
+ // Since Return log entries since this time. Accepts systemd time specifications (e.g., "1h", "2026-01-01 00:00:00").
+ Since *string `form:"since,omitempty" json:"since,omitempty"`
+
+ // Priority Filter by log priority level (e.g., "err", "warning", "info", "debug").
+ Priority *string `form:"priority,omitempty" json:"priority,omitempty"`
+}
+
// PostNodeNetworkPingJSONBody defines parameters for PostNodeNetworkPing.
type PostNodeNetworkPingJSONBody struct {
// Address The IP address of the server to ping. Supports both IPv4 and IPv6. Also accepts @fact. references that are resolved agent-side.
@@ -3117,6 +3229,15 @@ type ClientInterface interface {
// GetNodeLoad request
GetNodeLoad(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error)
+ // GetNodeLog request
+ GetNodeLog(ctx context.Context, hostname Hostname, params *GetNodeLogParams, reqEditors ...RequestEditorFn) (*http.Response, error)
+
+ // GetNodeLogSource request
+ GetNodeLogSource(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error)
+
+ // GetNodeLogUnit request
+ GetNodeLogUnit(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*http.Response, error)
+
// GetNodeMemory request
GetNodeMemory(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -3942,6 +4063,42 @@ func (c *Client) GetNodeLoad(ctx context.Context, hostname Hostname, reqEditors
return c.Client.Do(req)
}
+func (c *Client) GetNodeLog(ctx context.Context, hostname Hostname, params *GetNodeLogParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ req, err := NewGetNodeLogRequest(c.Server, hostname, params)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.Client.Do(req)
+}
+
+func (c *Client) GetNodeLogSource(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ req, err := NewGetNodeLogSourceRequest(c.Server, hostname)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.Client.Do(req)
+}
+
+func (c *Client) GetNodeLogUnit(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
+ req, err := NewGetNodeLogUnitRequest(c.Server, hostname, name, params)
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ if err := c.applyEditors(ctx, req, reqEditors); err != nil {
+ return nil, err
+ }
+ return c.Client.Do(req)
+}
+
func (c *Client) GetNodeMemory(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewGetNodeMemoryRequest(c.Server, hostname)
if err != nil {
@@ -6436,6 +6593,223 @@ func NewGetNodeLoadRequest(server string, hostname Hostname) (*http.Request, err
return req, nil
}
+// NewGetNodeLogRequest generates requests for GetNodeLog
+func NewGetNodeLogRequest(server string, hostname Hostname, params *GetNodeLogParams) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname)
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/node/%s/log", pathParam0)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ if params != nil {
+ queryValues := queryURL.Query()
+
+ if params.Lines != nil {
+
+ if queryFrag, err := runtime.StyleParamWithLocation("form", true, "lines", runtime.ParamLocationQuery, *params.Lines); err != nil {
+ return nil, err
+ } else if parsed, err := url.ParseQuery(queryFrag); err != nil {
+ return nil, err
+ } else {
+ for k, v := range parsed {
+ for _, v2 := range v {
+ queryValues.Add(k, v2)
+ }
+ }
+ }
+
+ }
+
+ if params.Since != nil {
+
+ if queryFrag, err := runtime.StyleParamWithLocation("form", true, "since", runtime.ParamLocationQuery, *params.Since); err != nil {
+ return nil, err
+ } else if parsed, err := url.ParseQuery(queryFrag); err != nil {
+ return nil, err
+ } else {
+ for k, v := range parsed {
+ for _, v2 := range v {
+ queryValues.Add(k, v2)
+ }
+ }
+ }
+
+ }
+
+ if params.Priority != nil {
+
+ if queryFrag, err := runtime.StyleParamWithLocation("form", true, "priority", runtime.ParamLocationQuery, *params.Priority); err != nil {
+ return nil, err
+ } else if parsed, err := url.ParseQuery(queryFrag); err != nil {
+ return nil, err
+ } else {
+ for k, v := range parsed {
+ for _, v2 := range v {
+ queryValues.Add(k, v2)
+ }
+ }
+ }
+
+ }
+
+ queryURL.RawQuery = queryValues.Encode()
+ }
+
+ req, err := http.NewRequest("GET", queryURL.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ return req, nil
+}
+
+// NewGetNodeLogSourceRequest generates requests for GetNodeLogSource
+func NewGetNodeLogSourceRequest(server string, hostname Hostname) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname)
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/node/%s/log/source", pathParam0)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest("GET", queryURL.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ return req, nil
+}
+
+// NewGetNodeLogUnitRequest generates requests for GetNodeLogUnit
+func NewGetNodeLogUnitRequest(server string, hostname Hostname, name UnitName, params *GetNodeLogUnitParams) (*http.Request, error) {
+ var err error
+
+ var pathParam0 string
+
+ pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname)
+ if err != nil {
+ return nil, err
+ }
+
+ var pathParam1 string
+
+ pathParam1, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name)
+ if err != nil {
+ return nil, err
+ }
+
+ serverURL, err := url.Parse(server)
+ if err != nil {
+ return nil, err
+ }
+
+ operationPath := fmt.Sprintf("/node/%s/log/unit/%s", pathParam0, pathParam1)
+ if operationPath[0] == '/' {
+ operationPath = "." + operationPath
+ }
+
+ queryURL, err := serverURL.Parse(operationPath)
+ if err != nil {
+ return nil, err
+ }
+
+ if params != nil {
+ queryValues := queryURL.Query()
+
+ if params.Lines != nil {
+
+ if queryFrag, err := runtime.StyleParamWithLocation("form", true, "lines", runtime.ParamLocationQuery, *params.Lines); err != nil {
+ return nil, err
+ } else if parsed, err := url.ParseQuery(queryFrag); err != nil {
+ return nil, err
+ } else {
+ for k, v := range parsed {
+ for _, v2 := range v {
+ queryValues.Add(k, v2)
+ }
+ }
+ }
+
+ }
+
+ if params.Since != nil {
+
+ if queryFrag, err := runtime.StyleParamWithLocation("form", true, "since", runtime.ParamLocationQuery, *params.Since); err != nil {
+ return nil, err
+ } else if parsed, err := url.ParseQuery(queryFrag); err != nil {
+ return nil, err
+ } else {
+ for k, v := range parsed {
+ for _, v2 := range v {
+ queryValues.Add(k, v2)
+ }
+ }
+ }
+
+ }
+
+ if params.Priority != nil {
+
+ if queryFrag, err := runtime.StyleParamWithLocation("form", true, "priority", runtime.ParamLocationQuery, *params.Priority); err != nil {
+ return nil, err
+ } else if parsed, err := url.ParseQuery(queryFrag); err != nil {
+ return nil, err
+ } else {
+ for k, v := range parsed {
+ for _, v2 := range v {
+ queryValues.Add(k, v2)
+ }
+ }
+ }
+
+ }
+
+ queryURL.RawQuery = queryValues.Encode()
+ }
+
+ req, err := http.NewRequest("GET", queryURL.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ return req, nil
+}
+
// NewGetNodeMemoryRequest generates requests for GetNodeMemory
func NewGetNodeMemoryRequest(server string, hostname Hostname) (*http.Request, error) {
var err error
@@ -8300,6 +8674,15 @@ type ClientWithResponsesInterface interface {
// GetNodeLoadWithResponse request
GetNodeLoadWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeLoadResponse, error)
+ // GetNodeLogWithResponse request
+ GetNodeLogWithResponse(ctx context.Context, hostname Hostname, params *GetNodeLogParams, reqEditors ...RequestEditorFn) (*GetNodeLogResponse, error)
+
+ // GetNodeLogSourceWithResponse request
+ GetNodeLogSourceWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeLogSourceResponse, error)
+
+ // GetNodeLogUnitWithResponse request
+ GetNodeLogUnitWithResponse(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*GetNodeLogUnitResponse, error)
+
// GetNodeMemoryWithResponse request
GetNodeMemoryWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeMemoryResponse, error)
@@ -9576,6 +9959,81 @@ func (r GetNodeLoadResponse) StatusCode() int {
return 0
}
+type GetNodeLogResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+ JSON200 *LogCollectionResponse
+ JSON401 *ErrorResponse
+ JSON403 *ErrorResponse
+ JSON500 *ErrorResponse
+}
+
+// Status returns HTTPResponse.Status
+func (r GetNodeLogResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r GetNodeLogResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
+type GetNodeLogSourceResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+ JSON200 *LogSourceCollectionResponse
+ JSON401 *ErrorResponse
+ JSON403 *ErrorResponse
+ JSON500 *ErrorResponse
+}
+
+// Status returns HTTPResponse.Status
+func (r GetNodeLogSourceResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r GetNodeLogSourceResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
+type GetNodeLogUnitResponse struct {
+ Body []byte
+ HTTPResponse *http.Response
+ JSON200 *LogCollectionResponse
+ JSON401 *ErrorResponse
+ JSON403 *ErrorResponse
+ JSON500 *ErrorResponse
+}
+
+// Status returns HTTPResponse.Status
+func (r GetNodeLogUnitResponse) Status() string {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.Status
+ }
+ return http.StatusText(0)
+}
+
+// StatusCode returns HTTPResponse.StatusCode
+func (r GetNodeLogUnitResponse) StatusCode() int {
+ if r.HTTPResponse != nil {
+ return r.HTTPResponse.StatusCode
+ }
+ return 0
+}
+
type GetNodeMemoryResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -11100,6 +11558,33 @@ func (c *ClientWithResponses) GetNodeLoadWithResponse(ctx context.Context, hostn
return ParseGetNodeLoadResponse(rsp)
}
+// GetNodeLogWithResponse request returning *GetNodeLogResponse
+func (c *ClientWithResponses) GetNodeLogWithResponse(ctx context.Context, hostname Hostname, params *GetNodeLogParams, reqEditors ...RequestEditorFn) (*GetNodeLogResponse, error) {
+ rsp, err := c.GetNodeLog(ctx, hostname, params, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseGetNodeLogResponse(rsp)
+}
+
+// GetNodeLogSourceWithResponse request returning *GetNodeLogSourceResponse
+func (c *ClientWithResponses) GetNodeLogSourceWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeLogSourceResponse, error) {
+ rsp, err := c.GetNodeLogSource(ctx, hostname, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseGetNodeLogSourceResponse(rsp)
+}
+
+// GetNodeLogUnitWithResponse request returning *GetNodeLogUnitResponse
+func (c *ClientWithResponses) GetNodeLogUnitWithResponse(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*GetNodeLogUnitResponse, error) {
+ rsp, err := c.GetNodeLogUnit(ctx, hostname, name, params, reqEditors...)
+ if err != nil {
+ return nil, err
+ }
+ return ParseGetNodeLogUnitResponse(rsp)
+}
+
// GetNodeMemoryWithResponse request returning *GetNodeMemoryResponse
func (c *ClientWithResponses) GetNodeMemoryWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeMemoryResponse, error) {
rsp, err := c.GetNodeMemory(ctx, hostname, reqEditors...)
@@ -13921,6 +14406,147 @@ func ParseGetNodeLoadResponse(rsp *http.Response) (*GetNodeLoadResponse, error)
return response, nil
}
+// ParseGetNodeLogResponse parses an HTTP response from a GetNodeLogWithResponse call
+func ParseGetNodeLogResponse(rsp *http.Response) (*GetNodeLogResponse, error) {
+ bodyBytes, err := io.ReadAll(rsp.Body)
+ defer func() { _ = rsp.Body.Close() }()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &GetNodeLogResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
+ var dest LogCollectionResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON200 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
+ var dest ErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON401 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
+ var dest ErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON403 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
+ var dest ErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON500 = &dest
+
+ }
+
+ return response, nil
+}
+
+// ParseGetNodeLogSourceResponse parses an HTTP response from a GetNodeLogSourceWithResponse call
+func ParseGetNodeLogSourceResponse(rsp *http.Response) (*GetNodeLogSourceResponse, error) {
+ bodyBytes, err := io.ReadAll(rsp.Body)
+ defer func() { _ = rsp.Body.Close() }()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &GetNodeLogSourceResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
+ var dest LogSourceCollectionResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON200 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
+ var dest ErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON401 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
+ var dest ErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON403 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
+ var dest ErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON500 = &dest
+
+ }
+
+ return response, nil
+}
+
+// ParseGetNodeLogUnitResponse parses an HTTP response from a GetNodeLogUnitWithResponse call
+func ParseGetNodeLogUnitResponse(rsp *http.Response) (*GetNodeLogUnitResponse, error) {
+ bodyBytes, err := io.ReadAll(rsp.Body)
+ defer func() { _ = rsp.Body.Close() }()
+ if err != nil {
+ return nil, err
+ }
+
+ response := &GetNodeLogUnitResponse{
+ Body: bodyBytes,
+ HTTPResponse: rsp,
+ }
+
+ switch {
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
+ var dest LogCollectionResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON200 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
+ var dest ErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON401 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
+ var dest ErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON403 = &dest
+
+ case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
+ var dest ErrorResponse
+ if err := json.Unmarshal(bodyBytes, &dest); err != nil {
+ return nil, err
+ }
+ response.JSON500 = &dest
+
+ }
+
+ return response, nil
+}
+
// ParseGetNodeMemoryResponse parses an HTTP response from a GetNodeMemoryWithResponse call
func ParseGetNodeMemoryResponse(rsp *http.Response) (*GetNodeMemoryResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
diff --git a/pkg/sdk/client/log.go b/pkg/sdk/client/log.go
new file mode 100644
index 000000000..ead63d075
--- /dev/null
+++ b/pkg/sdk/client/log.go
@@ -0,0 +1,137 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package client
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
+)
+
+// LogService provides log viewing operations.
+type LogService struct {
+ client *gen.ClientWithResponses
+}
+
+// Query returns journal log entries for the target host.
+func (s *LogService) Query(
+ ctx context.Context,
+ hostname string,
+ opts LogQueryOpts,
+) (*Response[Collection[LogEntryResult]], error) {
+ params := &gen.GetNodeLogParams{
+ Lines: opts.Lines,
+ Since: opts.Since,
+ Priority: opts.Priority,
+ }
+
+ resp, err := s.client.GetNodeLogWithResponse(ctx, hostname, params)
+ if err != nil {
+ return nil, fmt.Errorf("log query: %w", err)
+ }
+
+ if err := checkError(
+ resp.StatusCode(),
+ resp.JSON401,
+ resp.JSON403,
+ resp.JSON500,
+ ); err != nil {
+ return nil, err
+ }
+
+ if resp.JSON200 == nil {
+ return nil, &UnexpectedStatusError{APIError{
+ StatusCode: resp.StatusCode(),
+ Message: "nil response body",
+ }}
+ }
+
+ return NewResponse(logCollectionFromGen(resp.JSON200), resp.Body), nil
+}
+
+// Sources returns unique syslog identifiers available in the journal on the
+// target host.
+func (s *LogService) Sources(
+ ctx context.Context,
+ hostname string,
+) (*Response[Collection[LogSourceResult]], error) {
+ resp, err := s.client.GetNodeLogSourceWithResponse(ctx, hostname)
+ if err != nil {
+ return nil, fmt.Errorf("log sources: %w", err)
+ }
+
+ if err := checkError(
+ resp.StatusCode(),
+ resp.JSON401,
+ resp.JSON403,
+ resp.JSON500,
+ ); err != nil {
+ return nil, err
+ }
+
+ if resp.JSON200 == nil {
+ return nil, &UnexpectedStatusError{APIError{
+ StatusCode: resp.StatusCode(),
+ Message: "nil response body",
+ }}
+ }
+
+ return NewResponse(logSourceCollectionFromGen(resp.JSON200), resp.Body), nil
+}
+
+// QueryUnit returns journal log entries for a specific systemd unit on the
+// target host.
+func (s *LogService) QueryUnit(
+ ctx context.Context,
+ hostname string,
+ unit string,
+ opts LogQueryOpts,
+) (*Response[Collection[LogEntryResult]], error) {
+ params := &gen.GetNodeLogUnitParams{
+ Lines: opts.Lines,
+ Since: opts.Since,
+ Priority: opts.Priority,
+ }
+
+ resp, err := s.client.GetNodeLogUnitWithResponse(ctx, hostname, unit, params)
+ if err != nil {
+ return nil, fmt.Errorf("log query unit: %w", err)
+ }
+
+ if err := checkError(
+ resp.StatusCode(),
+ resp.JSON401,
+ resp.JSON403,
+ resp.JSON500,
+ ); err != nil {
+ return nil, err
+ }
+
+ if resp.JSON200 == nil {
+ return nil, &UnexpectedStatusError{APIError{
+ StatusCode: resp.StatusCode(),
+ Message: "nil response body",
+ }}
+ }
+
+ return NewResponse(logCollectionFromGen(resp.JSON200), resp.Body), nil
+}
diff --git a/pkg/sdk/client/log_public_test.go b/pkg/sdk/client/log_public_test.go
new file mode 100644
index 000000000..bcb7ccf66
--- /dev/null
+++ b/pkg/sdk/client/log_public_test.go
@@ -0,0 +1,600 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package client_test
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+type LogPublicTestSuite struct {
+ suite.Suite
+
+ ctx context.Context
+}
+
+func (suite *LogPublicTestSuite) SetupTest() {
+ suite.ctx = context.Background()
+}
+
+func (suite *LogPublicTestSuite) TestQuery() {
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ serverURL string
+ opts client.LogQueryOpts
+ validateFunc func(*client.Response[client.Collection[client.LogEntryResult]], error)
+ }{
+ {
+ name: "when querying logs returns result collection",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(
+ []byte(
+ `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","entries":[{"timestamp":"2026-01-01T00:00:00Z","unit":"sshd.service","priority":"info","message":"Accepted publickey","pid":1234,"hostname":"agent1"}]}]}`,
+ ),
+ )
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.NoError(err)
+ suite.NotNil(resp)
+ suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID)
+ suite.Len(resp.Data.Results, 1)
+ suite.Equal("agent1", resp.Data.Results[0].Hostname)
+ suite.Equal("ok", resp.Data.Results[0].Status)
+ suite.Require().Len(resp.Data.Results[0].Entries, 1)
+ suite.Equal("Accepted publickey", resp.Data.Results[0].Entries[0].Message)
+ suite.Equal("sshd.service", resp.Data.Results[0].Entries[0].Unit)
+ },
+ },
+ {
+ name: "when broadcast query returns multiple results",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(
+ []byte(
+ `{"job_id":"00000000-0000-0000-0000-000000000002","results":[{"hostname":"server1","status":"ok","entries":[]},{"hostname":"server2","status":"ok","entries":[]}]}`,
+ ),
+ )
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.NoError(err)
+ suite.NotNil(resp)
+ suite.Len(resp.Data.Results, 2)
+ },
+ },
+ {
+ name: "when server returns 401 returns AuthError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.AuthError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusUnauthorized, target.StatusCode)
+ },
+ },
+ {
+ name: "when server returns 403 returns AuthError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"error":"forbidden"}`))
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.AuthError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusForbidden, target.StatusCode)
+ },
+ },
+ {
+ name: "when server returns 500 returns ServerError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":"internal error"}`))
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.ServerError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusInternalServerError, target.StatusCode)
+ },
+ },
+ {
+ name: "when client HTTP call fails returns error",
+ serverURL: "http://127.0.0.1:0",
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+ suite.Contains(err.Error(), "log query")
+ },
+ },
+ {
+ name: "when server returns 200 with no JSON body returns UnexpectedStatusError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.UnexpectedStatusError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusOK, target.StatusCode)
+ suite.Equal("nil response body", target.Message)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ var (
+ serverURL string
+ cleanup func()
+ )
+
+ if tc.serverURL != "" {
+ serverURL = tc.serverURL
+ cleanup = func() {}
+ } else {
+ server := httptest.NewServer(tc.handler)
+ serverURL = server.URL
+ cleanup = server.Close
+ }
+ defer cleanup()
+
+ sut := client.New(
+ serverURL,
+ "test-token",
+ client.WithLogger(slog.Default()),
+ )
+
+ resp, err := sut.Log.Query(suite.ctx, "_any", tc.opts)
+ tc.validateFunc(resp, err)
+ })
+ }
+}
+
+func (suite *LogPublicTestSuite) TestQueryWithOpts() {
+ lines := 100
+ since := "1h"
+ priority := "err"
+
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ opts client.LogQueryOpts
+ validateFunc func(*client.Response[client.Collection[client.LogEntryResult]], error)
+ }{
+ {
+ name: "when all options are set returns result",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(
+ []byte(
+ `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","entries":[]}]}`,
+ ),
+ )
+ },
+ opts: client.LogQueryOpts{
+ Lines: &lines,
+ Since: &since,
+ Priority: &priority,
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.NoError(err)
+ suite.NotNil(resp)
+ suite.Len(resp.Data.Results, 1)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ server := httptest.NewServer(tc.handler)
+ defer server.Close()
+
+ sut := client.New(
+ server.URL,
+ "test-token",
+ client.WithLogger(slog.Default()),
+ )
+
+ resp, err := sut.Log.Query(suite.ctx, "_any", tc.opts)
+ tc.validateFunc(resp, err)
+ })
+ }
+}
+
+func (suite *LogPublicTestSuite) TestQueryUnit() {
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ serverURL string
+ validateFunc func(*client.Response[client.Collection[client.LogEntryResult]], error)
+ }{
+ {
+ name: "when querying unit logs returns result collection",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(
+ []byte(
+ `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","entries":[{"timestamp":"2026-01-01T00:00:00Z","unit":"nginx.service","priority":"info","message":"Started nginx","pid":5678,"hostname":"agent1"}]}]}`,
+ ),
+ )
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.NoError(err)
+ suite.NotNil(resp)
+ suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID)
+ suite.Len(resp.Data.Results, 1)
+ suite.Equal("agent1", resp.Data.Results[0].Hostname)
+ suite.Equal("ok", resp.Data.Results[0].Status)
+ suite.Require().Len(resp.Data.Results[0].Entries, 1)
+ suite.Equal("Started nginx", resp.Data.Results[0].Entries[0].Message)
+ suite.Equal("nginx.service", resp.Data.Results[0].Entries[0].Unit)
+ },
+ },
+ {
+ name: "when server returns 401 returns AuthError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.AuthError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusUnauthorized, target.StatusCode)
+ },
+ },
+ {
+ name: "when server returns 403 returns AuthError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"error":"forbidden"}`))
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.AuthError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusForbidden, target.StatusCode)
+ },
+ },
+ {
+ name: "when server returns 500 returns ServerError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":"internal error"}`))
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.ServerError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusInternalServerError, target.StatusCode)
+ },
+ },
+ {
+ name: "when client HTTP call fails returns error",
+ serverURL: "http://127.0.0.1:0",
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+ suite.Contains(err.Error(), "log query unit")
+ },
+ },
+ {
+ name: "when server returns 200 with no JSON body returns UnexpectedStatusError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogEntryResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.UnexpectedStatusError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusOK, target.StatusCode)
+ suite.Equal("nil response body", target.Message)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ var (
+ serverURL string
+ cleanup func()
+ )
+
+ if tc.serverURL != "" {
+ serverURL = tc.serverURL
+ cleanup = func() {}
+ } else {
+ server := httptest.NewServer(tc.handler)
+ serverURL = server.URL
+ cleanup = server.Close
+ }
+ defer cleanup()
+
+ sut := client.New(
+ serverURL,
+ "test-token",
+ client.WithLogger(slog.Default()),
+ )
+
+ resp, err := sut.Log.QueryUnit(
+ suite.ctx,
+ "_any",
+ "nginx.service",
+ client.LogQueryOpts{},
+ )
+ tc.validateFunc(resp, err)
+ })
+ }
+}
+
+func (suite *LogPublicTestSuite) TestSources() {
+ tests := []struct {
+ name string
+ handler http.HandlerFunc
+ serverURL string
+ validateFunc func(*client.Response[client.Collection[client.LogSourceResult]], error)
+ }{
+ {
+ name: "when querying sources returns result collection",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(
+ []byte(
+ `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","sources":["cron","nginx","sshd"]}]}`,
+ ),
+ )
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogSourceResult]],
+ err error,
+ ) {
+ suite.NoError(err)
+ suite.NotNil(resp)
+ suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID)
+ suite.Len(resp.Data.Results, 1)
+ suite.Equal("agent1", resp.Data.Results[0].Hostname)
+ suite.Equal("ok", resp.Data.Results[0].Status)
+ suite.Equal([]string{"cron", "nginx", "sshd"}, resp.Data.Results[0].Sources)
+ },
+ },
+ {
+ name: "when broadcast returns multiple results",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(
+ []byte(
+ `{"job_id":"00000000-0000-0000-0000-000000000002","results":[{"hostname":"server1","status":"ok","sources":["sshd"]},{"hostname":"server2","status":"ok","sources":[]}]}`,
+ ),
+ )
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogSourceResult]],
+ err error,
+ ) {
+ suite.NoError(err)
+ suite.NotNil(resp)
+ suite.Len(resp.Data.Results, 2)
+ },
+ },
+ {
+ name: "when server returns 401 returns AuthError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogSourceResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.AuthError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusUnauthorized, target.StatusCode)
+ },
+ },
+ {
+ name: "when server returns 403 returns AuthError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"error":"forbidden"}`))
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogSourceResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.AuthError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusForbidden, target.StatusCode)
+ },
+ },
+ {
+ name: "when server returns 500 returns ServerError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":"internal error"}`))
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogSourceResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.ServerError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusInternalServerError, target.StatusCode)
+ },
+ },
+ {
+ name: "when client HTTP call fails returns error",
+ serverURL: "http://127.0.0.1:0",
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogSourceResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+ suite.Contains(err.Error(), "log sources")
+ },
+ },
+ {
+ name: "when server returns 200 with no JSON body returns UnexpectedStatusError",
+ handler: func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ },
+ validateFunc: func(
+ resp *client.Response[client.Collection[client.LogSourceResult]],
+ err error,
+ ) {
+ suite.Error(err)
+ suite.Nil(resp)
+
+ var target *client.UnexpectedStatusError
+ suite.True(errors.As(err, &target))
+ suite.Equal(http.StatusOK, target.StatusCode)
+ suite.Equal("nil response body", target.Message)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ var (
+ serverURL string
+ cleanup func()
+ )
+
+ if tc.serverURL != "" {
+ serverURL = tc.serverURL
+ cleanup = func() {}
+ } else {
+ server := httptest.NewServer(tc.handler)
+ serverURL = server.URL
+ cleanup = server.Close
+ }
+ defer cleanup()
+
+ sut := client.New(
+ serverURL,
+ "test-token",
+ client.WithLogger(slog.Default()),
+ )
+
+ resp, err := sut.Log.Sources(suite.ctx, "_any")
+ tc.validateFunc(resp, err)
+ })
+ }
+}
+
+func TestLogPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(LogPublicTestSuite))
+}
diff --git a/pkg/sdk/client/log_types.go b/pkg/sdk/client/log_types.go
new file mode 100644
index 000000000..63abccf12
--- /dev/null
+++ b/pkg/sdk/client/log_types.go
@@ -0,0 +1,145 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package client
+
+import (
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
+)
+
+// LogEntryResult represents the result of a log query for one host.
+type LogEntryResult struct {
+ Hostname string `json:"hostname"`
+ Status string `json:"status"`
+ Entries []LogEntry `json:"entries,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// LogEntry represents a single journal entry.
+type LogEntry struct {
+ Timestamp string `json:"timestamp,omitempty"`
+ Unit string `json:"unit,omitempty"`
+ Priority string `json:"priority,omitempty"`
+ Message string `json:"message,omitempty"`
+ PID int `json:"pid,omitempty"`
+ Hostname string `json:"hostname,omitempty"`
+}
+
+// LogSourceResult represents the result of a log sources query for one host.
+type LogSourceResult struct {
+ Hostname string `json:"hostname"`
+ Status string `json:"status"`
+ Sources []string `json:"sources,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// LogQueryOpts contains options for log query operations.
+type LogQueryOpts struct {
+ // Lines is the maximum number of log lines to return.
+ Lines *int
+ // Since filters entries since this time (e.g., "1h", "2026-01-01 00:00:00").
+ Since *string
+ // Priority filters by log priority level (e.g., "err", "warning", "info").
+ Priority *string
+}
+
+// logCollectionFromGen converts a gen.LogCollectionResponse to a
+// Collection[LogEntryResult].
+func logCollectionFromGen(
+ g *gen.LogCollectionResponse,
+) Collection[LogEntryResult] {
+ results := make([]LogEntryResult, 0, len(g.Results))
+ for _, r := range g.Results {
+ results = append(results, logEntryResultFromGen(r))
+ }
+
+ return Collection[LogEntryResult]{
+ Results: results,
+ JobID: jobIDFromGen(g.JobId),
+ }
+}
+
+// logEntryResultFromGen converts a gen.LogResultEntry to a LogEntryResult.
+func logEntryResultFromGen(
+ r gen.LogResultEntry,
+) LogEntryResult {
+ result := LogEntryResult{
+ Hostname: r.Hostname,
+ Status: string(r.Status),
+ Error: derefString(r.Error),
+ }
+
+ if r.Entries != nil {
+ entries := make([]LogEntry, 0, len(*r.Entries))
+ for _, e := range *r.Entries {
+ entries = append(entries, logEntryInfoFromGen(e))
+ }
+ result.Entries = entries
+ }
+
+ return result
+}
+
+// logEntryInfoFromGen converts a gen.LogEntryInfo to a LogEntry.
+func logEntryInfoFromGen(
+ e gen.LogEntryInfo,
+) LogEntry {
+ return LogEntry{
+ Timestamp: derefString(e.Timestamp),
+ Unit: derefString(e.Unit),
+ Priority: derefString(e.Priority),
+ Message: derefString(e.Message),
+ PID: derefInt(e.Pid),
+ Hostname: derefString(e.Hostname),
+ }
+}
+
+// logSourceCollectionFromGen converts a gen.LogSourceCollectionResponse to a
+// Collection[LogSourceResult].
+func logSourceCollectionFromGen(
+ g *gen.LogSourceCollectionResponse,
+) Collection[LogSourceResult] {
+ results := make([]LogSourceResult, 0, len(g.Results))
+ for _, r := range g.Results {
+ results = append(results, logSourceResultFromGen(r))
+ }
+
+ return Collection[LogSourceResult]{
+ Results: results,
+ JobID: jobIDFromGen(g.JobId),
+ }
+}
+
+// logSourceResultFromGen converts a gen.LogSourceEntry to a LogSourceResult.
+func logSourceResultFromGen(
+ r gen.LogSourceEntry,
+) LogSourceResult {
+ result := LogSourceResult{
+ Hostname: r.Hostname,
+ Status: string(r.Status),
+ Error: derefString(r.Error),
+ }
+
+ if r.Sources != nil {
+ result.Sources = *r.Sources
+ }
+
+ return result
+}
diff --git a/pkg/sdk/client/log_types_public_test.go b/pkg/sdk/client/log_types_public_test.go
new file mode 100644
index 000000000..c092a3e2f
--- /dev/null
+++ b/pkg/sdk/client/log_types_public_test.go
@@ -0,0 +1,236 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package client_test
+
+import (
+ "testing"
+
+ openapi_types "github.com/oapi-codegen/runtime/types"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
+)
+
+type LogTypesPublicTestSuite struct {
+ suite.Suite
+}
+
+func (suite *LogTypesPublicTestSuite) TestLogCollectionFromGen() {
+ testUUID := openapi_types.UUID{
+ 0x55, 0x0e, 0x84, 0x00,
+ 0xe2, 0x9b, 0x41, 0xd4,
+ 0xa7, 0x16, 0x44, 0x66,
+ 0x55, 0x44, 0x00, 0x00,
+ }
+
+ tests := []struct {
+ name string
+ input *gen.LogCollectionResponse
+ validateFunc func(client.Collection[client.LogEntryResult])
+ }{
+ {
+ name: "when all fields are populated",
+ input: func() *gen.LogCollectionResponse {
+ ts := "2026-01-01T00:00:00Z"
+ unit := "sshd.service"
+ priority := "info"
+ message := "Accepted publickey for root"
+ pid := 1234
+ hostname := "web-01"
+ return &gen.LogCollectionResponse{
+ JobId: &testUUID,
+ Results: []gen.LogResultEntry{
+ {
+ Hostname: "web-01",
+ Status: gen.LogResultEntryStatusOk,
+ Entries: &[]gen.LogEntryInfo{
+ {
+ Timestamp: &ts,
+ Unit: &unit,
+ Priority: &priority,
+ Message: &message,
+ Pid: &pid,
+ Hostname: &hostname,
+ },
+ },
+ },
+ },
+ }
+ }(),
+ validateFunc: func(c client.Collection[client.LogEntryResult]) {
+ suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID)
+ suite.Require().Len(c.Results, 1)
+
+ r := c.Results[0]
+ suite.Equal("web-01", r.Hostname)
+ suite.Equal("ok", r.Status)
+ suite.Empty(r.Error)
+ suite.Require().Len(r.Entries, 1)
+
+ e := r.Entries[0]
+ suite.Equal("2026-01-01T00:00:00Z", e.Timestamp)
+ suite.Equal("sshd.service", e.Unit)
+ suite.Equal("info", e.Priority)
+ suite.Equal("Accepted publickey for root", e.Message)
+ suite.Equal(1234, e.PID)
+ suite.Equal("web-01", e.Hostname)
+ },
+ },
+ {
+ name: "when result has error",
+ input: func() *gen.LogCollectionResponse {
+ errMsg := "permission denied"
+ return &gen.LogCollectionResponse{
+ Results: []gen.LogResultEntry{
+ {
+ Hostname: "web-01",
+ Status: gen.LogResultEntryStatusFailed,
+ Error: &errMsg,
+ },
+ },
+ }
+ }(),
+ validateFunc: func(c client.Collection[client.LogEntryResult]) {
+ suite.Empty(c.JobID)
+ suite.Require().Len(c.Results, 1)
+
+ r := c.Results[0]
+ suite.Equal("web-01", r.Hostname)
+ suite.Equal("failed", r.Status)
+ suite.Equal("permission denied", r.Error)
+ suite.Nil(r.Entries)
+ },
+ },
+ {
+ name: "when multiple results",
+ input: func() *gen.LogCollectionResponse {
+ msg1 := "Started"
+ msg2 := "Stopped"
+ errMsg := "unsupported"
+ return &gen.LogCollectionResponse{
+ JobId: &testUUID,
+ Results: []gen.LogResultEntry{
+ {
+ Hostname: "web-01",
+ Status: gen.LogResultEntryStatusOk,
+ Entries: &[]gen.LogEntryInfo{
+ {Message: &msg1},
+ {Message: &msg2},
+ },
+ },
+ {
+ Hostname: "web-02",
+ Status: gen.LogResultEntryStatusSkipped,
+ Error: &errMsg,
+ },
+ },
+ }
+ }(),
+ validateFunc: func(c client.Collection[client.LogEntryResult]) {
+ suite.Require().Len(c.Results, 2)
+ suite.Len(c.Results[0].Entries, 2)
+ suite.Equal("unsupported", c.Results[1].Error)
+ },
+ },
+ {
+ name: "when entries pointer is nil",
+ input: &gen.LogCollectionResponse{
+ Results: []gen.LogResultEntry{
+ {
+ Hostname: "web-01",
+ Status: gen.LogResultEntryStatusSkipped,
+ },
+ },
+ },
+ validateFunc: func(c client.Collection[client.LogEntryResult]) {
+ suite.Require().Len(c.Results, 1)
+ suite.Nil(c.Results[0].Entries)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ result := client.LogCollectionFromGen(tc.input)
+ tc.validateFunc(result)
+ })
+ }
+}
+
+func (suite *LogTypesPublicTestSuite) TestLogEntryInfoFromGen() {
+ tests := []struct {
+ name string
+ input gen.LogEntryInfo
+ validateFunc func(client.LogEntry)
+ }{
+ {
+ name: "when all fields are populated",
+ input: func() gen.LogEntryInfo {
+ ts := "2026-01-01T12:00:00Z"
+ unit := "nginx.service"
+ priority := "err"
+ message := "connection refused"
+ pid := 9999
+ hostname := "web-01"
+ return gen.LogEntryInfo{
+ Timestamp: &ts,
+ Unit: &unit,
+ Priority: &priority,
+ Message: &message,
+ Pid: &pid,
+ Hostname: &hostname,
+ }
+ }(),
+ validateFunc: func(e client.LogEntry) {
+ suite.Equal("2026-01-01T12:00:00Z", e.Timestamp)
+ suite.Equal("nginx.service", e.Unit)
+ suite.Equal("err", e.Priority)
+ suite.Equal("connection refused", e.Message)
+ suite.Equal(9999, e.PID)
+ suite.Equal("web-01", e.Hostname)
+ },
+ },
+ {
+ name: "when all fields are nil",
+ input: gen.LogEntryInfo{},
+ validateFunc: func(e client.LogEntry) {
+ suite.Empty(e.Timestamp)
+ suite.Empty(e.Unit)
+ suite.Empty(e.Priority)
+ suite.Empty(e.Message)
+ suite.Zero(e.PID)
+ suite.Empty(e.Hostname)
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ suite.Run(tc.name, func() {
+ result := client.LogEntryInfoFromGen(tc.input)
+ tc.validateFunc(result)
+ })
+ }
+}
+
+func TestLogTypesPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(LogTypesPublicTestSuite))
+}
diff --git a/pkg/sdk/client/operations.go b/pkg/sdk/client/operations.go
index d504526fa..1d05f0837 100644
--- a/pkg/sdk/client/operations.go
+++ b/pkg/sdk/client/operations.go
@@ -155,6 +155,13 @@ const (
OpPackageListUpdates JobOperation = "node.package.listUpdates"
)
+// Log operations.
+const (
+ OpLogQuery JobOperation = "node.log.query"
+ OpLogQueryUnit JobOperation = "node.log.queryUnit"
+ OpLogSources JobOperation = "node.log.sources"
+)
+
// Target constants for job routing.
const (
// TargetAny routes to any available agent (load-balanced).
diff --git a/pkg/sdk/client/osapi.go b/pkg/sdk/client/osapi.go
index 0239b562a..0076f74db 100644
--- a/pkg/sdk/client/osapi.go
+++ b/pkg/sdk/client/osapi.go
@@ -128,6 +128,9 @@ type Client struct {
// install, remove, update, list updates).
Package *PackageService
+ // Log provides log viewing operations (query journal entries).
+ Log *LogService
+
httpClient *gen.ClientWithResponses
baseURL string
logger *slog.Logger
@@ -213,6 +216,7 @@ func New(
c.User = &UserService{client: httpClient}
c.Group = &GroupService{client: httpClient}
c.Package = &PackageService{client: httpClient}
+ c.Log = &LogService{client: httpClient}
return c
}
diff --git a/pkg/sdk/client/permissions.go b/pkg/sdk/client/permissions.go
index 0e0ce7150..107ab77bf 100644
--- a/pkg/sdk/client/permissions.go
+++ b/pkg/sdk/client/permissions.go
@@ -58,6 +58,7 @@ const (
PermUserWrite Permission = "user:write"
PermPackageRead Permission = "package:read"
PermPackageWrite Permission = "package:write"
+ PermLogRead Permission = "log:read"
)
// Role represents a built-in RBAC role name.
diff --git a/test/integration/log_test.go b/test/integration/log_test.go
new file mode 100644
index 000000000..11deffdcd
--- /dev/null
+++ b/test/integration/log_test.go
@@ -0,0 +1,120 @@
+//go:build integration
+
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package integration_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+)
+
+type LogSmokeSuite struct {
+ suite.Suite
+}
+
+// TestLogQuery verifies the log query endpoint is reachable and returns
+// a valid JSON response with a results array.
+func (s *LogSmokeSuite) TestLogQuery() {
+ tests := []struct {
+ name string
+ args []string
+ validateFunc func(stdout string, exitCode int)
+ }{
+ {
+ name: "query endpoint responds",
+ args: []string{
+ "client", "node", "log", "query",
+ "--target", "_any",
+ "--json",
+ },
+ validateFunc: func(
+ stdout string,
+ exitCode int,
+ ) {
+ s.Require().Equal(0, exitCode)
+
+ var result map[string]any
+ err := parseJSON(stdout, &result)
+ s.Require().NoError(err)
+
+ results, ok := result["results"].([]any)
+ s.Require().True(ok)
+ s.NotNil(results)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ stdout, _, exitCode := runCLI(tt.args...)
+ tt.validateFunc(stdout, exitCode)
+ })
+ }
+}
+
+// TestLogUnit verifies the log unit endpoint is reachable and returns
+// a valid JSON response with a results array.
+func (s *LogSmokeSuite) TestLogUnit() {
+ tests := []struct {
+ name string
+ args []string
+ validateFunc func(stdout string, exitCode int)
+ }{
+ {
+ name: "unit endpoint responds",
+ args: []string{
+ "client", "node", "log", "unit",
+ "--target", "_any",
+ "--name", "sshd.service",
+ "--json",
+ },
+ validateFunc: func(
+ stdout string,
+ exitCode int,
+ ) {
+ s.Require().Equal(0, exitCode)
+
+ var result map[string]any
+ err := parseJSON(stdout, &result)
+ s.Require().NoError(err)
+
+ results, ok := result["results"].([]any)
+ s.Require().True(ok)
+ s.NotNil(results)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ stdout, _, exitCode := runCLI(tt.args...)
+ tt.validateFunc(stdout, exitCode)
+ })
+ }
+}
+
+func TestLogSmokeSuite(
+ t *testing.T,
+) {
+ suite.Run(t, new(LogSmokeSuite))
+}