diff --git a/Gopkg.lock b/Gopkg.lock index 115549b..9bfca83 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1,12 +1,6 @@ # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. -[[projects]] - name = "bitbucket.org/tebeka/strftime" - packages = ["."] - revision = "af5e0ef38369dfb5819b56d27d593142841e4600" - version = "0.1.2" - [[projects]] name = "github.com/Microsoft/go-winio" packages = ["."] @@ -14,10 +8,10 @@ version = "v0.4.7" [[projects]] - branch = "master" name = "github.com/StackExchange/wmi" packages = ["."] revision = "5d049714c4a64225c3c79a7cf7d02f7fb5b96338" + version = "1.0.0" [[projects]] name = "github.com/davecgh/go-spew" @@ -32,7 +26,7 @@ "digestset", "reference" ] - revision = "6664ec703991875e14419ff4960921cce7678bab" + revision = "83389a148052d74ac602f5f1d62f86ff2f3c4aa5" [[projects]] name = "github.com/docker/docker" @@ -79,6 +73,12 @@ revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" version = "v1.4.7" +[[projects]] + branch = "master" + name = "github.com/gin-contrib/sse" + packages = ["."] + revision = "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae" + [[projects]] name = "github.com/gin-gonic/gin" packages = [ @@ -86,8 +86,8 @@ "binding", "render" ] - revision = "e2212d40c62a98b388a5eb48ecbdcf88534688ba" - version = "v1.1.4" + revision = "d459835d2b077e44f7c9b453505ee29881d5d12d" + version = "v1.2" [[projects]] name = "github.com/go-ole/go-ole" @@ -129,6 +129,7 @@ ".", "hcl/ast", "hcl/parser", + "hcl/printer", "hcl/scanner", "hcl/strconv", "hcl/token", @@ -136,7 +137,7 @@ "json/scanner", "json/token" ] - revision = "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8" + revision = "f40e974e75af4e271d97ce0fc917af5898ae7bda" [[projects]] name = "github.com/inconshreveable/mousetrap" @@ -145,10 +146,16 @@ version = "v1.0" [[projects]] - name = "github.com/lestrrat/go-file-rotatelogs" + name = "github.com/lestrrat-go/file-rotatelogs" packages = ["."] - revision = "ab335c655133cea61d8164853c1ed0e97d6c77cb" - version = "v2.0.0" + revision = "9df8b44f21785240553882138c5df2e9cc1db910" + version = "v2.1.0" + +[[projects]] + branch = "master" + name = "github.com/lestrrat/go-strftime" + packages = ["."] + revision = "ba3bf9c1d0421aa146564a632931730344f1f9f1" [[projects]] name = "github.com/magiconair/properties" @@ -156,12 +163,6 @@ revision = "c3beff4c2358b44d0493c7dda585e7db7ff28ae6" version = "v1.7.6" -[[projects]] - branch = "master" - name = "github.com/manucorporat/sse" - packages = ["."] - revision = "ee05b128a739a0fb76c7ebd3ae4810c1de808d6d" - [[projects]] name = "github.com/mattn/go-isatty" packages = ["."] @@ -172,7 +173,7 @@ branch = "master" name = "github.com/mitchellh/mapstructure" packages = ["."] - revision = "a4e142e9c047c904fa2f1e144d9a84e6133024bc" + revision = "00c29f56e2386353d58c599509e8dc3801b0d716" [[projects]] name = "github.com/opencontainers/go-digest" @@ -210,33 +211,23 @@ [[projects]] name = "github.com/rifflock/lfshook" packages = ["."] - revision = "6844c808343cb8fa357d7f141b1b990e05d24e41" - version = "v1.7" + revision = "bf539943797a1f34c1f502d07de419b5238ae6c6" + version = "v2.3" [[projects]] name = "github.com/shirou/gopsutil" packages = [ "cpu", - "host", - "internal/common", - "mem", - "net", - "process" + "internal/common" ] - revision = "c432be29ccce470088d07eea25b3ea7e68a8afbb" - version = "v2.18.01" - -[[projects]] - branch = "master" - name = "github.com/shirou/w32" - packages = ["."] - revision = "bb4de0191aa41b5507caa14b0650cdbddcd9280b" + revision = "5776ff9c7c5d063d574ef53d740f75c68b448e53" + version = "v2.18.02" [[projects]] name = "github.com/sirupsen/logrus" packages = ["."] - revision = "d682213848ed68c0a260ca37d6dd5ace8423f5ba" - version = "v1.0.4" + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" [[projects]] name = "github.com/spf13/afero" @@ -256,8 +247,8 @@ [[projects]] name = "github.com/spf13/cobra" packages = ["."] - revision = "7b2c5ac9fc04fc5efafb60700713d4fa609b777b" - version = "v0.0.1" + revision = "a1f051bc3eba734da4772d60e2d677f47cf93ef4" + version = "v0.0.2" [[projects]] branch = "master" @@ -274,20 +265,26 @@ [[projects]] name = "github.com/spf13/viper" packages = ["."] - revision = "25b30aa063fc18e48662b86996252eabdcf2f0c7" - version = "v1.0.0" + revision = "b5e8006cbee93ec955a89ab31e0e3ce3204f3736" + version = "v1.0.2" [[projects]] name = "github.com/stretchr/testify" packages = ["assert"] - revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0" - version = "v1.1.4" + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" + +[[projects]] + name = "github.com/ugorji/go" + packages = ["codec"] + revision = "9831f2c3ac1068a78f50999a30db84270f647af6" + version = "v1.1" [[projects]] branch = "master" name = "golang.org/x/crypto" packages = ["ssh/terminal"] - revision = "432090b8f568c018896cd8a0fb0345872bbac6ce" + revision = "12892e8c234f4fe6f6803f052061de9057903bb2" [[projects]] branch = "master" @@ -297,7 +294,7 @@ "context/ctxhttp", "proxy" ] - revision = "cbe0f9307d0156177f9dd5dc85da1a31abc5f2fb" + revision = "b68f30494add4df6bd8ef5e82803f308e7f7c59c" [[projects]] branch = "master" @@ -306,10 +303,9 @@ "unix", "windows" ] - revision = "37707fdb30a5b38865cfb95e5aab41707daec7fd" + revision = "378d26f46672a356c46195c28f61bdb4c0a781dd" [[projects]] - branch = "master" name = "golang.org/x/text" packages = [ "internal/gen", @@ -319,7 +315,8 @@ "unicode/cldr", "unicode/norm" ] - revision = "4e4a3210bb54bb31f6ab2cdca2edcc0b50c420c1" + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" [[projects]] name = "gopkg.in/go-playground/validator.v8" @@ -328,14 +325,14 @@ version = "v8.18.2" [[projects]] - branch = "v2" name = "gopkg.in/yaml.v2" packages = ["."] - revision = "d670f9405373e636a5a2765eea47fac0c9bc91a4" + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "c81145698e213e2c8f26c3d5b7e52033c4a2438cfb8eda51b6864770fac71fe4" + inputs-digest = "8e17495db05ff2c85d228a20157c41c223e428d28217e14f89ab7b764a8706dd" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 99b9f5a..c4a2c68 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -25,9 +25,6 @@ # unused-packages = true -[[constraint]] - name = "github.com/gin-gonic/gin" - version = "~1.1.4" [[constraint]] name = "github.com/google/jsonapi" @@ -37,14 +34,6 @@ name = "github.com/gorilla/websocket" version = "~1.2.0" -[[constraint]] - name = "github.com/lestrrat/go-file-rotatelogs" - version = "~2.0.0" - -[[constraint]] - name = "github.com/rifflock/lfshook" - version = "~1.7.0" - [[constraint]] name = "github.com/shirou/gopsutil" version = "2.17.6" @@ -53,9 +42,29 @@ name = "github.com/sirupsen/logrus" version = "~1.0.0" +[[constraint]] + name = "github.com/gin-gonic/gin" + version = "1.2.0" + +[[constraint]] + name = "github.com/lestrrat-go/file-rotatelogs" + version = "2.1.0" + +[[constraint]] + name = "github.com/rifflock/lfshook" + version = "2.2.0" + +[[constraint]] + name = "github.com/spf13/cobra" + version = "0.0.2" + +[[constraint]] + name = "github.com/spf13/viper" + version = "1.0.0" + [[constraint]] name = "github.com/stretchr/testify" - version = "~1.1.4" + version = "1.2.1" [prune] go-tests = true diff --git a/README.md b/README.md index e4685f8..431db1c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Pterodactyl wings [![travis](https://img.shields.io/travis/Pterodactyl/wings.svg?style=flat-square)](https://travis-ci.org/Pterodactyl/wings) [![codacy quality](https://img.shields.io/codacy/grade/27a1576bda86450f853b1052b12fa570.svg?style=flat-square)](https://www.codacy.com/app/schrej/wings/dashboard) [![codacy coverage](https://img.shields.io/codacy/coverage/27a1576bda86450f853b1052b12fa570.svg?style=flat-square)](https://www.codacy.com/app/schrej/wings/files) +# Pterodactyl wings [![travis](https://img.shields.io/travis/pterodactyl/wings.svg?style=flat-square)](https://travis-ci.org/pterodactyl/wings) [![codacy quality](https://img.shields.io/codacy/grade/27a1576bda86450f853b1052b12fa570.svg?style=flat-square)](https://www.codacy.com/app/schrej/wings/dashboard) [![codacy coverage](https://img.shields.io/codacy/coverage/27a1576bda86450f853b1052b12fa570.svg?style=flat-square)](https://www.codacy.com/app/schrej/wings/files) ``` ____ diff --git a/api/api.go b/api/api.go index ee0751c..42f62c5 100644 --- a/api/api.go +++ b/api/api.go @@ -2,23 +2,17 @@ package api import ( "fmt" - "net/http" "github.com/gin-gonic/gin" - log "github.com/sirupsen/logrus" - "github.com/spf13/viper" - "github.com/pterodactyl/wings/config" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" ) type InternalAPI struct { router *gin.Engine } -func NewAPI() InternalAPI { - return InternalAPI{} -} - // Configure the API and begin listening on the configured IP and Port. func (api *InternalAPI) Listen() { if !viper.GetBool(config.Debug) { @@ -28,21 +22,74 @@ func (api *InternalAPI) Listen() { api.router = gin.Default() api.router.RedirectTrailingSlash = false + // Setup Access-Control origin headers. Down the road once this is closer to + // release we should setup this header properly and lock it down to the domain + // used to run the Panel. api.router.Use(func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") }) api.router.OPTIONS("/", func(c *gin.Context) { c.Header("Access-Control-Allow-Methods", "POST, GET, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "X-Access-Token") + c.Header("Access-Control-Allow-Headers", "Authorization") }) - api.RegisterRoutes() + // Register all of the API route bindings. + api.register() - listenString := fmt.Sprintf("%s:%d", viper.GetString(config.APIHost), viper.GetInt(config.APIPort)) + l := fmt.Sprintf("%s:%d", viper.GetString(config.APIHost), viper.GetInt(config.APIPort)) + api.router.Run(l) - api.router.Run(listenString) - - log.Info("Now listening on %s", listenString) - log.Fatal(http.ListenAndServe(listenString, nil)) + logrus.Info("API Server is now listening on %s", l) +} + +// Register routes for v1 of the API. This API should be fully backwards compatable with +// the existing Nodejs Daemon API. +// +// Routes that are not yet completed are commented out. Routes are grouped where possible +// to keep this function organized. +func (api *InternalAPI) register() { + v1 := api.router.Group("/api/v1") + { + v1.GET("/", AuthHandler(""), GetIndex) + //v1.PATCH("/config", AuthHandler("c:config"), PatchConfiguration) + + v1.GET("/servers", AuthHandler("c:list"), handleGetServers) + v1.POST("/servers", AuthHandler("c:create"), handlePostServers) + + v1ServerRoutes := v1.Group("/servers/:server") + { + v1ServerRoutes.GET("/", AuthHandler("s:get"), handleGetServer) + v1ServerRoutes.PATCH("/", AuthHandler("s:config"), handlePatchServer) + v1ServerRoutes.DELETE("/", AuthHandler("g:server:delete"), handleDeleteServer) + v1ServerRoutes.POST("/reinstall", AuthHandler("s:install-server"), handlePostServerReinstall) + v1ServerRoutes.POST("/rebuild", AuthHandler("g:server:rebuild"), handlePostServerRebuild) + v1ServerRoutes.POST("/password", AuthHandler(""), handlePostServerPassword) + v1ServerRoutes.POST("/power", AuthHandler("s:power"), handlePostServerPower) + v1ServerRoutes.POST("/command", AuthHandler("s:command"), handlePostServerCommand) + v1ServerRoutes.GET("/log", AuthHandler("s:console"), handleGetServerLog) + v1ServerRoutes.POST("/suspend", AuthHandler(""), handlePostServerSuspend) + v1ServerRoutes.POST("/unsuspend", AuthHandler(""), handlePostServerUnsuspend) + } + + //v1ServerFileRoutes := v1.Group("/servers/:server/files") + //{ + // v1ServerFileRoutes.GET("/file/:file", AuthHandler("s:files:read"), handleGetFile) + // v1ServerFileRoutes.GET("/stat/:file", AuthHandler("s:files:"), handleGetFileStat) + // v1ServerFileRoutes.GET("/dir/:directory", AuthHandler("s:files:get"), handleGetDirectory) + // + // v1ServerFileRoutes.POST("/dir/:directory", AuthHandler("s:files:create"), handlePostFilesFolder) + // v1ServerFileRoutes.POST("/file/:file", AuthHandler("s:files:post"), handlePostFile) + // + // v1ServerFileRoutes.POST("/copy/:file", AuthHandler("s:files:copy"), handlePostFileCopy) + // v1ServerFileRoutes.POST("/move/:file", AuthHandler("s:files:move"), handlePostFileMove) + // v1ServerFileRoutes.POST("/rename/:file", AuthHandler("s:files:move"), handlePostFileMove) + // v1ServerFileRoutes.POST("/compress/:file", AuthHandler("s:files:compress"), handlePostFileCompress) + // v1ServerFileRoutes.POST("/decompress/:file", AuthHandler("s:files:decompress"), handlePostFileDecompress) + // + // v1ServerFileRoutes.DELETE("/file/:file", AuthHandler("s:files:delete"), handleDeleteFile) + // + // v1ServerFileRoutes.GET("/download/:token", handleGetDownloadFile) + //} + } } diff --git a/api/auth.go b/api/auth.go index 4f35b55..b9bb645 100644 --- a/api/auth.go +++ b/api/auth.go @@ -13,7 +13,7 @@ import ( ) const ( - accessTokenHeader = "X-Access-Token" + accessTokenHeader = "Authorization" contextVarServer = "server" contextVarAuth = "auth" diff --git a/api/handlers.go b/api/handlers.go index 83cff30..61bacd4 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -1,62 +1,83 @@ package api import ( + "fmt" "net/http" - "runtime" + //"runtime" "github.com/gin-gonic/gin" - "github.com/pterodactyl/wings/constants" "github.com/shirou/gopsutil/cpu" - "github.com/shirou/gopsutil/host" - "github.com/shirou/gopsutil/mem" + //"github.com/shirou/gopsutil/host" + //"github.com/shirou/gopsutil/mem" log "github.com/sirupsen/logrus" ) func GetIndex(c *gin.Context) { - auth := GetContextAuthManager(c) + //auth := GetContextAuthManager(c) + //if auth == nil { + // c.Header("Content-Type", "text/html") + // c.String(http.StatusOK, constants.IndexPage) + //} - if auth != nil && auth.HasPermission("c:info") { - hostInfo, err := host.Info() - if err != nil { - log.WithError(err).Error("Failed to retrieve host information.") - } - cpuInfo, err := cpu.Info() - if err != nil { - log.WithError(err).Error("Failed to retrieve CPU information.") - } - memInfo, err := mem.VirtualMemory() - if err != nil { - log.WithError(err).Error("Failed to retrieve memory information.") - } - - info := struct { - Name string `json:"name"` - Version string `json:"version"` - System struct { - SystemType string `json:"type"` - Platform string `json:"platform"` - Arch string `json:"arch"` - Release string `json:"release"` - Cpus int32 `json:"cpus"` - Freemem uint64 `json:"freemem"` - } `json:"system"` - }{ - Name: "Pterodactyl wings", - Version: constants.Version, - } - info.System.SystemType = hostInfo.OS - info.System.Platform = hostInfo.Platform - info.System.Arch = runtime.GOARCH - info.System.Release = hostInfo.KernelVersion - info.System.Cpus = cpuInfo[0].Cores - info.System.Freemem = memInfo.Free - - c.JSON(http.StatusOK, info) - return + s, err := cpu.Counts(true) + if err != nil { + log.WithError(err).Error("Failed to retrieve host information.") } - c.Header("Content-Type", "text/html") - c.String(http.StatusOK, constants.IndexPage) + fmt.Println(s) + i := struct { + Name string + Cpu struct { + Cores int + } + }{ + Name: "Wings", + } + + i.Cpu.Cores = s + + c.JSON(http.StatusOK, i) + return + + //if auth != nil && auth.HasPermission("c:info") { + // hostInfo, err := host.Info() + // if err != nil { + // log.WithError(err).Error("Failed to retrieve host information.") + // } + // cpuInfo, err := cpu.Info() + // if err != nil { + // log.WithError(err).Error("Failed to retrieve CPU information.") + // } + // memInfo, err := mem.VirtualMemory() + // if err != nil { + // log.WithError(err).Error("Failed to retrieve memory information.") + // } + // + // info := struct { + // Name string `json:"name"` + // Version string `json:"version"` + // System struct { + // SystemType string `json:"type"` + // Platform string `json:"platform"` + // Arch string `json:"arch"` + // Release string `json:"release"` + // Cpus int32 `json:"cpus"` + // Freemem uint64 `json:"freemem"` + // } `json:"system"` + // }{ + // Name: "Pterodactyl wings", + // Version: constants.Version, + // } + // info.System.SystemType = hostInfo.OS + // info.System.Platform = hostInfo.Platform + // info.System.Arch = runtime.GOARCH + // info.System.Release = hostInfo.KernelVersion + // info.System.Cpus = cpuInfo[0].Cores + // info.System.Freemem = memInfo.Free + // + // c.JSON(http.StatusOK, info) + // return + //} } type incomingConfiguration struct { diff --git a/command/root.go b/command/root.go index 272b1ea..124c2f3 100644 --- a/command/root.go +++ b/command/root.go @@ -17,10 +17,11 @@ import ( // RootCommand is the root command of wings var RootCommand = &cobra.Command{ - Use: "wings", - Short: "", - Long: "", - Run: run, + Use: "wings", + Short: "Wings is the next generation server control daemon for Pterodactyl", + Long: "Wings is the next generation server control daemon for Pterodactyl", + Run: run, + Version: constants.Version, } var configPath string @@ -38,7 +39,8 @@ func run(cmd *cobra.Command, args []string) { utils.InitLogging() log.Info("Loading configuration...") if err := config.LoadConfiguration(configPath); err != nil { - log.WithError(err).Fatal("Failed to find configuration file") + log.WithError(err).Fatal("Could not locate a suitable config.yml file. Aborting startup.") + log.Exit(1) } utils.ConfigureLogging() @@ -52,17 +54,15 @@ func run(cmd *cobra.Command, args []string) { log.Info("Configuration loaded successfully.") - log.Info("Loading configured servers...") + log.Info("Loading configured servers.") if err := control.LoadServerConfigurations(filepath.Join(viper.GetString(config.DataPath), constants.ServersPath)); err != nil { log.WithError(err).Error("Failed to load configured servers.") } - if amount := len(control.GetServers()); amount == 1 { - log.Info("Loaded 1 server.") - } else { - log.Info("Loaded " + strconv.Itoa(amount) + " servers.") + if amount := len(control.GetServers()); amount > 0 { + log.Info("Loaded " + strconv.Itoa(amount) + " server(s).") } - log.Info("Starting api webserver...") - api := api.NewAPI() + log.Info("Starting API server.") + api := &api.InternalAPI{} api.Listen() } diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..4eb8787 --- /dev/null +++ b/config.example.yml @@ -0,0 +1,41 @@ +debug: false +data: '/srv/daemon-data' +api: + host: '0.0.0.0' + port: 8080 + ssl: + enabled: false + cert: '' + key: '' + uploads: + size_limit: 150000000 +docker: + container: + user: '' + network: + interface: '172.18.0.1' + name: 'pterodactyl_nw' + update_images: true + socket: '/var/run/docker.sock' + timezone_path: '/etc/timezone' +sftp: + host: '0.0.0.0' + port: 2022 + keypair: + bits: 2048 + e: 65537 +log: + path: './logs/' + level: 'info' + prune_days: 10 +internal: + temp_logs: '/tmp/pterodactyl' + disk_check_seconds: 30 + set_permissions_on_boot: true + throttle: + kill_at_count: 5 + decay: 10 + bytes: 4096 + check_interval_ms: 100 +remote: 'http://example.com' +token: 'test123' diff --git a/config.yml.example b/config.yml.example deleted file mode 100644 index a170504..0000000 --- a/config.yml.example +++ /dev/null @@ -1,28 +0,0 @@ -debug: false -data: '/srv/daemon-data' -api: - host: '0.0.0.0' - port: 8080 - ssl: - enabled: false - cert: '' - key: '' - uploads: - maximumSize: 150000000 -docker: - socket: '/var/run/docker.sock' - autoupdateImages: true - networkInterface: '172.18.0.1' - timezonePath: '/etc/timezone' -sftp: - host: '0.0.0.0' - port: 2022 -query: - killOnFail: true - failLimit: 5 -remote: 'http://example.com' -log: - path: './logs/' - level: 'info' - deleteAfterDays: 10 -authKey: 'test123' diff --git a/utils/logging.go b/utils/logger.go similarity index 88% rename from utils/logging.go rename to utils/logger.go index 876c735..ade097b 100644 --- a/utils/logging.go +++ b/utils/logger.go @@ -4,10 +4,11 @@ import ( "os" "path/filepath" "time" + //"time" "github.com/pterodactyl/wings/constants" - "github.com/lestrrat/go-file-rotatelogs" + rotatelogs "github.com/lestrrat-go/file-rotatelogs" "github.com/rifflock/lfshook" log "github.com/sirupsen/logrus" "github.com/spf13/viper" @@ -30,12 +31,15 @@ func ConfigureLogging() error { if err := os.MkdirAll(path, constants.DefaultFolderPerms); err != nil { return err } - writer := rotatelogs.New( + writer, err := rotatelogs.New( path+"/wings.%Y%m%d-%H%M.log", rotatelogs.WithLinkName(path), rotatelogs.WithMaxAge(time.Duration(viper.GetInt(config.LogDeleteAfterDays))*time.Hour*24), - rotatelogs.WithRotationTime(time.Duration(604800)*time.Second), + rotatelogs.WithRotationTime(time.Hour*24), ) + if err != nil { + return err + } log.AddHook(lfshook.NewHook(lfshook.WriterMap{ log.DebugLevel: writer, @@ -43,7 +47,7 @@ func ConfigureLogging() error { log.WarnLevel: writer, log.ErrorLevel: writer, log.FatalLevel: writer, - })) + }, &log.JSONFormatter{})) level := viper.GetString(config.LogLevel) diff --git a/wings-api.paw b/wings-api.paw index 0df8451..a13dc59 100644 Binary files a/wings-api.paw and b/wings-api.paw differ