Compare commits

...

12 Commits

Author SHA1 Message Date
IamTheFij afffddc07b Comply with updated linting
continuous-integration/drone/push Build is passing Details
2023-09-19 13:55:08 -07:00
IamTheFij b74edaff2f Update go version and slack dependency
continuous-integration/drone/push Build is failing Details
2023-09-19 13:18:44 -07:00
IamTheFij fb0fe18eb7 Don't delete certs when cleaning 2023-09-19 13:18:27 -07:00
IamTheFij 83279f4fbf Fix git links in pre-commit-config 2023-09-19 13:18:14 -07:00
IamTheFij a388a3f78b Add linux-arm64 builds
continuous-integration/drone/push Build is passing Details
2022-01-27 21:17:32 -08:00
IamTheFij 5d2f4b1bba Update readme to describe removal of openssl dependency
continuous-integration/drone/push Build is passing Details
2021-02-17 10:31:06 -08:00
IamTheFij 776a1129f9 Fix typo
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2021-02-17 10:24:34 -08:00
IamTheFij a47d73b669 Ensure certs exist before running linting
continuous-integration/drone/push Build is failing Details
2021-02-17 10:22:22 -08:00
IamTheFij 0b0b7fe395 Add embedded certificates
continuous-integration/drone/push Build is failing Details
Removes runtime requirement on openssl

Fixes #1
2021-02-17 09:43:53 -08:00
IamTheFij c303bc055d Add darwin-arm64 builds
Fixes #2
2021-02-16 13:08:32 -08:00
IamTheFij 461fa0f4e7 Update linting 2021-02-09 10:20:38 -08:00
IamTheFij 03399ac35d Move config to directory returned by os.UserConfigDir()
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
This version will migrate configuration from the old path, but migration
will be removed at some point in the future.
2021-02-05 11:43:23 -08:00
11 changed files with 191 additions and 113 deletions

View File

@ -5,6 +5,9 @@ name: test
steps:
- name: check
image: iamthefij/drone-pre-commit:personal
commands:
- make certs
- pre-commit run --all-files
---
kind: pipeline
@ -23,7 +26,7 @@ trigger:
steps:
- name: build all binaries
image: golang:1.15
image: golang:1.16
environment:
VERSION: ${DRONE_TAG:-${DRONE_COMMIT}}
CLIENT_ID:

1
.gitignore vendored
View File

@ -19,3 +19,4 @@ slack-status
slack-status-cli
dist/
.env
certs/

29
.golangci.yml Normal file
View File

@ -0,0 +1,29 @@
---
linters:
enable:
- asciicheck
- bodyclose
- dogsled
- dupl
- exhaustive
- gochecknoinits
- gocognit
- gocritic
- gocyclo
- goerr113
- gofumpt
- goimports
- gomnd
- goprintffuncname
- gosec
- interfacer
- maligned
- misspell
- nakedret
- nestif
- nlreturn
- noctx
- unparam
- wsl
disable:
- gochecknoglobals

View File

@ -7,7 +7,7 @@ repos:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- repo: git://github.com/dnephin/pre-commit-golang
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.3.5
hooks:
- id: go-fmt

View File

@ -1,6 +1,6 @@
OUTPUT = slack-status
GOFILES = *.go go.mod go.sum
DIST_ARCH = darwin-amd64 linux-amd64
DIST_ARCH = darwin-amd64 darwin-arm64 linux-amd64 linux-arm64
DIST_TARGETS = $(addprefix dist/$(OUTPUT)-,$(DIST_ARCH))
VERSION ?= $(shell git describe --tags --dirty)
@ -19,24 +19,38 @@ test:
pre-commit run --all-files
go test
slack-status: $(GOFILES)
slack-status: $(GOFILES) certs
go build -o $(OUTPUT)
.PHONY: dist
dist: $(DIST_TARGETS)
$(DIST_TARGETS): $(GOFILES)
$(DIST_TARGETS): $(GOFILES) certs
@mkdir -p ./dist
GOOS=$(word 3, $(subst -, ,$(@))) GOARCH=$(word 4, $(subst -, ,$(@))) \
go build \
-ldflags '-X "main.version=${VERSION}" -X "main.defaultClientID=$(CLIENT_ID)" -X "main.defaultClientSecret=$(CLIENT_SECRET)"' \
-o $@
.PHONY: certs
certs: certs/key.pem
certs/cert.pem: certs/key.pem
certs/key.pem:
mkdir -p certs/
openssl req -x509 -subj "/C=US/O=Slack Status CLI/CN=localhost:8888" \
-nodes -days 365 -newkey "rsa:2048" \
-addext "subjectAltName=DNS:localhost:8888" \
-keyout certs/key.pem -out certs/cert.pem
.PHONY: clean
clean:
rm -f ./slack-status
rm -fr ./dist
.PHONY: clean-certs
clean-certs:
rm -fr ./certs
.PHONY: install-hooks
install-hooks:
pre-commit install --overwrite --install-hooks

View File

@ -4,9 +4,9 @@ Set your Slack status via the command line
## Requirements
Rather than host a web server that you would need to trust, this command runs one on your local machine to retrieve the OAuth code. This page is hosted with an auto generated, self-signed certificate.
Rather than host a web server that you would need to trust, this command runs one on your local machine to retrieve the OAuth code. This page is hosted with an embedded, self-signed certificate.
This requires you to have `openssl` installed on your machine and, when the page loads for the first time, it will require you to trust the certificate or ignore the warning.
When the page loads for the first time, it will require you to trust the certificate or ignore the warning.
Here's how to do that on [Firefox](https://support.mozilla.org/en-US/kb/error-codes-secure-websites?as=u&utm_source=inproduct#w_self-signed-certificate). On Chrome, you may have to enable `chrome://flags/#allow-insecure-localhost`. If you can't get the link to open in Chrome, you can copy the URL into a different browser and try it there. It should work in Firefox or Safari.
@ -69,3 +69,7 @@ Clear existing status and snooze durations
Snooze notifications without updating your status
slack-status -duration 15m -snooze
## Development
Building requires you to have `openssl` installed on your machine and to export `CLIENT_ID` and `CLIENT_SECRET` environment variables. The certificate and client info can be overridden at runtime for development purposes by running with exported variables post compilation as well as placing the your openssl pem files in the application's config directory. See `auth.go` for more information.

91
auth.go
View File

@ -2,11 +2,12 @@ package main
import (
"context"
"crypto/tls"
_ "embed"
"errors"
"fmt"
"log"
"net/http"
"os/exec"
"strings"
"time"
@ -14,9 +15,20 @@ import (
)
var (
// These are set via build flags but can be overriden via environment variables.
// These are set via build flags but can be overridden via environment variables.
defaultClientID = ""
defaultClientSecret = ""
//go:embed "certs/cert.pem"
certPem []byte
//go:embed "certs/key.pem"
keyPem []byte
)
const (
httpReadTimeout = 5 * time.Second
httpWriteTimeout = 10 * time.Second
httpIdleTimeout = 120 * time.Second
)
type slackApp struct {
@ -42,12 +54,42 @@ func (app slackApp) listenForCode() (string, error) {
// start an http listener and listen for the redirect and return the code from params
var code string
certPath, err := getConfigFilePath("cert.pem")
if err != nil {
return "", fmt.Errorf("failed checking config path for cert: %w", err)
}
keyPath, err := getConfigFilePath("key.pem")
if err != nil {
return "", fmt.Errorf("failed checking config path for key: %w", err)
}
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
// If config files don't exist, use embedded
if !fileExists(certPath) && !fileExists(keyPath) {
cert, err := tls.X509KeyPair(certPem, keyPem)
if err != nil {
return "", fmt.Errorf("failed loading embedded key pair: %w", err)
}
tlsCfg.Certificates = make([]tls.Certificate, 1)
tlsCfg.Certificates[0] = cert
// Empty out paths since they don't exist so embedded certs will be used
certPath = ""
keyPath = ""
}
// Also, should generate TLS certificate to use since https is a required scheme
server := http.Server{
Addr: app.listenHost,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
IdleTimeout: httpIdleTimeout,
TLSConfig: tlsCfg,
}
http.HandleFunc(app.listenPath, func(w http.ResponseWriter, r *http.Request) {
@ -68,22 +110,6 @@ func (app slackApp) listenForCode() (string, error) {
}()
})
certPath, err := getConfigFilePath("cert.pem")
if err != nil {
return "", err
}
keyPath, err := getConfigFilePath("key.pem")
if err != nil {
return "", err
}
if !fileExists(certPath) || !fileExists(keyPath) {
if err := generateSelfSignedCertificates(certPath, keyPath); err != nil {
return "", err
}
}
if err := server.ListenAndServeTLS(certPath, keyPath); err != nil && !errors.Is(err, http.ErrServerClosed) {
return "", err
}
@ -91,29 +117,6 @@ func (app slackApp) listenForCode() (string, error) {
return code, nil
}
func generateSelfSignedCertificates(certPath, keyPath string) error {
command := exec.Command(
"openssl",
"req",
"-x509",
"-subj",
"/C=US/O=Slack Status CLI/CN=localhost:8888",
"-nodes",
"-days",
"365",
"-newkey",
"rsa:2048",
"-addext",
"subjectAltName=DNS:localhost:8888",
"-keyout",
keyPath,
"-out",
certPath,
)
return command.Run()
}
func authenticate() (string, error) {
app := slackApp{
userScopes: []string{"dnd:write", "users.profile:write", "team:read"},

View File

@ -4,37 +4,63 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"os"
"os/user"
"path/filepath"
)
var (
errUnknownDomain = errors.New("unknown domain")
const (
PERM_OWNER_RW = 0o600
PERM_RW_ALL = 0o755
)
var errUnknownDomain = errors.New("unknown domain")
type configData struct {
DefaultDomain string
DomainTokens map[string]string
}
// getConfigFilePath returns the path of a given file within the config folder.
// The config folder will be created in ~/.config/slack-status-cli if it does not exist.
// getConfigFilePath returns the path of a given file within the UserConfigDir.
func getConfigFilePath(filename string) (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
usr, err := user.Current()
if err != nil {
return "", fmt.Errorf("error getting current user information: %w", err)
}
configHome = filepath.Join(usr.HomeDir, ".config")
configApplicationName := "slack-status-cli"
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("error getting current config: %w", err)
}
configDir := filepath.Join(configHome, "slack-status-cli")
_ = os.MkdirAll(configDir, 0755)
configDir = filepath.Join(configDir, configApplicationName)
_ = os.MkdirAll(configDir, PERM_RW_ALL)
configFile := filepath.Join(configDir, filename)
return filepath.Join(configDir, filename), nil
// Handle migration of old config file path
// NOTE: Will be removed in future versions
if !fileExists(configFile) {
// Get old config path to see if we should migrate
userHomeDir, _ := os.UserHomeDir()
legacyConfigFile := filepath.Join(
userHomeDir,
".config",
configApplicationName,
filename,
)
if fileExists(legacyConfigFile) {
log.Printf("Migrating config from %s to %s\n", legacyConfigFile, configFile)
err = os.Rename(legacyConfigFile, configFile)
if err != nil {
err = fmt.Errorf(
"error migrating old config from %s: %w",
legacyConfigFile,
err,
)
}
}
}
return configFile, err
}
// readConfig returns the current configuration
@ -48,7 +74,7 @@ func readConfig() (*configData, error) {
return &configData{DomainTokens: map[string]string{}}, nil
}
content, err := ioutil.ReadFile(configPath)
content, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("error reading config from file: %w", err)
}
@ -75,7 +101,7 @@ func writeConfig(config configData) error {
return fmt.Errorf("failed converting config to json: %w", err)
}
if err = ioutil.WriteFile(configPath, contents, 0600); err != nil {
if err = os.WriteFile(configPath, contents, PERM_OWNER_RW); err != nil {
return fmt.Errorf("error writing config to file: %w", err)
}

6
go.mod
View File

@ -1,5 +1,7 @@
module github.com/iamthefij/slack-status-cli
go 1.15
go 1.21
require github.com/slack-go/slack v0.7.4
require github.com/slack-go/slack v0.12.3
require github.com/gorilla/websocket v1.5.0 // indirect

15
go.sum
View File

@ -1,14 +1,11 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho=
github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/slack-go/slack v0.7.4 h1:Z+7CmUDV+ym4lYLA4NNLFIpr3+nDgViHrx8xsuXgrYs=
github.com/slack-go/slack v0.7.4/go.mod h1:FGqNzJBmxIsZURAxh2a8D21AnOVvvXZvGligs4npPUM=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/slack-go/slack v0.12.3 h1:92/dfFU8Q5XP6Wp5rr5/T5JHLM5c5Smtn53fhToAP88=
github.com/slack-go/slack v0.12.3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

65
main.go
View File

@ -10,23 +10,19 @@ import (
"github.com/slack-go/slack"
)
var (
version = "dev"
)
var version = "dev"
// statusInfo contains all args passed from the command line
type statusInfo struct {
// status contents
emoji, statusText string
duration time.Duration
snooze bool
}
// domain and login management
login, makeDefault bool
domain string
// other
showVersion bool
// commandOptions contains non-status options passed to the command
type commandOptions struct {
login, makeDefault, showVersion bool
domain string
}
// getExipirationTime returns epoch time that status should expire from the duration.
@ -74,7 +70,7 @@ func readDurationArgs(args []string) ([]string, *time.Duration) {
}
// readFlags will read all flags off the command line.
func readFlags() statusInfo {
func readFlags() (statusInfo, commandOptions) {
// Non-status flags
login := flag.Bool("login", false, "login to a Slack workspace")
domain := flag.String("domain", "", "domain to set status on")
@ -111,16 +107,16 @@ func readFlags() statusInfo {
statusText := strings.Join(args, " ")
return statusInfo{
duration: *duration,
snooze: *snooze,
emoji: *emoji,
statusText: statusText,
login: *login,
domain: *domain,
makeDefault: *makeDefault,
showVersion: *showVersion,
}
duration: *duration,
snooze: *snooze,
emoji: *emoji,
statusText: statusText,
}, commandOptions{
login: *login,
domain: *domain,
makeDefault: *makeDefault,
showVersion: *showVersion,
}
}
// loginAndSave will return a client after a new login flow and save the results
@ -152,6 +148,7 @@ func loginAndSave(domain string) (*slack.Client, error) {
// getClient returns a client either via the provided login or default login
func getClient(domain string) (*slack.Client, error) {
var accessToken string
var err error
if domain == "" {
@ -170,21 +167,23 @@ func getClient(domain string) (*slack.Client, error) {
}
func main() {
args := readFlags()
status, options := readFlags()
if args.showVersion {
if options.showVersion {
fmt.Println("version:", version)
return
}
var client *slack.Client
var err error
// If the new-auth flag is present, force an auth flow
if args.login {
client, err = loginAndSave(args.domain)
if options.login {
client, err = loginAndSave(options.domain)
} else {
client, err = getClient(args.domain)
client, err = getClient(options.domain)
}
// We encountered some error in logging in
@ -193,21 +192,21 @@ func main() {
log.Fatal(fmt.Errorf("failed to get or save client: %w", err))
}
// If a domain is provided and asked to make deafult, save it to config
if args.makeDefault && args.domain != "" {
if err = saveDefaultLogin(args.domain); err != nil {
log.Fatal(fmt.Errorf("failed saving default domain %s: %w", args.domain, err))
// If a domain is provided and asked to make default, save it to config
if options.makeDefault && options.domain != "" {
if err = saveDefaultLogin(options.domain); err != nil {
log.Fatal(fmt.Errorf("failed saving default domain %s: %w", options.domain, err))
}
}
err = client.SetUserCustomStatus(args.statusText, args.emoji, args.getExpirationTime())
err = client.SetUserCustomStatus(status.statusText, status.emoji, status.getExpirationTime())
if err != nil {
fmt.Println("error setting status")
panic(err)
}
if args.snooze {
_, err = client.SetSnooze(int(args.duration.Minutes()))
if status.snooze {
_, err = client.SetSnooze(int(status.duration.Minutes()))
if err != nil {
fmt.Println("error setting snooze")
panic(err)