Commit 39c797df authored by Amos Wenger's avatar Amos Wenger

Closes #1 🎉

parents
Pipeline #10369 failed with stage
in 1 minute and 28 seconds
stages:
- test
test:windows:
stage: test
tags:
- windows
script:
- scripts/ci.sh
test:darwin:
stage: test
tags:
- darwin
script:
- scripts/ci.sh
MIT LICENSE
Copyright (c) 2018 Leaf Corcoran and Amos Wenger, https://itch.io
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.
# smaug
[![build status](https://git.itch.ovh/itchio/smaug/badges/master/build.svg)](https://git.itch.ovh/itchio/smaug/commits/master)
[![codecov](https://codecov.io/gh/itchio/smaug/branch/master/graph/badge.svg)](https://codecov.io/gh/itchio/smaug)
[![Go Report Card](https://goreportcard.com/badge/github.com/itchio/smaug)](https://goreportcard.com/report/github.com/itchio/smaug)
[![GoDoc](https://godoc.org/github.com/itchio/smaug?status.svg)](https://godoc.org/github.com/itchio/smaug)
[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/itchio/smaug/blob/master/LICENSE)
smaug contains utilities for running processes:
* ...tied to a context (like `exec.CommandWithContext`)
* ...in a process group (so a whole process tree can be waited on or killed)
* ...optionaly in a sandbox, such as:
* firejail on Linux
* sandbox-exec on macOS
* a separate user on Windows (see `fuji`)
## License
Licensed under MIT License, see `LICENSE` for details.
//+build windows
package fuji
import (
"syscall"
"github.com/itchio/ox/syscallex"
"github.com/itchio/ox/winox"
"github.com/itchio/wharf/state"
"github.com/pkg/errors"
)
func (i *instance) Check(consumer *state.Consumer) error {
consumer.Opf("Retrieving player data from registry...")
creds, err := i.getCredentials()
if err != nil {
return errors.WithStack(err)
}
consumer.Statf("Sandbox user is (%s)", creds.Username)
consumer.Statf("Sandbox password is (%s)", creds.Password)
consumer.Opf("Trying to log in...")
token, err := winox.Logon(creds.Username, ".", creds.Password)
if err != nil {
rescued := false
if en, ok := winox.AsErrno(err); ok {
switch en {
case syscallex.ERROR_PASSWORD_EXPIRED:
case syscallex.ERROR_PASSWORD_MUST_CHANGE:
// Some Windows versions (10 for example) expire password automatically.
// Thankfully, we can renew it without administrator access, simply by using the old one.
consumer.Opf("Password has expired, setting new password...")
newPassword := generatePassword()
err := syscallex.NetUserChangePassword(
nil, // domainname
syscall.StringToUTF16Ptr(creds.Username),
syscall.StringToUTF16Ptr(creds.Password),
syscall.StringToUTF16Ptr(newPassword),
)
if err != nil {
return errors.WithStack(err)
}
creds.Password = newPassword
err = i.saveCredentials(creds)
if err != nil {
return errors.WithStack(err)
}
token, err = winox.Logon(creds.Username, ".", creds.Password)
if err != nil {
return errors.WithStack(err)
}
consumer.Statf("Set new password successfully!")
rescued = true
}
}
if !rescued {
return errors.WithStack(err)
}
}
defer winox.SafeRelease(uintptr(token))
consumer.Statf("Everything looks good!")
return nil
}
//+build windows
package fuji
import (
"github.com/pkg/errors"
"golang.org/x/sys/windows/registry"
)
type Credentials struct {
Username string
Password string
}
func (i *instance) getCredentials() (*Credentials, error) {
username, err := getRegistryString(i.settings, "username")
if err != nil {
return nil, errors.WithStack(err)
}
password, err := getRegistryString(i.settings, "password")
if err != nil {
return nil, errors.WithStack(err)
}
creds := &Credentials{
Username: username,
Password: password,
}
return creds, nil
}
func (i *instance) saveCredentials(creds *Credentials) error {
err := setRegistryString(i.settings, "username", creds.Username)
if err != nil {
return errors.WithStack(err)
}
err = setRegistryString(i.settings, "password", creds.Password)
if err != nil {
return errors.WithStack(err)
}
return nil
}
// registry utilities
func getRegistryString(s *Settings, name string) (string, error) {
key, _, err := registry.CreateKey(registry.CURRENT_USER, s.CredentialsRegistryKey, registry.READ)
if err != nil {
return "", errors.WithStack(err)
}
defer key.Close()
ret, _, err := key.GetStringValue(name)
if err != nil {
return "", errors.WithStack(err)
}
return ret, nil
}
func setRegistryString(s *Settings, name string, value string) error {
key, _, err := registry.CreateKey(registry.CURRENT_USER, s.CredentialsRegistryKey, registry.WRITE)
if err != nil {
return errors.WithStack(err)
}
defer key.Close()
err = key.SetStringValue(name, value)
if err != nil {
return errors.WithStack(err)
}
return nil
}
//+build windows
package fuji
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_Credentials(t *testing.T) {
iif, err := NewInstance(&Settings{
CredentialsRegistryKey: `SOFTWARE\smaug-test\Sandbox`,
})
assert.NoError(t, err)
i, ok := iif.(*instance)
assert.True(t, ok)
err = i.saveCredentials(&Credentials{
Username: "gecko",
Password: "jesus",
})
assert.NoError(t, err)
creds, err := i.getCredentials()
assert.NoError(t, err)
assert.EqualValues(t, "gecko", creds.Username)
assert.EqualValues(t, "jesus", creds.Password)
}
package fuji
type Settings struct {
// CredentialsRegistryKey is the path of a key under HKEY_CURRENT_USER
// itch uses `SOFTWARE\itch\Sandbox`.
CredentialsRegistryKey string
}
type Instance interface {
Settings() *Settings
}
//+build windows
package fuji
import "github.com/pkg/errors"
type instance struct {
settings *Settings
}
var _ Instance = (*instance)(nil)
func NewInstance(settings *Settings) (Instance, error) {
if settings.CredentialsRegistryKey == "" {
return nil, errors.Errorf("CredentialsRegistryKey cannot be empty")
}
i := &instance{
settings: settings,
}
return i, nil
}
func (i *instance) Settings() *Settings {
return i.settings
}
// Package fuji implements a sandbox for Windows. It works by
// creating a less-privileged user, `itch-player-XXXXX`, which
// we hide from login and share a game's folder before we launch
// it (then unshare it immediately after).
//
// If you want to see/manage the user the sandbox creates,
// you can use "lusrmgr.msc" on Windows (works in Win+R)
package fuji
//+build windows
package fuji
import (
"math/rand"
"strings"
"time"
)
/** Letters used when generating a random password */
const kLetters = "abcdefghijklmnopqrstuvwxyz"
/** Numbers used when generating a random password */
const kNumbers = "0123456789"
/** Special characters used when generating a random password */
const kSpecial = "!_?-.;+/()=&"
func randomCharFromSet(prng *rand.Rand, set string) string {
index := prng.Intn(len(set))
return set[index : index+1]
}
func generatePassword() string {
pwd := ""
prng := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < 16; i++ {
var token string
switch i % 4 {
case 0:
token = randomCharFromSet(prng, kLetters)
case 1:
token = randomCharFromSet(prng, kNumbers)
case 2:
token = randomCharFromSet(prng, kSpecial)
case 3:
token = strings.ToUpper(randomCharFromSet(prng, kLetters))
}
pwd += token
}
return pwd
}
//+build windows
package fuji
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_GeneratePassword(t *testing.T) {
previousPasswords := make(map[string]bool)
for i := 0; i < 100; i++ {
pass := generatePassword()
assert.True(t, strings.ContainsAny(pass, kLetters), "password has letters")
assert.True(t, strings.ContainsAny(pass, kNumbers), "password has numbers")
assert.True(t, strings.ContainsAny(pass, kSpecial), "password has special characters")
assert.False(t, previousPasswords[pass], "password is the not the same as the previous one")
previousPasswords[pass] = true
}
}
//+build windows
package fuji
import (
"fmt"
"github.com/itchio/ox/syscallex"
"github.com/itchio/ox/winox"
"github.com/itchio/wharf/state"
"github.com/pkg/errors"
)
type SetFilePermissionParams struct {
// The file or folder to apply permissions to
Path string
// Inherit determines whether permissions also apply to all folders and files
// in the container, recursively
Inherit bool
// Rights if one of "read", "write", "execute", "all", or "full"
Rights string
// Trustee is the name of the account permissions are granted to or revoked
Trustee string
// Change is one of "grant" or "revoke"
Change string
}
func SetFilePermissions(consumer *state.Consumer, params *SetFilePermissionParams) error {
entry := &winox.ShareEntry{
Path: params.Path,
}
if params.Inherit {
entry.Inheritance = winox.InheritanceModeFull
} else {
entry.Inheritance = winox.InheritanceModeNone
}
switch params.Rights {
case "read":
entry.Rights = winox.RightsRead
case "write":
entry.Rights = winox.RightsWrite
case "execute":
entry.Rights = winox.RightsExecute
case "all":
entry.Rights = winox.RightsAll
case "full":
entry.Rights = winox.RightsFull
default:
return fmt.Errorf("unknown rights: %s", params.Rights)
}
policy := &winox.SharingPolicy{
Trustee: params.Trustee,
Entries: []*winox.ShareEntry{entry},
}
switch params.Change {
case "grant":
consumer.Opf("Granting %s", policy)
err := policy.Grant(consumer)
if err != nil {
return errors.WithStack(err)
}
case "revoke":
consumer.Opf("Revoking %s", policy)
err := policy.Revoke(consumer)
if err != nil {
return errors.WithStack(err)
}
default:
return fmt.Errorf("unknown change: %s", params.Change)
}
consumer.Statf("Policy applied successfully")
return nil
}
type checkAccessSpec struct {
name string
flags uint32
}
var checkAccessSpecs = []checkAccessSpec{
{"read", syscallex.GENERIC_READ},
{"write", syscallex.GENERIC_WRITE},
{"execute", syscallex.GENERIC_EXECUTE},
{"all", syscallex.GENERIC_ALL},
}
type CheckAccessParams struct {
File string
}
func (i *instance) CheckAccess(consumer *state.Consumer, params *CheckAccessParams) error {
creds, err := i.getCredentials()
if err != nil {
return errors.WithStack(err)
}
impersonationToken, err := winox.GetImpersonationToken(creds.Username, ".", creds.Password)
if err != nil {
return errors.WithStack(err)
}
defer winox.SafeRelease(uintptr(impersonationToken))
for _, spec := range checkAccessSpecs {
hasAccess, err := winox.UserHasPermission(
impersonationToken,
spec.flags,
params.File,
)
if err != nil {
return errors.WithStack(err)
}
if hasAccess {
consumer.Opf("User has %s access", spec.name)
} else {
consumer.Opf("User does not have %s access", spec.name)
}
}
return nil
}
//+build windows
package fuji
import (
"fmt"
"time"
"github.com/itchio/butler/comm"
"github.com/itchio/ox/winox"
"github.com/itchio/wharf/state"
"github.com/pkg/errors"
)
// Setup ensures that fuji can run properly, ie.: that the
// registry contains credentials for the sandbox user, and
// that we can log in using those credentials.
//
// If any of those checks fail, it will need Administrator
// privileges to continue.
//
// It'll create a new user account, with a randomly generated
// username and pasword and store the credentials in the registry,
// in a location specified by settings.
func (i *instance) Setup(consumer *state.Consumer) error {
startTime := time.Now()
nullConsumer := &state.Consumer{}
err := i.Check(nullConsumer)
if err == nil {
consumer.Statf("Already set up properly!")
return nil
}
var username string
var password string
existingCreds, err := i.getCredentials()
if err != nil {
return errors.WithStack(err)
}
username = existingCreds.Username
if username != "" {
comm.Opf("Trying to salvage existing account (%s)....", username)
password = generatePassword()
err = winox.ForceSetPassword(username, password)
if err != nil {
consumer.Warnf("Could not force password: %+v", err)
username = ""
} else {
comm.Statf("Forced password successfully")
}
}
if username == "" {
username = fmt.Sprintf("itch-player-%x", time.Now().Unix())
comm.Opf("Generated username (%s)", username)
password = generatePassword()
comm.Opf("Generated password (%s)", password)
comment := "itch.io sandbox user"
comm.Opf("Adding user...")
err = winox.AddUser(username, password, comment)
if err != nil {
return errors.WithStack(err)
}
}
comm.Opf("Removing from Users group (so it doesn't show up as a login option)...")
err = winox.RemoveUserFromUsersGroup(username)
if err != nil {
return errors.WithStack(err)
}
comm.Opf("Loading profile for the first time (to create some directories)...")
err = winox.LoadProfileOnce(username, ".", password)
if err != nil {
return errors.WithStack(err)
}
comm.Opf("Saving to credentials registry...")
creds := &Credentials{
Username: username,
Password: password,
}
err = i.saveCredentials(creds)
if err != nil {
return errors.WithStack(err)
}
comm.Statf("All done! (in %s)", time.Since(startTime))
return nil
}
//+build darwin
package runner
import (
"os"
"os/exec"
"os/signal"
"github.com/itchio/ox/macox"
"github.com/pkg/errors"
)
type appRunner struct {
params *RunnerParams
}
var _ Runner = (*appRunner)(nil)
func newAppRunner(params *RunnerParams) (Runner, error) {
ar := &appRunner{
params: params,
}
return ar, nil
}
func (ar *appRunner) Prepare() error {
// nothing to prepare
return nil
}
func (ar *appRunner) Run() error {
params := ar.params
return RunAppBundle(
params,
params.FullTargetPath,
)
}
func RunAppBundle(params *RunnerParams, bundlePath string) error {
consumer := params.RequestContext.Consumer
var args = []string{
"-W",
bundlePath,
"--args",
}
args = append(args, params.Args...)
consumer.Infof("App bundle is (%s)", bundlePath)
binaryPath, err := macox.GetExecutablePath(bundlePath)
if err != nil {
return errors.WithStack(err)
}
consumer.Infof("Actual binary is (%s)", binaryPath)
cmd := exec.Command("open", args...)
// I doubt this matters
cmd.Dir = params.Dir
cmd.Env = params.Env
// 'open' does not relay stdout or stderr, so we don't
// even bother setting them
processDone := make(chan struct{})
go func() {
// catch SIGINT and kill the child if needed
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
consumer.Infof("Signal handler installed...")
// Block until a signal is received.
select {
case <-params.Ctx.Done():
consumer.Warnf("Context done!")
case s := <-c:
consumer.Warnf("Got signal: %v", s)
case <-processDone:
return
}
consumer.Warnf("Killing app...")
// TODO: kill the actual binary, not the app
cmd := exec.Command("pkill", "-f", binaryPath)
err := cmd.Run()
if err != nil {
consumer.Errorf("While killing: %s", err.Error())
}
os.Exit(0)
}()