Files
snapd/errtracker/errtracker.go
2021-08-26 18:24:02 +01:00

514 lines
13 KiB
Go

// -*- Mode: Go; indent-tabs-mode: t -*-
/*
* Copyright (C) 2017 Canonical Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3 as
* published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package errtracker
import (
"bytes"
"crypto/md5"
"crypto/sha512"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/snapcore/bolt"
"gopkg.in/mgo.v2/bson"
"github.com/snapcore/snapd/arch"
"github.com/snapcore/snapd/dirs"
"github.com/snapcore/snapd/logger"
"github.com/snapcore/snapd/osutil"
"github.com/snapcore/snapd/release"
"github.com/snapcore/snapd/snapdenv"
"github.com/snapcore/snapd/snapdtool"
)
var (
CrashDbURLBase string
SnapdVersion string
// The machine-id file is at different locations depending on how the system
// is setup. On Fedora for example /var/lib/dbus/machine-id doesn't exist
// but we have /etc/machine-id. See
// https://www.freedesktop.org/software/systemd/man/machine-id.html for a
// few more details.
machineIDs = []string{"/etc/machine-id", "/var/lib/dbus/machine-id"}
mockedHostSnapd = ""
mockedCoreSnapd = ""
snapConfineProfile = "/etc/apparmor.d/usr.lib.snapd.snap-confine"
procCpuinfo = "/proc/cpuinfo"
procSelfExe = "/proc/self/exe"
procSelfCwd = "/proc/self/cwd"
procSelfCmdline = "/proc/self/cmdline"
osGetenv = os.Getenv
timeNow = time.Now
)
type reportsDB struct {
db *bolt.DB
// time until an error report is cleaned from the database,
// usually 7 days
cleanupTime time.Duration
}
func hashString(s string) string {
h := sha512.New()
io.WriteString(h, s)
return fmt.Sprintf("%x", h.Sum(nil))
}
func newReportsDB(fname string) (*reportsDB, error) {
if err := os.MkdirAll(filepath.Dir(fname), 0755); err != nil {
return nil, err
}
bdb, err := bolt.Open(fname, 0600, &bolt.Options{
Timeout: 10 * time.Second,
})
if err != nil {
return nil, err
}
bdb.Update(func(tx *bolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte("reported"))
if err != nil {
return fmt.Errorf("create bucket: %s", err)
}
return nil
})
db := &reportsDB{
db: bdb,
cleanupTime: time.Duration(7 * 24 * time.Hour),
}
return db, nil
}
func (db *reportsDB) Close() error {
return db.db.Close()
}
// AlreadyReported returns true if an identical report has been sent recently
func (db *reportsDB) AlreadyReported(dupSig string) bool {
// robustness
if db == nil {
return false
}
var reported []byte
db.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("reported"))
reported = b.Get([]byte(hashString(dupSig)))
return nil
})
return len(reported) > 0
}
func (db *reportsDB) cleanupOldRecords() {
db.db.Update(func(tx *bolt.Tx) error {
now := time.Now()
b := tx.Bucket([]byte("reported"))
b.ForEach(func(dupSigHash, reportTime []byte) error {
var t time.Time
t.UnmarshalBinary(reportTime)
if now.After(t.Add(db.cleanupTime)) {
if err := b.Delete(dupSigHash); err != nil {
return err
}
}
return nil
})
return nil
})
}
// MarkReported marks an error report as reported to the error tracker
func (db *reportsDB) MarkReported(dupSig string) error {
// robustness
if db == nil {
return fmt.Errorf("cannot mark error report as reported with an uninitialized reports database")
}
db.cleanupOldRecords()
return db.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("reported"))
tb, err := time.Now().MarshalBinary()
if err != nil {
return err
}
return b.Put([]byte(hashString(dupSig)), tb)
})
}
func whoopsieEnabled() bool {
cmd := exec.Command("systemctl", "is-enabled", "whoopsie.service")
output, _ := cmd.CombinedOutput()
switch string(output) {
case "enabled\n":
return true
case "disabled\n":
return false
default:
logger.Debugf("unexpected output when checking for whoopsie.service (not installed?): %s", output)
return true
}
}
// distroRelease returns a distro release as it is expected by daisy.ubuntu.com
func distroRelease() string {
ID := release.ReleaseInfo.ID
if ID == "ubuntu" {
ID = "Ubuntu"
}
return fmt.Sprintf("%s %s", ID, release.ReleaseInfo.VersionID)
}
func readMachineID() ([]byte, error) {
for _, id := range machineIDs {
machineID, err := ioutil.ReadFile(id)
if err == nil {
return bytes.TrimSpace(machineID), nil
} else if !os.IsNotExist(err) {
logger.Noticef("cannot read %s: %s", id, err)
}
}
return nil, fmt.Errorf("cannot report: no suitable machine id file found")
}
func snapConfineProfileDigest(suffix string) string {
profileText, err := ioutil.ReadFile(filepath.Join(dirs.GlobalRootDir, snapConfineProfile+suffix))
if err != nil {
return ""
}
// NOTE: uses md5sum for easier comparison against dpkg meta-data
return fmt.Sprintf("%x", md5.Sum(profileText))
}
var didSnapdReExec = func() string {
didReexec, err := snapdtool.IsReexecd()
if err != nil {
return "unknown"
}
if didReexec {
return "yes"
}
return "no"
}
// Report reports an error with the given snap to the error tracker
func Report(snap, errMsg, dupSig string, extra map[string]string) (string, error) {
if extra == nil {
extra = make(map[string]string)
}
extra["ProblemType"] = "Snap"
extra["Snap"] = snap
// check if we haven't already reported this error
db, err := newReportsDB(dirs.ErrtrackerDbDir)
if err != nil {
return "", fmt.Errorf("cannot open error reports database: %v", err)
}
defer db.Close()
if db.AlreadyReported(dupSig) {
return "already-reported", nil
}
// do the actual report
oopsID, err := report(errMsg, dupSig, extra)
if err != nil {
return "", err
}
if err := db.MarkReported(dupSig); err != nil {
logger.Noticef("cannot mark %s as reported: %s", oopsID, err)
}
return oopsID, nil
}
// ReportRepair reports an error with the given repair assertion script
// to the error tracker
func ReportRepair(repair, errMsg, dupSig string, extra map[string]string) (string, error) {
if extra == nil {
extra = make(map[string]string)
}
extra["ProblemType"] = "Repair"
extra["Repair"] = repair
return report(errMsg, dupSig, extra)
}
func detectVirt() string {
cmd := exec.Command("systemd-detect-virt")
output, err := cmd.CombinedOutput()
if err != nil {
return ""
}
return strings.TrimSpace(string(output))
}
func journalError() string {
// TODO: look into using systemd package (needs refactor)
// Before changing this line to be more consistent or nicer or anything
// else, remember it needs to run a lot of different systemd's: today,
// anything from 238 (on arch) to 204 (on ubuntu 14.04); this is why
// doing the refactor to the systemd package to only worry about this in
// there might be worth it.
output, err := exec.Command("journalctl", "-b", "--priority=warning..err", "--lines=1000").CombinedOutput()
if err != nil {
if len(output) == 0 {
return fmt.Sprintf("error: %v", err)
}
output = append(output, fmt.Sprintf("\nerror: %v", err)...)
}
return string(output)
}
func procCpuinfoMinimal() string {
buf, err := ioutil.ReadFile(procCpuinfo)
if err != nil {
// if we can't read cpuinfo, we want to know _why_
return fmt.Sprintf("error: %v", err)
}
idx := bytes.LastIndex(buf, []byte("\nprocessor\t:"))
// if not found (which will happen on non-x86 architectures, which is ok
// because they'd typically not have the same info over and over again),
// return whole buffer; otherwise, return from just after the \n
return string(buf[idx+1:])
}
func procExe() string {
out, err := os.Readlink(procSelfExe)
if err != nil {
return fmt.Sprintf("error: %v", err)
}
return out
}
func procCwd() string {
out, err := os.Readlink(procSelfCwd)
if err != nil {
return fmt.Sprintf("error: %v", err)
}
return out
}
func procCmdline() string {
out, err := ioutil.ReadFile(procSelfCmdline)
if err != nil {
return fmt.Sprintf("error: %v", err)
}
return string(out)
}
func environ() string {
safeVars := []string{
"SHELL", "TERM", "LANGUAGE", "LANG", "LC_CTYPE",
"LC_COLLATE", "LC_TIME", "LC_NUMERIC",
"LC_MONETARY", "LC_MESSAGES", "LC_PAPER",
"LC_NAME", "LC_ADDRESS", "LC_TELEPHONE",
"LC_MEASUREMENT", "LC_IDENTIFICATION", "LOCPATH",
}
unsafeVars := []string{"XDG_RUNTIME_DIR", "LD_PRELOAD", "LD_LIBRARY_PATH"}
knownPaths := map[string]bool{
"/snap/bin": true,
"/var/lib/snapd/snap/bin": true,
"/sbin": true,
"/bin": true,
"/usr/sbin": true,
"/usr/bin": true,
"/usr/local/sbin": true,
"/usr/local/bin": true,
"/usr/local/games": true,
"/usr/games": true,
}
// + 1 for PATH
out := make([]string, 0, len(safeVars)+len(unsafeVars)+1)
for _, k := range safeVars {
if v := osGetenv(k); v != "" {
out = append(out, fmt.Sprintf("%s=%s", k, v))
}
}
for _, k := range unsafeVars {
if v := osGetenv(k); v != "" {
out = append(out, k+"=<set>")
}
}
if paths := filepath.SplitList(osGetenv("PATH")); len(paths) > 0 {
for i, p := range paths {
p = filepath.Clean(p)
if !knownPaths[p] {
if strings.Contains(p, "/home") || strings.Contains(p, "/tmp") {
p = "(user)"
} else {
p = "(custom)"
}
}
paths[i] = p
}
out = append(out, fmt.Sprintf("PATH=%s", strings.Join(paths, string(filepath.ListSeparator))))
}
return strings.Join(out, "\n")
}
func report(errMsg, dupSig string, extra map[string]string) (string, error) {
if CrashDbURLBase == "" {
return "", nil
}
if extra == nil || extra["ProblemType"] == "" {
return "", fmt.Errorf(`key "ProblemType" not set in %v`, extra)
}
if !whoopsieEnabled() {
return "", nil
}
machineID, err := readMachineID()
if err != nil {
return "", err
}
identifier := fmt.Sprintf("%x", sha512.Sum512(machineID))
crashDbUrl := fmt.Sprintf("%s/%s", CrashDbURLBase, identifier)
hostSnapdPath := filepath.Join(dirs.DistroLibExecDir, "snapd")
coreSnapdPath := filepath.Join(dirs.SnapMountDir, "core/current/usr/lib/snapd/snapd")
if mockedHostSnapd != "" {
hostSnapdPath = mockedHostSnapd
}
if mockedCoreSnapd != "" {
coreSnapdPath = mockedCoreSnapd
}
hostBuildID, _ := osutil.ReadBuildID(hostSnapdPath)
coreBuildID, _ := osutil.ReadBuildID(coreSnapdPath)
if hostBuildID == "" {
hostBuildID = "unknown"
}
if coreBuildID == "" {
coreBuildID = "unknown"
}
report := map[string]string{
"Architecture": arch.DpkgArchitecture(),
"SnapdVersion": SnapdVersion,
"DistroRelease": distroRelease(),
"HostSnapdBuildID": hostBuildID,
"CoreSnapdBuildID": coreBuildID,
"Date": timeNow().Format(time.ANSIC),
"KernelVersion": osutil.KernelVersion(),
"ErrorMessage": errMsg,
"DuplicateSignature": dupSig,
"JournalError": journalError(),
"ExecutablePath": procExe(),
"ProcCmdline": procCmdline(),
"ProcCpuinfoMinimal": procCpuinfoMinimal(),
"ProcCwd": procCwd(),
"ProcEnviron": environ(),
"DetectedVirt": detectVirt(),
"SourcePackage": "snapd",
"DidSnapdReExec": didSnapdReExec(),
}
if desktop := osGetenv("XDG_CURRENT_DESKTOP"); desktop != "" {
report["CurrentDesktop"] = desktop
}
for k, v := range extra {
// only set if empty
if _, ok := report[k]; !ok {
report[k] = v
}
}
// include md5 hashes of the apparmor conffile for easier debbuging
// of not-updated snap-confine apparmor profiles
for _, sp := range []struct {
suffix string
key string
}{
{"", "MD5SumSnapConfineAppArmorProfile"},
{".dpkg-new", "MD5SumSnapConfineAppArmorProfileDpkgNew"},
{".real", "MD5SumSnapConfineAppArmorProfileReal"},
{".real.dpkg-new", "MD5SumSnapConfineAppArmorProfileRealDpkgNew"},
} {
digest := snapConfineProfileDigest(sp.suffix)
if digest != "" {
report[sp.key] = digest
}
}
// see if we run in testing mode
if snapdenv.Testing() {
logger.Noticef("errtracker.Report is *not* sent because SNAPPY_TESTING is set")
logger.Noticef("report: %v", report)
return "oops-not-sent", nil
}
// send it for real
reportBson, err := bson.Marshal(report)
if err != nil {
return "", err
}
client := &http.Client{}
req, err := http.NewRequest("POST", crashDbUrl, bytes.NewBuffer(reportBson))
if err != nil {
return "", err
}
req.Header.Add("Content-Type", "application/octet-stream")
req.Header.Add("X-Whoopsie-Version", snapdenv.UserAgent())
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("cannot upload error report, return code: %d", resp.StatusCode)
}
oopsID, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(oopsID), nil
}