Skip to content

Commit

Permalink
Merge pull request #96 from ureeves/cmd-sensor
Browse files Browse the repository at this point in the history
Add command-based sensor type
  • Loading branch information
markusressel authored Apr 2, 2022
2 parents b1dc76b + 563c38f commit 5ce65a1
Show file tree
Hide file tree
Showing 14 changed files with 297 additions and 13 deletions.
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ run:
go build -o ${OUTPUT_DIR}${BINARY_NAME} main.go
./${OUTPUT_DIR}${BINARY_NAME}

test:
sudo go test -v ./...

clean:
go clean
rm ${OUTPUT_DIR}${BINARY_NAME}
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ sensors:
# A user defined ID, which is used to reference
# a sensor in a curve configuration (see below)
- id: cpu_package
# The type of sensor configuration, one of: hwmon | file
# The type of sensor configuration, one of: hwmon | file | cmd
hwmon:
# A regex matching a controller platform displayed by `fan2go detect`, f.ex.:
# "coretemp", "it8620", "corsaircpro-*" etc.
Expand All @@ -164,6 +164,7 @@ sensors:
sensors:
- id: file_sensor
file:
# Path to the file containing sensor values
path: /tmp/file_sensor
```
Expand All @@ -174,6 +175,19 @@ The file contains a value in milli-units, like milli-degrees.
10000
```

```yaml
sensors:
- id: cmd_fan
cmd:
# Path to the executable to run to retrieve the current sensor value
exec: /usr/bin/nvidia-settings
# (optional) arguments to pass to the executable
args: [ '-q', 'gpucoretemp', '-t' ]
```
Please also make sure to read the section
about [considerations for using the cmd sensor/fan](##Using external commands for sensors/fans).
### Curves
Under `curves:` you need to define a list of fan speed curves, which represent the speed of a fan based on one or more
Expand Down Expand Up @@ -256,6 +270,25 @@ or to validate a specific config file:
ERROR Validation failed: Curve m2_ssd_curve: no curve definition with id 'm2_first_ssd_curve123' found
```

## Using external commands for sensors/fans

fan2go supports using external executables for use as both sensor input, as well as fan output (and rpm input). There
are some considerations you should take into account before using this feature though:

### Security

Since fan2go requires root permissions to interact with lm-sensors, executables run by fan2go are also executed as root.
To prevent some malicious actor from taking advantage of this fan2go will only allow the execution of files that only
allow the root user (UID 0) to modify the file.

### Side effects

Running external commands repeatedly through fan2go can have unintended side effects. F.ex., on a laptop using hybrid
graphics, running `nvidia-settings` can cause the dedicated GPU to wake up, resulting in substantial increase in power
consumption while on battery. Also, fan2go expects to be able to update sensor values with a minimal delay, so using a
long running script or some network call with a long timeout could also cause problems. With great power comes great
responsibility, always remember that :)

## Run

After successfully verifying your configuration you can launch fan2go from the CLI and make sure the initial setup is
Expand Down
2 changes: 1 addition & 1 deletion cmd/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ var validateCmd = &cobra.Command{
ui.Info("Using configuration file at: %s", configPath)
configuration.LoadConfig()

if err := configuration.Validate(); err != nil {
if err := configuration.Validate(configPath); err != nil {
ui.Error("Validation failed: %v", err)
os.Exit(1)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/curve.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ var curveCmd = &cobra.Command{
configPath := configuration.DetectConfigFile()
ui.Info("Using configuration file at: %s", configPath)
configuration.LoadConfig()
err := configuration.Validate()
err := configuration.Validate(configPath)
if err != nil {
ui.Fatal(err.Error())
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/fan/fan.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func getFan(id string) (fans.Fan, error) {
configPath := configuration.DetectConfigFile()
ui.Info("Using configuration file at: %s", configPath)
configuration.LoadConfig()
err := configuration.Validate()
err := configuration.Validate(configPath)
if err != nil {
ui.Fatal(err.Error())
}
Expand Down
7 changes: 6 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/markusressel/fan2go/internal"
"github.com/markusressel/fan2go/internal/configuration"
"github.com/markusressel/fan2go/internal/ui"
"github.com/markusressel/fan2go/internal/util"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
"os"
Expand All @@ -34,11 +35,15 @@ on your computer based on temperature sensors.`,
configPath := configuration.DetectConfigFile()
ui.Info("Using configuration file at: %s", configPath)
configuration.LoadConfig()
err := configuration.Validate()
err := configuration.Validate(configPath)
if err != nil {
ui.Fatal(err.Error())
}

if _, err := util.CheckFilePermissionsForExecution(configPath); err != nil {
ui.Fatal("Config file '%s' has invalid permissions: %s", configPath, err)
}

internal.RunDaemon()
},
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/sensor/sensor.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func getSensor(id string) (sensors.Sensor, error) {
configPath := configuration.DetectConfigFile()
ui.Info("Using configuration file at: %s", configPath)
configuration.LoadConfig()
err := configuration.Validate()
err := configuration.Validate(configPath)
if err != nil {
ui.Fatal(err.Error())
}
Expand Down
6 changes: 6 additions & 0 deletions internal/configuration/sensors.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ type SensorConfig struct {
ID string `json:"id"`
HwMon *HwMonSensorConfig `json:"hwMon,omitempty"`
File *FileSensorConfig `json:"file,omitempty"`
Cmd *CmdSensorConfig `json:"cmd,omitempty"`
}

type HwMonSensorConfig struct {
Expand All @@ -15,3 +16,8 @@ type HwMonSensorConfig struct {
type FileSensorConfig struct {
Path string `json:"path"`
}

type CmdSensorConfig struct {
Exec string `json:"exec"`
Args []string `json:"args"`
}
15 changes: 10 additions & 5 deletions internal/configuration/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import (
"github.com/markusressel/fan2go/internal/util"
)

func Validate() error {
func Validate(configPath string) error {
if _, err := util.CheckFilePermissionsForExecution(configPath); err != nil {
return errors.New(fmt.Sprintf("Config file '%s' has invalid permissions: %s", configPath, err))
}

return ValidateConfig(&CurrentConfig)
}

Expand All @@ -28,12 +32,13 @@ func ValidateConfig(config *Configuration) error {

func validateSensors(config *Configuration) error {
for _, sensorConfig := range config.Sensors {
if sensorConfig.HwMon != nil && sensorConfig.File != nil {

if sensorConfig.HwMon != nil && sensorConfig.File != nil && sensorConfig.Cmd != nil {
return errors.New(fmt.Sprintf("Sensor %s: only one sensor type can be used per sensor definition block", sensorConfig.ID))
}

if sensorConfig.HwMon == nil && sensorConfig.File == nil {
return errors.New(fmt.Sprintf("Sensor %s: sub-configuration for sensor is missing, use one of: hwmon | file", sensorConfig.ID))
if sensorConfig.HwMon == nil && sensorConfig.File == nil && sensorConfig.Cmd == nil {
return errors.New(fmt.Sprintf("Sensor %s: sub-configuration for sensor is missing, use one of: hwmon | file | cmd", sensorConfig.ID))
}

if !isSensorConfigInUse(sensorConfig, config.Curves) {
Expand Down Expand Up @@ -158,7 +163,7 @@ func validateFans(config *Configuration) error {
}

if fanConfig.HwMon == nil && fanConfig.File == nil {
return errors.New(fmt.Sprintf("Fans %s: sub-configuration for fan is missing, use one of: hwmon | file", fanConfig.ID))
return errors.New(fmt.Sprintf("Fans %s: sub-configuration for fan is missing, use one of: hwmon | file | cmd", fanConfig.ID))
}

if len(fanConfig.Curve) <= 0 {
Expand Down
4 changes: 2 additions & 2 deletions internal/configuration/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func TestValidateFanSubConfigIsMissing(t *testing.T) {
err := ValidateConfig(&config)

// THEN
assert.EqualError(t, err, "Fans fan: sub-configuration for fan is missing, use one of: hwmon | file")
assert.EqualError(t, err, "Fans fan: sub-configuration for fan is missing, use one of: hwmon | file | cmd")
}

func TestValidateFanCurveWithIdIsNotDefined(t *testing.T) {
Expand Down Expand Up @@ -254,7 +254,7 @@ func TestValidateSensorSubConfigSensorIdIsMissing(t *testing.T) {
err := ValidateConfig(&config)

// THEN
assert.EqualError(t, err, "Sensor sensor: sub-configuration for sensor is missing, use one of: hwmon | file")
assert.EqualError(t, err, "Sensor sensor: sub-configuration for sensor is missing, use one of: hwmon | file | cmd")
}

func TestValidateSensor(t *testing.T) {
Expand Down
71 changes: 71 additions & 0 deletions internal/sensors/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package sensors

import (
"context"
"errors"
"fmt"
"github.com/markusressel/fan2go/internal/configuration"
"github.com/markusressel/fan2go/internal/ui"
"github.com/markusressel/fan2go/internal/util"
"os/exec"
"strconv"
"strings"
"time"
)

type CmdSensor struct {
Name string `json:"name"`
Exec string `json:"exec"`
Args []string `json:"args"`
Config configuration.SensorConfig `json:"configuration"`
MovingAvg float64 `json:"moving_avg"`
}

func (sensor CmdSensor) GetId() string {
return sensor.Config.ID
}

func (sensor CmdSensor) GetConfig() configuration.SensorConfig {
return sensor.Config
}

func (sensor CmdSensor) GetValue() (float64, error) {
if _, err := util.CheckFilePermissionsForExecution(sensor.Exec); err != nil {
return 0, errors.New(fmt.Sprintf("Sensor %s: Cannot execute %s: %s", sensor.Config.ID, sensor.Exec, err))
}

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, sensor.Exec, sensor.Args...)
out, err := cmd.Output()

if ctx.Err() == context.DeadlineExceeded {
ui.Warning("Sensor %s: Command timed out: %s", sensor.Config.ID, sensor.Exec)
return 0, err
}

if err != nil {
ui.Warning("Sensor %s: Command failed to execute: %s", sensor.Config.ID, sensor.Exec)
return 0, err
}

strout := string(out)
strout = strings.Trim(strout, "\n")

temp, err := strconv.ParseFloat(strout, 64)
if err != nil {
ui.Warning("Sensor %s: Unable to read int from command output: %s", sensor.Config.ID, sensor.Exec)
return 0, err
}

return temp, nil
}

func (sensor CmdSensor) GetMovingAvg() (avg float64) {
return sensor.MovingAvg
}

func (sensor *CmdSensor) SetMovingAvg(avg float64) {
sensor.MovingAvg = avg
}
9 changes: 9 additions & 0 deletions internal/sensors/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,14 @@ func NewSensor(config configuration.SensorConfig) (Sensor, error) {
}, nil
}

if config.Cmd != nil {
return &CmdSensor{
Name: config.ID,
Exec: config.Cmd.Exec,
Args: config.Cmd.Args,
Config: config,
}, nil
}

return nil, fmt.Errorf("no matching sensor type for sensor: %s", config.ID)
}
37 changes: 37 additions & 0 deletions internal/util/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,45 @@ import (
"regexp"
"strconv"
"strings"
"syscall"
)

// CheckFilePermissionsForExecution checks whether the given filePath owner, group and permissions
// are safe to use this file for execution by fan2go.
func CheckFilePermissionsForExecution(filePath string) (bool, error) {
var file = filePath

file, err := filepath.EvalSymlinks(file)
if err != nil {
panic(err)
}

info, err := os.Stat(file)
if os.IsNotExist(err) {
return false, errors.New("file not found")
}

stat := info.Sys().(*syscall.Stat_t)
if stat.Uid != 0 {
return false, errors.New("owner is not root")
}

if stat.Gid != 0 {
mode := info.Mode()
groupWrite := mode & (os.FileMode(0o020))
if groupWrite != 0 {
return false, errors.New("group is not root but has write permission")
}
}

otherWrite := info.Mode() & (os.FileMode(0o002))
if otherWrite != 0 {
return false, errors.New("others have write permission")
}

return true, nil
}

func ReadIntFromFile(path string) (value int, err error) {
data, err := ioutil.ReadFile(path)
if err != nil {
Expand Down
Loading

0 comments on commit 5ce65a1

Please sign in to comment.