本文首发于 xlog,可跳转获得更佳阅读体验

前言

在构建 Linux/Unix 系安装包时,除了打包成标准的适用于各种发行版的软件包以外,我们更多的可能希望可以提供一个 shell 脚本进行程序的安装,将安装步骤简单收敛为两步:下载脚本 + 运行脚本。

通常,这种大多数的安装脚本都是再次从互联网上下载所需资源的,这样可以最小化脚本的体积并保证安装的始终是最新版本,但是这同样导致了下载到的「安装包」本质上是个「安装器」,无法离线安装。

本文将介绍一种已经在生产环境验证过的方案,来动态在安装包中嵌入网址。

受限于一些原因,本文更多的从原理层面进行讲解,暂无法提供完整的代码解决方案,敬请谅解

另外,以下代码均为根据原理为本文撰写,虽然原理已经经过生产验证但所使用的代码并未经过严格的生产验证,如有 bug 烦请告知

脚本构成

整个脚本由 head + embed-bin 两部份构成;embed-bin 是不加改动的将我们的程序进行嵌入的,而 head 是一个动态生成的脚本,用于从当前脚本中提取 embed-bin 并执行。

head 脚本随是动态生成,但为了维护的简单,这里采用模板的形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#!/bin/sh
#
# NAME: {{ .Name }}
# PLATFORM: { .Platform }}
# DIGEST: {{ .MD5 }}, @LINES@

THIS_DIR=$(DIRNAME=$(dirname "$0"); cd "$DIRNAME"; pwd)
THIS_FILE=$(basename "$0")
THIS_PATH="$THIS_DIR/$THIS_FILE"
EXTRACT={{ if .AutoExtract }}1{{ else }}0{{ end }}
FORCE_EXTRACT=0
PREFIX={{ .DefaultPrefix }}
EXECUTE={{ if .AutoExecute }}1{{ else }}0{{ end }}
{{- end }}

USAGE="{{ .Opts.Usage }}"

while getopts ":h{{ .Opts.FlagNames }}" flag; do
case "$flag" in
h)
printf "%s" "$USAGE"
exit 2
;;
{{- range .Opts.All }}
{{ .Name }})
{{ range .Action.DoIfSet }}{{ . }}
{{ end }};;{{ end }}
*)
printf "ERROR: did not recognize option '%s', please try -h\\n" "$1"
exit 1
;;
esac
done

# Verify MD5
printf "%s\\n" "Verifying file..."
MD5=$(tail -n +@LINES@ "$THIS_PATH" | md5sum)
if ! echo "$MD5" | grep {{ .MD5 }} >/dev/null; then
printf "ERROR: md5sum mismatch of tar archive\\n" >&2
printf "expected: {{ .MD5 }}\\n" >&2
printf " got: %s\\n" "$MD5" >&2
exit 3
fi

{{ if .Archive -}}
if [ -z "$PREFIX" ]; then
PREFIX=$(mktemp -d -p $(pwd))
fi

if [ "$EXTRACT" = "1" ]; then
if [ "$FORCE_EXTRACT" = "1" ] || [ ! -f "$PREFIX/.extract-done" ] || [ "$(cat "$PREFIX/.extract-done")" != "{{ .MD5}}" ]; then
printf "Extracting archive to %s ...\\n" "$PREFIX"

{
dd if="$THIS_PATH" bs=1 skip=@ARCHIVE_FIRST_OFFSET@ count=@ARCHIVE_FIRST_BYTES@ 2>/dev/null
dd if="$THIS_PATH" bs=@BLOCK_SIZE@ skip=@ARCHIVE_BLOCK_OFFSET@ count=@ARCHIVE_BLOCKS_COUNT@ 2>/dev/null
dd if="$THIS_PATH" bs=1 skip=@ARCHIVE_LAST_OFFSET@ count=@ARCHIVE_LAST_BYTES@ 2>/dev/null
} | tar zxf - -C "$PREFIX"

echo -n {{ .MD5 }} > "$PREFIX/.extract-done"
else
printf "Archive has already been extracted to %s\\n" "$PREFIX"
fi
fi

if [ "$EXECUTE" = "1" ]; then
echo "Run Command:" {{ .Command }}
cd "$PREFIX" && {{ .Command }}
fi
{{- end }}

exit 0
## --- DATA --- ##

这个脚本模板存在着两种类型的变量:{{ XX }}%XX%,其中主要的区别在于整个模板渲染要分成两步:首先渲染所有的 {{ XX }} 变量,然后再渲染剩余的 %XX% 变量;渲染前者时无特殊要求,而渲染后者时需要保证变量的渲染前后文本的长度与行数不变。

这个脚本会将 embed-bin 作为压缩包进行解压,这主要是因为我们内部使用时相关数据可能很大(数百兆乃至上 GB),如果你只需要一个小的脚本可以移除压缩有关的代码。

另外,这个脚本会在执行前进行一次 MD5 校验,这主要是为了防止一些情况下脚本下载不完全导致的。但是因为本身 embed-bin 就是压缩包了,因此可以删除校验有关的代码来加快安装速度(我们内部保留的原因一方面是因为我们 embed 的内容不止压缩包甚至不止一个文件,另一方面就是为了给出更好的错误提示)。

这个脚本也提供了参数传递的能力和部分默认值的指定,这是因为在某些情况下相关步骤可能异常而全量执行所有步骤较为耗时,在实际使用中你可根据实际需要删改脚本参数。

脚本的参数由模板渲染引擎给出,这主要是为了可维护性,如果你更希望在脚本中撰写相关的内容则可以修改相关部分

渲染脚本

话不多说,直接上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
//go:embed "header.sh.tmpl"
var headerTemplate string

type headerOptions struct {
Name string
MD5 string

Opts *Opts

*ArchiveOptions
}

type ArchiveOptions struct {
DefaultPrefix string
AutoExtract bool
AutoExecute bool
Command string // 使用 $PREFIX 引用 prefix

Filename string // 供 builder 使用,不会打入最终文件
}

func (o *ArchiveOptions) QuotedCommand() string {
return shells.Quote(o.Command)
}

func renderHeaders(o *headerOptions) ([]byte, error) {
t := template.New("")

tt, err := t.Parse(headerTemplate)
if err != nil {
return nil, ee.Wrap(err, "invalid template")
}

b := bytes.Buffer{}

err = tt.Execute(&b, o)
if err != nil {
return nil, err
}

return b.Bytes(), nil
}

func getHeaders(o *headerOptions) ([]byte, error) {
tmpl, err := renderHeaders(o)
if err != nil {
return nil, err
}

lines := bytes.Count(tmpl, []byte("\n")) + 1

tmpl = bytes.ReplaceAll(tmpl, []byte("@LINES@"), []byte(strconv.Itoa(lines)))

replaceAndFillSpace(tmpl, "@BLOCK_SIZE@", blockSize)

return tmpl, nil
}

func replaceAndFillSpace(data []byte, old string, new int64) {
oldBytes := []byte(old)
newString := strconv.FormatInt(new, 10)

newWithExtraSpace := append([]byte(newString), bytes.Repeat([]byte{' '}, len(old)-len(newString))...)

// assert len(old) == len(newWithExtraSpace)

// Apply replacements to buffer.
start := 0
for {
i := bytes.Index(data[start:], oldBytes)
if i == -1 {
return // stop
}

start += i
start += copy(data[start:], newWithExtraSpace)
}
}

type Opts struct {
All []*Opt
}

func (opts *Opts) FlagNames() string {
b := strings.Builder{}
for _, opt := range opts.All {
b.WriteString(opt.Name)
if len(opt.Arg) != 0 {
b.WriteString(":")
}
}

return b.String()
}

func (opts *Opts) Usage() string {
b := strings.Builder{}

b.WriteString("Usage: $0 [options]\n\n")

all := make([][2]string, 0, 1+len(opts.All))

nameLen := 2

all = append(all, [2]string{"-h", "Print this help message and exit"})

for _, opt := range opts.All {
bb := strings.Builder{}
bb.WriteString("-")
bb.WriteString(opt.Name)

if opt.Arg != "" {
bb.WriteString(" [")
bb.WriteString(opt.Arg)
bb.WriteString("]")
}

name := bb.String()

if len(name) > nameLen {
nameLen = len(name)
}

all = append(all, [2]string{name, opt.Help})
}

for _, a := range all {
b.WriteString(a[0])
b.WriteString(strings.Repeat(" ", nameLen-len(a[0])))
b.WriteString("\t")
b.WriteString(a[1])
b.WriteString("\n")
}

return b.String()
}

type Opt struct {
Name string
Arg string
Help string
Action OptAction
}

type OptAction interface {
DoIfSet() []string
}

type DoAndExitAction struct {
Do []string
ExitCode int
}

func (a *DoAndExitAction) DoIfSet() []string {
r := append([]string{}, a.Do...)
r = append(r, "exit "+strconv.Itoa(a.ExitCode))
return r
}

type DoAndContinueAction struct {
Do []string
}

func (a *DoAndContinueAction) DoIfSet() []string {
return a.Do
}

func SimpleSetEnvAction(envName string, envValue interface{}) *DoAndContinueAction {
return &DoAndContinueAction{
Do: []string{fmt.Sprintf("%s=%v", envName, envValue)},
}
}

type Builder struct {
Name string

ArchiveOptions *ArchiveOptions
}

func openAndWrite(filename string, w io.Writer) (int64, error) {
f, err := os.Open(filename)
if err != nil {
return 0, err
}
defer f.Close()

return io.Copy(w, f)
}

func fillAndSetHeader(prefix, filename string, f io.Writer, headers []byte, offset int64) (int64, error) {

fileLength, err := openAndWrite(filename, f)
if err != nil {
return 0, ee.Wrap(err, "cannot append data for "+prefix)
}

firstOffset := offset
firstBytes := blockSize - (firstOffset % blockSize)
replaceAndFillSpace(headers, fmt.Sprintf("@%s_FIRST_OFFSET@", prefix), firstOffset)
replaceAndFillSpace(headers, fmt.Sprintf("@%s_FIRST_BYTES@", prefix), firstBytes)

copy2Start := firstOffset + firstBytes
copy2Skip := copy2Start / blockSize
copy2Blocks := (fileLength - copy2Start + firstOffset) / blockSize
replaceAndFillSpace(headers, fmt.Sprintf("@%s_BLOCK_OFFSET@", prefix), copy2Skip)
replaceAndFillSpace(headers, fmt.Sprintf("@%s_BLOCKS_COUNT@", prefix), copy2Blocks)

copy3Start := (copy2Skip + copy2Blocks) * blockSize
copy3Size := fileLength - firstBytes - (copy2Blocks * blockSize)
replaceAndFillSpace(headers, fmt.Sprintf("@%s_LAST_OFFSET@", prefix), copy3Start)
replaceAndFillSpace(headers, fmt.Sprintf("@%s_LAST_BYTES@", prefix), copy3Size)

return fileLength, nil
}

func (b *Builder) Build(saveTo string) error {
header := &headerOptions{
Name: b.Name,
ArchiveOptions: b.ArchiveOptions,
Opts: &Opts{},
}

fileMD5 := md5.New()

var dataSize int64

if header.ArchiveOptions != nil {
if header.ArchiveOptions.AutoExtract {
header.Opts.All = append(header.Opts.All, &Opt{
Name: "E",
Help: "Do not extract archive",
Action: SimpleSetEnvAction("EXTRACT", 0),
})
} else {
header.Opts.All = append(header.Opts.All, &Opt{
Name: "e",
Help: "Also extract archive",
Action: SimpleSetEnvAction("EXTRACT", 1),
})
}

header.Opts.All = append(header.Opts.All, &Opt{
Name: "f",
Help: "Force extract archive",
Action: SimpleSetEnvAction("FORCE_EXTRACT", 1),
})

prefixOpt := &Opt{
Name: "d",
Arg: "DIR",
Help: "Extract to directory",
Action: &DoAndContinueAction{
Do: []string{`PREFIX="${OPTARG}"`},
},
}
if header.ArchiveOptions.DefaultPrefix != "" {
prefixOpt.Help += fmt.Sprintf(" (default: %s)", header.ArchiveOptions.DefaultPrefix)
}

header.Opts.All = append(header.Opts.All, prefixOpt)

if header.ArchiveOptions.Command != "" {
if header.ArchiveOptions.AutoExecute {
header.Opts.All = append(header.Opts.All, &Opt{
Name: "X",
Help: "Do not execute command",
Action: SimpleSetEnvAction("EXECUTE", 0),
})
} else {
header.Opts.All = append(header.Opts.All, &Opt{
Name: "x",
Help: "Also execute the command",
Action: SimpleSetEnvAction("EXECUTE", 1),
})
}
}

n, err := openAndWrite(header.ArchiveOptions.Filename, fileMD5)
if err != nil {
return ee.Wrap(err, "failed to read archive file to get md5")
}
dataSize += n
}

_ = dataSize

header.MD5 = hex.EncodeToString(fileMD5.Sum(nil))

headers, err := getHeaders(header)
if err != nil {
return ee.Wrap(err, "failed to get headers")
}

f, err := os.OpenFile(saveTo, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return ee.Wrap(err, "failed to write file")
}
defer f.Close()

// write header
headersLen, err := f.Write(headers)
if err != nil {
return ee.Wrap(err, "failed to write headers")
}

currentOffset := int64(headersLen)

// embed archive
if header.ArchiveOptions != nil {
n, err := fillAndSetHeader("ARCHIVE", header.ArchiveOptions.Filename, f, headers, currentOffset)
if err != nil {
return ee.Wrap(err, "failed to embed installer")
}
currentOffset += n
}

_ = currentOffset

// rewrite headers
_, err = f.Seek(0, 0)
if err != nil {
return ee.Wrap(err, "failed to seek file")
}
newHeadersLen, err := f.Write(headers)
if err != nil {
return ee.Wrap(err, "failed to rewrite headers")
}
if headersLen != newHeadersLen {
return ee.New("headers unexpected change after rewrite")
}

return nil
}

使用则是

1
2
3
4
5
6
7
8
9
10
11
b := &Builder{
Name: name,
ArchiveOptions: &binbundler.ArchiveOptions{
DefaultPrefix: "/path/to/extract",
AutoExtract: true,
AutoExecute: true,
Command: "bash $PREFIX/install.sh", # 安装命令,简单可直接执行,复杂可使用一个额外的脚本
Filename: "/path/to/embed",
},
}
err = b.Build("/path/to/script-save-to.sh")

在整个脚本中,动态插入了相关模板变量,并计算了相关 offset

后记

本文更多的只是提供一种思路(利用 dd 来解压、动态生成 opt 来控制执行过程),相比于网上更多的利用 grep 等手段来定位二进制内容更加的高效、易维护。

在此基础上,其实还可以实现更多的事情(依赖验证、安装多个文件等),欢迎尝试